diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b2a395..7584889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,10 +45,11 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - registry-url: 'https://registry.npmjs.org' - always-auth: true + + - name: Update npm + run: npm install -g npm@latest - name: Install dependencies run: npm ci @@ -63,8 +64,6 @@ jobs: DEBUG: release-it:*,@release-it/* HUSKY: 0 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | VERSION_ARG="" if [ -n "${{ inputs.version }}" ]; then diff --git a/.gitignore b/.gitignore index 18c6b09..fdbed48 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ stats.html .tool-versions .cache *-stats.txt +.npmrc diff --git a/.husky/pre-commit b/.husky/pre-commit index 670761a..1118438 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# Husky pre-commit hook: format and run related tests on staged files +# Husky pre-commit hook: format and lint staged files npx --no -- lint-staged diff --git a/.husky/pre-push b/.husky/pre-push index 8df7150..4c4af85 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -3,5 +3,4 @@ # Husky pre-push hook: typecheck, run full tests, and build npm run typecheck || exit 1 -#npm run test:ci || exit 1 -npm run build || exit 1 +npm run test:ci || exit 1 diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 39e5ffe..0000000 --- a/.mailmap +++ /dev/null @@ -1 +0,0 @@ -Addon Stack <191148085+addon-stack@users.noreply.github.com> \ No newline at end of file diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index a7db0d7..0000000 --- a/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -dist/ -build/ \ No newline at end of file diff --git a/.release-it.cjs b/.release-it.cjs index c415ee2..f979ad8 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -97,7 +97,7 @@ const types = new Map([ ["perf", "⚑️ Performance Improvements"], ["refactor", "πŸ› οΈ Refactoring"], ["docs", "πŸ“ Documentation"], - ["test", "Tests"], + ["test", "πŸ§ͺ Tests"], ["build", "πŸ—οΈ Build System"], ["ci", "πŸ€– CI"], ["chore", "🧹 Chores"], @@ -107,7 +107,71 @@ const types = new Map([ const normalizeRepoUrl = url => url.replace(/^git\+/, "").replace(/\.git$/, ""); const repoUrl = pkg?.repository?.url ? normalizeRepoUrl(pkg.repository.url) : null; -module.exports = () => { +const breakingChangePattern = /\bBREAKING(?: |-)?CHANGE\b/i; + +function hasBreakingChange(commit) { + if (commit.breaking) { + return true; + } + + const type = String(commit.type || "").trim(); + + if (type.endsWith("!")) { + return true; + } + + if (typeof commit.header === "string" && /^\w+(?:\([^)]+\))?!:/.test(commit.header)) { + return true; + } + + if ( + commit.notes?.some(note => + [note.title, note.text].some(value => typeof value === "string" && breakingChangePattern.test(value)) + ) + ) { + return true; + } + + return typeof commit.footer === "string" && breakingChangePattern.test(commit.footer); +} + +function whatBump(commits, currentVersion = pkg.version) { + let isBreaking = false; + let isMinor = false; + let isPatch = false; + + for (const commit of commits) { + if (hasBreakingChange(commit)) { + isBreaking = true; + } + + const type = String(commit.type || "") + .trim() + .toLowerCase() + .replace(/!+$/, ""); + + if (["feat", "revert"].includes(type)) { + isMinor = true; + } + + if (["fix", "perf", "refactor", "ci"].includes(type)) { + isPatch = true; + } + } + + if (isBreaking) { + const currentMajor = Number.parseInt(String(currentVersion).replace(/^v/i, "").split(".")[0], 10); + + return {level: Number.isNaN(currentMajor) || currentMajor >= 1 ? 0 : 1}; + } + + if (isMinor) return {level: 1}; + if (isPatch) return {level: 2}; + + return null; +} + +const createReleaseConfig = () => { const contributors = getContributors(); return { @@ -139,8 +203,11 @@ module.exports = () => { npm: { publish: true, + skipChecks: true, + provenance: true, + access: "public", + registry: "https://registry.npmjs.org/", versionArgs: ["--no-git-tag-version"], - publishArgs: ["--provenance", "--access", "public"], }, plugins: { @@ -165,37 +232,7 @@ module.exports = () => { contributors, }, - recommendedBumpOpts: { - preset: "conventionalcommits", - whatBump: commits => { - let isMajor = false; - let isMinor = false; - let isPatch = false; - - for (const commit of commits) { - if (commit.notes?.some(n => /BREAKING CHANGE/i.test(n.title || n.text || ""))) { - isMajor = true; - break; - } - - const type = (commit.type || "").toLowerCase(); - - if (type === "feat") { - isMinor = true; - } - - if (["fix", "perf", "refactor", "ci"].includes(type)) { - isPatch = true; - } - } - - if (isMajor) return {level: 0}; - if (isMinor) return {level: 1}; - if (isPatch) return {level: 2}; - - return null; - }, - }, + whatBump, writerOpts: { headerPartial: "## πŸš€ Release {{#if name}}`{{name}}` {{else}}{{#if @root.pkg}}`{{@root.pkg.name}}` {{/if}}{{/if}}v{{version}} ({{date}})\n\n", @@ -254,3 +291,5 @@ module.exports = () => { }, }; }; + +module.exports = Object.assign(createReleaseConfig, {whatBump}); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e638304..299825e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,172 +1,192 @@ - - # Contributing to @addon-core/inject-script -Thank you for taking the time to contribute! This document describes our workflow, quality gates, commit conventions, and release process. By participating, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). - -## Table of Contents - -1. Reporting Bugs -2. Suggesting Enhancements -3. Branching Model & Workflow -4. Development Setup -5. Quality Gates (Lint, Format, Types, Tests) -6. Commit Messages (Conventional Commits) -7. Submitting a Pull Request -8. Releases -9. Code of Conduct -10. Security -11. License - ---- - -## Reporting Bugs - -To file a clear, actionable bug report: - -1. Search existing issues to avoid duplicates. -2. If not found, open a new issue and include: - - Descriptive title and summary - - Steps to reproduce (minimal repro if possible) - - Expected vs. actual behavior - - Environment details (OS, browser, Node.js/npm) - - Relevant logs, stack traces, or screenshots - -## Suggesting Enhancements - -When proposing an enhancement: - -1. Check open issues/PRs for similar ideas. -2. Open a new issue describing: - - Motivation and use case - - Proposed API/UX (code snippets welcome) - - Alternatives considered and trade-offs - -## Branching Model & Workflow - -We use a simplified GitFlow: - -- Default branch: `develop` -- Feature branches: `feature/` cut from `develop` -- Regular work: open PRs into `develop` -- Releases: open a PR from `develop` to `main` - - When the PR is merged, a release pipeline runs automatically on `main` - - After publishing, `main` is synced back into `develop` - -See the workflows in `.github/workflows/`: -- CI: `.github/workflows/ci.yml` (runs on pushes/PRs to `develop` and `feature/**`) -- Release: `.github/workflows/release.yml` (runs on push to `main` and on manual dispatch) - -## Development Setup - -1. Clone the repository: - ```bash - git clone git@github.com:addon-stack/inject-script.git - cd inject-script - ``` -2. Install dependencies (Node.js 20+ recommended): - ```bash - npm install - ``` -3. Useful scripts: - - `npm run dev` β€” build in watch mode (tsup) - - `npm run build` β€” production build (tsup) - - `npm run format` β€” format with Biome - - `npm run format:check` β€” check formatting only - - `npm run lint` β€” Biome lint + format checks - - `npm run lint:fix` β€” autofix safe issues - - `npm run lint:fix:unsafe` β€” autofix including unsafe transforms - - `npm run typecheck` β€” TypeScript type checking - - `npm run test` β€” run tests (Jest) - - `npm run test:ci` β€” CI-friendly tests with coverage - - `npm run test:related` β€” run tests related to staged/changed files - - `npm run release` β€” run release-it locally - -## Quality Gates (Lint, Format, Types, Tests) - -We treat code quality seriously and run multiple static checks locally and in CI: - -- Biome (formatter + linter) β€” configured via `biome.json` - - `npm run format` / `npm run lint` -- TypeScript type checks β€” `npm run typecheck` -- Unit tests (Jest) β€” `npm run test` -- Pre-commit automation (`lint-staged` via Husky pre-commit hook): - - For `src/**/*.{js,jsx,ts,tsx,cjs,mjs}`: `biome check --write --unsafe` then `npm run test:related` - - For `*.{json,css,scss,html}`: `biome format --write` -- CI mirrors this pipeline (lint β†’ typecheck β†’ test β†’ build) and uploads coverage artifacts - -Please ensure all commands above pass before opening a PR. - -## Commit Messages (Conventional Commits) - -All commits MUST follow [Conventional Commits](https://www.conventionalcommits.org/): +Thank you for helping improve `@addon-core/inject-script`. + +This package provides one typed script-injection contract across Manifest V2 and Manifest V3. Contributions should preserve that cross-manifest boundary, keep unsupported capabilities explicit, and include verification for every affected adapter. + +By participating, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities through the private process described in [SECURITY.md](SECURITY.md), not through a public issue. + +## Development workflow + +The repository uses a simplified GitFlow model: + +- `main` contains released code. +- `develop` is the integration branch and the normal pull-request target. +- `feature/` branches start from `develop`. +- Releases are prepared by merging `develop` into `main`. +- After a successful release, the workflow syncs `main` back into `develop`. + +Create a branch from the latest `develop`: + +```bash +git switch develop +git pull --ff-only +git switch -c feature/ +``` + +## Local setup + +Node.js 20 is the default CI environment. The full release gate also exercises Node.js 18, 20, and 22. + +```bash +git clone git@github.com:addon-stack/inject-script.git +cd inject-script +npm ci +``` + +Useful commands: + +| Command | Purpose | +| --- | --- | +| `npm run dev` | Build continuously with tsup | +| `npm run build` | Create ESM, CJS, and declaration outputs | +| `npm run format` | Format supported files with Biome | +| `npm run format:check` | Check formatting without writing | +| `npm run lint` | Run Biome formatting and lint checks | +| `npm run lint:fix` | Apply safe Biome fixes | +| `npm run lint:fix:unsafe` | Apply safe and unsafe Biome fixes | +| `npm run typecheck` | Type-check package sources | +| `npm run test:types` | Type-check public API contract fixtures | +| `npm run test` | Build and run the Jest suite | +| `npm run test:ci` | Build and run Jest with coverage | +| `npm run release` | Perform a real maintainer release through release-it | + +`npm run release` is not a dry run. Do not execute it unless you are intentionally publishing a release and have the required maintainer access. + +## Contribution guidelines + +Keep changes focused and preserve the package's public design: + +- Every operation has one explicit `target`. +- Target selectors remain mutually exclusive. +- Unsupported targets and options fail explicitly; they are never removed silently. +- `run()` returns package-owned per-frame outcomes instead of raw browser results. +- Callback arguments and results remain strictly JSON-compatible. +- MV2 injected code must stay self-contained because imports and caller closures do not cross the injection boundary. +- Runtime code must not introduce `eval` or `new Function`. +- Frame discovery, RPC fan-out, concurrency queues, and application-specific aggregation remain outside this package. + +When changing public behavior, update the implementation, types, tests, and README together. + +## Tests + +The test suite covers three different contracts: +- `tests/types.test.ts` verifies compile-time API behavior. +- `tests/inject-script.test.cjs` verifies MV2/MV3 runtime behavior against the built package. +- `tests/release-it.test.cjs` verifies release versioning policy. + +For adapter changes, cover the affected combinations where relevant: + +- Manifest V2 and Manifest V3; +- callback-based `global.chrome` and Promise-based `global.browser`; +- top frame, `allFrames`, explicit `frameIds`, and `documentIds`; +- `fulfilled`, `rejected`, and `unknown` outcomes; +- valid JSON data and runtime-only invalid values; +- preparation errors, native delivery failures, and timeouts. + +MV2 payload tests execute the generated code in a separate process. Keep this path covered when changing serialized injected logic. + +## Quality gates + +Before opening a pull request, run: + +```bash +npm run lint +npm run typecheck +npm run test:ci ``` + +`test:ci` already runs type-contract tests and a production build before Jest. + +Local hooks provide additional protection: + +- `pre-commit` runs `lint-staged`, which formats and lints supported staged source/config files. +- `commit-msg` validates Conventional Commits with commitlint. +- `pre-push` runs `typecheck` and the full `test:ci` command. + +GitHub Actions repeats lint, typecheck, tests, coverage, and production build checks. Release runs use the full operating-system and Node.js matrix. + +## Commit messages + +All commits must follow [Conventional Commits](https://www.conventionalcommits.org/): + +```text (optional scope): [optional body] -[optional footer(s)] +[optional footer] ``` -Common types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. +Common examples: + +```text +feat(target): add document targeting +fix(mv2): preserve partial timeout results +refactor(results): centralize native normalization +test(types): cover interface arguments +docs: simplify the quick start +``` -Examples: -- `feat(core): add MV3 documentId targeting` -- `fix(v2): handle timeout cleanup` -- `docs: update README with API examples` +Use `!` or a `BREAKING CHANGE:` footer for an incompatible public change: -Enforcement: -- We use Husky + commitlint. The `commit-msg` hook runs commitlint and blocks non‑conforming messages. -- Config: `.commitlintrc.json` extends `@commitlint/config-conventional`. +```text +feat!: replace the legacy target options +``` -Tip: for complex changes, prefer multiple small commits over one large commit. +Commit messages are enforced by `.husky/commit-msg` and `.commitlintrc.json`. -## Submitting a Pull Request +## Versioning policy -1. Create a branch from `develop`: - ```bash - git checkout -b feature/ - ``` -2. Make your changes under `src/` and update docs/tests if needed. -3. Run quality gates locally: - ```bash - npm run lint && npm run typecheck && npm run test && npm run build - ``` -4. Commit using Conventional Commits. The Husky hook will validate your message. -5. Push and open a PR into `develop`. -6. PR checklist: - - [ ] Lint, types, and tests pass in CI - - [ ] Docs updated (README/CONTRIBUTING if applicable) - - [ ] Linked related issues (e.g., `Closes #123`) - - [ ] Clear description of changes and motivation +Release versions are derived from commit history by `release-it` and `@release-it/conventional-changelog`: -Maintainers perform release PRs from `develop` β†’ `main` (see below). +- A breaking change increments major at `1.x` and newer. +- A breaking change increments minor while the package is on `0.x`. +- `feat` and `revert` increment minor. +- `fix`, `perf`, `refactor`, and `ci` increment patch. +- `docs`, `test`, `build`, `chore`, and `style` do not trigger a release by themselves. +- When several changes are present, the highest applicable increment wins. -## Releases +Do not edit released changelog entries manually. `CHANGELOG.md` is generated from Conventional Commits during release. -Releases are automated via GitHub Actions + `release-it`: +## Pull requests -- Trigger: merge/push to `main` (or manual dispatch with inputs) -- Steps (see `.github/workflows/release.yml`): - 1. Run the full CI matrix - 2. Execute `release-it` in CI (`--ci`) with conventional changelog - 3. Create a Git tag and GitHub Release - 4. Publish to npm using `NPM_TOKEN` and selected dist-tag - 5. Sync `main` back into `develop` +Open regular pull requests against `develop` and include: -Local dry-run is available via `npm run release` (requires proper credentials for a real publish). +- a concise explanation of the problem and the chosen solution; +- tests for changed behavior; +- documentation for public API or contract changes; +- migration notes for breaking changes; +- links to related issues when available. + +Pull-request checklist: + +- [ ] The change is focused and contains no unrelated edits. +- [ ] MV2 and MV3 implications have been considered. +- [ ] `npm run lint` passes. +- [ ] `npm run typecheck` passes. +- [ ] `npm run test:ci` passes. +- [ ] Public documentation is updated when necessary. +- [ ] Commit messages follow Conventional Commits. + +Maintainers create release pull requests from `develop` into `main`. + +## Releases -## Code of Conduct +The release workflow runs on pushes to `main` and through manual dispatch: -Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md) in all interactions. +1. Run the full CI matrix. +2. Calculate the next version from Conventional Commits unless an exact version was provided. +3. Update `package.json` and `CHANGELOG.md`. +4. Create the release commit and Git tag. +5. Create a GitHub Release. +6. Publish the public npm package with provenance through trusted publishing. +7. Sync `main` back into `develop`. -## Security +The workflow accepts optional prerelease and npm dist-tag inputs. It uses GitHub OIDC permissions for npm provenance and does not depend on a documented contributor `NPM_TOKEN` flow. -If you discover a vulnerability, please follow our [Security Policy](SECURITY.md) and avoid disclosing publicly until fixed. +Publishing is a maintainer operation. Contributors only need to prepare a complete, verified pull request into `develop`. ## License -By contributing, you agree that your contributions will be licensed under the project’s MIT License. See [LICENSE.md](LICENSE.md). +By contributing, you agree that your contributions are licensed under the project's [MIT License](LICENSE.md). diff --git a/README.md b/README.md index a427098..a61658c 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,513 @@ # @addon-core/inject-script -[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-script.svg?logo=npm)](https://www.npmjs.com/package/@addon-core/inject-script) -[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-script.svg)](https://www.npmjs.com/package/@addon-core/inject-script) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) -[![CI](https://github.com/addon-stack/inject-script/actions/workflows/ci.yml/badge.svg)](https://github.com/addon-stack/inject-script/actions/workflows/ci.yml) +[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-script.svg?logo=npm&style=for-the-badge)](https://www.npmjs.com/package/@addon-core/inject-script) +[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-script.svg?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@addon-core/inject-script) +[![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/inject-script/ci.yml?style=for-the-badge)](https://github.com/addon-stack/inject-script/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE.md) -A lightweight, TypeScript-ready library for injecting JavaScript functions and external script files into browser extension pages. It automatically detects Manifest V2/V3 and uses the appropriate API implementation. +Run typed functions or inject script files into browser extension tabs with one API for Manifest V2 and Manifest V3. -## Installation +`@addon-core/inject-script` selects the correct browser adapter, translates explicit frame and document targets, and turns native browser responses into predictable per-frame outcomes. You write the callback and choose the target; the package handles the manifest-specific execution path. -### npm: +- One target model for the top frame, all frames, selected frames, or selected documents +- Typed synchronous and asynchronous callbacks with explicit arguments +- Structured `fulfilled`, `rejected`, and `unknown` outcomes +- Strict JSON-compatible data validation with actionable error paths +- No `eval`, no `new Function`, and no extra frame-enumeration permissions + +## Install ```bash npm install @addon-core/inject-script ``` -### pnpm: - ```bash pnpm add @addon-core/inject-script ``` -### yarn: +Your extension still needs the native permissions required for script injection, including `scripting` in MV3 and appropriate host or `activeTab` access. The package does not modify the manifest. -```bash -yarn add @addon-core/inject-script +## Quick start + +```ts +import injectScript from "@addon-core/inject-script"; + +const outcomes = await injectScript({ + target: { + tabId: 123, + allFrames: true, + }, + timeoutMs: 5_000, +}).run( + (selector: string) => ({ + href: location.href, + text: document.querySelector(selector)?.textContent ?? null, + }), + ["h1"], +); + +for (const outcome of outcomes) { + if (outcome.status === "fulfilled") { + console.log(outcome.target.frameId, outcome.result); + } else if (outcome.status === "rejected") { + console.error(outcome.target.frameId, outcome.error); + } else { + console.warn(outcome.target.frameId, "No result or error was exposed"); + } +} ``` -## Quick Start +The package detects the current manifest version automatically. The same call works through `tabs.executeScript` in MV2 and `scripting.executeScript` in MV3. + +## Choose what to target + +Every operation has exactly one explicit target. Target selectors are mutually exclusive in TypeScript and validated again at runtime. + +| Need | Target | +| --- | --- | +| Main frame | `{tabId: 123}` | +| Every injectable frame | `{tabId: 123, allFrames: true}` | +| One frame | `{tabId: 123, frameIds: [7]}` | +| Selected frames | `{tabId: 123, frameIds: [0, 7, 12]}` | +| Selected documents | `{tabId: 123, documentIds: ["document-a", "document-b"]}` | ```ts -import injectScript, { type InjectScriptOptions } from "@addon-core/inject-script"; +const topFrame = injectScript({ + target: {tabId: 123}, +}); -// Initialize an injector for a specific tab -const injector = injectScript({ - tabId: 123, - frameId: false, // top frame only - matchAboutBlank: true, // include about:blank and similar pages - runAt: "document_idle", // injection timing (MV2) - // timeFallback: 5000, // (MV2) default timeout is 4000 ms - // world: 'ISOLATED', // (MV3) execution world - // documentId: 'abc123', // (MV3) target by documentId -} satisfies InjectScriptOptions); - -// Execute a function in the page context (for all target frames) -const results = await injector.run( - (msg: string) => { - console.log(msg); - return `Echo: ${msg}`; +const selectedFrames = injectScript({ + target: {tabId: 123, frameIds: [0, 7]}, +}); + +const allFrames = injectScript({ + target: {tabId: 123, allFrames: true}, +}); +``` + +`allFrames` accepts only the literal `true`. Omitting a selector means the top frame; there is no `allFrames: false` mode. + +For a runtime choice, construct the complete target: + +```ts +import type {InjectScriptTarget} from "@addon-core/inject-script"; + +const target: InjectScriptTarget = includeAllFrames + ? {tabId: 123, allFrames: true} + : {tabId: 123}; + +const injector = injectScript({target}); +``` + +`documentIds` require an MV3 runtime that supports native document targeting. An unsupported selector throws `UnsupportedInjectScriptTargetError`; the package never removes it or silently falls back to the top frame. + +### Observed results, not frame discovery + +An `allFrames` call is one native browser operation. It returns outcomes for the frames the browser reports as executed; it is not a frame snapshot or an exhaustive RPC fan-out. + +With explicit `frameIds`, a normal execution returns one outcome per requested frame. MV2 can mark a known frame that did not answer as `unknown`. MV3 returns the native outcomes exposed by the browser and does not fabricate missing frame results. + +If an application requires exactly one outcome for every previously discovered frame, enumerate those frames in the application layer and call them through explicit `frameIds` targets. + +## Run a function + +Callbacks may be synchronous or asynchronous: + +```ts +const outcomes = await injectScript({ + target: {tabId: 123}, +}).run( + async (url: string) => { + const response = await fetch(url); + + return { + ok: response.ok, + status: response.status, + body: await response.text(), + }; }, - ["Hello from the extension!"] + ["https://example.com/data"], ); +``` + +### Keep the callback self-contained + +The callback runs in the target page. Runtime variables from the extension module or caller closure are not available there. + +```ts +const selector = ".product-title"; + +// Incorrect: selector is part of the caller closure. +await injector.run(() => { + return document.querySelector(selector)?.textContent ?? null; +}); + +// Correct: pass the value explicitly. +await injector.run( + (targetSelector: string) => { + return document.querySelector(targetSelector)?.textContent ?? null; + }, + [selector], +); +``` + +Type-only annotations are safe because they disappear during compilation. Imported runtime values and closed-over variables are not. + +## Work with outcomes + +When the browser request itself succeeds, `run()` resolves to an array of package-owned outcomes: -// Inject one or more external files +```ts +type InjectScriptResult = + | { + target: {tabId: number; frameId: number; documentId?: string}; + status: "fulfilled"; + result: T; + } + | { + target: {tabId: number; frameId: number; documentId?: string}; + status: "rejected"; + error: {name: string; message: string; stack?: string}; + } + | { + target: {tabId: number; frameId: number; documentId?: string}; + status: "unknown"; + }; +``` + +- `fulfilled` means the browser exposed a valid callback result. +- `rejected` means a frame-level callback or result-validation error was available. +- `unknown` means the browser reported the frame but exposed neither a result nor an error. + +`target` describes the actual execution context reported by the browser. Results are sorted by `frameId`, with the main frame (`frameId: 0`) first, and preserve `documentId` when available. + +A rejected frame does not discard successful results from other frames. + +```ts +for (const outcome of outcomes) { + switch (outcome.status) { + case "fulfilled": + useValue(outcome.target, outcome.result); + break; + + case "rejected": + reportFrameError(outcome.target, outcome.error); + break; + + case "unknown": + reportMissingOutcome(outcome.target); + break; + } +} +``` + +## Return application-level errors as data + +Package outcomes describe injection and frame execution. If your callback is acting like an RPC method and needs a guaranteed business-level result, return an explicit JSON-compatible envelope: + +```ts +type RemoteResult = + | {ok: true; valuePresent: true; value: T} + | {ok: true; valuePresent: false} + | {ok: false; error: {name: string; message: string; stack?: string}}; + +const outcomes = await injector.run( + (selector: string): RemoteResult => { + try { + const element = document.querySelector(selector); + + if (!element) { + return {ok: true, valuePresent: false}; + } + + return { + ok: true, + valuePresent: true, + value: element.textContent ?? "", + }; + } catch (error) { + return { + ok: false, + error: { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && error.stack ? {stack: error.stack} : {}), + }, + }; + } + }, + [".product-title"], +); +``` + +This produces two intentionally separate levels: + +```text +InjectScriptResult.status -> Was the frame execution observable and valid? +RemoteResult.ok -> Did the application operation succeed? +``` + +## Pass and return plain data + +Arguments and callback results must be JSON-compatible: + +- `null`, booleans, finite numbers, and strings +- dense plain arrays containing supported values +- plain objects with string keys and supported values + +```ts +await injector.run(() => ({ + id: 123, + title: document.title, + price: null, + tags: ["sale", "featured"], +})); +``` + +The following values are not supported: + +```ts +undefined; +Number.NaN; +Infinity; +123n; +new Date(); +new Map(); +document.body; +classInstance; +circularObject; +``` + +Arrays must not contain holes, custom enumerable properties, or use an `Array` subclass. Plain arrays and objects must not have enumerable symbol-keyed properties. Omit an optional property instead of assigning `undefined`, or use `null` when the absence is meaningful. + +TypeScript catches most incompatible values through `JsonCompatible`. Runtime validation covers the remaining cases and reports the exact path and reason before injection when possible: + +```text +Invalid InjectScript arguments: arguments[0].limit is undefined; JSON has no undefined value. Omit the key or use null. + +Injected function result is not JSON-compatible: result is a Date instance; pass a plain object. +``` + +MV2 validates the result inside the injected payload. MV3 validates the native result returned by the browser. Chrome may serialize or convert a value before returning it, so the package cannot reconstruct information already lost at the native boundary. + +## Inject script files + +```ts await injector.file("scripts/content.js"); -await injector.file(["scripts/lib.js", "scripts/util.js"]); + +await injector.file([ + "scripts/vendor.js", + "scripts/content.js", +]); ``` -## Features +Files are injected in the provided order. `file()` uses the same target and execution options as `run()`, rejects an empty list, and returns `Promise` because browser APIs do not provide a portable per-frame result contract for files. -- Unified API for Manifest V2 and V3 (version detection via `@addon-core/browser`). -- Inject functions (`run`) and files (`file`). -- Precise targeting: top frame, specific `frameId[]`, all frames, or (MV3) `documentId[]`. -- `world` support (MV3): `MAIN`/`ISOLATED`; instant injection when `runAt: 'document_start'`. -- Strongly-typed results: returns an array of `InjectionResult>` (one per frame). -- Update options on the fly with `options()`. +## Reuse an injector -## API +Replace the complete target with `target()`: -### `injectScript(options: InjectScriptOptions): InjectScriptContract` +```ts +injector + .target({tabId: 123, frameIds: [7]}) + .target({tabId: 123, allFrames: true}); +``` -Creates and returns a new script injector. The implementation is chosen internally based on your manifest (MV2/MV3). +The second call replaces the previous selector instead of merging with it. A validation failure leaves the existing target unchanged. -#### Contract +Update only execution options with `options()`: ```ts -interface InjectScriptContract { - run( - func: (...args: A) => R, - args?: A - ): Promise>[]>; +injector.options({ + timeoutMs: 8_000, + world: "ISOLATED", +}); +``` - file(files: string | string[]): Promise; +`options()` never accepts or changes a target. - options(options: Partial): this; -} +## Execution options + +The portable baseline is simple: + +```ts +const injector = injectScript({ + target: {tabId: 123}, + timeoutMs: 5_000, + runAt: "document_idle", + world: "ISOLATED", +}); ``` -#### Options +| Option | MV2 | MV3 | +| --- | --- | --- | +| `timeoutMs` | Supported; default `4_000` ms | Supported; default `4_000` ms | +| `matchAboutBlank` | Supported; native default `false` | Rejected; no equivalent native option | +| `runAt: "document_start"` | Passed to `tabs.executeScript` | Mapped to `injectImmediately: true` | +| `runAt: "document_idle"` or omitted | Native scheduling | Native scheduling | +| `runAt: "document_end"` | Passed to `tabs.executeScript` | Rejected; cannot be represented | +| `world: "ISOLATED"` | Accepted as native MV2 behavior | Passed to `scripting.executeScript` | +| `world: "MAIN"` | Rejected | Passed to `scripting.executeScript` | + +Explicit unsupported options throw `UnsupportedInjectScriptOptionError`. They are never ignored or removed silently. + +When an application intentionally needs adapter-specific behavior, branch before creating the injector: ```ts -interface InjectScriptOptions { - tabId: number; - frameId?: boolean | number | number[]; - matchAboutBlank?: boolean; // defaults to true (MV2/MV3) +import {isManifestVersion3} from "@addon-core/browser"; + +const injector = isManifestVersion3() + ? injectScript({ + target: {tabId: 123}, + world: "MAIN", + runAt: "document_start", + }) + : injectScript({ + target: {tabId: 123}, + matchAboutBlank: true, + runAt: "document_end", + }); +``` + +## Handle operation failures - // MV2 - runAt?: chrome.extensionTypes.RunAt; // 'document_start' | 'document_end' | 'document_idle' - timeFallback?: number; // timeout in ms, default 4000 +Frame-level failures belong in the resolved outcome array. Preparation, delivery, capability, and overall timeout failures reject the operation. - // MV3 - world?: chrome.scripting.ExecutionWorld | `${chrome.scripting.ExecutionWorld}`; // 'MAIN' | 'ISOLATED' - documentId?: string | string[]; // Firefox does not support documentIds in target +```ts +import { + InjectScriptBaseError, + InjectScriptTimeoutError, +} from "@addon-core/inject-script"; + +try { + const outcomes = await injector.run(() => document.title); + consume(outcomes); +} catch (error) { + if (error instanceof InjectScriptTimeoutError) { + console.error("Injection timed out", { + target: error.target, + timeoutMs: error.timeoutMs, + partialResults: error.partialResults, + missingCount: error.missingCount, + }); + } else if (error instanceof InjectScriptBaseError) { + console.error(error.code, error.message, error.cause); + } else { + throw error; + } } ``` -- `frameId`: `true` β€” all frames; a number or array β€” specific `frameId`s; `false` or undefined β€” top frame only. -- `matchAboutBlank`: if omitted, the library enables it by default (`true`). -- `runAt`: Chrome default is `document_idle` (when not specified). -- `timeFallback` (MV2): if results do not arrive in time, the promise will be rejected with an error. -- `world` (MV3): sets the execution world. When `runAt: 'document_start'`, `injectImmediately` is enabled. -- `documentId` (MV3): target specific documents. Firefox does not support `documentIds`; the library gracefully avoids using them there. +Every package error extends `InjectScriptBaseError` and exposes a stable `code`. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist; `instanceof` is convenient within one package instance. + +### Cross-browser outcome details + +- Firefox can expose a literal `throw undefined` as an existing `error` property whose value is `undefined`. The package preserves it as `rejected`. +- A defined `result` takes precedence over an `error: undefined` placeholder. +- MV2 can identify an unsupported callback result of `undefined` and returns a frame-level `TypeError`. +- If MV3 exposes neither `result` nor `error`, the outcome is `unknown`; the package does not guess whether the callback returned nothing or the browser omitted an exception. +- For MV2 top-frame and explicit `frameIds` calls, a known frame that does not answer before `timeoutMs` becomes `unknown`. +- For MV2 `allFrames`, a missing response cannot be assigned to a frame without another permission-dependent API. The operation rejects with `InjectScriptTimeoutError` and preserves `partialResults` and `missingCount`. + +Return `null` or an explicit application envelope when the caller must distinguish a successful no-value result from an unavailable native outcome. -## Examples +## API reference -Inject into specific frames: +The reference stays compact on purpose: most applications need one factory and four methods. + +### Factory ```ts -const injector = injectScript({ tabId: 123, frameId: [0, 2] }); -const results = await injector.run(() => window.location.href); -console.log(results.map(r => ({ frameId: r.frameId, url: r.result }))); +injectScript(options: InjectScriptOptions): InjectScriptContract; ``` -All frames: +The factory is available as both a default and named export: ```ts -await injectScript({ tabId: 123, frameId: true }).file(["a.js", "b.js"]); +import injectScript from "@addon-core/inject-script"; +import {injectScript} from "@addon-core/inject-script"; ``` -MV3: target by documentId and choose execution world: +### Methods + +Simplified signatures are shown below. The published TypeScript declarations additionally enforce JSON-compatible callback arguments and results. ```ts -await injectScript({ - tabId: 123, - documentId: ["doc-1", "doc-2"], - world: "MAIN", -}).run(() => ({ ready: document.readyState })); +interface InjectScriptContract { + run( + func: (...args: Args) => Result, + args?: Args, + ): Promise>[]>; + + file(files: string | NonEmptyReadonlyArray): Promise; + target(target: InjectScriptTarget): this; + options(options: Partial): this; +} +``` + +### Options + +```ts +interface InjectScriptOptions { + target: InjectScriptTarget; + matchAboutBlank?: boolean; + runAt?: "document_start" | "document_end" | "document_idle"; + timeoutMs?: number; + world?: "ISOLATED" | "MAIN"; +} ``` -Update options on the fly: +### Runtime exports ```ts -const inj = injectScript({ tabId: 123, frameId: false }); -await inj.options({ frameId: true }).file("content.js"); +injectScript +InjectScriptBaseError +InjectScriptDeliveryError +InjectScriptTimeoutError +InvalidInjectScriptArgumentsError +InvalidInjectScriptFilesError +InvalidInjectScriptOptionsError +InvalidInjectScriptTargetError +UnsupportedInjectScriptOptionError +UnsupportedInjectScriptTargetError ``` -## MV2/MV3 Compatibility +### Type exports -- MV2: the library serializes your function and arguments, executes the code in target frames, and returns results via `chrome.runtime.sendMessage`. - - If your function throws, the result for that frame will be `undefined`, and the error will be logged in the page DevTools console. - - Timeout is controlled by the `timeFallback` option (default 4000 ms). - - Result order: the top frame (`frameId = 0`) is the first element in the array. -- MV3: uses `chrome.scripting.executeScript` with a properly constructed `target` (`tabId`, `frameIds`/`allFrames`, or `documentIds` when available) and `world`/`injectImmediately` options. - - Firefox does not support `documentIds` β€” the library will automatically avoid using them. +Core types: + +```ts +InjectScriptContract +InjectScriptOptions +InjectScriptExecutionOptions +InjectScriptTarget +InjectScriptResult +InjectScriptResultTarget +SerializedInjectScriptError +InjectScriptErrorCode +InjectScriptTimeoutDetails +``` + +Advanced target and JSON types: + +```ts +InjectScriptTopFrameTarget +InjectScriptAllFramesTarget +InjectScriptFramesTarget +InjectScriptDocumentsTarget +InjectScriptFunctionResult +JsonCompatible +JsonPrimitive +JsonValue +NonEmptyReadonlyArray +``` -## Recipes +## Design boundaries -- Inject as early as possible: - - Set `runAt: 'document_start'` (MV2); in MV3 this enables `injectImmediately: true`. -- Inject into an isolated world (MV3): - - `world: 'ISOLATED'` β€” your code won’t conflict with the page script. -- Performance: - - Group files in a single `file([..])` call when possible to reduce overhead. -- Safety: - - Functions passed to `run` should be self-contained: rely only on what’s available in the page context. In MV2 they are string-serialized. +The package deliberately stays focused on portable script injection. It does not enumerate frames, create address snapshots, run per-frame concurrency queues, or provide application-specific all-settled aggregation. Those behaviors belong in the caller that understands the application protocol. -## Troubleshooting +The runtime never uses `eval` or `new Function`. MV3 passes the callback directly to `scripting.executeScript`; MV2 embeds the callback source in the code accepted by `tabs.executeScript`. -- Timeout error (MV2): increase `timeFallback`. -- `undefined` result (MV2): check the page console β€” the function may have thrown. -- Nothing happens: - - Ensure your extension has permissions for the target tab/frame(s). - - Verify `tabId`, `frameId`/`documentId`, and the `runAt` timing. -- Conflicts with page code (MV3): use `world: 'ISOLATED'`. +## License +[MIT](LICENSE.md) diff --git a/SECURITY.md b/SECURITY.md index b243bb0..b004c81 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Please report security issues privately and avoid opening public issues with exp When reporting, please include (if possible): -- Affected version(s) and package name (adnbn) and how you installed it +- Affected version(s), package name (`@addon-core/inject-script`), and how you installed it - Environment details (OS, Node.js version, browser/runtime, relevant configs) - Steps to reproduce and a minimal proof of concept (PoC) - Impact assessment (what an attacker can do and likely severity) diff --git a/biome.json b/biome.json index 22ecd90..65081a1 100644 --- a/biome.json +++ b/biome.json @@ -25,6 +25,7 @@ "includes": [ "src/**/*.{ts,tsx,js,jsx}", "!src/**/*.test.{ts,tsx,js,jsx}", + "tests/**/*.{ts,tsx,js,jsx}", "**/*.{json,jsonc,md,mdx,cjs,mjs}", "!coverage/**", "!dist/**" diff --git a/package-lock.json b/package-lock.json index 639fcfe..c8d1ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,14 @@ "version": "0.3.1", "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.2.1", - "nanoid": "^5.1.5" + "@addon-core/browser": "^0.7.2" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", "@release-it/conventional-changelog": "^10.0.1", - "@types/chrome": "^0.1.12", + "@types/chrome": "^0.2.7", "@types/jest": "^30.0.0", "husky": "^9.1.7", "jest": "^30.1.3", @@ -28,12 +27,12 @@ } }, "node_modules/@addon-core/browser": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.2.1.tgz", - "integrity": "sha512-1T4K6nHBc6zRzNx+zQrECJ8PFM4RAI8hcbZwAK/N7dAyNNXP2RaDsJ+5JK8d4WcL8yQu2tJZRjgZaVHZHJmzQQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.2.tgz", + "integrity": "sha512-noDIPQktJOl7HVFUBZONL1iAVK4Spq1K+JgtN3OTsNPSvkgogrWUvpjq00p99R02y+YWORR70gu3tR+NDh09vw==", "license": "MIT", - "peerDependencies": { - "@types/chrome": "*" + "dependencies": { + "@types/chrome": "^0.2.2" } }, "node_modules/@babel/code-frame": { @@ -3252,9 +3251,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.22", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.22.tgz", - "integrity": "sha512-5uXbw/3V+Pdu9BaoTvudvutITxeIiC0CK5WhQr8lzEhAQ70lTpe5ebnoai9iQrqWjhIa3qynYhKV0NN0MKv4qA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.7.tgz", + "integrity": "sha512-9kjBozQ+jyDVt1eai3VZqjHDTN95JCuRmbRHOryOltBJmzrTmUw/9r/DznmjRaQ8WlyIfeCA4WNzoj0IvormJA==", "license": "MIT", "dependencies": { "@types/filesystem": "*", @@ -7922,24 +7921,6 @@ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" } }, - "node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", diff --git a/package.json b/package.json index bf34957..a5ac70e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@addon-core/inject-script", "version": "0.3.1", - "description": "A lightweight, TypeScript-ready library for injecting JavaScript functions or external scripts into Chrome extension tabs and frames (Manifest V2 & V3).", + "description": "A lightweight, TypeScript-ready library for injecting JavaScript functions or external scripts into browser extension tabs and frames (Manifest V2 & V3).", "keywords": [ "browser", "addon", @@ -17,17 +17,18 @@ ], "repository": { "type": "git", - "url": "https://github.com/addon-stack/inject-script" + "url": "git+https://github.com/addon-stack/inject-script.git" + }, + "publishConfig": { + "access": "public", + "provenance": true }, "homepage": "https://github.com/addon-stack/inject-script", "bugs": { "url": "https://github.com/addon-stack/inject-script/issues" }, "license": "MIT", - "author": "Addon Stack ", - "contributors": [ - "Anjey Tsibylskij (https://github.com/atldays)" - ], + "author": "Anjey Tsibylskij (https://github.com/atldays)", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", @@ -53,31 +54,35 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "lint:fix:unsafe": "biome check --write --unsafe .", - "test": "jest", - "test:ci": "jest --ci --passWithNoTests --coverage", - "test:related": "jest --bail --passWithNoTests --findRelatedTests", + "test": "npm run test:types && npm run build && jest --bail --passWithNoTests", + "test:ci": "npm run test:types && npm run build && jest --ci --passWithNoTests --coverage", + "test:types": "tsc -p tests/tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit", "release": "release-it" }, "lint-staged": { "src/**/*.{js,jsx,ts,tsx,cjs,mjs}": [ - "biome check --write --unsafe", - "npm run test:related --" + "biome check --write --unsafe" ], "*.{json,css,scss,html}": [ "biome format --write" ] }, + "jest": { + "coverageProvider": "v8", + "testMatch": [ + "/tests/**/*.test.cjs" + ] + }, "dependencies": { - "@addon-core/browser": "^0.2.1", - "nanoid": "^5.1.5" + "@addon-core/browser": "^0.7.2" }, "devDependencies": { "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", "@release-it/conventional-changelog": "^10.0.1", - "@types/chrome": "^0.1.12", + "@types/chrome": "^0.2.7", "@types/jest": "^30.0.0", "husky": "^9.1.7", "jest": "^30.1.3", diff --git a/src/AbstractInjectScript.ts b/src/AbstractInjectScript.ts index 5fb5532..44bf8bc 100644 --- a/src/AbstractInjectScript.ts +++ b/src/AbstractInjectScript.ts @@ -1,36 +1,109 @@ -import type {InjectScriptContract, InjectScriptOptions} from "./types"; +import {InjectScriptDeliveryError, InjectScriptTimeoutError} from "./errors"; +import { + validateInjectScriptArguments, + validateInjectScriptExecutionOptions, + validateInjectScriptFiles, + validateInjectScriptOptions, + validateInjectScriptTarget, +} from "./validation"; +import type { + InjectScriptContract, + InjectScriptExecutionOptions, + InjectScriptOptions, + InjectScriptResult, + InjectScriptTarget, + NonEmptyReadonlyArray, +} from "./types"; -type Awaited = chrome.scripting.Awaited; -type InjectionResult = chrome.scripting.InjectionResult; +const DEFAULT_TIMEOUT_MS = 4_000; export default abstract class implements InjectScriptContract { - public constructor(protected _options: InjectScriptOptions) {} + protected _target: InjectScriptTarget; + protected _execution: InjectScriptExecutionOptions; - public options(options: Partial): this { - this._options = {...this._options, ...options, tabId: options.tabId ?? this._options.tabId}; + public constructor(options: InjectScriptOptions) { + const normalized = validateInjectScriptOptions(options); + + this._target = normalized.target; + this._execution = normalized.execution; + } + + public target(target: InjectScriptTarget): this { + const normalizedTarget = validateInjectScriptTarget(target); + + this.assertAdapterSupport(normalizedTarget, this._execution); + this._target = normalizedTarget; + + return this; + } + + public options(options: Partial): this { + const normalizedOptions = validateInjectScriptExecutionOptions(options); + const nextExecution = {...this._execution, ...normalizedOptions}; + + this.assertAdapterSupport(this._target, nextExecution); + this._execution = nextExecution; return this; } - public abstract run(func: (...args: A) => R, args?: A): Promise>[]>; + public abstract run( + func: (...args: A) => R, + args?: A + ): Promise>[]>; + + public abstract file(files: string | NonEmptyReadonlyArray): Promise; + + protected abstract assertAdapterSupport(target: InjectScriptTarget, execution: InjectScriptExecutionOptions): void; + + protected validateArguments(args: readonly unknown[] | undefined): void { + validateInjectScriptArguments(args); + } + + protected normalizeFiles(files: string | NonEmptyReadonlyArray): string[] { + return validateInjectScriptFiles(files); + } - public abstract file(files: string | string[]): Promise; + protected snapshotTarget(): InjectScriptTarget { + return validateInjectScriptTarget(this._target); + } - protected get frameIds(): number[] | undefined { - const {frameId} = this._options; + protected snapshotExecution(): InjectScriptExecutionOptions { + return {...this._execution}; + } - return typeof frameId === "number" ? [frameId] : typeof frameId !== "boolean" ? frameId : undefined; + protected get timeoutMs(): number { + return this._execution.timeoutMs ?? DEFAULT_TIMEOUT_MS; } - protected get allFrames(): boolean | undefined { - const {frameId} = this._options; + protected async withTimeout(task: Promise, target: InjectScriptTarget, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (callback: () => void): void => { + if (settled) return; + + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const timeoutId = setTimeout(() => { + finish(() => reject(new InjectScriptTimeoutError(target, timeoutMs))); + }, timeoutMs); - return typeof frameId === "boolean" ? frameId : undefined; + task.then( + value => finish(() => resolve(value)), + error => finish(() => reject(error)) + ); + }); } - protected get matchAboutBlank(): boolean { - const {matchAboutBlank} = this._options; + protected deliveryError(target: InjectScriptTarget, error: unknown): Error { + if (error instanceof InjectScriptDeliveryError || error instanceof InjectScriptTimeoutError) { + return error; + } - return typeof matchAboutBlank === "boolean" ? matchAboutBlank : true; + return new InjectScriptDeliveryError(target, error); } } diff --git a/src/InjectScriptV2.ts b/src/InjectScriptV2.ts index 5f13abd..36f6602 100644 --- a/src/InjectScriptV2.ts +++ b/src/InjectScriptV2.ts @@ -1,166 +1,370 @@ -import {executeScriptTab, getAllFrames, onMessage} from "@addon-core/browser"; -import {nanoid} from "nanoid/non-secure"; +import {executeScriptTab, onMessage} from "@addon-core/browser"; import AbstractInjectScript from "./AbstractInjectScript"; +import { + InjectScriptDeliveryError, + InjectScriptTimeoutError, + UnsupportedInjectScriptOptionError, + UnsupportedInjectScriptTargetError, +} from "./errors"; +import {createRequestId} from "./requestId"; +import {createResultTarget, normalizeInjectionError, sortInjectionResults} from "./results"; +import {findJsonCompatibilityIssue} from "./validation"; +import type { + InjectScriptExecutionOptions, + InjectScriptOptions, + InjectScriptResult, + InjectScriptTarget, + NonEmptyReadonlyArray, + SerializedInjectScriptError, +} from "./types"; -type Awaited = chrome.scripting.Awaited; type MessageSender = chrome.runtime.MessageSender; type InjectDetails = chrome.extensionTypes.InjectDetails; -type InjectionResult = chrome.scripting.InjectionResult; + +type InjectedOutcome = {status: "fulfilled"; result: T} | {status: "rejected"; error: SerializedInjectScriptError}; export default class extends AbstractInjectScript { - public async run(func: (...args: A) => R, args?: A): Promise>[]> { - return new Promise>[]>((resolve, reject) => { - const {tabId, runAt} = this._options; + public constructor(options: InjectScriptOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); + } - const type = `inject-script-${nanoid()}`; - const injectResults: InjectionResult>[] = []; + public async run( + func: (...args: A) => R, + args?: A + ): Promise>[]> { + this.validateArguments(args); - let frameCount: number = 0; + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; - const listener = (message: any, sender: MessageSender) => { - if (message?.type !== type) return; + return new Promise>[]>((resolve, reject) => { + const messageType = createRequestId(); + const results = new Map>>(); + const knownFrameIds = this.getKnownFrameIds(target); - const {result, error} = (message as any)?.data ?? {}; - const {frameId, documentId = ""} = sender; + let expectedCount: number | undefined; + let deliveryCompleted = false; + let settled = false; - frameCount -= 1; + const finish = (callback: () => void): void => { + if (settled) return; - if (frameId == null) { - throw new Error("frameId or documentId is missing in sender"); - } + settled = true; + unsubscribe(); + clearTimeout(timeoutId); + callback(); + }; - if (error) { - console.error(`Error in injection listener with frameId = ${frameId}`, error); + const maybeResolve = (): void => { + if (!deliveryCompleted) return; + + if (knownFrameIds) { + if (knownFrameIds.some(frameId => !results.has(frameId))) return; + } else if (expectedCount === undefined || results.size < expectedCount) { + return; } - frameId === 0 - ? injectResults.unshift({frameId, documentId, result}) - : injectResults.push({frameId, documentId, result}); + finish(() => resolve(sortInjectionResults([...results.values()]))); + }; + + const listener = (message: unknown, sender: MessageSender): void => { + if (!this.isInjectedResponse(message, messageType)) return; + if (sender.tab?.id !== target.tabId) return; + + const {frameId, documentId} = sender; - if (frameCount === 0) { - unsubscribe(); - clearTimeout(timeoutId); - resolve(injectResults); + if (frameId === undefined) { + finish(() => + reject( + new InjectScriptDeliveryError( + target, + new Error("The injected response did not include a frame ID.") + ) + ) + ); + return; } + + if (!this.isExpectedFrame(target, frameId)) return; + if (results.has(frameId)) return; + + const resultTarget = createResultTarget(target.tabId, frameId, documentId); + const outcome = message.data; + + results.set( + frameId, + outcome.status === "fulfilled" + ? { + target: resultTarget, + status: "fulfilled", + result: outcome.result as Awaited, + } + : { + target: resultTarget, + status: "rejected", + error: normalizeInjectionError(outcome.error), + } + ); + + maybeResolve(); }; const unsubscribe = onMessage(listener); const timeoutId = setTimeout(() => { - unsubscribe(); - clearTimeout(timeoutId); - reject(new Error("Script execution timed out.")); - }, this._options.timeFallback || 4000); + const partialResults = sortInjectionResults([...results.values()]); + + if (deliveryCompleted && knownFrameIds) { + for (const frameId of knownFrameIds) { + if (results.has(frameId)) continue; + + results.set(frameId, { + target: createResultTarget(target.tabId, frameId), + status: "unknown", + }); + } + + finish(() => resolve(sortInjectionResults([...results.values()]))); + return; + } + + const missingCount = + expectedCount === undefined ? undefined : Math.max(0, expectedCount - results.size); + + finish(() => reject(new InjectScriptTimeoutError(target, timeoutMs, {partialResults, missingCount}))); + }, timeoutMs); const details: InjectDetails = { - runAt, - code: this.getCode(type, func, args), - matchAboutBlank: this.matchAboutBlank, + code: this.getCode(messageType, func, args), + ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), }; - void (async () => { - try { - if (this.allFrames) { - frameCount = ((await getAllFrames(tabId)) || []).length; + void this.executeRun(target, details) + .then(count => { + deliveryCompleted = true; + expectedCount = count; + maybeResolve(); + }) + .catch(error => { + finish(() => reject(this.deliveryError(target, error))); + }); + }); + } - await executeScriptTab(tabId, {...details, allFrames: true}); - } else if (this.frameIds) { - frameCount = this.frameIds.length; + public async file(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + let stopped = false; - await Promise.all(this.frameIds.map(frameId => executeScriptTab(tabId, {...details, frameId}))); - } else { - frameCount = 1; + const task = (async (): Promise => { + for (const file of fileList) { + if (stopped) return; - await executeScriptTab(tabId, details); - } - } catch (e) { - unsubscribe(); - clearTimeout(timeoutId); - reject(e as Error); - } - })(); - }); + const details: InjectDetails = { + file, + ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), + }; + + await this.executeFile(target, details); + } + })(); + + try { + await this.withTimeout(task, target, timeoutMs); + } catch (error) { + stopped = true; + throw this.deliveryError(target, error); + } } - public async file(files: string | string[]): Promise { - const {tabId, runAt} = this._options; + protected assertAdapterSupport(target: InjectScriptTarget, execution: InjectScriptExecutionOptions): void { + if ("documentIds" in target && target.documentIds !== undefined) { + throw new UnsupportedInjectScriptTargetError('"documentIds" are not supported by the MV2 adapter.'); + } - const fileList = typeof files === "string" ? [files] : files; + if (execution.world !== undefined && execution.world !== "ISOLATED") { + throw new UnsupportedInjectScriptOptionError('"world: MAIN" is not supported by the MV2 adapter.'); + } + } - const injectTasks: Promise[] = []; + private async executeRun(target: InjectScriptTarget, details: InjectDetails): Promise { + if ("allFrames" in target && target.allFrames === true) { + const nativeResults = await executeScriptTab(target.tabId, {...details, allFrames: true}); - for (const file of fileList) { - const details: InjectDetails = {file, runAt, matchAboutBlank: this.matchAboutBlank}; + return this.getNativeResultCount(nativeResults); + } - if (this.allFrames) { - injectTasks.push(executeScriptTab(tabId, {...details, allFrames: true})); - } else if (this.frameIds) { - injectTasks.push(...this.frameIds.map(frameId => executeScriptTab(tabId, {...details, frameId}))); - } else { - injectTasks.push(executeScriptTab(tabId, details)); - } + if ("frameIds" in target && target.frameIds !== undefined) { + const nativeResults = await Promise.all( + target.frameIds.map(frameId => executeScriptTab(target.tabId, {...details, frameId})) + ); + + return nativeResults.reduce((count, result) => count + this.getNativeResultCount(result), 0); } - await Promise.all(injectTasks); + const nativeResults = await executeScriptTab(target.tabId, details); + + return this.getNativeResultCount(nativeResults); } - protected getCode(type: string, func: (...args: any[]) => any, args?: any[]): string { - const codeSource = this.generateCode().toString(); - const funcSource = func.toString(); - const serializedType = JSON.stringify(type); - const serializedArgs = JSON.stringify(args ?? []); + private async executeFile(target: InjectScriptTarget, details: InjectDetails): Promise { + if ("allFrames" in target && target.allFrames === true) { + await executeScriptTab(target.tabId, {...details, allFrames: true}); + return; + } + + if ("frameIds" in target && target.frameIds !== undefined) { + await Promise.all(target.frameIds.map(frameId => executeScriptTab(target.tabId, {...details, frameId}))); + return; + } - return `(${codeSource})(${serializedType}, ${funcSource}, ${serializedArgs})`; + await executeScriptTab(target.tabId, details); } - protected generateCode(): (type: string, func: (...args: any[]) => any, args: any[]) => void { - return (type: string, func: (...args: any[]) => any, args: any[]): void => { - const getBrowser = (): typeof chrome | undefined => { - const api = globalThis?.browser?.runtime?.id ? globalThis.browser : globalThis.chrome; + private getNativeResultCount(results: unknown[] | undefined): number { + if (!Array.isArray(results)) { + throw new Error("The browser did not report how many frames received the injected script."); + } - return api?.runtime ? api : undefined; - }; + return results.length; + } - const sendMessage = (message: any): void => { - const browser = getBrowser(); + private isInjectedResponse( + message: unknown, + messageType: string + ): message is {type: string; data: InjectedOutcome} { + if (typeof message !== "object" || message === null) return false; - if (!browser) { - return; - } + const candidate = message as {type?: unknown; data?: unknown}; - try { - browser.runtime.sendMessage(message, () => { - const error = browser.runtime.lastError; + if (candidate.type !== messageType || typeof candidate.data !== "object" || candidate.data === null) { + return false; + } + + const outcome = candidate.data as {status?: unknown}; + + return outcome.status === "fulfilled" || outcome.status === "rejected"; + } + + private getKnownFrameIds(target: InjectScriptTarget): readonly number[] | undefined { + if ("allFrames" in target && target.allFrames === true) return undefined; + if ("frameIds" in target && target.frameIds !== undefined) return target.frameIds; + + return [0]; + } + + private isExpectedFrame(target: InjectScriptTarget, frameId: number): boolean { + const knownFrameIds = this.getKnownFrameIds(target); + + return knownFrameIds === undefined || knownFrameIds.includes(frameId); + } + + private getCode(messageType: string, func: (...args: A) => R, args?: A): string { + const codeSource = this.generateCode().toString(); + const funcSource = func.toString(); + const validatorSource = findJsonCompatibilityIssue.toString(); + const serializedType = JSON.stringify(messageType); + const serializedArgs = JSON.stringify(args ?? []); + + return `(${codeSource})(${serializedType}, ${funcSource}, ${serializedArgs}, ${validatorSource})`; + } + + private generateCode(): ( + type: string, + func: (...args: unknown[]) => unknown, + args: unknown[], + findCompatibilityIssue: typeof findJsonCompatibilityIssue + ) => void { + return ( + type: string, + func: (...args: unknown[]) => unknown, + args: unknown[], + findCompatibilityIssue: typeof findJsonCompatibilityIssue + ): void => { + const sendMessage = (message: unknown): void => { + const browserApi = (globalThis as unknown as {browser?: typeof chrome}).browser; + const chromeApi = (globalThis as unknown as {chrome?: typeof chrome}).chrome; + const promiseApi = browserApi?.runtime?.id ? browserApi : undefined; + const callbackApi = chromeApi?.runtime?.id ? chromeApi : undefined; + const api = promiseApi ?? callbackApi; + + if (!api) return; - if (error) { - console.error( - `Failed to send a message from the injected script: ${error?.message ?? "unknown error"}` - ); + try { + if (promiseApi) { + const dispatch = promiseApi.runtime.sendMessage(message) as unknown; + + if ( + typeof dispatch === "object" && + dispatch !== null && + "then" in dispatch && + typeof dispatch.then === "function" + ) { + Promise.resolve(dispatch).catch(error => { + console.error( + `Failed to send a message from the injected script: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); } - }); - } catch (e) { + + return; + } + + callbackApi?.runtime.sendMessage(message); + } catch (error) { console.error( - `Unexpected exception during message dispatch from injected context: ${e instanceof Error ? e.message : String(e)}` + `Unexpected exception during message dispatch from injected context: ${ + error instanceof Error ? error.message : String(error) + }` ); } }; - const data: Record = {}; + const serializeError = (value: unknown): SerializedInjectScriptError => { + if (value instanceof Error) { + return { + name: value.name || "Error", + message: value.message, + ...(value.stack ? {stack: value.stack} : {}), + }; + } + + if (typeof value === "object" && value !== null) { + const candidate = value as {name?: unknown; message?: unknown; stack?: unknown}; + + return { + name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Error", + message: typeof candidate.message === "string" ? candidate.message : String(value), + ...(typeof candidate.stack === "string" && candidate.stack ? {stack: candidate.stack} : {}), + }; + } + + return {name: "Error", message: String(value)}; + }; Promise.resolve() .then(() => func(...args)) .then(result => { - data.result = result; - }) - .catch(e => { - data.error = { - message: e?.message, - name: e?.name, - stack: e?.stack, - }; + const issue = findCompatibilityIssue(result, "result"); + + if (issue) { + throw new TypeError( + `Injected function result is not JSON-compatible: ${issue.path} ${issue.reason}` + ); + } + + sendMessage({type, data: {status: "fulfilled", result}}); }) - .finally(() => { - sendMessage({type, data}); + .catch(error => { + sendMessage({type, data: {status: "rejected", error: serializeError(error)}}); }); }; } diff --git a/src/InjectScriptV3.ts b/src/InjectScriptV3.ts index 8621290..af1acc6 100644 --- a/src/InjectScriptV3.ts +++ b/src/InjectScriptV3.ts @@ -1,67 +1,159 @@ -import {browser, executeScript} from "@addon-core/browser"; +import {executeScript} from "@addon-core/browser"; import AbstractInjectScript from "./AbstractInjectScript"; +import {UnsupportedInjectScriptOptionError, UnsupportedInjectScriptTargetError} from "./errors"; +import {normalizeNativeInjectionResult, sortInjectionResults} from "./results"; +import type { + InjectScriptExecutionOptions, + InjectScriptOptions, + InjectScriptResult, + InjectScriptTarget, + JsonValue, + NonEmptyReadonlyArray, +} from "./types"; -type Awaited = chrome.scripting.Awaited; type InjectionTarget = chrome.scripting.InjectionTarget; -type InjectionResult = chrome.scripting.InjectionResult; export default class extends AbstractInjectScript { - public async run(func: (...args: A) => R, args?: A): Promise>[]> { - return executeScript({ - target: this.target(), - world: this._options.world, - injectImmediately: this.injectImmediately, - func, - args, - }); + public constructor(options: InjectScriptOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); } - public async file(fileList: string | string[]): Promise { - await executeScript({ - target: this.target(), - world: this._options.world, - injectImmediately: this.injectImmediately, - files: typeof fileList === "string" ? [fileList] : fileList, - }); + public async run( + func: (...args: A) => R, + args?: A + ): Promise>[]> { + this.validateArguments(args); + + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + try { + const nativeResults = await this.withTimeout( + executeScript({ + target: this.toNativeTarget(target), + func: func as unknown as (...args: JsonValue[]) => R, + ...(execution.world !== undefined ? {world: execution.world} : {}), + ...(execution.runAt === "document_start" ? {injectImmediately: true} : {}), + ...(args ? {args: [...args] as JsonValue[]} : {}), + }), + target, + timeoutMs + ); + + return sortInjectionResults( + nativeResults.map(result => normalizeNativeInjectionResult>(target.tabId, result)) + ); + } catch (error) { + if (this.isUnsupportedDocumentTargetError(target, error)) { + throw new UnsupportedInjectScriptTargetError( + '"documentIds" are not supported by the current browser.', + error + ); + } + + this.throwUnsupportedExecutionCapability(execution, error); + + throw this.deliveryError(target, error); + } } - protected target(): InjectionTarget { - const target = {tabId: this._options.tabId}; + public async file(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + try { + await this.withTimeout( + executeScript({ + target: this.toNativeTarget(target), + files: fileList, + ...(execution.world !== undefined ? {world: execution.world} : {}), + ...(execution.runAt === "document_start" ? {injectImmediately: true} : {}), + }).then(() => undefined), + target, + timeoutMs + ); + } catch (error) { + if (this.isUnsupportedDocumentTargetError(target, error)) { + throw new UnsupportedInjectScriptTargetError( + '"documentIds" are not supported by the current browser.', + error + ); + } - if (this.frameIds && this.frameIds.length > 0) { - return {...target, frameIds: this.frameIds}; + this.throwUnsupportedExecutionCapability(execution, error); + + throw this.deliveryError(target, error); } + } - if (this.allFrames === true) { - return {...target, allFrames: true}; + protected assertAdapterSupport(_target: InjectScriptTarget, execution: InjectScriptExecutionOptions): void { + if (execution.matchAboutBlank !== undefined) { + throw new UnsupportedInjectScriptOptionError('"matchAboutBlank" is not supported by the MV3 adapter.'); } - // Firefox does not support `documentIds` in the target - // getBrowserInfo is only available in firefox - let isFirefox = false; - try { - // @ts-expect-error - isFirefox = !!browser().runtime.getBrowserInfo; - } catch (_e) {} + if (execution.runAt === "document_end") { + throw new UnsupportedInjectScriptOptionError( + '"runAt: document_end" cannot be represented by the MV3 scripting API.' + ); + } + } - if (!isFirefox) { - const documentIds = this.documentIds; + private toNativeTarget(target: InjectScriptTarget): InjectionTarget { + if ("frameIds" in target && target.frameIds !== undefined) { + return {tabId: target.tabId, frameIds: [...target.frameIds]}; + } - if (documentIds && documentIds.length > 0) { - return {...target, documentIds}; - } + if ("documentIds" in target && target.documentIds !== undefined) { + return {tabId: target.tabId, documentIds: [...target.documentIds]}; + } + + if ("allFrames" in target && target.allFrames === true) { + return {tabId: target.tabId, allFrames: true}; + } + + return {tabId: target.tabId}; + } + + private isUnsupportedDocumentTargetError(target: InjectScriptTarget, error: unknown): boolean { + if (!("documentIds" in target) || target.documentIds === undefined) { + return false; } - return target; + const message = error instanceof Error ? error.message : String(error); + + return /documentIds?/i.test(message) && this.isUnsupportedCapabilityMessage(message); } - protected get documentIds(): string[] | undefined { - const {documentId} = this._options; + private throwUnsupportedExecutionCapability(execution: InjectScriptExecutionOptions, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); - return typeof documentId === "string" ? [documentId] : documentId; + if ( + execution.world !== undefined && + /\bworld\b/i.test(message) && + this.isUnsupportedCapabilityMessage(message) + ) { + throw new UnsupportedInjectScriptOptionError('"world" is not supported by the current browser.', error); + } + + if ( + execution.runAt === "document_start" && + /injectImmediately/i.test(message) && + this.isUnsupportedCapabilityMessage(message) + ) { + throw new UnsupportedInjectScriptOptionError( + '"runAt: document_start" is not supported by the current browser.', + error + ); + } } - protected get injectImmediately(): boolean { - return this._options.runAt === "document_start"; + private isUnsupportedCapabilityMessage(message: string): boolean { + // Native extension APIs expose validation failures as messages rather than stable error codes. + // Keep this matcher paired with browser-message fixtures in tests. + return /(not supported|unsupported|unexpected|unknown|unrecognized|invalid|not (?:a )?valid)\b/i.test(message); } } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..51a6b6d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,123 @@ +import type {InjectScriptResult, InjectScriptTarget} from "./types"; + +export type InjectScriptErrorCode = + | "ERR_INJECT_SCRIPT_DELIVERY" + | "ERR_INJECT_SCRIPT_INVALID_ARGUMENTS" + | "ERR_INJECT_SCRIPT_INVALID_FILES" + | "ERR_INJECT_SCRIPT_INVALID_OPTIONS" + | "ERR_INJECT_SCRIPT_INVALID_TARGET" + | "ERR_INJECT_SCRIPT_TIMEOUT" + | "ERR_INJECT_SCRIPT_UNSUPPORTED_OPTION" + | "ERR_INJECT_SCRIPT_UNSUPPORTED_TARGET"; + +export class InjectScriptBaseError extends Error { + public readonly code: InjectScriptErrorCode; + public override readonly cause?: unknown; + + protected constructor(name: string, code: InjectScriptErrorCode, message: string, cause?: unknown) { + super(message); + this.name = name; + this.code = code; + + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export class InvalidInjectScriptTargetError extends InjectScriptBaseError { + public constructor(message: string) { + super( + "InvalidInjectScriptTargetError", + "ERR_INJECT_SCRIPT_INVALID_TARGET", + `Invalid InjectScript target: ${message}` + ); + } +} + +export class UnsupportedInjectScriptTargetError extends InjectScriptBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectScriptTargetError", + "ERR_INJECT_SCRIPT_UNSUPPORTED_TARGET", + `Unsupported InjectScript target: ${message}`, + cause + ); + } +} + +export class InvalidInjectScriptOptionsError extends InjectScriptBaseError { + public constructor(message: string) { + super( + "InvalidInjectScriptOptionsError", + "ERR_INJECT_SCRIPT_INVALID_OPTIONS", + `Invalid InjectScript options: ${message}` + ); + } +} + +export class UnsupportedInjectScriptOptionError extends InjectScriptBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectScriptOptionError", + "ERR_INJECT_SCRIPT_UNSUPPORTED_OPTION", + `Unsupported InjectScript option: ${message}`, + cause + ); + } +} + +export class InvalidInjectScriptArgumentsError extends InjectScriptBaseError { + public constructor(message: string) { + super( + "InvalidInjectScriptArgumentsError", + "ERR_INJECT_SCRIPT_INVALID_ARGUMENTS", + `Invalid InjectScript arguments: ${message}` + ); + } +} + +export class InvalidInjectScriptFilesError extends InjectScriptBaseError { + public constructor(message: string) { + super( + "InvalidInjectScriptFilesError", + "ERR_INJECT_SCRIPT_INVALID_FILES", + `Invalid InjectScript files: ${message}` + ); + } +} + +export interface InjectScriptTimeoutDetails { + missingCount?: number; + partialResults?: readonly InjectScriptResult[]; +} + +export class InjectScriptTimeoutError extends InjectScriptBaseError { + public readonly target: InjectScriptTarget; + public readonly timeoutMs: number; + public readonly partialResults: readonly InjectScriptResult[]; + public readonly missingCount?: number; + + public constructor(target: InjectScriptTarget, timeoutMs: number, details: InjectScriptTimeoutDetails = {}) { + super( + "InjectScriptTimeoutError", + "ERR_INJECT_SCRIPT_TIMEOUT", + `Script execution timed out after ${timeoutMs} ms.` + ); + this.target = target; + this.timeoutMs = timeoutMs; + this.partialResults = [...(details.partialResults ?? [])]; + this.missingCount = details.missingCount; + } +} + +export class InjectScriptDeliveryError extends InjectScriptBaseError { + public readonly target: InjectScriptTarget; + + public constructor(target: InjectScriptTarget, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + + super("InjectScriptDeliveryError", "ERR_INJECT_SCRIPT_DELIVERY", `Script injection failed: ${message}`, cause); + this.target = target; + } +} diff --git a/src/index.ts b/src/index.ts index bbd4708..9f76a7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,39 @@ import InjectScriptV2 from "./InjectScriptV2"; import InjectScriptV3 from "./InjectScriptV3"; import type {InjectScriptContract, InjectScriptOptions} from "./types"; -export type {InjectScriptContract, InjectScriptOptions}; +export { + InjectScriptBaseError, + InjectScriptDeliveryError, + InjectScriptTimeoutError, + InvalidInjectScriptArgumentsError, + InvalidInjectScriptFilesError, + InvalidInjectScriptOptionsError, + InvalidInjectScriptTargetError, + UnsupportedInjectScriptOptionError, + UnsupportedInjectScriptTargetError, +} from "./errors"; +export type {InjectScriptErrorCode, InjectScriptTimeoutDetails} from "./errors"; +export type { + InjectScriptAllFramesTarget, + InjectScriptContract, + InjectScriptDocumentsTarget, + InjectScriptExecutionOptions, + InjectScriptFramesTarget, + InjectScriptFunctionResult, + InjectScriptOptions, + InjectScriptResult, + InjectScriptResultTarget, + InjectScriptTarget, + InjectScriptTopFrameTarget, + JsonCompatible, + JsonPrimitive, + JsonValue, + NonEmptyReadonlyArray, + SerializedInjectScriptError, +} from "./types"; -export default (options: InjectScriptOptions): InjectScriptContract => { +export const injectScript = (options: InjectScriptOptions): InjectScriptContract => { return isManifestVersion3() ? new InjectScriptV3(options) : new InjectScriptV2(options); }; + +export default injectScript; diff --git a/src/requestId.ts b/src/requestId.ts new file mode 100644 index 0000000..57af59d --- /dev/null +++ b/src/requestId.ts @@ -0,0 +1,19 @@ +let sequence = 0; + +const randomPart = (): string => { + if (typeof globalThis.crypto?.getRandomValues === "function") { + const values = new Uint32Array(2); + + globalThis.crypto.getRandomValues(values); + + return Array.from(values, value => value.toString(36)).join(""); + } + + return Math.random().toString(36).slice(2); +}; + +export const createRequestId = (): string => { + sequence = sequence >= Number.MAX_SAFE_INTEGER ? 1 : sequence + 1; + + return `inject-script-${Date.now().toString(36)}-${sequence.toString(36)}-${randomPart()}`; +}; diff --git a/src/results.ts b/src/results.ts new file mode 100644 index 0000000..0e2df38 --- /dev/null +++ b/src/results.ts @@ -0,0 +1,83 @@ +import {findJsonCompatibilityIssue} from "./validation"; +import type {InjectScriptResult, InjectScriptResultTarget, SerializedInjectScriptError} from "./types"; + +interface NativeInjectionResult { + frameId: number; + documentId?: string; + result?: T; + error?: unknown; +} + +const serializeError = (value: unknown): SerializedInjectScriptError => { + if (value instanceof Error) { + return { + name: value.name || "Error", + message: value.message, + ...(value.stack ? {stack: value.stack} : {}), + }; + } + + if (typeof value === "object" && value !== null) { + const candidate = value as {name?: unknown; message?: unknown; stack?: unknown}; + + return { + name: typeof candidate.name === "string" && candidate.name ? candidate.name : "Error", + message: typeof candidate.message === "string" ? candidate.message : String(value), + ...(typeof candidate.stack === "string" && candidate.stack ? {stack: candidate.stack} : {}), + }; + } + + return {name: "Error", message: String(value)}; +}; + +export const createResultTarget = (tabId: number, frameId: number, documentId?: string): InjectScriptResultTarget => { + return { + tabId, + frameId, + ...(documentId ? {documentId} : {}), + }; +}; + +export const normalizeNativeInjectionResult = ( + tabId: number, + nativeResult: NativeInjectionResult +): InjectScriptResult => { + const target = createResultTarget(tabId, nativeResult.frameId, nativeResult.documentId); + + if ("result" in nativeResult && nativeResult.result !== undefined) { + const issue = findJsonCompatibilityIssue(nativeResult.result, "result"); + + if (issue) { + return { + target, + status: "rejected", + error: { + name: "TypeError", + message: `Injected function result is not JSON-compatible: ${issue.path} ${issue.reason}`, + }, + }; + } + + return {target, status: "fulfilled", result: nativeResult.result as T}; + } + + if ("error" in nativeResult) { + return {target, status: "rejected", error: serializeError(nativeResult.error)}; + } + + return {target, status: "unknown"}; +}; + +export const sortInjectionResults = (results: InjectScriptResult[]): InjectScriptResult[] => { + return [...results].sort((left, right) => { + const frameOrder = left.target.frameId - right.target.frameId; + + if (frameOrder !== 0) { + return frameOrder; + } + + return (left.target.documentId ?? "").localeCompare(right.target.documentId ?? ""); + }); +}; + +export const normalizeInjectionError = serializeError; diff --git a/src/types.ts b/src/types.ts index 8d23ddc..fd81678 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,26 +1,119 @@ type RunAt = chrome.extensionTypes.RunAt; -type Awaited = chrome.scripting.Awaited; type ExecutionWorld = chrome.scripting.ExecutionWorld; -type InjectionResult = chrome.scripting.InjectionResult; -export interface InjectScriptContract { - run: (func: (...arg: A) => R, args?: A) => Promise>[]>; +export type JsonPrimitive = boolean | null | number | string; + +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { + readonly [key: string]: JsonValue; + }; - file: (files: string | string[]) => Promise; +export type JsonCompatible = T extends JsonValue + ? T + : T extends (...args: any[]) => unknown + ? never + : T extends readonly unknown[] + ? {[K in keyof T]: JsonCompatible} + : T extends object + ? {[K in keyof T]: JsonCompatible} + : never; - options: (options: Partial) => this; +export type NonEmptyReadonlyArray = readonly [T, ...T[]]; + +export interface InjectScriptTopFrameTarget { + tabId: number; + allFrames?: never; + frameIds?: never; + documentIds?: never; } -export interface InjectScriptOptions { +export interface InjectScriptAllFramesTarget { tabId: number; - frameId?: boolean | number | number[]; - matchAboutBlank?: boolean; + allFrames: true; + frameIds?: never; + documentIds?: never; +} - // Options for MV2 - runAt?: RunAt; - timeFallback?: number; +export interface InjectScriptFramesTarget { + tabId: number; + frameIds: NonEmptyReadonlyArray; + allFrames?: never; + documentIds?: never; +} + +export interface InjectScriptDocumentsTarget { + tabId: number; + documentIds: NonEmptyReadonlyArray; + allFrames?: never; + frameIds?: never; +} - // Options for MV3 +export type InjectScriptTarget = + | InjectScriptTopFrameTarget + | InjectScriptAllFramesTarget + | InjectScriptFramesTarget + | InjectScriptDocumentsTarget; + +export interface InjectScriptExecutionOptions { + matchAboutBlank?: boolean; + runAt?: RunAt; + timeoutMs?: number; world?: ExecutionWorld | `${ExecutionWorld}`; - documentId?: string | string[]; +} + +export interface InjectScriptOptions extends InjectScriptExecutionOptions { + target: InjectScriptTarget; +} + +export interface InjectScriptResultTarget { + tabId: number; + frameId: number; + documentId?: string; +} + +export interface SerializedInjectScriptError { + name: string; + message: string; + stack?: string; +} + +export type InjectScriptResult = + | { + target: InjectScriptResultTarget; + status: "fulfilled"; + result: T; + } + | { + target: InjectScriptResultTarget; + status: "rejected"; + error: SerializedInjectScriptError; + } + | { + target: InjectScriptResultTarget; + status: "unknown"; + }; + +export type InjectScriptFunctionResult = T | PromiseLike; + +type JsonCompatibleReturn = [Awaited] extends [never] + ? unknown + : Awaited extends JsonCompatible> + ? unknown + : never; + +export interface InjectScriptContract { + run(func: (() => R) & JsonCompatibleReturn, args?: readonly []): Promise>[]>; + + run, R>( + func: ((...args: A) => R) & JsonCompatibleReturn, + args: A & JsonCompatible + ): Promise>[]>; + + file(files: string | NonEmptyReadonlyArray): Promise; + + target(target: InjectScriptTarget): this; + + options(options: Partial): this; } diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..ce77df1 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,447 @@ +import { + InvalidInjectScriptArgumentsError, + InvalidInjectScriptFilesError, + InvalidInjectScriptOptionsError, + InvalidInjectScriptTargetError, +} from "./errors"; +import type {InjectScriptExecutionOptions, InjectScriptTarget, NonEmptyReadonlyArray} from "./types"; + +const TARGET_KEYS = new Set(["tabId", "allFrames", "frameIds", "documentIds"]); +const EXECUTION_OPTION_KEYS = new Set(["matchAboutBlank", "runAt", "timeoutMs", "world"]); +const INJECT_SCRIPT_OPTION_KEYS = new Set(["target", ...EXECUTION_OPTION_KEYS]); +const RUN_AT_VALUES = new Set(["document_start", "document_end", "document_idle"]); +const WORLD_VALUES = new Set(["ISOLATED", "MAIN"]); + +const isObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const assertKnownKeys = (value: Record, keys: Set, subject: string): void => { + const unknownKeys = Object.keys(value).filter(key => !keys.has(key)); + + if (unknownKeys.length > 0) { + throw new InvalidInjectScriptOptionsError( + `${subject} contains unknown ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys + .map(key => `"${key}"`) + .join(", ")}.` + ); + } +}; + +const cloneTarget = (target: InjectScriptTarget): InjectScriptTarget => { + if ("frameIds" in target && target.frameIds !== undefined) { + return {tabId: target.tabId, frameIds: [...target.frameIds] as NonEmptyReadonlyArray}; + } + + if ("documentIds" in target && target.documentIds !== undefined) { + return {tabId: target.tabId, documentIds: [...target.documentIds] as NonEmptyReadonlyArray}; + } + + if ("allFrames" in target && target.allFrames === true) { + return {tabId: target.tabId, allFrames: true}; + } + + return {tabId: target.tabId}; +}; + +export const validateInjectScriptTarget = (value: unknown): InjectScriptTarget => { + if (!isObject(value)) { + throw new InvalidInjectScriptTargetError("target must be an object."); + } + + const unknownKeys = Object.keys(value).filter(key => !TARGET_KEYS.has(key)); + + if (unknownKeys.length > 0) { + throw new InvalidInjectScriptTargetError( + `target contains unknown ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys + .map(key => `"${key}"`) + .join(", ")}.` + ); + } + + if (!Number.isInteger(value.tabId) || (value.tabId as number) < 0) { + throw new InvalidInjectScriptTargetError('"tabId" must be a non-negative integer.'); + } + + const selectors = ["allFrames", "frameIds", "documentIds"].filter(key => value[key] !== undefined); + + if (selectors.length > 1) { + throw new InvalidInjectScriptTargetError('"allFrames", "frameIds", and "documentIds" are mutually exclusive.'); + } + + if (value.allFrames !== undefined && value.allFrames !== true) { + throw new InvalidInjectScriptTargetError('"allFrames" must be exactly true when provided.'); + } + + if (value.frameIds !== undefined) { + if (!Array.isArray(value.frameIds) || value.frameIds.length === 0) { + throw new InvalidInjectScriptTargetError('"frameIds" must contain at least one frame ID.'); + } + + if (value.frameIds.some(frameId => !Number.isInteger(frameId) || frameId < 0)) { + throw new InvalidInjectScriptTargetError("frame ID must be a non-negative integer."); + } + + if (new Set(value.frameIds).size !== value.frameIds.length) { + throw new InvalidInjectScriptTargetError('"frameIds" must not contain duplicate frame IDs.'); + } + } + + if (value.documentIds !== undefined) { + if (!Array.isArray(value.documentIds) || value.documentIds.length === 0) { + throw new InvalidInjectScriptTargetError('"documentIds" must contain at least one document ID.'); + } + + if (value.documentIds.some(documentId => typeof documentId !== "string" || documentId.trim().length === 0)) { + throw new InvalidInjectScriptTargetError("document ID must be a non-empty string."); + } + + if (new Set(value.documentIds).size !== value.documentIds.length) { + throw new InvalidInjectScriptTargetError('"documentIds" must not contain duplicate document IDs.'); + } + } + + return cloneTarget(value as unknown as InjectScriptTarget); +}; + +export const validateInjectScriptExecutionOptions = (value: unknown): InjectScriptExecutionOptions => { + if (!isObject(value)) { + throw new InvalidInjectScriptOptionsError("execution options must be an object."); + } + + assertKnownKeys(value, EXECUTION_OPTION_KEYS, "execution options"); + + if (value.matchAboutBlank !== undefined && typeof value.matchAboutBlank !== "boolean") { + throw new InvalidInjectScriptOptionsError('"matchAboutBlank" must be a boolean.'); + } + + if (value.runAt !== undefined && (typeof value.runAt !== "string" || !RUN_AT_VALUES.has(value.runAt))) { + throw new InvalidInjectScriptOptionsError( + '"runAt" must be "document_start", "document_end", or "document_idle".' + ); + } + + if ( + value.timeoutMs !== undefined && + (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs <= 0) + ) { + throw new InvalidInjectScriptOptionsError('"timeoutMs" must be a positive integer.'); + } + + if (value.world !== undefined && (typeof value.world !== "string" || !WORLD_VALUES.has(value.world))) { + throw new InvalidInjectScriptOptionsError('"world" must be "ISOLATED" or "MAIN".'); + } + + return {...(value as InjectScriptExecutionOptions)}; +}; + +export const validateInjectScriptOptions = ( + value: unknown +): {target: InjectScriptTarget; execution: InjectScriptExecutionOptions} => { + if (!isObject(value)) { + throw new InvalidInjectScriptOptionsError("options must be an object."); + } + + assertKnownKeys(value, INJECT_SCRIPT_OPTION_KEYS, "options"); + + const {target, ...execution} = value; + + return { + target: validateInjectScriptTarget(target), + execution: validateInjectScriptExecutionOptions(execution), + }; +}; + +export interface JsonCompatibilityIssue { + readonly path: string; + readonly reason: string; +} + +/** + * This function must remain self-contained because MV2 serializes it into the + * injected payload. Do not reference module-level values from its body. + */ +export const findJsonCompatibilityIssue = (value: unknown, path: string): JsonCompatibilityIssue | undefined => { + const appendPropertyPath = (parent: string, key: string): string => { + return /^[A-Za-z_$][\w$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`; + }; + + const describeThrownValue = (thrown: unknown): string => { + if (thrown instanceof Error && thrown.message) return thrown.message; + + try { + return String(thrown); + } catch { + return "an unknown error"; + } + }; + + const inspect = ( + candidate: unknown, + currentPath: string, + ancestors: Map + ): JsonCompatibilityIssue | undefined => { + if (candidate === null || typeof candidate === "boolean" || typeof candidate === "string") { + return undefined; + } + + if (typeof candidate === "number") { + if (Number.isFinite(candidate)) return undefined; + + const valueName = Number.isNaN(candidate) ? "NaN" : candidate > 0 ? "Infinity" : "-Infinity"; + + return {path: currentPath, reason: `is ${valueName}; JSON supports only finite numbers.`}; + } + + if (candidate === undefined) { + return { + path: currentPath, + reason: "is undefined; JSON has no undefined value. Omit the key or use null.", + }; + } + + if (typeof candidate !== "object") { + return { + path: currentPath, + reason: `has type "${typeof candidate}"; JSON does not support values of this type.`, + }; + } + + const ancestorPath = ancestors.get(candidate); + + if (ancestorPath !== undefined) { + return { + path: currentPath, + reason: `contains a circular reference to ${ancestorPath}.`, + }; + } + + let array: boolean; + + try { + array = Array.isArray(candidate); + } catch (error) { + return { + path: currentPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + let prototype: object | null; + + try { + prototype = Object.getPrototypeOf(candidate); + } catch (error) { + return { + path: currentPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + const plainPrototype = array + ? prototype === Array.prototype + : prototype === Object.prototype || prototype === null; + + if (!plainPrototype) { + let constructorName: string | undefined; + + try { + const constructorValue = (prototype as {constructor?: unknown} | null)?.constructor; + + if ( + typeof constructorValue === "function" && + constructorValue.name && + constructorValue.name !== (array ? "Array" : "Object") + ) { + constructorName = constructorValue.name; + } + } catch { + // A hostile prototype must not hide the actionable plain-value requirement. + } + + return { + path: currentPath, + reason: constructorName + ? `is a ${constructorName} instance; pass a plain ${array ? "array" : "object"}.` + : `is not a plain ${array ? "array" : "object"}; pass a plain ${array ? "array" : "object"}.`, + }; + } + + ancestors.set(candidate, currentPath); + + let enumerableSymbol: symbol | undefined; + + try { + enumerableSymbol = Object.getOwnPropertySymbols(candidate).find(symbol => { + return Object.getOwnPropertyDescriptor(candidate, symbol)?.enumerable === true; + }); + } catch (error) { + ancestors.delete(candidate); + + return { + path: currentPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + if (enumerableSymbol !== undefined) { + ancestors.delete(candidate); + + return { + path: currentPath, + reason: `has an enumerable symbol-keyed property (${String(enumerableSymbol)}); JSON supports only string property keys.`, + }; + } + + if (array) { + let length: number; + let keys: string[]; + + try { + length = (candidate as unknown[]).length; + keys = Object.keys(candidate); + } catch (error) { + ancestors.delete(candidate); + + return { + path: currentPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + const extraKey = keys.find(key => { + const index = Number(key); + + return !Number.isInteger(index) || index < 0 || index >= length || String(index) !== key; + }); + + if (extraKey !== undefined) { + ancestors.delete(candidate); + + return { + path: appendPropertyPath(currentPath, extraKey), + reason: "is an additional array property; JSON serializes only indexed array elements.", + }; + } + + for (let index = 0; index < length; index += 1) { + const itemPath = `${currentPath}[${index}]`; + let hasItem: boolean; + + try { + hasItem = Object.getOwnPropertyDescriptor(candidate, index) !== undefined; + } catch (error) { + ancestors.delete(candidate); + + return { + path: itemPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + if (!hasItem) { + ancestors.delete(candidate); + + return { + path: itemPath, + reason: "is missing; sparse arrays are not supported. Use null for an empty slot.", + }; + } + + let item: unknown; + + try { + item = (candidate as unknown[])[index]; + } catch (error) { + ancestors.delete(candidate); + + return { + path: itemPath, + reason: `could not be read: ${describeThrownValue(error)}.`, + }; + } + + const issue = inspect(item, itemPath, ancestors); + + if (issue) { + ancestors.delete(candidate); + return issue; + } + } + + ancestors.delete(candidate); + return undefined; + } + + let keys: string[]; + + try { + keys = Object.keys(candidate); + } catch (error) { + ancestors.delete(candidate); + + return { + path: currentPath, + reason: `could not be inspected: ${describeThrownValue(error)}.`, + }; + } + + for (const key of keys) { + const propertyPath = appendPropertyPath(currentPath, key); + let property: unknown; + + try { + property = (candidate as Record)[key]; + } catch (error) { + ancestors.delete(candidate); + + return { + path: propertyPath, + reason: `could not be read: ${describeThrownValue(error)}.`, + }; + } + + const issue = inspect(property, propertyPath, ancestors); + + if (issue) { + ancestors.delete(candidate); + return issue; + } + } + + ancestors.delete(candidate); + return undefined; + }; + + return inspect(value, path, new Map()); +}; + +export const validateInjectScriptArguments = (args: readonly unknown[] | undefined): void => { + if (args === undefined) { + return; + } + + if (!Array.isArray(args)) { + throw new InvalidInjectScriptArgumentsError("arguments must be an array."); + } + + const issue = findJsonCompatibilityIssue(args, "arguments"); + + if (issue) { + throw new InvalidInjectScriptArgumentsError(`${issue.path} ${issue.reason}`); + } +}; + +export const validateInjectScriptFiles = (files: string | NonEmptyReadonlyArray): string[] => { + const fileList = typeof files === "string" ? [files] : files; + + if (!Array.isArray(fileList) || fileList.length === 0) { + throw new InvalidInjectScriptFilesError("at least one file is required."); + } + + if (fileList.some(file => typeof file !== "string" || file.trim().length === 0)) { + throw new InvalidInjectScriptFilesError("each file must be a non-empty string."); + } + + return [...fileList]; +}; diff --git a/tests/inject-script.test.cjs b/tests/inject-script.test.cjs new file mode 100644 index 0000000..ae054e0 --- /dev/null +++ b/tests/inject-script.test.cjs @@ -0,0 +1,1091 @@ +const {execFileSync} = require("node:child_process"); +const {mkdtempSync, rmSync, writeFileSync} = require("node:fs"); +const {tmpdir} = require("node:os"); +const {join} = require("node:path"); + +const { + default: injectScript, + injectScript: namedInjectScript, + InjectScriptBaseError, + InjectScriptDeliveryError, + InjectScriptTimeoutError, + InvalidInjectScriptArgumentsError, + InvalidInjectScriptFilesError, + InvalidInjectScriptOptionsError, + InvalidInjectScriptTargetError, + UnsupportedInjectScriptOptionError, + UnsupportedInjectScriptTargetError, +} = require("../dist/index.cjs"); + +const executeGeneratedCode = (code, namespace) => { + const directory = mkdtempSync(join(tmpdir(), "inject-script-test-")); + const scriptPath = join(directory, "injected.cjs"); + const returnsPromise = namespace === "browser" ? "return Promise.resolve();" : ""; + + writeFileSync( + scriptPath, + `globalThis.${namespace} = { + runtime: { + id: "test-extension", + sendMessage(message, callback) { + if (callback !== undefined) { + throw new Error("Generated payload requested an unused response callback."); + } + process.stdout.write(JSON.stringify(message)); + ${returnsPromise} + } + } + }; + ${code}\n` + ); + + try { + return JSON.parse(execFileSync(process.execPath, [scriptPath], {encoding: "utf8"})); + } finally { + rmSync(directory, {recursive: true, force: true}); + } +}; + +const createRuntime = manifestVersion => { + const listeners = new Set(); + + return { + listeners, + runtime: { + id: "test-extension", + lastError: undefined, + getManifest: () => ({manifest_version: manifestVersion}), + onMessage: { + addListener: listener => listeners.add(listener), + removeListener: listener => listeners.delete(listener), + }, + }, + }; +}; + +const getMessageType = code => { + return code.match(/"(inject-script-[^"]+)"/)?.[1]; +}; + +describe("package exports", () => { + test("exports the factory as both default and named", () => { + expect(namedInjectScript).toBe(injectScript); + }); +}); + +describe("InjectScript target API", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + }); + + test.each([ + [{target: {tabId: -1}}, '"tabId" must be a non-negative integer'], + [{target: {tabId: 1, allFrames: false}}, '"allFrames" must be exactly true'], + [{target: {tabId: 1, frameIds: []}}, '"frameIds" must contain at least one'], + [{target: {tabId: 1, frameIds: [1, 1]}}, '"frameIds" must not contain duplicate'], + [{target: {tabId: 1, frameIds: [1.5]}}, "frame ID must be a non-negative integer"], + [{target: {tabId: 1, documentIds: [""]}}, "document ID must be a non-empty string"], + [{target: {tabId: 1, documentIds: ["doc", "doc"]}}, '"documentIds" must not contain duplicate'], + [ + {target: {tabId: 1, allFrames: true, frameIds: [1]}}, + '"allFrames", "frameIds", and "documentIds" are mutually exclusive', + ], + [{target: {tabId: 1, frameId: 2}}, 'unknown field: "frameId"'], + ])("rejects invalid target %#", (options, message) => { + const {runtime} = createRuntime(3); + global.chrome = {runtime}; + + expect(() => injectScript(options)).toThrow(InvalidInjectScriptTargetError); + expect(() => injectScript(options)).toThrow(message); + }); + + test("rejects invalid and unknown execution options", () => { + const {runtime} = createRuntime(3); + global.chrome = {runtime}; + + expect(() => injectScript({target: {tabId: 1}, timeoutMs: 0})).toThrow(InvalidInjectScriptOptionsError); + expect(() => injectScript({target: {tabId: 1}, unexpected: true})).toThrow(InvalidInjectScriptOptionsError); + + try { + injectScript({target: {tabId: 1}, timeoutMs: 0}); + } catch (error) { + expect(error).toBeInstanceOf(InjectScriptBaseError); + expect(error.code).toBe("ERR_INJECT_SCRIPT_INVALID_OPTIONS"); + } + }); + + test("copies and atomically replaces targets", async () => { + const {runtime} = createRuntime(3); + const calls = []; + const frameIds = [1]; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + calls.push(details); + callback([]); + }, + }, + }; + + const injector = injectScript({target: {tabId: 4, frameIds}}); + + frameIds.push(2); + injector.target({tabId: 4, allFrames: true}); + await injector.file("/content.js"); + + expect(calls[0].target).toEqual({tabId: 4, allFrames: true}); + }); + + test("keeps the previous target when replacement validation fails", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + calls.push(details); + callback([]); + }, + }, + }; + + const injector = injectScript({target: {tabId: 4, frameIds: [2]}}); + + expect(() => injector.target({tabId: 4, frameIds: []})).toThrow(InvalidInjectScriptTargetError); + + await injector.file("/content.js"); + + expect(calls[0].target).toEqual({tabId: 4, frameIds: [2]}); + }); +}); + +describe("MV3 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + }); + + test("translates targets and returns sorted observed outcomes", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + calls.push(details); + callback([ + {frameId: 8, documentId: "doc-8", error: {name: "Error", message: "failed"}}, + {frameId: 9, documentId: "doc-9", error: undefined}, + {frameId: 10, documentId: "doc-10", result: "ok", error: undefined}, + {frameId: 0, documentId: "doc-0", result: "top"}, + {frameId: 3, documentId: "doc-3", result: undefined}, + ]); + }, + }, + }; + + const results = await injectScript({ + target: {tabId: 5, frameIds: [8, 0, 3, 9, 10]}, + runAt: "document_start", + world: "MAIN", + }).run(() => "value"); + + expect(calls[0].target).toEqual({tabId: 5, frameIds: [8, 0, 3, 9, 10]}); + expect(calls[0].injectImmediately).toBe(true); + expect(results).toEqual([ + {target: {tabId: 5, frameId: 0, documentId: "doc-0"}, status: "fulfilled", result: "top"}, + {target: {tabId: 5, frameId: 3, documentId: "doc-3"}, status: "unknown"}, + { + target: {tabId: 5, frameId: 8, documentId: "doc-8"}, + status: "rejected", + error: {name: "Error", message: "failed"}, + }, + { + target: {tabId: 5, frameId: 9, documentId: "doc-9"}, + status: "rejected", + error: {name: "Error", message: "undefined"}, + }, + {target: {tabId: 5, frameId: 10, documentId: "doc-10"}, status: "fulfilled", result: "ok"}, + ]); + }); + + test("validates every observable native result before fulfilling it", async () => { + const {runtime} = createRuntime(3); + const cyclic = {}; + cyclic.self = cyclic; + + const sparse = []; + sparse.length = 1; + + const symbolKeyed = {}; + symbolKeyed[Symbol("metadata")] = true; + + const arrayWithMetadata = []; + arrayWithMetadata.metadata = true; + + class CustomArray extends Array {} + + global.chrome = { + runtime, + scripting: { + executeScript: (_details, callback) => { + callback([ + {frameId: 0, result: null}, + {frameId: 1, result: true}, + {frameId: 2, result: 42}, + {frameId: 3, result: {nested: ["ok"]}}, + {frameId: 4, result: new Date(0)}, + {frameId: 5, result: new Map([["key", "value"]])}, + {frameId: 6, result: Number.NaN}, + {frameId: 7, result: cyclic}, + {frameId: 8, result: sparse}, + {frameId: 9, result: symbolKeyed}, + {frameId: 10, result: arrayWithMetadata}, + {frameId: 11, result: new CustomArray()}, + ]); + }, + }, + }; + + const results = await injectScript({ + target: {tabId: 5, frameIds: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}, + }).run(() => null); + const byFrame = new Map(results.map(result => [result.target.frameId, result])); + + expect(results.slice(0, 4)).toEqual([ + {target: {tabId: 5, frameId: 0}, status: "fulfilled", result: null}, + {target: {tabId: 5, frameId: 1}, status: "fulfilled", result: true}, + {target: {tabId: 5, frameId: 2}, status: "fulfilled", result: 42}, + {target: {tabId: 5, frameId: 3}, status: "fulfilled", result: {nested: ["ok"]}}, + ]); + expect(byFrame.get(4)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result is a Date instance")}, + }); + expect(byFrame.get(5)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result is a Map instance")}, + }); + expect(byFrame.get(6)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result is NaN")}, + }); + expect(byFrame.get(7)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result.self contains a circular reference")}, + }); + expect(byFrame.get(8)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result[0] is missing")}, + }); + expect(byFrame.get(9)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("enumerable symbol-keyed property")}, + }); + expect(byFrame.get(10)).toMatchObject({ + status: "rejected", + error: { + name: "TypeError", + message: expect.stringContaining("result.metadata is an additional array property"), + }, + }); + expect(byFrame.get(11)).toMatchObject({ + status: "rejected", + error: {name: "TypeError", message: expect.stringContaining("result is a CustomArray instance")}, + }); + }); + + test("supports a Promise-based browser namespace", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.browser = { + runtime, + scripting: { + executeScript: details => { + calls.push(details); + return Promise.resolve([{frameId: 0, result: {namespace: "browser"}}]); + }, + }, + }; + + await expect(injectScript({target: {tabId: 12}}).run(() => "value")).resolves.toEqual([ + { + target: {tabId: 12, frameId: 0}, + status: "fulfilled", + result: {namespace: "browser"}, + }, + ]); + expect(calls).toHaveLength(1); + expect(calls[0].target).toEqual({tabId: 12}); + }); + + test("passes document targets directly without browser-name fallback", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime: {...runtime, getBrowserInfo: () => Promise.resolve({name: "Firefox"})}, + scripting: { + executeScript: (details, callback) => { + calls.push(details); + callback([]); + }, + }, + }; + + await injectScript({target: {tabId: 7, documentIds: ["doc"]}}).file("/content.js"); + + expect(calls[0].target).toEqual({tabId: 7, documentIds: ["doc"]}); + }); + + test.each([ + [{tabId: 7}, {tabId: 7}], + [ + {tabId: 7, allFrames: true}, + {tabId: 7, allFrames: true}, + ], + [ + {tabId: 7, frameIds: [0, 2]}, + {tabId: 7, frameIds: [0, 2]}, + ], + [ + {tabId: 7, documentIds: ["doc-a", "doc-b"]}, + {tabId: 7, documentIds: ["doc-a", "doc-b"]}, + ], + ])("uses the same target translation for run and file %#", async (target, nativeTarget) => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + calls.push(details.target); + callback(details.func ? [{frameId: 0, result: null}] : []); + }, + }, + }; + + const injector = injectScript({target}); + + await injector.run(() => null); + await injector.file("/content.js"); + + expect(calls).toEqual([nativeTarget, nativeTarget]); + }); + + test("updates execution options without changing the target", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + calls.push(details); + callback([]); + }, + }, + }; + + const injector = injectScript({target: {tabId: 7, frameIds: [2]}, world: "ISOLATED"}); + + injector.options({world: "MAIN", runAt: "document_start", timeoutMs: 50}); + await injector.file("/content.js"); + + expect(calls[0]).toMatchObject({ + target: {tabId: 7, frameIds: [2]}, + world: "MAIN", + injectImmediately: true, + }); + }); + + test.each([ + [() => "sync", "sync"], + [async () => "async", "async"], + ])("supports synchronous and asynchronous callbacks", async (func, expected) => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + Promise.resolve(details.func(...(details.args ?? []))).then(result => { + callback([{frameId: 0, result}]); + }); + }, + }, + }; + + await expect(injectScript({target: {tabId: 7}}).run(func)).resolves.toEqual([ + {target: {tabId: 7, frameId: 0}, status: "fulfilled", result: expected}, + ]); + }); + + test.each([ + [{matchAboutBlank: true}, '"matchAboutBlank" is not supported'], + [{runAt: "document_end"}, '"runAt: document_end" cannot be represented'], + ])("rejects unsupported execution option %#", (execution, message) => { + const {runtime} = createRuntime(3); + global.chrome = {runtime}; + + expect(() => injectScript({target: {tabId: 1}, ...execution})).toThrow(UnsupportedInjectScriptOptionError); + expect(() => injectScript({target: {tabId: 1}, ...execution})).toThrow(message); + }); + + test("normalizes documentIds capability errors without falling back", async () => { + const {runtime} = createRuntime(3); + const nativeError = {message: 'Unexpected property "documentIds"'}; + + global.chrome = { + runtime, + scripting: { + executeScript: (_details, callback) => { + global.chrome.runtime.lastError = nativeError; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const rejection = injectScript({target: {tabId: 2, documentIds: ["doc"]}}).file("/file.js"); + + await expect(rejection).rejects.toMatchObject({ + code: "ERR_INJECT_SCRIPT_UNSUPPORTED_TARGET", + cause: expect.any(Error), + }); + await expect(rejection).rejects.toThrow(UnsupportedInjectScriptTargetError); + }); + + test("normalizes unsupported native execution capabilities", async () => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Unexpected property: world"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const rejection = injectScript({target: {tabId: 2}, world: "MAIN"}).file("/file.js"); + + await expect(rejection).rejects.toMatchObject({ + code: "ERR_INJECT_SCRIPT_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + await expect(rejection).rejects.toThrow(UnsupportedInjectScriptOptionError); + }); + + test("rejects delivery failures and timeouts with package errors", async () => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + await expect(injectScript({target: {tabId: 2}}).file("/file.js")).rejects.toThrow(InjectScriptDeliveryError); + + global.chrome.scripting.executeScript = () => {}; + + await expect(injectScript({target: {tabId: 2}, timeoutMs: 5}).file("/file.js")).rejects.toThrow( + InjectScriptTimeoutError + ); + }); + + test("times out run() with structured timeout details", async () => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: () => {}, + }, + }; + + await expect(injectScript({target: {tabId: 2}, timeoutMs: 5}).run(() => "late")).rejects.toMatchObject({ + code: "ERR_INJECT_SCRIPT_TIMEOUT", + timeoutMs: 5, + partialResults: [], + target: {tabId: 2}, + }); + }); +}); + +describe("MV2 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + }); + + test("collects all-frame responses without webNavigation and ignores duplicates", async () => { + const {runtime, listeners} = createRuntime(2); + const calls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + calls.push({tabId, details}); + + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: "child"}}, + {tab: {id: tabId}, frameId: 4} + ); + listener( + {type, data: {status: "fulfilled", result: "duplicate"}}, + {tab: {id: tabId}, frameId: 4} + ); + listener( + {type, data: {status: "fulfilled", result: "wrong tab"}}, + {tab: {id: 999}, frameId: 0} + ); + listener( + {type, data: {status: "rejected", error: {name: "Error", message: "top failed"}}}, + {tab: {id: tabId}, frameId: 0} + ); + } + }); + + callback([undefined, undefined]); + }, + }, + }; + + const results = await injectScript({target: {tabId: 3, allFrames: true}}).run(() => "value"); + + expect(calls).toHaveLength(1); + expect(calls[0].details.allFrames).toBe(true); + expect(calls[0].details.matchAboutBlank).toBeUndefined(); + expect(global.chrome.webNavigation).toBeUndefined(); + expect(results).toEqual([ + { + target: {tabId: 3, frameId: 0}, + status: "rejected", + error: {name: "Error", message: "top failed"}, + }, + {target: {tabId: 3, frameId: 4}, status: "fulfilled", result: "child"}, + ]); + expect(listeners.size).toBe(0); + }); + + test("preserves frame zero and injects each explicit frame", async () => { + const {runtime, listeners} = createRuntime(2); + const frameCalls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + frameCalls.push(details.frameId); + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: details.frameId}}, + {tab: {id: tabId}, frameId: details.frameId} + ); + } + }); + + callback([undefined]); + }, + }, + }; + + const results = await injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => 1); + + expect(frameCalls).toEqual([2, 0]); + expect(results.map(result => result.target.frameId)).toEqual([0, 2]); + }); + + test("collects fulfilled and rejected outcomes from explicit frames", async () => { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + details.frameId === 2 + ? {type, data: {status: "rejected", error: {name: "Error", message: "failed"}}} + : {type, data: {status: "fulfilled", result: "top"}}, + {tab: {id: tabId}, frameId: details.frameId} + ); + } + }); + + callback([undefined]); + }, + }, + }; + + await expect(injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => "value")).resolves.toEqual([ + {target: {tabId: 6, frameId: 0}, status: "fulfilled", result: "top"}, + { + target: {tabId: 6, frameId: 2}, + status: "rejected", + error: {name: "Error", message: "failed"}, + }, + ]); + }); + + test.each([ + [ + "chrome", + async value => ({value}), + ["async"], + {target: {tabId: 8, frameId: 0}, status: "fulfilled", result: {value: "async"}}, + ], + [ + "browser", + () => { + throw new Error("frame failed"); + }, + [], + { + target: {tabId: 8, frameId: 0}, + status: "rejected", + error: expect.objectContaining({name: "Error", message: "frame failed"}), + }, + ], + [ + "chrome", + () => new Date(0), + [], + { + target: {tabId: 8, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + name: "TypeError", + message: + "Injected function result is not JSON-compatible: result is a Date instance; pass a plain object.", + }), + }, + ], + [ + "chrome", + () => ({settings: {limit: undefined}}), + [], + { + target: {tabId: 8, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + name: "TypeError", + message: + "Injected function result is not JSON-compatible: result.settings.limit is undefined; JSON has no undefined value. Omit the key or use null.", + }), + }, + ], + [ + "chrome", + () => undefined, + [], + { + target: {tabId: 8, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + name: "TypeError", + message: + "Injected function result is not JSON-compatible: result is undefined; JSON has no undefined value. Omit the key or use null.", + }), + }, + ], + ])("executes the generated payload through the %s namespace", async (namespace, func, args, expected) => { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + const message = executeGeneratedCode(details.code, namespace); + + for (const listener of listeners) { + listener(message, {tab: {id: tabId}, frameId: 0}); + } + + callback([undefined]); + }, + }, + }; + + await expect(injectScript({target: {tabId: 8}}).run(func, args)).resolves.toEqual([expected]); + }); + + test("supports Promise-based native delivery through the browser namespace", async () => { + const {runtime, listeners} = createRuntime(2); + + global.browser = { + runtime, + tabs: { + executeScript: (tabId, details) => { + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: {namespace: "browser"}}}, + {tab: {id: tabId}, frameId: 0} + ); + } + }); + + return Promise.resolve([undefined]); + }, + }, + }; + + await expect(injectScript({target: {tabId: 12}}).run(() => "value")).resolves.toEqual([ + { + target: {tabId: 12, frameId: 0}, + status: "fulfilled", + result: {namespace: "browser"}, + }, + ]); + }); + + test("cleans up the listener and timer after success", async () => { + jest.useFakeTimers(); + + try { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + const type = getMessageType(details.code); + + for (const listener of listeners) { + listener({type, data: {status: "fulfilled", result: null}}, {tab: {id: tabId}, frameId: 0}); + } + + callback([undefined]); + }, + }, + }; + + await expect(injectScript({target: {tabId: 1}}).run(() => null)).resolves.toHaveLength(1); + + expect(listeners.size).toBe(0); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + test("injects files in order", async () => { + const {runtime} = createRuntime(2); + const files = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, details, callback) => { + files.push(details.file); + callback([undefined]); + }, + }, + }; + + await injectScript({target: {tabId: 1}}).file(["/first.js", "/second.js"]); + + expect(files).toEqual(["/first.js", "/second.js"]); + }); + + test("passes explicit MV2 execution options without changing native isolated-world behavior", async () => { + const {runtime} = createRuntime(2); + const calls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, details, callback) => { + calls.push(details); + callback([undefined]); + }, + }, + }; + + await injectScript({ + target: {tabId: 1}, + matchAboutBlank: true, + runAt: "document_start", + world: "ISOLATED", + }).file("/content.js"); + + expect(calls[0]).toMatchObject({ + file: "/content.js", + matchAboutBlank: true, + runAt: "document_start", + }); + expect(calls[0].world).toBeUndefined(); + }); + + test("does not start later files after a timeout", async () => { + jest.useFakeTimers(); + + try { + const {runtime} = createRuntime(2); + const files = []; + let finishFirst; + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, details, callback) => { + files.push(details.file); + finishFirst = () => callback([undefined]); + }, + }, + }; + + const pending = injectScript({target: {tabId: 1}, timeoutMs: 10}).file(["/first.js", "/second.js"]); + const rejection = expect(pending).rejects.toThrow(InjectScriptTimeoutError); + + jest.advanceTimersByTime(10); + await rejection; + + finishFirst(); + await Promise.resolve(); + await Promise.resolve(); + + expect(files).toEqual(["/first.js"]); + } finally { + jest.useRealTimers(); + } + }); + + test("rejects unsupported targets and options before injection", () => { + const {runtime} = createRuntime(2); + global.chrome = {runtime}; + + expect(() => injectScript({target: {tabId: 1, documentIds: ["doc"]}})).toThrow( + UnsupportedInjectScriptTargetError + ); + expect(() => injectScript({target: {tabId: 1}, world: "MAIN"})).toThrow(UnsupportedInjectScriptOptionError); + }); + + test("rejects non-JSON arguments and empty files, but marks a known missing response as unknown", async () => { + const {runtime} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, _details, callback) => callback([undefined]), + }, + }; + + const injector = injectScript({target: {tabId: 1}, timeoutMs: 5}); + + await expect(injector.run(value => value, [new Date()])).rejects.toThrow(InvalidInjectScriptArgumentsError); + await expect(injector.run(value => value, [{value: undefined}])).rejects.toThrow( + InvalidInjectScriptArgumentsError + ); + + const cyclic = {}; + cyclic.self = cyclic; + + await expect(injector.run(value => value, [cyclic])).rejects.toThrow(InvalidInjectScriptArgumentsError); + await expect(injector.file([])).rejects.toThrow(InvalidInjectScriptFilesError); + await expect(injector.run(() => "never delivered")).resolves.toEqual([ + {target: {tabId: 1, frameId: 0}, status: "unknown"}, + ]); + }); + + test("reports the exact path and reason for incompatible arguments", async () => { + const {runtime} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, _details, callback) => callback([undefined]), + }, + }; + + class Dto { + constructor(id) { + this.id = id; + } + } + + const cyclic = {}; + cyclic.self = cyclic; + + const sparse = []; + sparse.length = 1; + + const unreadable = Object.defineProperty({}, "limit", { + enumerable: true, + get() { + throw new Error("getter failed"); + }, + }); + + const symbolKeyed = {}; + symbolKeyed[Symbol("metadata")] = true; + + const arrayWithMetadata = []; + arrayWithMetadata.metadata = true; + + class CustomArray extends Array {} + + const injector = injectScript({target: {tabId: 1}}); + + await expect(injector.run(value => value, [{limit: undefined}])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0].limit is undefined; JSON has no undefined value. Omit the key or use null." + ); + await expect(injector.run(value => value, [new Dto(1)])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0] is a Dto instance; pass a plain object." + ); + await expect(injector.run(value => value, [{limit: Number.NaN}])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0].limit is NaN; JSON supports only finite numbers." + ); + await expect(injector.run(value => value, [sparse])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0][0] is missing; sparse arrays are not supported. Use null for an empty slot." + ); + await expect(injector.run(value => value, [cyclic])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0].self contains a circular reference to arguments[0]." + ); + await expect(injector.run(value => value, [unreadable])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0].limit could not be read: getter failed." + ); + await expect(injector.run(value => value, [symbolKeyed])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0] has an enumerable symbol-keyed property (Symbol(metadata)); JSON supports only string property keys." + ); + await expect(injector.run(value => value, [arrayWithMetadata])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0].metadata is an additional array property; JSON serializes only indexed array elements." + ); + await expect(injector.run(value => value, [new CustomArray()])).rejects.toThrow( + "Invalid InjectScript arguments: arguments[0] is a CustomArray instance; pass a plain array." + ); + }); + + test("preserves known frame results and marks only missing explicit frames as unknown", async () => { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + const type = getMessageType(details.code); + + if (details.frameId === 0) { + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: "top"}}, + {tab: {id: tabId}, frameId: 0} + ); + } + }); + } + + callback([undefined]); + }, + }, + }; + + await expect( + injectScript({target: {tabId: 1, frameIds: [2, 0]}, timeoutMs: 5}).run(() => "value") + ).resolves.toEqual([ + {target: {tabId: 1, frameId: 0}, status: "fulfilled", result: "top"}, + {target: {tabId: 1, frameId: 2}, status: "unknown"}, + ]); + }); + + test("reports all-frame timeout details without discarding partial results", async () => { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: "top"}}, + {tab: {id: tabId}, frameId: 0} + ); + } + }); + + callback([undefined, undefined]); + }, + }, + }; + + await expect( + injectScript({target: {tabId: 1, allFrames: true}, timeoutMs: 5}).run(() => "value") + ).rejects.toMatchObject({ + code: "ERR_INJECT_SCRIPT_TIMEOUT", + timeoutMs: 5, + missingCount: 1, + partialResults: [{target: {tabId: 1, frameId: 0}, status: "fulfilled", result: "top"}], + }); + }); + + test("cleans up listeners and timers after delivery errors and timeouts", async () => { + jest.useFakeTimers(); + + try { + const {runtime, listeners} = createRuntime(2); + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + await expect(injectScript({target: {tabId: 1}}).run(() => null)).rejects.toThrow(InjectScriptDeliveryError); + expect(listeners.size).toBe(0); + expect(jest.getTimerCount()).toBe(0); + + global.chrome.tabs.executeScript = () => {}; + + const pending = injectScript({target: {tabId: 1}, timeoutMs: 10}).run(() => null); + const rejection = expect(pending).rejects.toThrow(InjectScriptTimeoutError); + + expect(listeners.size).toBe(1); + jest.advanceTimersByTime(10); + await rejection; + + expect(listeners.size).toBe(0); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/tests/release-it.test.cjs b/tests/release-it.test.cjs new file mode 100644 index 0000000..64ea776 --- /dev/null +++ b/tests/release-it.test.cjs @@ -0,0 +1,43 @@ +const {whatBump} = require("../.release-it.cjs"); + +describe("release-it version policy", () => { + describe("breaking changes", () => { + test.each([ + ["parser breaking field", {type: "feat", breaking: "!"}], + ["type suffix", {type: "feat!"}], + ["header suffix", {type: "feat", header: "feat(inject-script)!: remove legacy API"}], + ["BREAKING CHANGE note", {type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], + ["BREAKING-CHANGE footer", {type: "fix", footer: "BREAKING-CHANGE: new contract"}], + ])("treats %s as a pre-1.0 minor bump", (_label, commit) => { + expect(whatBump([commit], "0.3.1")).toEqual({level: 1}); + }); + + test("becomes a major bump after 1.0", () => { + expect( + whatBump([{type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], "1.4.2") + ).toEqual({level: 0}); + }); + + test("takes precedence over lower-level changes after 1.0", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}, {type: "refactor", breaking: true}], "2.0.0")).toEqual({ + level: 0, + }); + }); + }); + + test.each(["feat", "revert"])("uses a minor bump for %s", type => { + expect(whatBump([{type}], "0.3.1")).toEqual({level: 1}); + }); + + test.each(["fix", "perf", "refactor", "ci"])("uses a patch bump for %s", type => { + expect(whatBump([{type}], "0.3.1")).toEqual({level: 2}); + }); + + test("uses the highest non-breaking bump", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}], "0.3.1")).toEqual({level: 1}); + }); + + test.each(["docs", "test", "chore", "build"])("does not release for %s alone", type => { + expect(whatBump([{type}], "0.3.1")).toBeNull(); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..0d82ef0 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".." + }, + "include": [ + "../src/**/*.ts", + "./types.test.ts" + ], + "exclude": [] +} diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..9a4c892 --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,93 @@ +import injectScript, { + type InjectScriptErrorCode, + type InjectScriptResult, + type InjectScriptTimeoutDetails, + type JsonValue, + injectScript as namedInjectScript, + type SerializedInjectScriptError, +} from "../src/index"; + +declare const tabId: number; +declare const frameId: number; +declare const documentId: string; + +const topFrame = injectScript({target: {tabId}}); + +namedInjectScript({target: {tabId}}); + +injectScript({target: {tabId, allFrames: true}}); +injectScript({target: {tabId, frameIds: [0, frameId]}}); +injectScript({target: {tabId, documentIds: [documentId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectScript({target: {tabId, allFrames: true, frameIds: [frameId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectScript({target: {tabId, frameIds: [frameId], documentIds: [documentId]}}); + +// @ts-expect-error explicit frame targets must not be empty +injectScript({target: {tabId, frameIds: []}}); + +// @ts-expect-error explicit document targets must not be empty +injectScript({target: {tabId, documentIds: []}}); + +// @ts-expect-error allFrames only accepts literal true +injectScript({target: {tabId, allFrames: false}}); + +topFrame.run(() => document.title); +topFrame.run(() => document.title, []); +topFrame.run((selector: string) => document.querySelector(selector)?.textContent ?? null, [".title"]); +topFrame.run(async (value: JsonValue) => ({value}), ["serializable"]); + +interface Product { + id: number; + title: string; +} + +interface ProductQuery { + limit?: number; + product: Product; +} + +declare const product: Product; +declare const query: ProductQuery; + +topFrame.run((): Product => ({id: 1, title: "product"})); +topFrame.run(async (): Promise => ({id: 1, title: "product"})); +topFrame.run((value: Product) => value.id, [product]); +topFrame.run((value: ProductQuery) => value.product, [query]); +topFrame.run((): never => { + throw new Error("frame failed"); +}); + +// @ts-expect-error required callback arguments must be provided +topFrame.run((selector: string) => document.querySelector(selector)?.textContent ?? null); + +// @ts-expect-error callback arguments must be JSON-compatible +topFrame.run((value: JsonValue) => value, [new Date()]); + +// @ts-expect-error callback results must be JSON-compatible +topFrame.run(() => document.body); + +declare const result: InjectScriptResult; + +if (result.status === "fulfilled") { + result.result.toUpperCase(); +} + +if (result.status === "rejected") { + result.error.message.toUpperCase(); +} + +if (result.status === "unknown") { + // @ts-expect-error unknown outcomes intentionally expose no result + result.result; +} + +declare const serializedError: SerializedInjectScriptError; +declare const errorCode: InjectScriptErrorCode; +declare const timeoutDetails: InjectScriptTimeoutDetails; + +serializedError.message.toUpperCase(); +errorCode.toUpperCase(); +timeoutDetails.partialResults?.map(item => item.status); diff --git a/tsconfig.json b/tsconfig.json index 1958e6d..535196a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,13 +14,10 @@ "noEmitOnError": false, "noEmit": false, "skipLibCheck": true, - "noImplicitAny": false, "typeRoots": [ "node_modules/@types" ], - "baseUrl": "./src", "isolatedModules": true, - "allowJs": true, "resolveJsonModule": true, "sourceMap": true, "declarationMap": true