From e4d2cc0273586f21b349bfa5704135caca6d9b94 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:29:49 +0200 Subject: [PATCH 01/12] ci(release): update Node.js version, improve npm setup, and refine release configs --- .github/workflows/release.yml | 11 +++++------ .gitignore | 1 + .release-it.cjs | 5 ++++- package.json | 6 +++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b2a395..21409e3 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 @@ -83,7 +82,7 @@ jobs: fi NPM_TAG_ARG="--npm.tag=${NPM_TAG}" - npm run release -- --ci $PREID_ARG $NPM_TAG_ARG $VERSION_ARG + npx release-it --ci $PREID_ARG $NPM_TAG_ARG $VERSION_ARG - name: Sync main → develop uses: devmasx/merge-branch@v1.4.0 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/.release-it.cjs b/.release-it.cjs index f39a630..333df10 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -139,8 +139,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: { diff --git a/package.json b/package.json index 14c0be8..aa9b8a3 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,11 @@ ], "repository": { "type": "git", - "url": "https://github.com/addon-stack/inject-css" + "url": "git+https://github.com/addon-stack/inject-css.git" + }, + "publishConfig": { + "access": "public", + "provenance": true }, "homepage": "https://github.com/addon-stack/inject-css", "bugs": { From 7802515336077bf24fc34d8d21af324dfdb746d6 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:24:52 +0300 Subject: [PATCH 02/12] feat!: align CSS injection contract across MV2 and MV3 BREAKING CHANGE: InjectCssOptions now requires a nested target. Legacy flat target fields are replaced by allFrames, frameIds, and documentIds selectors. Target changes now use target(). Empty file arrays are rejected. Native defaults stay omitted. --- src/AbstractInjectCss.ts | 100 +++++- src/InjectCssV2.ts | 95 ++++-- src/InjectCssV3.ts | 135 ++++++-- src/errors.ts | 98 ++++++ src/index.ts | 29 +- src/types.ts | 58 +++- src/validation.ts | 173 ++++++++++ tests/inject-css.test.cjs | 673 ++++++++++++++++++++++++++++++++++++++ tests/tsconfig.json | 12 + tests/types.test.ts | 86 +++++ 10 files changed, 1365 insertions(+), 94 deletions(-) create mode 100644 src/errors.ts create mode 100644 src/validation.ts create mode 100644 tests/inject-css.test.cjs create mode 100644 tests/tsconfig.json create mode 100644 tests/types.test.ts diff --git a/src/AbstractInjectCss.ts b/src/AbstractInjectCss.ts index d2127ed..132289c 100644 --- a/src/AbstractInjectCss.ts +++ b/src/AbstractInjectCss.ts @@ -1,33 +1,105 @@ -import type {InjectCssContract, InjectCssOptions} from "./types"; +import {InjectCssDeliveryError, InjectCssTimeoutError} from "./errors"; +import { + validateInjectCssCode, + validateInjectCssExecutionOptions, + validateInjectCssFiles, + validateInjectCssOptions, + validateInjectCssTarget, +} from "./validation"; +import type { + InjectCssContract, + InjectCssExecutionOptions, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; + +const DEFAULT_TIMEOUT_MS = 4_000; export default abstract class implements InjectCssContract { - constructor(protected _options: InjectCssOptions) {} + protected _target: InjectCssTarget; + protected _execution: InjectCssExecutionOptions; + + public constructor(options: InjectCssOptions) { + const normalized = validateInjectCssOptions(options); + + this._target = normalized.target; + this._execution = normalized.execution; + } + + public target(target: InjectCssTarget): this { + const normalizedTarget = validateInjectCssTarget(target); - public options(options: Partial): this { - this._options = {...this._options, ...options, tabId: options.tabId ?? this._options.tabId}; + this.assertAdapterSupport(normalizedTarget, this._execution); + this._target = normalizedTarget; + + return this; + } + + public options(options: Partial): this { + const normalizedOptions = validateInjectCssExecutionOptions(options); + const nextExecution = {...this._execution, ...normalizedOptions}; + + this.assertAdapterSupport(this._target, nextExecution); + this._execution = nextExecution; return this; } public abstract insert(css: string): Promise; - public abstract file(files: string | string[]): Promise; + public abstract file(files: string | NonEmptyReadonlyArray): Promise; - protected get frameIds(): number[] | undefined { - const {frameId} = this._options; + protected abstract assertAdapterSupport(target: InjectCssTarget, execution: InjectCssExecutionOptions): void; - return typeof frameId === "number" ? [frameId] : typeof frameId !== "boolean" ? frameId : undefined; + protected validateCode(css: string): string { + return validateInjectCssCode(css); } - protected get allFrames(): boolean | undefined { - const {frameId} = this._options; + protected normalizeFiles(files: string | NonEmptyReadonlyArray): string[] { + return validateInjectCssFiles(files); + } + + protected snapshotTarget(): InjectCssTarget { + return validateInjectCssTarget(this._target); + } + + protected snapshotExecution(): InjectCssExecutionOptions { + return {...this._execution}; + } + + protected get timeoutMs(): number { + return this._execution.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + protected async withTimeout(task: Promise, target: InjectCssTarget, 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 InjectCssTimeoutError(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: InjectCssTarget, error: unknown): Error { + if (error instanceof InjectCssDeliveryError || error instanceof InjectCssTimeoutError) { + return error; + } - return typeof matchAboutBlank === "boolean" ? matchAboutBlank : true; + return new InjectCssDeliveryError(target, error); } } diff --git a/src/InjectCssV2.ts b/src/InjectCssV2.ts index 0bbdcdf..1f17977 100644 --- a/src/InjectCssV2.ts +++ b/src/InjectCssV2.ts @@ -1,59 +1,84 @@ import {insertCssTab} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; +import {UnsupportedInjectCssTargetError} from "./errors"; +import type {InjectCssExecutionOptions, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; type CSSOrigin = chrome.extensionTypes.CSSOrigin; type InjectDetails = chrome.extensionTypes.InjectDetails; export default class extends AbstractInjectCss { + public constructor(options: InjectCssOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); + } + public async insert(code: string): Promise { - const {tabId, runAt} = this._options; + this.validateCode(code); - const details: InjectDetails = { - code, - runAt, - cssOrigin: this.cssOrigin, - matchAboutBlank: this.matchAboutBlank, - }; + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + const details = this.createDetails(execution, {code}); - if (this.allFrames) { - await insertCssTab(tabId, {...details, allFrames: true}); - } else if (this.frameIds) { - await Promise.all(this.frameIds.map(frameId => insertCssTab(tabId, {...details, frameId}))); - } else { - await insertCssTab(tabId, details); + try { + await this.withTimeout(this.execute(target, details), target, timeoutMs); + } catch (error) { + throw this.deliveryError(target, error); } } - public async file(files: string | string[]): Promise { - const {tabId, runAt} = this._options; + 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; - const fileList = typeof files === "string" ? [files] : files; + const task = (async (): Promise => { + for (const file of fileList) { + if (stopped) return; - const injectTasks: Promise[] = []; + await this.execute(target, this.createDetails(execution, {file})); + } + })(); - for (const file of fileList) { - const details: InjectDetails = { - file, - runAt, - cssOrigin: this.cssOrigin, - matchAboutBlank: this.matchAboutBlank, - }; + try { + await this.withTimeout(task, target, timeoutMs); + } catch (error) { + stopped = true; + throw this.deliveryError(target, error); + } + } - if (this.allFrames) { - injectTasks.push(insertCssTab(tabId, {...details, allFrames: true})); - } else if (this.frameIds) { - injectTasks.push(...this.frameIds.map(frameId => insertCssTab(tabId, {...details, frameId}))); - } else { - injectTasks.push(insertCssTab(tabId, details)); - } + protected assertAdapterSupport(target: InjectCssTarget, _execution: InjectCssExecutionOptions): void { + if ("documentIds" in target && target.documentIds !== undefined) { + throw new UnsupportedInjectCssTargetError('"documentIds" are not supported by the MV2 adapter.'); } + } - await Promise.all(injectTasks); + private createDetails( + execution: InjectCssExecutionOptions, + source: Pick | Pick + ): InjectDetails { + return { + ...source, + ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.origin !== undefined ? {cssOrigin: execution.origin.toLowerCase() as CSSOrigin} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), + }; } - protected get cssOrigin(): CSSOrigin | undefined { - const {origin} = this._options; + private async execute(target: InjectCssTarget, details: InjectDetails): Promise { + if ("allFrames" in target && target.allFrames === true) { + await insertCssTab(target.tabId, {...details, allFrames: true}); + return; + } + + if ("frameIds" in target && target.frameIds !== undefined) { + await Promise.all(target.frameIds.map(frameId => insertCssTab(target.tabId, {...details, frameId}))); + return; + } - return origin && (origin.toLowerCase() as CSSOrigin); + await insertCssTab(target.tabId, details); } } diff --git a/src/InjectCssV3.ts b/src/InjectCssV3.ts index 8ccc941..fec789b 100644 --- a/src/InjectCssV3.ts +++ b/src/InjectCssV3.ts @@ -1,58 +1,127 @@ import {insertCss} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; +import {UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError} from "./errors"; +import type {InjectCssExecutionOptions, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; +type CSSInjection = chrome.scripting.CSSInjection; type InjectionTarget = chrome.scripting.InjectionTarget; export default class extends AbstractInjectCss { - public async insert(css: string): Promise { - await insertCss({ - target: this.target, - origin: this._options.origin, - css, - }); + public constructor(options: InjectCssOptions) { + super(options); + this.assertAdapterSupport(this._target, this._execution); } - public async file(fileList: string | string[]): Promise { - await insertCss({ - target: this.target, - origin: this._options.origin, - files: typeof fileList === "string" ? [fileList] : fileList, - }); + public async insert(css: string): Promise { + this.validateCode(css); + + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + await this.execute( + target, + execution, + { + target: this.toNativeTarget(target), + css, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); } - protected get 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; + + await this.execute( + target, + execution, + { + target: this.toNativeTarget(target), + files: fileList, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); + } - if (this.frameIds && this.frameIds.length > 0) { - return {...target, frameIds: this.frameIds}; + protected assertAdapterSupport(_target: InjectCssTarget, execution: InjectCssExecutionOptions): void { + if (execution.matchAboutBlank !== undefined) { + throw new UnsupportedInjectCssOptionError('"matchAboutBlank" is not supported by the MV3 adapter.'); } - if (this.allFrames === true) { - return {...target, allFrames: true}; + if (execution.runAt !== undefined) { + throw new UnsupportedInjectCssOptionError('"runAt" is not supported by the MV3 adapter.'); } + } - // Firefox does not support `documentIds` in the target - // getBrowserInfo is only available in firefox - let isFirefox = false; + private async execute( + target: InjectCssTarget, + execution: InjectCssExecutionOptions, + injection: CSSInjection, + timeoutMs: number + ): Promise { try { - // @ts-expect-error - isFirefox = !!browser().runtime.getBrowserInfo; - } catch (_e) {} + await this.withTimeout(insertCss(injection), target, timeoutMs); + } catch (error) { + if (this.isUnsupportedDocumentTargetError(target, error)) { + throw new UnsupportedInjectCssTargetError( + '"documentIds" are not supported by the current browser.', + error + ); + } - if (!isFirefox) { - const documentIds = this.documentIds; + this.throwUnsupportedOriginCapability(execution, error); + throw this.deliveryError(target, error); + } + } - if (documentIds && documentIds.length > 0) { - return {...target, documentIds}; - } + private toNativeTarget(target: InjectCssTarget): InjectionTarget { + if ("frameIds" in target && target.frameIds !== undefined) { + return {tabId: target.tabId, frameIds: [...target.frameIds]}; + } + + 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: InjectCssTarget, 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) && + /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i.test(message) + ); } - protected get documentIds(): string[] | undefined { - const {documentId} = this._options; + private throwUnsupportedOriginCapability(execution: InjectCssExecutionOptions, error: unknown): void { + if (execution.origin === undefined) return; + + const message = error instanceof Error ? error.message : String(error); + + if (/\b(?:(?:css|style)[\s_-]*)?origin\b/i.test(message) && this.isUnsupportedCapabilityMessage(message)) { + throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); + } + } - return typeof documentId === "string" ? [documentId] : documentId; + 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..b2ca689 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,98 @@ +import type {InjectCssTarget} from "./types"; + +export type InjectCssErrorCode = + | "ERR_INJECT_CSS_DELIVERY" + | "ERR_INJECT_CSS_INVALID_CODE" + | "ERR_INJECT_CSS_INVALID_FILES" + | "ERR_INJECT_CSS_INVALID_OPTIONS" + | "ERR_INJECT_CSS_INVALID_TARGET" + | "ERR_INJECT_CSS_TIMEOUT" + | "ERR_INJECT_CSS_UNSUPPORTED_OPTION" + | "ERR_INJECT_CSS_UNSUPPORTED_TARGET"; + +export class InjectCssBaseError extends Error { + public readonly code: InjectCssErrorCode; + public override readonly cause?: unknown; + + protected constructor(name: string, code: InjectCssErrorCode, message: string, cause?: unknown) { + super(message); + this.name = name; + this.code = code; + + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export class InvalidInjectCssTargetError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssTargetError", "ERR_INJECT_CSS_INVALID_TARGET", `Invalid InjectCss target: ${message}`); + } +} + +export class UnsupportedInjectCssTargetError extends InjectCssBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectCssTargetError", + "ERR_INJECT_CSS_UNSUPPORTED_TARGET", + `Unsupported InjectCss target: ${message}`, + cause + ); + } +} + +export class InvalidInjectCssOptionsError extends InjectCssBaseError { + public constructor(message: string) { + super( + "InvalidInjectCssOptionsError", + "ERR_INJECT_CSS_INVALID_OPTIONS", + `Invalid InjectCss options: ${message}` + ); + } +} + +export class UnsupportedInjectCssOptionError extends InjectCssBaseError { + public constructor(message: string, cause?: unknown) { + super( + "UnsupportedInjectCssOptionError", + "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + `Unsupported InjectCss option: ${message}`, + cause + ); + } +} + +export class InvalidInjectCssCodeError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssCodeError", "ERR_INJECT_CSS_INVALID_CODE", `Invalid InjectCss code: ${message}`); + } +} + +export class InvalidInjectCssFilesError extends InjectCssBaseError { + public constructor(message: string) { + super("InvalidInjectCssFilesError", "ERR_INJECT_CSS_INVALID_FILES", `Invalid InjectCss files: ${message}`); + } +} + +export class InjectCssTimeoutError extends InjectCssBaseError { + public readonly target: InjectCssTarget; + public readonly timeoutMs: number; + + public constructor(target: InjectCssTarget, timeoutMs: number) { + super("InjectCssTimeoutError", "ERR_INJECT_CSS_TIMEOUT", `CSS injection timed out after ${timeoutMs} ms.`); + this.target = target; + this.timeoutMs = timeoutMs; + } +} + +export class InjectCssDeliveryError extends InjectCssBaseError { + public readonly target: InjectCssTarget; + + public constructor(target: InjectCssTarget, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + + super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", `CSS injection failed: ${message}`, cause); + this.target = target; + } +} diff --git a/src/index.ts b/src/index.ts index 4a91110..b1a5f4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,33 @@ import InjectCssV2 from "./InjectCssV2"; import InjectCssV3 from "./InjectCssV3"; import type {InjectCssContract, InjectCssOptions} from "./types"; -export type {InjectCssContract, InjectCssOptions}; +export { + InjectCssBaseError, + InjectCssDeliveryError, + InjectCssTimeoutError, + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +export type {InjectCssErrorCode} from "./errors"; +export type { + InjectCssAllFramesTarget, + InjectCssContract, + InjectCssDocumentsTarget, + InjectCssExecutionOptions, + InjectCssFramesTarget, + InjectCssOptions, + InjectCssOrigin, + InjectCssTarget, + InjectCssTopFrameTarget, + NonEmptyReadonlyArray, +} from "./types"; -export default (options: InjectCssOptions): InjectCssContract => { +export const injectCss = (options: InjectCssOptions): InjectCssContract => { return isManifestVersion3() ? new InjectCssV3(options) : new InjectCssV2(options); }; + +export default injectCss; diff --git a/src/types.ts b/src/types.ts index a046d8f..becc614 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,23 +1,61 @@ type RunAt = chrome.extensionTypes.RunAt; type StyleOrigin = chrome.scripting.StyleOrigin; -export interface InjectCssOptions { +export type NonEmptyReadonlyArray = readonly [T, ...T[]]; + +export type InjectCssOrigin = StyleOrigin | `${StyleOrigin}`; + +export interface InjectCssTopFrameTarget { tabId: number; - frameId?: boolean | number | number[]; - matchAboutBlank?: boolean; - origin?: StyleOrigin; + allFrames?: never; + frameIds?: never; + documentIds?: never; +} - // Options for MV2 +export interface InjectCssAllFramesTarget { + tabId: number; + allFrames: true; + frameIds?: never; + documentIds?: never; +} + +export interface InjectCssFramesTarget { + tabId: number; + frameIds: NonEmptyReadonlyArray; + allFrames?: never; + documentIds?: never; +} + +export interface InjectCssDocumentsTarget { + tabId: number; + documentIds: NonEmptyReadonlyArray; + allFrames?: never; + frameIds?: never; +} + +export type InjectCssTarget = + | InjectCssTopFrameTarget + | InjectCssAllFramesTarget + | InjectCssFramesTarget + | InjectCssDocumentsTarget; + +export interface InjectCssExecutionOptions { + matchAboutBlank?: boolean; runAt?: RunAt; + origin?: InjectCssOrigin; + timeoutMs?: number; +} - // Options for MV3 - documentId?: string | string[]; +export interface InjectCssOptions extends InjectCssExecutionOptions { + target: InjectCssTarget; } export interface InjectCssContract { - insert: (css: string) => Promise; + insert(css: string): Promise; + + file(files: string | NonEmptyReadonlyArray): Promise; - file: (files: string | string[]) => Promise; + target(target: InjectCssTarget): this; - options: (options: Partial) => this; + options(options: Partial): this; } diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..6983f08 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,173 @@ +import { + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, +} from "./errors"; +import type {InjectCssExecutionOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; + +const TARGET_KEYS = new Set(["tabId", "allFrames", "frameIds", "documentIds"]); +const EXECUTION_OPTION_KEYS = new Set(["matchAboutBlank", "runAt", "origin", "timeoutMs"]); +const INJECT_CSS_OPTION_KEYS = new Set(["target", ...EXECUTION_OPTION_KEYS]); +const RUN_AT_VALUES = new Set(["document_start", "document_end", "document_idle"]); +const ORIGIN_VALUES = new Set(["AUTHOR", "USER"]); + +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 InvalidInjectCssOptionsError( + `${subject} contains unknown ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys + .map(key => `"${key}"`) + .join(", ")}.` + ); + } +}; + +const cloneTarget = (target: InjectCssTarget): InjectCssTarget => { + 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 validateInjectCssTarget = (value: unknown): InjectCssTarget => { + if (!isObject(value)) { + throw new InvalidInjectCssTargetError("target must be an object."); + } + + const unknownKeys = Object.keys(value).filter(key => !TARGET_KEYS.has(key)); + + if (unknownKeys.length > 0) { + throw new InvalidInjectCssTargetError( + `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 InvalidInjectCssTargetError('"tabId" must be a non-negative integer.'); + } + + const selectors = ["allFrames", "frameIds", "documentIds"].filter(key => value[key] !== undefined); + + if (selectors.length > 1) { + throw new InvalidInjectCssTargetError('"allFrames", "frameIds", and "documentIds" are mutually exclusive.'); + } + + if (value.allFrames !== undefined && value.allFrames !== true) { + throw new InvalidInjectCssTargetError('"allFrames" must be exactly true when provided.'); + } + + if (value.frameIds !== undefined) { + if (!Array.isArray(value.frameIds) || value.frameIds.length === 0) { + throw new InvalidInjectCssTargetError('"frameIds" must contain at least one frame ID.'); + } + + if (value.frameIds.some(frameId => !Number.isInteger(frameId) || frameId < 0)) { + throw new InvalidInjectCssTargetError("frame ID must be a non-negative integer."); + } + + if (new Set(value.frameIds).size !== value.frameIds.length) { + throw new InvalidInjectCssTargetError('"frameIds" must not contain duplicate frame IDs.'); + } + } + + if (value.documentIds !== undefined) { + if (!Array.isArray(value.documentIds) || value.documentIds.length === 0) { + throw new InvalidInjectCssTargetError('"documentIds" must contain at least one document ID.'); + } + + if (value.documentIds.some(documentId => typeof documentId !== "string" || documentId.trim().length === 0)) { + throw new InvalidInjectCssTargetError("document ID must be a non-empty string."); + } + + if (new Set(value.documentIds).size !== value.documentIds.length) { + throw new InvalidInjectCssTargetError('"documentIds" must not contain duplicate document IDs.'); + } + } + + return cloneTarget(value as unknown as InjectCssTarget); +}; + +export const validateInjectCssExecutionOptions = (value: unknown): InjectCssExecutionOptions => { + if (!isObject(value)) { + throw new InvalidInjectCssOptionsError("execution options must be an object."); + } + + assertKnownKeys(value, EXECUTION_OPTION_KEYS, "execution options"); + + if (value.matchAboutBlank !== undefined && typeof value.matchAboutBlank !== "boolean") { + throw new InvalidInjectCssOptionsError('"matchAboutBlank" must be a boolean.'); + } + + if (value.runAt !== undefined && (typeof value.runAt !== "string" || !RUN_AT_VALUES.has(value.runAt))) { + throw new InvalidInjectCssOptionsError('"runAt" must be "document_start", "document_end", or "document_idle".'); + } + + if (value.origin !== undefined && (typeof value.origin !== "string" || !ORIGIN_VALUES.has(value.origin))) { + throw new InvalidInjectCssOptionsError('"origin" must be "AUTHOR" or "USER".'); + } + + if ( + value.timeoutMs !== undefined && + (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs <= 0) + ) { + throw new InvalidInjectCssOptionsError('"timeoutMs" must be a positive integer.'); + } + + return {...(value as InjectCssExecutionOptions)}; +}; + +export const validateInjectCssOptions = ( + value: unknown +): {target: InjectCssTarget; execution: InjectCssExecutionOptions} => { + if (!isObject(value)) { + throw new InvalidInjectCssOptionsError("options must be an object."); + } + + assertKnownKeys(value, INJECT_CSS_OPTION_KEYS, "options"); + + const {target, ...execution} = value; + + return { + target: validateInjectCssTarget(target), + execution: validateInjectCssExecutionOptions(execution), + }; +}; + +export const validateInjectCssCode = (value: unknown): string => { + if (typeof value !== "string" || value.trim().length === 0) { + throw new InvalidInjectCssCodeError("code must be a non-empty string."); + } + + return value; +}; + +export const validateInjectCssFiles = (files: string | NonEmptyReadonlyArray): string[] => { + const fileList = typeof files === "string" ? [files] : files; + + if (!Array.isArray(fileList) || fileList.length === 0) { + throw new InvalidInjectCssFilesError("at least one file is required."); + } + + if (fileList.some(file => typeof file !== "string" || file.trim().length === 0)) { + throw new InvalidInjectCssFilesError("each file must be a non-empty string."); + } + + return [...fileList]; +}; diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs new file mode 100644 index 0000000..45512b4 --- /dev/null +++ b/tests/inject-css.test.cjs @@ -0,0 +1,673 @@ +const { + default: injectCss, + injectCss: namedInjectCss, + InjectCssBaseError, + InjectCssDeliveryError, + InjectCssTimeoutError, + InvalidInjectCssCodeError, + InvalidInjectCssFilesError, + InvalidInjectCssOptionsError, + InvalidInjectCssTargetError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} = require("../dist/index.cjs"); + +const createRuntime = manifestVersion => ({ + id: "test-extension", + lastError: undefined, + getManifest: () => ({manifest_version: manifestVersion}), +}); + +const flushAsync = () => new Promise(resolve => setImmediate(resolve)); + +describe("package exports", () => { + test("exports the factory as both default and named", () => { + expect(namedInjectCss).toBe(injectCss); + }); +}); + +describe("InjectCss target and execution options", () => { + 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: []}}, '"documentIds" must contain at least one'], + [{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) => { + global.chrome = {runtime: createRuntime(3)}; + + expect(() => injectCss(options)).toThrow(InvalidInjectCssTargetError); + expect(() => injectCss(options)).toThrow(message); + + try { + injectCss(options); + } catch (error) { + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_TARGET"); + } + }); + + test.each([ + [{timeoutMs: 0}, '"timeoutMs" must be a positive integer'], + [{timeoutMs: 1.5}, '"timeoutMs" must be a positive integer'], + [{matchAboutBlank: "yes"}, '"matchAboutBlank" must be a boolean'], + [{runAt: "immediately"}, '"runAt" must be'], + [{origin: "author"}, '"origin" must be "AUTHOR" or "USER"'], + [{unexpected: true}, 'unknown field: "unexpected"'], + ])("rejects invalid execution options %#", (execution, message) => { + global.chrome = {runtime: createRuntime(3)}; + + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(InvalidInjectCssOptionsError); + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(message); + + try { + injectCss({target: {tabId: 1}, ...execution}); + } catch (error) { + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_OPTIONS"); + } + }); + + test.each(["", " ", 42, null])("rejects invalid CSS code %# before native injection", async css => { + const insertCSS = jest.fn((_details, callback) => callback()); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + const rejection = injectCss({target: {tabId: 1}}) + .insert(css) + .catch(error => error); + const error = await rejection; + + expect(error).toBeInstanceOf(InvalidInjectCssCodeError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_CODE"); + expect(insertCSS).not.toHaveBeenCalled(); + }); + + test.each([[[]], [["/valid.css", " "]], [""], [" "], [42]])( + "rejects invalid CSS files %# before native injection", + async files => { + const insertCSS = jest.fn((_details, callback) => callback()); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + const error = await injectCss({target: {tabId: 1}}) + .file(files) + .catch(cause => cause); + + expect(error).toBeInstanceOf(InvalidInjectCssFilesError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error.code).toBe("ERR_INJECT_CSS_INVALID_FILES"); + expect(insertCSS).not.toHaveBeenCalled(); + } + ); + + test("copies target arrays instead of retaining caller-owned state", async () => { + const calls = []; + const frameIds = [1]; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 4, frameIds}}); + frameIds.push(2); + + await injector.insert("body { color: red; }"); + + expect(calls[0].target).toEqual({tabId: 4, frameIds: [1]}); + }); + + test("atomically replaces targets and prevents options() from mutating them", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 4, frameIds: [2]}}); + + expect(() => injector.target({tabId: 4, frameIds: []})).toThrow(InvalidInjectCssTargetError); + expect(() => injector.options({target: {tabId: 9}})).toThrow(InvalidInjectCssOptionsError); + expect(injector.options({origin: "AUTHOR"})).toBe(injector); + + await injector.file("/content.css"); + + expect(calls[0].target).toEqual({tabId: 4, frameIds: [2]}); + }); +}); + +describe("MV3 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + jest.useRealTimers(); + }); + + 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"]}, + ], + ])("maps target %# to one native CSS injection", async (target, nativeTarget) => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await expect(injectCss({target}).insert("body { color: red; }")).resolves.toBeUndefined(); + + expect(calls).toEqual([{target: nativeTarget, css: "body { color: red; }"}]); + }); + + test("supports Promise-based MV3 delivery through the browser namespace", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(3), + scripting: { + insertCSS: details => { + calls.push(details); + return Promise.resolve(); + }, + }, + }; + + await expect(injectCss({target: {tabId: 12}}).file("/content.css")).resolves.toBeUndefined(); + expect(calls).toEqual([{target: {tabId: 12}, files: ["/content.css"]}]); + }); + + test("passes ordered files as a native batch and preserves canonical origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await expect( + injectCss({target: {tabId: 7}, origin: "USER"}).file(["/first.css", "/second.css"]) + ).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + target: {tabId: 7}, + files: ["/first.css", "/second.css"], + origin: "USER", + }, + ]); + }); + + test("does not materialize an omitted origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 7}}).file("/content.css"); + + expect(calls[0]).not.toHaveProperty("origin"); + }); + + test.each([ + [{matchAboutBlank: false}, '"matchAboutBlank" is not supported'], + [{matchAboutBlank: true}, '"matchAboutBlank" is not supported'], + [{runAt: "document_start"}, '"runAt" is not supported'], + [{runAt: "document_end"}, '"runAt" is not supported'], + [{runAt: "document_idle"}, '"runAt" is not supported'], + ])("rejects unsupported execution option %# before native injection", (execution, message) => { + const insertCSS = jest.fn(); + global.chrome = {runtime: createRuntime(3), scripting: {insertCSS}}; + + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(UnsupportedInjectCssOptionError); + expect(() => injectCss({target: {tabId: 1}, ...execution})).toThrow(message); + + try { + injectCss({target: {tabId: 1}, ...execution}); + } catch (error) { + expect(error.code).toBe("ERR_INJECT_CSS_UNSUPPORTED_OPTION"); + } + + expect(insertCSS).not.toHaveBeenCalled(); + }); + + test("passes document targets directly without browser-name fallback", async () => { + const calls = []; + const runtime = {...createRuntime(3), getBrowserInfo: () => Promise.resolve({name: "Firefox"})}; + + global.chrome = { + runtime, + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 7, documentIds: ["doc"]}}).file("/content.css"); + + expect(calls[0].target).toEqual({tabId: 7, documentIds: ["doc"]}); + }); + + test("normalizes a native documentIds capability error without falling back", async () => { + const runtime = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + insertCSS: (details, callback) => { + calls.push(details); + global.chrome.runtime.lastError = {message: 'Unexpected property "documentIds"'}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2, documentIds: ["doc"]}}) + .file("/file.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssTargetError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_TARGET", + cause: expect.any(Error), + }); + expect(calls).toHaveLength(1); + expect(calls[0].target).toEqual({tabId: 2, documentIds: ["doc"]}); + }); + + test("keeps a stale documentId failure classified as a delivery error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Invalid documentId: stale-doc"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 2, documentIds: ["stale-doc"]}; + const error = await injectCss({target}) + .file("/file.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).not.toBeInstanceOf(UnsupportedInjectCssTargetError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + }); + + test("normalizes a native origin capability error", async () => { + const runtime = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: 'Unexpected property "origin"'}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + }); + + test("reports native delivery failures with the target and cause", async () => { + const runtime = createRuntime(3); + const target = {tabId: 2, frameIds: [0, 3]}; + + global.chrome = { + runtime, + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + expect(error.cause.message).toBe("Missing host permission"); + }); + + test("reports timeouts with the target and configured timeout", async () => { + jest.useFakeTimers(); + + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: () => {}, + }, + }; + + const target = {tabId: 2, allFrames: true}; + const pending = injectCss({target, timeoutMs: 5}) + .file("/file.css") + .catch(error => error); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toBeInstanceOf(InjectCssBaseError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_TIMEOUT", + target, + timeoutMs: 5, + }); + }); +}); + +describe("MV2 adapter", () => { + afterEach(() => { + delete global.chrome; + delete global.browser; + jest.useRealTimers(); + }); + + test.each([ + [{tabId: 7}, [{tabId: 7, details: {code: "body { color: red; }"}}]], + [{tabId: 7, allFrames: true}, [{tabId: 7, details: {code: "body { color: red; }", allFrames: true}}]], + [ + {tabId: 7, frameIds: [0, 2]}, + [ + {tabId: 7, details: {code: "body { color: red; }", frameId: 0}}, + {tabId: 7, details: {code: "body { color: red; }", frameId: 2}}, + ], + ], + ])("maps target %# to tabs.insertCSS calls", async (target, expectedCalls) => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callback(); + }, + }, + }; + + await expect(injectCss({target}).insert("body { color: red; }")).resolves.toBeUndefined(); + + expect(calls).toEqual(expectedCalls); + }); + + test("supports Promise-based MV2 delivery through the browser namespace", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details) => { + calls.push({tabId, details}); + return Promise.resolve(); + }, + }, + }; + + await expect(injectCss({target: {tabId: 12}}).insert("body { color: red; }")).resolves.toBeUndefined(); + expect(calls).toEqual([{tabId: 12, details: {code: "body { color: red; }"}}]); + }); + + test("maps canonical origin to MV2 casing and preserves supported execution options", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({ + target: {tabId: 1}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + }).file("/content.css"); + + expect(calls[0]).toEqual({ + file: "/content.css", + cssOrigin: "user", + matchAboutBlank: true, + runAt: "document_start", + }); + }); + + test("does not materialize omitted matchAboutBlank, runAt, or origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + await injectCss({target: {tabId: 1}}).file("/content.css"); + + expect(calls[0]).toEqual({file: "/content.css"}); + expect(calls[0]).not.toHaveProperty("matchAboutBlank"); + }); + + test("rejects document targets before native injection and retains the previous target", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callback(); + }, + }, + }; + + expect(() => injectCss({target: {tabId: 1, documentIds: ["doc"]}})).toThrow(UnsupportedInjectCssTargetError); + + const injector = injectCss({target: {tabId: 1}}); + expect(() => injector.target({tabId: 9, documentIds: ["doc"]})).toThrow(UnsupportedInjectCssTargetError); + + try { + injector.target({tabId: 9, documentIds: ["doc"]}); + } catch (error) { + expect(error.code).toBe("ERR_INJECT_CSS_UNSUPPORTED_TARGET"); + } + + await injector.file("/content.css"); + + expect(calls).toEqual([{tabId: 1, details: {file: "/content.css"}}]); + }); + + test("reports native delivery failures with the target and cause", async () => { + const runtime = createRuntime(2); + const target = {tabId: 2}; + + global.chrome = { + runtime, + tabs: { + insertCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + cause: expect.any(Error), + }); + }); + + test("injects files sequentially while dispatching each file to frames in parallel", async () => { + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const pending = injectCss({target: {tabId: 6, frameIds: [0, 3]}}).file(["/first.css", "/second.css"]); + + expect(calls).toEqual([ + {tabId: 6, details: {file: "/first.css", frameId: 0}}, + {tabId: 6, details: {file: "/first.css", frameId: 3}}, + ]); + + callbacks.shift()(); + await flushAsync(); + expect(calls).toHaveLength(2); + + callbacks.shift()(); + await flushAsync(); + expect(calls).toEqual([ + {tabId: 6, details: {file: "/first.css", frameId: 0}}, + {tabId: 6, details: {file: "/first.css", frameId: 3}}, + {tabId: 6, details: {file: "/second.css", frameId: 0}}, + {tabId: 6, details: {file: "/second.css", frameId: 3}}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await expect(pending).resolves.toBeUndefined(); + }); + + test("does not start a later MV2 file after the operation times out", async () => { + jest.useFakeTimers(); + + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const target = {tabId: 6, frameIds: [0, 3]}; + const pending = injectCss({target, timeoutMs: 5}) + .file(["/first.css", "/second.css"]) + .catch(error => error); + + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toMatchObject({target, timeoutMs: 5}); + + callbacks.forEach(callback => { + callback(); + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + }); +}); 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..f738266 --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,86 @@ +import injectCss, { + type InjectCssErrorCode, + type InjectCssExecutionOptions, + type InjectCssOrigin, + type InjectCssTarget, + type NonEmptyReadonlyArray, + injectCss as namedInjectCss, +} from "../src/index"; + +declare const tabId: number; +declare const frameId: number; +declare const documentId: string; + +const topFrame = injectCss({target: {tabId}}); + +namedInjectCss({target: {tabId}}); + +injectCss({target: {tabId, allFrames: true}}); +injectCss({target: {tabId, frameIds: [0, frameId]}}); +injectCss({target: {tabId, documentIds: [documentId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectCss({target: {tabId, allFrames: true, frameIds: [frameId]}}); + +// @ts-expect-error selectors are mutually exclusive +injectCss({target: {tabId, frameIds: [frameId], documentIds: [documentId]}}); + +// @ts-expect-error explicit frame targets must not be empty +injectCss({target: {tabId, frameIds: []}}); + +// @ts-expect-error explicit document targets must not be empty +injectCss({target: {tabId, documentIds: []}}); + +// @ts-expect-error allFrames only accepts literal true +injectCss({target: {tabId, allFrames: false}}); + +const authorOrigin: InjectCssOrigin = "AUTHOR"; +const userOrigin: InjectCssOrigin = "USER"; + +injectCss({target: {tabId}, origin: authorOrigin}); +injectCss({target: {tabId}, origin: userOrigin}); +injectCss({target: {tabId}, runAt: "document_start", matchAboutBlank: false, timeoutMs: 50}); + +// @ts-expect-error origins use the canonical WebExtension casing +injectCss({target: {tabId}, origin: "author"}); + +const inserted: Promise = topFrame.insert("body { color: red; }"); +const singleFile: Promise = topFrame.file("/content.css"); +const files: NonEmptyReadonlyArray = ["/first.css", "/second.css"]; +const multipleFiles: Promise = topFrame.file(files); + +topFrame.file(["/content.css"]); + +// @ts-expect-error at least one file is required +topFrame.file([]); + +// @ts-expect-error every file must be a string +topFrame.file([1]); + +// @ts-expect-error CSS code must be a string +topFrame.insert(42); + +topFrame.target({tabId, frameIds: [frameId]}).options({origin: "USER", timeoutMs: 100}); + +// @ts-expect-error target changes belong to target(), not options() +topFrame.options({target: {tabId: 99}}); + +// @ts-expect-error legacy flat target fields are not execution options +topFrame.options({tabId: 99}); + +const executionOptions: InjectCssExecutionOptions = { + matchAboutBlank: true, + origin: "AUTHOR", + runAt: "document_idle", + timeoutMs: 1_000, +}; +const target: InjectCssTarget = {tabId, documentIds: [documentId]}; +const errorCode: InjectCssErrorCode = "ERR_INJECT_CSS_DELIVERY"; + +topFrame.options(executionOptions); +topFrame.target(target); +errorCode.toUpperCase(); + +void inserted; +void singleFile; +void multipleFiles; From bad2749b209ff23ef5c6b7cdae3c6df422e8bd68 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:25:31 +0300 Subject: [PATCH 03/12] build: align validation and release tooling with inject-script --- .github/workflows/release.yml | 3 +- .husky/pre-commit | 6 +- .husky/pre-push | 1 - .release-it.cjs | 100 +++++--- biome.json | 1 + package-lock.json | 442 +++++++++++++++++++++++++++++++--- package.json | 26 +- tests/release-it.test.cjs | 43 ++++ tsconfig.json | 3 - 9 files changed, 544 insertions(+), 81 deletions(-) create mode 100644 tests/release-it.test.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21409e3..1da6296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: fi NPM_TAG_ARG="--npm.tag=${NPM_TAG}" - npx release-it --ci $PREID_ARG $NPM_TAG_ARG $VERSION_ARG + npm run release -- --ci $PREID_ARG $NPM_TAG_ARG $VERSION_ARG - name: Sync main → develop uses: devmasx/merge-branch@v1.4.0 @@ -93,4 +93,3 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} env: HUSKY: 0 - diff --git a/.husky/pre-commit b/.husky/pre-commit index 7a43b16..1118438 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +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 -npm run lint:fix:aggressive || exit 1; -npm run test || exit 1; -npm run format || exit 1; +npx --no -- lint-staged diff --git a/.husky/pre-push b/.husky/pre-push index 6ea0353..4c4af85 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -4,4 +4,3 @@ npm run typecheck || exit 1 npm run test:ci || exit 1 -npm run build || exit 1 diff --git a/.release-it.cjs b/.release-it.cjs index 333df10..f979ad8 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -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 { @@ -168,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", @@ -257,3 +291,5 @@ module.exports = () => { }, }; }; + +module.exports = Object.assign(createReleaseConfig, {whatBump}); diff --git a/biome.json b/biome.json index 22ecd90..88aad84 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,cjs,mjs}", "**/*.{json,jsonc,md,mdx,cjs,mjs}", "!coverage/**", "!dist/**" diff --git a/package-lock.json b/package-lock.json index 1f200c9..cad357d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,29 +9,30 @@ "version": "0.3.1", "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.2.2" + "@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", + "lint-staged": "^16.2.1", "release-it": "^19.0.5", "tsup": "^8.5.0", "typescript": "^5.9.2" } }, "node_modules/@addon-core/browser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.2.3.tgz", - "integrity": "sha512-q0wIKy682I8WxssCB5YmKzZzx1kD1wXur5ddCFD9zupZGmY7k+F7+w3gPh983oCGLDpTEk72s19cUfdQppIXGg==", + "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": { @@ -3171,9 +3172,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.24", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.24.tgz", - "integrity": "sha512-9iO9HL2bMeGS4C8m6gNFWUyuPE5HEUFk+rGh+7oriUjg+ata4Fc9PoVlu8xvGm7yoo3AmS3J6fAjoFj61NL2rw==", + "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": "*", @@ -4194,6 +4195,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4329,6 +4364,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5030,6 +5072,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5171,6 +5226,13 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5387,9 +5449,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5529,24 +5591,6 @@ } } }, - "node_modules/git-semver-tags/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/git-up": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/git-up/-/git-up-8.1.1.tgz", @@ -7315,6 +7359,127 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -7456,6 +7621,131 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -8681,6 +8971,13 @@ "node": ">= 4" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", @@ -8837,6 +9134,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8989,6 +9332,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9227,11 +9580,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -9925,6 +10281,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index aa9b8a3..1905f53 100644 --- a/package.json +++ b/package.json @@ -52,29 +52,47 @@ "prepare": "husky", "build": "tsup", "build:watch": "tsup --watch", + "dev": "tsup --watch", "prepublishOnly": "npm run build", "format": "biome format --write .", "format:check": "biome check --formatter-enabled=true --linter-enabled=false .", "lint": "biome check .", "lint:fix": "biome check --write .", "lint:fix:aggressive": "biome check --write --unsafe .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "test": "npm run test:types && npm run build && jest --bail", + "test:ci": "npm run test:types && npm run build && jest --ci --coverage", + "test:types": "tsc -p tests/tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "jest --bail --passWithNoTests", - "test:ci": "jest --ci --passWithNoTests --coverage", "release": "release-it" }, + "lint-staged": { + "{src,tests}/**/*.{js,jsx,ts,tsx,cjs,mjs}": [ + "biome check --write --unsafe" + ], + "*.{json,css,scss,html}": [ + "biome format --write" + ] + }, + "jest": { + "coverageProvider": "v8", + "testMatch": [ + "/tests/**/*.test.cjs" + ] + }, "dependencies": { - "@addon-core/browser": "^0.2.2" + "@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", + "lint-staged": "^16.2.1", "release-it": "^19.0.5", "tsup": "^8.5.0", "typescript": "^5.9.2" diff --git a/tests/release-it.test.cjs b/tests/release-it.test.cjs new file mode 100644 index 0000000..5c17b5d --- /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-css)!: 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/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 From 141a9e6115979895eec5e466b7f96443b1bf1b03 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:25:48 +0300 Subject: [PATCH 04/12] docs: explain the cross-manifest CSS injection contract --- CONTRIBUTING.md | 21 +++- README.md | 290 ++++++++++++++++++++++++++++++++++++------------ SECURITY.md | 2 +- 3 files changed, 236 insertions(+), 77 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6827e25..0775f39 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ # Contributing to @addon-core/inject-css -This document outlines the process for contributing, reporting issues, and submitting patches. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). +This package provides one typed CSS-injection contract across Manifest V2 and Manifest V3. Contributions should preserve that boundary, keep unsupported capabilities explicit, and include verification for every affected adapter. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). ## Table of Contents @@ -70,17 +70,30 @@ The following scripts are available and should be used during development: - `npm run build` — build the project with tsup - `npm run build:watch` — build in watch mode +- `npm run dev` — build in watch mode - `npm run format` — format code with Biome - `npm run format:check` — check formatting only - `npm run lint` — lint code with Biome - `npm run lint:fix` — attempt to automatically fix lint issues - `npm run lint:fix:aggressive` — fix lint issues using unsafe rules +- `npm run lint:fix:unsafe` — fix lint issues using unsafe rules - `npm run typecheck` — run TypeScript type checks -- `npm run test` — run tests with Jest +- `npm run test:types` — type-check public API contract fixtures +- `npm run test` — type-check fixtures, build the package, and run Jest - `npm run test:ci` — run tests in CI with coverage - `npm run release` — trigger release via release-it -Note: Husky hooks are configured. On commit, your message is validated with commitlint; on pre-commit, linting/formatting/tests are run. +Note: Husky hooks are configured. Commit messages are validated with commitlint, pre-commit runs `lint-staged`, and pre-push runs the full type/test/build path. + +## Contract Guidelines + +- Every operation has one explicit `target`. +- `allFrames`, `frameIds`, and `documentIds` remain mutually exclusive. +- Unsupported targets and options fail explicitly; they are never removed silently or replaced with a broader target. +- `insert()` and `file()` remain strict `Promise` operations because native CSS APIs expose no portable per-frame result. +- File order is part of the CSS cascade contract and must be preserved. +- Omitted options preserve native defaults. +- Frame enumeration, document discovery, and application-specific result aggregation remain outside this package. ## Pull Request Workflow @@ -98,7 +111,7 @@ Note: Husky hooks are configured. On commit, your message is validated with comm npm run test ``` 4. Commit changes using [Conventional Commits](https://www.conventionalcommits.org/). -5. Push to your fork and open a Pull Request against the `main` branch. +5. Push to your fork and open a Pull Request against the `develop` branch. 6. Provide a clear title and description, referencing related issues (e.g., `Closes #123`). ## Code Style & Quality diff --git a/README.md b/README.md index 31cbd7e..4f6f2c4 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,271 @@ # @addon-core/inject-css -[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-css.svg?logo=npm)](https://www.npmjs.com/package/@addon-core/inject-css) -[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-css.svg)](https://www.npmjs.com/package/@addon-core/inject-css) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) -[![CI](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml/badge.svg)](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml) +[![npm version](https://img.shields.io/npm/v/%40addon-core%2Finject-css.svg?logo=npm&style=for-the-badge)](https://www.npmjs.com/package/@addon-core/inject-css) +[![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Finject-css.svg?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@addon-core/inject-css) +[![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/inject-css/ci.yml?style=for-the-badge)](https://github.com/addon-stack/inject-css/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 CSS into browser extension pages. -Automatically detects Chrome Extension Manifest V2 and V3 and delegates to the appropriate API via [@addon-core/browser](https://github.com/addon-stack/browser). +Inject CSS code or extension stylesheets into browser tabs with one typed API for Manifest V2 and Manifest V3. -## Table of Contents +`@addon-core/inject-css` selects the correct native adapter, validates the target before injection, and keeps unsupported browser behavior explicit. -- [Installation](#installation) -- [Usage](#usage) - - [Injecting CSS Code](#injecting-css-code) - - [Injecting CSS Files](#injecting-css-files) - - [Updating Options](#updating-options) -- [API](#api) -- [Options](#options) -- [Examples](#examples) -- [License](#license) +- One target model for the top frame, all frames, selected frames, or selected documents +- Runtime validation that matches the TypeScript contract +- Ordered stylesheet injection +- Stable package errors for invalid, unsupported, failed, and timed-out operations +- No silent selector fallback and no extra frame-enumeration permissions -## Installation - -### npm: +## Install ```bash npm install @addon-core/inject-css ``` -### pnpm: - ```bash pnpm add @addon-core/inject-css ``` -### yarn: +Your extension still needs the native permissions required for CSS injection, including `scripting` in MV3 and appropriate host or `activeTab` access. The package does not modify the manifest. -```bash -yarn add @addon-core/inject-css +## Quick start + +```ts +import injectCss from "@addon-core/inject-css"; + +const injector = injectCss({ + target: {tabId: 123}, +}); + +await injector.insert("body { background: #f5f5f5; }"); ``` +The package detects the current manifest version automatically. The same call uses `tabs.insertCSS` in MV2 and `scripting.insertCSS` in MV3. + +## Choose what to target -## Usage +Every injector has exactly one 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 injectCss, {InjectCssOptions} from "@addon-core/inject-css"; +const topFrame = injectCss({ + target: {tabId: 123}, +}); + +const selectedFrames = injectCss({ + target: {tabId: 123, frameIds: [0, 7]}, +}); + +const allFrames = injectCss({ + target: {tabId: 123, allFrames: true}, +}); +``` + +`allFrames` accepts only the literal `true`. Omitting a selector means the top frame; there is no `allFrames: false` mode. + +`documentIds` require an MV3 runtime with native document targeting. An unsupported target throws `UnsupportedInjectCssTargetError`; the package never drops `documentIds` or falls back to a broader target. + +`allFrames` remains one native browser operation. The package does not enumerate frames or promise an exhaustive frame snapshot. + +## Insert CSS code + +```ts +await injector.insert(` + html { + color-scheme: dark; + } + + body { + background: #111; + color: #eee; + } +`); +``` + +The CSS source must be a non-empty string. + +## Insert CSS files + +```ts +await injector.file("styles/content.css"); + +await injector.file([ + "styles/reset.css", + "styles/theme.css", +]); +``` + +File lists must be non-empty and every path must be a non-empty string. Files are injected in the provided order. In MV2, one file completes for the requested target before the next file starts, preserving CSS cascade order. + +## Reuse an injector -// Initialize an injector with a target tab ID -const injector = injectCss({tabId: 123}); +Replace the complete target with `target()`: -// Inject raw CSS code into the page -await injector.insert("body { background-color: #f0f0f0; }"); +```ts +injector + .target({tabId: 123, frameIds: [7]}) + .target({tabId: 123, allFrames: true}); +``` + +The second call replaces the previous selector instead of merging with it. A validation failure leaves the existing target unchanged. -// Inject one or more CSS files (paths relative to extension) -await injector.file("styles/main.css"); -await injector.file(["styles/reset.css", "styles/theme.css"]); +Update only execution options with `options()`: -// Update options dynamically and reuse the injector -injector.options({frameId: true, origin: "USER"}); -await injector.insert("p { color: red; }"); +```ts +injector.options({ + origin: "USER", + timeoutMs: 8_000, +}); ``` -### Injecting CSS Code +`options()` never accepts or changes a target. -Use the `insert(css: string)` method to inject raw CSS code. +## Execution options -### Injecting CSS Files +The portable baseline is to omit adapter-specific options: -Use the `file(files: string | string[])` method to inject CSS file(s). +```ts +const injector = injectCss({ + target: {tabId: 123}, + origin: "AUTHOR", + timeoutMs: 5_000, +}); +``` -### Updating Options +| Option | MV2 | MV3 | +| --- | --- | --- | +| `origin` | Mapped to `author` or `user` | Passed as `AUTHOR` or `USER` | +| `timeoutMs` | Supported; default `4_000` ms | Supported; default `4_000` ms | +| `matchAboutBlank` | Passed only when explicitly set | Rejected; no native equivalent | +| `runAt` | Passed to `tabs.insertCSS` | Rejected; no native equivalent | -Use the `options(opts: Partial)` method to merge or override options on an existing injector instance. +When `matchAboutBlank` is omitted, the package preserves the native default instead of forcing it to `true`. -## API +Explicit unsupported options throw `UnsupportedInjectCssOptionError`. They are never ignored silently. -### `injectCss(options: InjectCssOptions): InjectCssContract` +## Handle failures -Creates a new CSS injector instance. Detects the manifest version (V2 or V3) via `@addon-core/browser` and delegates to the appropriate implementation. +```ts +import {InjectCssBaseError} from "@addon-core/inject-css"; + +try { + await injector.file("styles/content.css"); +} catch (error) { + if (error instanceof InjectCssBaseError) { + console.error(error.code, error.message, error.cause); + } else { + throw error; + } +} +``` + +Every package error extends `InjectCssBaseError` and exposes a stable `code`. Delivery and timeout errors also retain the request target. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. + +Known validation and adapter incompatibilities fail before injection. Browser capabilities discovered only by a native call are normalized after that call. -#### `InjectCssContract` +### What `Promise` means -- `insert(css: string): Promise` — Inject raw CSS code. -- `file(files: string | string[]): Promise` — Inject one or more CSS files. -- `options(opts: Partial): this` — Update injector options. +Native CSS injection APIs do not provide a portable per-frame result. `insert()` and `file()` therefore resolve with no value. -## Options +A resolved promise means the native operation completed. It does not prove that CSS was applied in every requested frame. A rejected multi-target operation is not transactional: some targets or earlier files may already have received CSS. -The injector accepts the following options (passed to `injectCss(options)` and/or `injector.options(opts)`): +In MV2, a timeout stops the package from starting later files in a sequential batch. In MV3, the complete file list is handed to the browser in one native call before a timeout can occur. In either adapter, a timeout cannot cancel a native browser operation that is already in progress. -| Option | Type | Description | -| --------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| tabId | number | Required. Target browser tab ID. | -| frameId | boolean \| number \| number[] | Optional. Select frames to inject into: `true` for all frames; number or array for specific. | -| matchAboutBlank | boolean | Optional. (V2 only) Include `about:blank` and similar subframes. Defaults to `true`. | -| runAt | 'document_start' \| 'document_end' \| 'document_idle' | Optional. (V2 only) Injection timing, matches Chrome's `runAt` in `insertCSS`. | -| documentId | string \| string[] | Optional. (V3 only) Document IDs for scripting targets. | -| origin | 'AUTHOR' \| 'USER' | Optional. CSS origin matching Chrome's API (`cssOrigin` in V2, `origin` in V3). | +## Migrating from 0.3.x -## Examples +Targets now live under the required `target` field: ```ts -import injectCss from "@addon-core/inject-css"; +// Before +const injector = injectCss({ + tabId: 123, + frameId: [1, 2], + origin: "USER", +}); + +injector.options({frameId: true}); -// Initialize with a mix of options +// Now const injector = injectCss({ - tabId: 123, - frameId: [1, 2], // (V2 & V3) - runAt: "document_end", // (V2 only) - documentId: "main-doc-id", // (V3 only) - origin: "AUTHOR", // 'AUTHOR' or 'USER' + target: {tabId: 123, frameIds: [1, 2]}, + origin: "USER", }); -// Inject raw CSS code -await injector.insert("body { background-color: #fafafa; }"); +injector.target({tabId: 123, allFrames: true}); +``` + +Migration map: + +- `{tabId}` becomes `{target: {tabId}}`. +- `frameId: false` becomes a top-frame target with no selector. +- `frameId: 7` becomes `frameIds: [7]`. +- `frameId: [2, 7]` becomes `frameIds: [2, 7]`. +- `documentId: "document-a"` becomes `documentIds: ["document-a"]`. +- Target changes move from `.options()` to `.target()`. +- `file([])` is now a compile-time and runtime error. +- Code relying on the old implicit `matchAboutBlank: true` must set it explicitly in MV2. + +## API reference + +The factory is available as both a default and named export: + +```ts +import injectCss from "@addon-core/inject-css"; +import {injectCss} from "@addon-core/inject-css"; +``` + +```ts +interface InjectCssContract { + insert(css: string): Promise; + file(files: string | NonEmptyReadonlyArray): Promise; + target(target: InjectCssTarget): this; + options(options: Partial): this; +} +``` -// Inject one or more CSS files -await injector.file(["styles/reset.css", "styles/theme.css"]); +Runtime exports: + +```text +injectCss +InjectCssBaseError +InjectCssDeliveryError +InjectCssTimeoutError +InvalidInjectCssCodeError +InvalidInjectCssFilesError +InvalidInjectCssOptionsError +InvalidInjectCssTargetError +UnsupportedInjectCssOptionError +UnsupportedInjectCssTargetError ``` +Core type exports: + +```text +InjectCssContract +InjectCssOptions +InjectCssExecutionOptions +InjectCssOrigin +InjectCssTarget +InjectCssTopFrameTarget +InjectCssAllFramesTarget +InjectCssFramesTarget +InjectCssDocumentsTarget +InjectCssErrorCode +NonEmptyReadonlyArray +``` + +## Design boundaries + +The package focuses on portable programmatic CSS injection. It does not enumerate frames, discover document IDs, aggregate application-specific per-frame results, or claim atomic delivery across targets. + +Removing injected CSS is a separate lifecycle capability and is not part of the current contract. ## License -MIT © Addon Stack +[MIT](LICENSE.md) diff --git a/SECURITY.md b/SECURITY.md index f6e9282..9f0de63 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), the package name (`@addon-core/inject-css`), 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) From 6bd74322d755ef6c4ce5fffec1f47686540e7f29 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:50:07 +0300 Subject: [PATCH 05/12] chore: update package author metadata --- .mailmap | 2 -- package.json | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .mailmap diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 2f327b1..0000000 --- a/.mailmap +++ /dev/null @@ -1,2 +0,0 @@ -Addon Stack <191148085+addon-stack@users.noreply.github.com> -Addon Stack \ No newline at end of file diff --git a/package.json b/package.json index 1905f53..2b1b12c 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,7 @@ "url": "https://github.com/addon-stack/inject-css/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", From 5cc9bcb51235b4bf2469180c29ca4abcada1c542 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:52:33 +0300 Subject: [PATCH 06/12] fix: improve MV2 CSS delivery diagnostics --- README.md | 3 ++- src/InjectCssV2.ts | 33 ++++++++++++++++++++--- src/InjectCssV3.ts | 4 +-- src/errors.ts | 16 +++++++++++ src/index.ts | 1 + tests/inject-css.test.cjs | 56 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4f6f2c4..3ab88e5 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ try { } ``` -Every package error extends `InjectCssBaseError` and exposes a stable `code`. Delivery and timeout errors also retain the request target. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. +Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with the failed `tabId`, `frameId`, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. Known validation and adapter incompatibilities fail before injection. Browser capabilities discovered only by a native call are normalized after that call. @@ -235,6 +235,7 @@ Runtime exports: injectCss InjectCssBaseError InjectCssDeliveryError +InjectCssFrameDeliveryError InjectCssTimeoutError InvalidInjectCssCodeError InvalidInjectCssFilesError diff --git a/src/InjectCssV2.ts b/src/InjectCssV2.ts index 1f17977..8a4050e 100644 --- a/src/InjectCssV2.ts +++ b/src/InjectCssV2.ts @@ -1,6 +1,6 @@ import {insertCssTab} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; -import {UnsupportedInjectCssTargetError} from "./errors"; +import {InjectCssFrameDeliveryError, UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError} from "./errors"; import type {InjectCssExecutionOptions, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; type CSSOrigin = chrome.extensionTypes.CSSOrigin; @@ -12,8 +12,8 @@ export default class extends AbstractInjectCss { this.assertAdapterSupport(this._target, this._execution); } - public async insert(code: string): Promise { - this.validateCode(code); + public async insert(css: string): Promise { + const code = this.validateCode(css); const target = this.snapshotTarget(); const execution = this.snapshotExecution(); @@ -23,6 +23,7 @@ export default class extends AbstractInjectCss { try { await this.withTimeout(this.execute(target, details), target, timeoutMs); } catch (error) { + this.throwUnsupportedOriginCapability(execution, error); throw this.deliveryError(target, error); } } @@ -46,6 +47,7 @@ export default class extends AbstractInjectCss { await this.withTimeout(task, target, timeoutMs); } catch (error) { stopped = true; + this.throwUnsupportedOriginCapability(execution, error); throw this.deliveryError(target, error); } } @@ -75,10 +77,33 @@ export default class extends AbstractInjectCss { } if ("frameIds" in target && target.frameIds !== undefined) { - await Promise.all(target.frameIds.map(frameId => insertCssTab(target.tabId, {...details, frameId}))); + await Promise.all( + target.frameIds.map(frameId => this.executeFrame(target.tabId, frameId, {...details, frameId})) + ); return; } await insertCssTab(target.tabId, details); } + + private async executeFrame(tabId: number, frameId: number, details: InjectDetails): Promise { + try { + await insertCssTab(tabId, details); + } catch (error) { + throw new InjectCssFrameDeliveryError(tabId, frameId, error); + } + } + + private throwUnsupportedOriginCapability(execution: InjectCssExecutionOptions, error: unknown): void { + if (execution.origin === undefined) return; + + const message = error instanceof Error ? error.message : String(error); + + if ( + /\b(?:css[\s_-]*)?origin\b/i.test(message) && + /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i.test(message) + ) { + throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); + } + } } diff --git a/src/InjectCssV3.ts b/src/InjectCssV3.ts index fec789b..e7d262f 100644 --- a/src/InjectCssV3.ts +++ b/src/InjectCssV3.ts @@ -13,7 +13,7 @@ export default class extends AbstractInjectCss { } public async insert(css: string): Promise { - this.validateCode(css); + const code = this.validateCode(css); const target = this.snapshotTarget(); const execution = this.snapshotExecution(); @@ -24,7 +24,7 @@ export default class extends AbstractInjectCss { execution, { target: this.toNativeTarget(target), - css, + css: code, ...(execution.origin !== undefined ? {origin: execution.origin} : {}), }, timeoutMs diff --git a/src/errors.ts b/src/errors.ts index b2ca689..b79b6d5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -96,3 +96,19 @@ export class InjectCssDeliveryError extends InjectCssBaseError { this.target = target; } } + +export class InjectCssFrameDeliveryError extends Error { + public readonly tabId: number; + public readonly frameId: number; + public override readonly cause: unknown; + + public constructor(tabId: number, frameId: number, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + + super(`CSS delivery failed in frame ${frameId} of tab ${tabId}: ${message}`); + this.name = "InjectCssFrameDeliveryError"; + this.tabId = tabId; + this.frameId = frameId; + this.cause = cause; + } +} diff --git a/src/index.ts b/src/index.ts index b1a5f4a..2cd7506 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type {InjectCssContract, InjectCssOptions} from "./types"; export { InjectCssBaseError, InjectCssDeliveryError, + InjectCssFrameDeliveryError, InjectCssTimeoutError, InvalidInjectCssCodeError, InvalidInjectCssFilesError, diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs index 45512b4..d6cde24 100644 --- a/tests/inject-css.test.cjs +++ b/tests/inject-css.test.cjs @@ -3,6 +3,7 @@ const { injectCss: namedInjectCss, InjectCssBaseError, InjectCssDeliveryError, + InjectCssFrameDeliveryError, InjectCssTimeoutError, InvalidInjectCssCodeError, InvalidInjectCssFilesError, @@ -593,6 +594,61 @@ describe("MV2 adapter", () => { }); }); + test("retains the failed frame in the MV2 delivery cause", async () => { + const runtime = createRuntime(2); + const target = {tabId: 2, frameIds: [0, 3]}; + + global.chrome = { + runtime, + tabs: { + insertCSS: (_tabId, details, callback) => { + if (details.frameId === 3) { + global.chrome.runtime.lastError = {message: "Frame 3 is unavailable"}; + } + + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target}) + .insert("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error.target).toEqual(target); + expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toMatchObject({ + tabId: 2, + frameId: 3, + cause: expect.objectContaining({message: "Frame 3 is unavailable"}), + }); + }); + + test("normalizes an MV2 cssOrigin capability error", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: 'Unexpected property "cssOrigin"'}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .file("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + }); + test("injects files sequentially while dispatching each file to frames in parallel", async () => { const calls = []; const callbacks = []; From a3d2722096966088c25939cbac7c6af8d8a9b521 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:54:06 +0300 Subject: [PATCH 07/12] feat: make execution option resets explicit --- README.md | 14 ++++++++++++-- src/AbstractInjectCss.ts | 3 ++- src/index.ts | 1 + src/types.ts | 6 +++++- tests/inject-css.test.cjs | 32 ++++++++++++++++++++++++++++++++ tests/tsconfig.json | 1 + tests/types.test.ts | 7 +++++++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3ab88e5..873de01 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,16 @@ injector.options({ }); ``` -`options()` never accepts or changes a target. +`options()` never accepts or changes a target. Passing an explicit `undefined` resets that option instead of retaining its previous value: + +```ts +injector.options({ + origin: undefined, + timeoutMs: undefined, +}); +``` + +The next operation then uses the native origin default and the package's default timeout. ## Execution options @@ -225,7 +234,7 @@ interface InjectCssContract { insert(css: string): Promise; file(files: string | NonEmptyReadonlyArray): Promise; target(target: InjectCssTarget): this; - options(options: Partial): this; + options(options: InjectCssExecutionOptionsPatch): this; } ``` @@ -251,6 +260,7 @@ Core type exports: InjectCssContract InjectCssOptions InjectCssExecutionOptions +InjectCssExecutionOptionsPatch InjectCssOrigin InjectCssTarget InjectCssTopFrameTarget diff --git a/src/AbstractInjectCss.ts b/src/AbstractInjectCss.ts index 132289c..cf1f7ea 100644 --- a/src/AbstractInjectCss.ts +++ b/src/AbstractInjectCss.ts @@ -9,6 +9,7 @@ import { import type { InjectCssContract, InjectCssExecutionOptions, + InjectCssExecutionOptionsPatch, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray, @@ -36,7 +37,7 @@ export default abstract class implements InjectCssContract { return this; } - public options(options: Partial): this { + public options(options: InjectCssExecutionOptionsPatch): this { const normalizedOptions = validateInjectCssExecutionOptions(options); const nextExecution = {...this._execution, ...normalizedOptions}; diff --git a/src/index.ts b/src/index.ts index 2cd7506..632a142 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export type { InjectCssContract, InjectCssDocumentsTarget, InjectCssExecutionOptions, + InjectCssExecutionOptionsPatch, InjectCssFramesTarget, InjectCssOptions, InjectCssOrigin, diff --git a/src/types.ts b/src/types.ts index becc614..f7ffe5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,10 @@ export interface InjectCssExecutionOptions { timeoutMs?: number; } +export type InjectCssExecutionOptionsPatch = { + [Key in keyof InjectCssExecutionOptions]?: InjectCssExecutionOptions[Key] | undefined; +}; + export interface InjectCssOptions extends InjectCssExecutionOptions { target: InjectCssTarget; } @@ -57,5 +61,5 @@ export interface InjectCssContract { target(target: InjectCssTarget): this; - options(options: Partial): this; + options(options: InjectCssExecutionOptionsPatch): this; } diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs index d6cde24..1c4da04 100644 --- a/tests/inject-css.test.cjs +++ b/tests/inject-css.test.cjs @@ -538,6 +538,38 @@ describe("MV2 adapter", () => { expect(calls[0]).not.toHaveProperty("matchAboutBlank"); }); + test("resets execution options when options() receives explicit undefined values", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, details, callback) => { + calls.push(details); + setTimeout(callback, 10); + }, + }, + }; + + const injector = injectCss({ + target: {tabId: 1}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + timeoutMs: 1, + }); + + injector.options({ + origin: undefined, + matchAboutBlank: undefined, + runAt: undefined, + timeoutMs: undefined, + }); + + await expect(injector.file("/content.css")).resolves.toBeUndefined(); + expect(calls[0]).toEqual({file: "/content.css"}); + }); + test("rejects document targets before native injection and retains the previous target", async () => { const calls = []; diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 0d82ef0..32399e8 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "exactOptionalPropertyTypes": true, "noEmit": true, "rootDir": ".." }, diff --git a/tests/types.test.ts b/tests/types.test.ts index f738266..782cfbe 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -1,6 +1,7 @@ import injectCss, { type InjectCssErrorCode, type InjectCssExecutionOptions, + type InjectCssExecutionOptionsPatch, type InjectCssOrigin, type InjectCssTarget, type NonEmptyReadonlyArray, @@ -61,6 +62,7 @@ topFrame.file([1]); topFrame.insert(42); topFrame.target({tabId, frameIds: [frameId]}).options({origin: "USER", timeoutMs: 100}); +topFrame.options({matchAboutBlank: undefined, runAt: undefined, origin: undefined, timeoutMs: undefined}); // @ts-expect-error target changes belong to target(), not options() topFrame.options({target: {tabId: 99}}); @@ -74,10 +76,15 @@ const executionOptions: InjectCssExecutionOptions = { runAt: "document_idle", timeoutMs: 1_000, }; +const executionOptionsPatch: InjectCssExecutionOptionsPatch = { + origin: undefined, + timeoutMs: 2_000, +}; const target: InjectCssTarget = {tabId, documentIds: [documentId]}; const errorCode: InjectCssErrorCode = "ERR_INJECT_CSS_DELIVERY"; topFrame.options(executionOptions); +topFrame.options(executionOptionsPatch); topFrame.target(target); errorCode.toUpperCase(); From 6ea2a06234a94ff19a1cc31bb8a743114bd78184 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:02:43 +0300 Subject: [PATCH 08/12] feat: add CSS removal support --- CONTRIBUTING.md | 6 +- README.md | 49 +++++-- src/AbstractInjectCss.ts | 18 ++- src/InjectCssV2.ts | 133 +++++++++++++++-- src/InjectCssV3.ts | 86 ++++++++++- src/errors.ts | 39 ++++- src/index.ts | 2 + src/types.ts | 5 + tests/inject-css.test.cjs | 298 ++++++++++++++++++++++++++++++++++++++ tests/types.test.ts | 20 +++ 10 files changed, 614 insertions(+), 42 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0775f39..80076f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ # Contributing to @addon-core/inject-css -This package provides one typed CSS-injection contract across Manifest V2 and Manifest V3. Contributions should preserve that boundary, keep unsupported capabilities explicit, and include verification for every affected adapter. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). +This package provides one typed CSS insertion and removal contract across Manifest V2 and Manifest V3. Contributions should preserve that boundary, keep unsupported capabilities explicit, and include verification for every affected adapter. By participating, you agree to abide by the project’s [Code of Conduct](CODE_OF_CONDUCT.md). ## Table of Contents @@ -90,9 +90,11 @@ Note: Husky hooks are configured. Commit messages are validated with commitlint, - Every operation has one explicit `target`. - `allFrames`, `frameIds`, and `documentIds` remain mutually exclusive. - Unsupported targets and options fail explicitly; they are never removed silently or replaced with a broader target. -- `insert()` and `file()` remain strict `Promise` operations because native CSS APIs expose no portable per-frame result. +- `insert()`, `file()`, `remove()`, and `removeFile()` remain strict `Promise` operations because native CSS APIs expose no portable per-frame result. - File order is part of the CSS cascade contract and must be preserved. - Omitted options preserve native defaults. +- Explicit `undefined` values passed to `options()` reset the corresponding option. +- Removal must preserve the caller's exact source, target, and origin; the package does not track prior insertions. - Frame enumeration, document discovery, and application-specific result aggregation remain outside this package. ## Pull Request Workflow diff --git a/README.md b/README.md index 873de01..6c32e65 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ [![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/inject-css/ci.yml?style=for-the-badge)](https://github.com/addon-stack/inject-css/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE.md) -Inject CSS code or extension stylesheets into browser tabs with one typed API for Manifest V2 and Manifest V3. +Insert and remove CSS code or extension stylesheets in browser tabs with one typed API for Manifest V2 and Manifest V3. -`@addon-core/inject-css` selects the correct native adapter, validates the target before injection, and keeps unsupported browser behavior explicit. +`@addon-core/inject-css` selects the correct native adapter, validates the target before delivery, and keeps unsupported browser behavior explicit. - One target model for the top frame, all frames, selected frames, or selected documents - Runtime validation that matches the TypeScript contract - Ordered stylesheet injection +- Matching stylesheet removal where the browser exposes a native removal API - Stable package errors for invalid, unsupported, failed, and timed-out operations - No silent selector fallback and no extra frame-enumeration permissions @@ -37,9 +38,12 @@ const injector = injectCss({ }); await injector.insert("body { background: #f5f5f5; }"); + +// The source, target, and origin match the insertion. +await injector.remove("body { background: #f5f5f5; }"); ``` -The package detects the current manifest version automatically. The same call uses `tabs.insertCSS` in MV2 and `scripting.insertCSS` in MV3. +The package detects the current manifest version automatically. Insertion uses `tabs.insertCSS` in MV2 and `scripting.insertCSS` in MV3; removal uses the matching native removal API when available. ## Choose what to target @@ -103,6 +107,27 @@ await injector.file([ File lists must be non-empty and every path must be a non-empty string. Files are injected in the provided order. In MV2, one file completes for the requested target before the next file starts, preserving CSS cascade order. +## Remove CSS + +Remove CSS code with `remove()` and extension stylesheets with `removeFile()`: + +```ts +await injector.remove("body { background: #f5f5f5; }"); + +await injector.removeFile("styles/content.css"); + +await injector.removeFile([ + "styles/reset.css", + "styles/theme.css", +]); +``` + +Removal uses the injector's current target and origin. The CSS source, file list, target, and origin must match the values used for insertion. Removing a stylesheet that is not present is a native no-op. + +MV3 uses `scripting.removeCSS`. MV2 uses `tabs.removeCSS` only when the current browser exposes it; otherwise removal rejects with `UnsupportedInjectCssOperationError`. This capability is checked when removal is requested, so insertion remains available in MV2 browsers without `tabs.removeCSS`. + +MV2 removes multiple files sequentially in the provided order, matching its insertion behavior. `runAt` affects insertion only because the MV2 removal API has no corresponding field. + ## Reuse an injector Replace the complete target with `target()`: @@ -174,17 +199,17 @@ try { } ``` -Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with the failed `tabId`, `frameId`, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. +Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target and expose `operation` as `"insert"` or `"remove"`. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with the failed `tabId`, `frameId`, operation, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. -Known validation and adapter incompatibilities fail before injection. Browser capabilities discovered only by a native call are normalized after that call. +Known validation and adapter incompatibilities fail before delivery. Browser capabilities discovered only by a native call are normalized after that call. ### What `Promise` means -Native CSS injection APIs do not provide a portable per-frame result. `insert()` and `file()` therefore resolve with no value. +Native CSS injection and removal APIs do not provide a portable per-frame result. `insert()`, `file()`, `remove()`, and `removeFile()` therefore resolve with no value. -A resolved promise means the native operation completed. It does not prove that CSS was applied in every requested frame. A rejected multi-target operation is not transactional: some targets or earlier files may already have received CSS. +A resolved promise means the native operation completed. It does not prove that CSS was inserted or removed in every requested frame. A rejected multi-target operation is not transactional: some targets or earlier files may already have completed the requested change. -In MV2, a timeout stops the package from starting later files in a sequential batch. In MV3, the complete file list is handed to the browser in one native call before a timeout can occur. In either adapter, a timeout cannot cancel a native browser operation that is already in progress. +In MV2, a timeout stops the package from starting later files in a sequential insertion or removal batch. In MV3, the complete file list is handed to the browser in one native call before a timeout can occur. In either adapter, a timeout cannot cancel a native browser operation that is already in progress. ## Migrating from 0.3.x @@ -233,6 +258,8 @@ import {injectCss} from "@addon-core/inject-css"; interface InjectCssContract { insert(css: string): Promise; file(files: string | NonEmptyReadonlyArray): Promise; + remove(css: string): Promise; + removeFile(files: string | NonEmptyReadonlyArray): Promise; target(target: InjectCssTarget): this; options(options: InjectCssExecutionOptionsPatch): this; } @@ -251,6 +278,7 @@ InvalidInjectCssFilesError InvalidInjectCssOptionsError InvalidInjectCssTargetError UnsupportedInjectCssOptionError +UnsupportedInjectCssOperationError UnsupportedInjectCssTargetError ``` @@ -261,6 +289,7 @@ InjectCssContract InjectCssOptions InjectCssExecutionOptions InjectCssExecutionOptionsPatch +InjectCssOperation InjectCssOrigin InjectCssTarget InjectCssTopFrameTarget @@ -273,9 +302,9 @@ NonEmptyReadonlyArray ## Design boundaries -The package focuses on portable programmatic CSS injection. It does not enumerate frames, discover document IDs, aggregate application-specific per-frame results, or claim atomic delivery across targets. +The package focuses on portable programmatic CSS insertion and removal. It does not enumerate frames, discover document IDs, track which stylesheets were inserted, aggregate application-specific per-frame results, or claim atomic delivery across targets. -Removing injected CSS is a separate lifecycle capability and is not part of the current contract. +Callers remain responsible for retaining the exact source, target, and origin needed for later removal. ## License diff --git a/src/AbstractInjectCss.ts b/src/AbstractInjectCss.ts index cf1f7ea..78348e0 100644 --- a/src/AbstractInjectCss.ts +++ b/src/AbstractInjectCss.ts @@ -10,6 +10,7 @@ import type { InjectCssContract, InjectCssExecutionOptions, InjectCssExecutionOptionsPatch, + InjectCssOperation, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray, @@ -51,6 +52,10 @@ export default abstract class implements InjectCssContract { public abstract file(files: string | NonEmptyReadonlyArray): Promise; + public abstract remove(css: string): Promise; + + public abstract removeFile(files: string | NonEmptyReadonlyArray): Promise; + protected abstract assertAdapterSupport(target: InjectCssTarget, execution: InjectCssExecutionOptions): void; protected validateCode(css: string): string { @@ -73,7 +78,12 @@ export default abstract class implements InjectCssContract { return this._execution.timeoutMs ?? DEFAULT_TIMEOUT_MS; } - protected async withTimeout(task: Promise, target: InjectCssTarget, timeoutMs: number): Promise { + protected async withTimeout( + task: Promise, + target: InjectCssTarget, + timeoutMs: number, + operation: InjectCssOperation = "insert" + ): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -86,7 +96,7 @@ export default abstract class implements InjectCssContract { }; const timeoutId = setTimeout(() => { - finish(() => reject(new InjectCssTimeoutError(target, timeoutMs))); + finish(() => reject(new InjectCssTimeoutError(target, timeoutMs, operation))); }, timeoutMs); task.then( @@ -96,11 +106,11 @@ export default abstract class implements InjectCssContract { }); } - protected deliveryError(target: InjectCssTarget, error: unknown): Error { + protected deliveryError(target: InjectCssTarget, error: unknown, operation: InjectCssOperation = "insert"): Error { if (error instanceof InjectCssDeliveryError || error instanceof InjectCssTimeoutError) { return error; } - return new InjectCssDeliveryError(target, error); + return new InjectCssDeliveryError(target, error, operation); } } diff --git a/src/InjectCssV2.ts b/src/InjectCssV2.ts index 8a4050e..6a7b6f7 100644 --- a/src/InjectCssV2.ts +++ b/src/InjectCssV2.ts @@ -1,7 +1,18 @@ -import {insertCssTab} from "@addon-core/browser"; +import {browser, insertCssTab, removeCssTab} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; -import {InjectCssFrameDeliveryError, UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError} from "./errors"; -import type {InjectCssExecutionOptions, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; +import { + InjectCssFrameDeliveryError, + UnsupportedInjectCssOperationError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +import type { + InjectCssExecutionOptions, + InjectCssOperation, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; type CSSOrigin = chrome.extensionTypes.CSSOrigin; type InjectDetails = chrome.extensionTypes.InjectDetails; @@ -18,10 +29,10 @@ export default class extends AbstractInjectCss { const target = this.snapshotTarget(); const execution = this.snapshotExecution(); const timeoutMs = this.timeoutMs; - const details = this.createDetails(execution, {code}); + const details = this.createDetails("insert", execution, {code}); try { - await this.withTimeout(this.execute(target, details), target, timeoutMs); + await this.withTimeout(this.execute("insert", target, details), target, timeoutMs); } catch (error) { this.throwUnsupportedOriginCapability(execution, error); throw this.deliveryError(target, error); @@ -39,7 +50,7 @@ export default class extends AbstractInjectCss { for (const file of fileList) { if (stopped) return; - await this.execute(target, this.createDetails(execution, {file})); + await this.execute("insert", target, this.createDetails("insert", execution, {file})); } })(); @@ -52,6 +63,39 @@ export default class extends AbstractInjectCss { } } + public async remove(css: string): Promise { + const code = this.validateCode(css); + + await this.removeSource({code}); + } + + public async removeFile(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + let stopped = false; + + this.assertRemovalSupport(); + + const task = (async (): Promise => { + for (const file of fileList) { + if (stopped) return; + + await this.execute("remove", target, this.createDetails("remove", execution, {file})); + } + })(); + + try { + await this.withTimeout(task, target, timeoutMs, "remove"); + } catch (error) { + stopped = true; + this.throwUnsupportedRemovalCapability(error); + this.throwUnsupportedOriginCapability(execution, error); + throw this.deliveryError(target, error, "remove"); + } + } + protected assertAdapterSupport(target: InjectCssTarget, _execution: InjectCssExecutionOptions): void { if ("documentIds" in target && target.documentIds !== undefined) { throw new UnsupportedInjectCssTargetError('"documentIds" are not supported by the MV2 adapter.'); @@ -59,38 +103,99 @@ export default class extends AbstractInjectCss { } private createDetails( + operation: InjectCssOperation, execution: InjectCssExecutionOptions, source: Pick | Pick ): InjectDetails { return { ...source, - ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(operation === "insert" && execution.runAt !== undefined ? {runAt: execution.runAt} : {}), ...(execution.origin !== undefined ? {cssOrigin: execution.origin.toLowerCase() as CSSOrigin} : {}), ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), }; } - private async execute(target: InjectCssTarget, details: InjectDetails): Promise { + private async execute( + operation: InjectCssOperation, + target: InjectCssTarget, + details: InjectDetails + ): Promise { + const deliver = operation === "insert" ? insertCssTab : removeCssTab; + if ("allFrames" in target && target.allFrames === true) { - await insertCssTab(target.tabId, {...details, allFrames: true}); + await deliver(target.tabId, {...details, allFrames: true}); return; } if ("frameIds" in target && target.frameIds !== undefined) { await Promise.all( - target.frameIds.map(frameId => this.executeFrame(target.tabId, frameId, {...details, frameId})) + target.frameIds.map(frameId => + this.executeFrame(operation, target.tabId, frameId, {...details, frameId}) + ) ); return; } - await insertCssTab(target.tabId, details); + await deliver(target.tabId, details); } - private async executeFrame(tabId: number, frameId: number, details: InjectDetails): Promise { + private async executeFrame( + operation: InjectCssOperation, + tabId: number, + frameId: number, + details: InjectDetails + ): Promise { try { - await insertCssTab(tabId, details); + const deliver = operation === "insert" ? insertCssTab : removeCssTab; + + await deliver(tabId, details); } catch (error) { - throw new InjectCssFrameDeliveryError(tabId, frameId, error); + throw new InjectCssFrameDeliveryError(tabId, frameId, error, operation); + } + } + + private async removeSource(source: Pick | Pick): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + this.assertRemovalSupport(); + + try { + await this.withTimeout( + this.execute("remove", target, this.createDetails("remove", execution, source)), + target, + timeoutMs, + "remove" + ); + } catch (error) { + this.throwUnsupportedRemovalCapability(error); + this.throwUnsupportedOriginCapability(execution, error); + throw this.deliveryError(target, error, "remove"); + } + } + + private assertRemovalSupport(): void { + if (typeof browser().tabs?.removeCSS !== "function") { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV2 browser does not expose "tabs.removeCSS".' + ); + } + } + + private throwUnsupportedRemovalCapability(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + + if ( + /remove\s*css/i.test(message) && + /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i.test(message) + ) { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV2 browser does not support "tabs.removeCSS".', + error + ); } } diff --git a/src/InjectCssV3.ts b/src/InjectCssV3.ts index e7d262f..211e198 100644 --- a/src/InjectCssV3.ts +++ b/src/InjectCssV3.ts @@ -1,10 +1,21 @@ -import {insertCss} from "@addon-core/browser"; +import {browser, insertCss, removeCss} from "@addon-core/browser"; import AbstractInjectCss from "./AbstractInjectCss"; -import {UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError} from "./errors"; -import type {InjectCssExecutionOptions, InjectCssOptions, InjectCssTarget, NonEmptyReadonlyArray} from "./types"; +import { + UnsupportedInjectCssOperationError, + UnsupportedInjectCssOptionError, + UnsupportedInjectCssTargetError, +} from "./errors"; +import type { + InjectCssExecutionOptions, + InjectCssOperation, + InjectCssOptions, + InjectCssTarget, + NonEmptyReadonlyArray, +} from "./types"; type CSSInjection = chrome.scripting.CSSInjection; type InjectionTarget = chrome.scripting.InjectionTarget; +type CSSSource = {css: string; files?: never} | {files: string[]; css?: never}; export default class extends AbstractInjectCss { public constructor(options: InjectCssOptions) { @@ -20,6 +31,7 @@ export default class extends AbstractInjectCss { const timeoutMs = this.timeoutMs; await this.execute( + "insert", target, execution, { @@ -38,6 +50,7 @@ export default class extends AbstractInjectCss { const timeoutMs = this.timeoutMs; await this.execute( + "insert", target, execution, { @@ -49,6 +62,18 @@ export default class extends AbstractInjectCss { ); } + public async remove(css: string): Promise { + const code = this.validateCode(css); + + await this.removeInjection({css: code}); + } + + public async removeFile(files: string | NonEmptyReadonlyArray): Promise { + const fileList = this.normalizeFiles(files); + + await this.removeInjection({files: fileList}); + } + protected assertAdapterSupport(_target: InjectCssTarget, execution: InjectCssExecutionOptions): void { if (execution.matchAboutBlank !== undefined) { throw new UnsupportedInjectCssOptionError('"matchAboutBlank" is not supported by the MV3 adapter.'); @@ -60,13 +85,16 @@ export default class extends AbstractInjectCss { } private async execute( + operation: InjectCssOperation, target: InjectCssTarget, execution: InjectCssExecutionOptions, injection: CSSInjection, timeoutMs: number ): Promise { try { - await this.withTimeout(insertCss(injection), target, timeoutMs); + const task = operation === "insert" ? insertCss(injection) : removeCss(injection); + + await this.withTimeout(task, target, timeoutMs, operation); } catch (error) { if (this.isUnsupportedDocumentTargetError(target, error)) { throw new UnsupportedInjectCssTargetError( @@ -75,8 +103,56 @@ export default class extends AbstractInjectCss { ); } + if (operation === "remove") { + this.throwUnsupportedRemovalCapability(error); + } + this.throwUnsupportedOriginCapability(execution, error); - throw this.deliveryError(target, error); + throw this.deliveryError(target, error, operation); + } + } + + private async removeInjection(source: CSSSource): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + + this.assertRemovalSupport(); + + await this.execute( + "remove", + target, + execution, + { + target: this.toNativeTarget(target), + ...source, + ...(execution.origin !== undefined ? {origin: execution.origin} : {}), + }, + timeoutMs + ); + } + + private assertRemovalSupport(): void { + if (typeof browser().scripting?.removeCSS !== "function") { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV3 browser does not expose "scripting.removeCSS".' + ); + } + } + + private throwUnsupportedRemovalCapability(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + + if ( + /remove\s*css/i.test(message) && + /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i.test(message) + ) { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV3 browser does not support "scripting.removeCSS".', + error + ); } } diff --git a/src/errors.ts b/src/errors.ts index b79b6d5..9534635 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import type {InjectCssTarget} from "./types"; +import type {InjectCssOperation, InjectCssTarget} from "./types"; export type InjectCssErrorCode = | "ERR_INJECT_CSS_DELIVERY" @@ -8,6 +8,7 @@ export type InjectCssErrorCode = | "ERR_INJECT_CSS_INVALID_TARGET" | "ERR_INJECT_CSS_TIMEOUT" | "ERR_INJECT_CSS_UNSUPPORTED_OPTION" + | "ERR_INJECT_CSS_UNSUPPORTED_OPERATION" | "ERR_INJECT_CSS_UNSUPPORTED_TARGET"; export class InjectCssBaseError extends Error { @@ -63,6 +64,20 @@ export class UnsupportedInjectCssOptionError extends InjectCssBaseError { } } +export class UnsupportedInjectCssOperationError extends InjectCssBaseError { + public readonly operation: InjectCssOperation; + + public constructor(operation: InjectCssOperation, message: string, cause?: unknown) { + super( + "UnsupportedInjectCssOperationError", + "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + `Unsupported InjectCss operation "${operation}": ${message}`, + cause + ); + this.operation = operation; + } +} + export class InvalidInjectCssCodeError extends InjectCssBaseError { public constructor(message: string) { super("InvalidInjectCssCodeError", "ERR_INJECT_CSS_INVALID_CODE", `Invalid InjectCss code: ${message}`); @@ -78,37 +93,47 @@ export class InvalidInjectCssFilesError extends InjectCssBaseError { export class InjectCssTimeoutError extends InjectCssBaseError { public readonly target: InjectCssTarget; public readonly timeoutMs: number; + public readonly operation: InjectCssOperation; + + public constructor(target: InjectCssTarget, timeoutMs: number, operation: InjectCssOperation = "insert") { + const action = operation === "insert" ? "injection" : "removal"; - public constructor(target: InjectCssTarget, timeoutMs: number) { - super("InjectCssTimeoutError", "ERR_INJECT_CSS_TIMEOUT", `CSS injection timed out after ${timeoutMs} ms.`); + super("InjectCssTimeoutError", "ERR_INJECT_CSS_TIMEOUT", `CSS ${action} timed out after ${timeoutMs} ms.`); this.target = target; this.timeoutMs = timeoutMs; + this.operation = operation; } } export class InjectCssDeliveryError extends InjectCssBaseError { public readonly target: InjectCssTarget; + public readonly operation: InjectCssOperation; - public constructor(target: InjectCssTarget, cause: unknown) { + public constructor(target: InjectCssTarget, cause: unknown, operation: InjectCssOperation = "insert") { const message = cause instanceof Error ? cause.message : String(cause); + const action = operation === "insert" ? "injection" : "removal"; - super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", `CSS injection failed: ${message}`, cause); + super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", `CSS ${action} failed: ${message}`, cause); this.target = target; + this.operation = operation; } } export class InjectCssFrameDeliveryError extends Error { public readonly tabId: number; public readonly frameId: number; + public readonly operation: InjectCssOperation; public override readonly cause: unknown; - public constructor(tabId: number, frameId: number, cause: unknown) { + public constructor(tabId: number, frameId: number, cause: unknown, operation: InjectCssOperation = "insert") { const message = cause instanceof Error ? cause.message : String(cause); + const action = operation === "insert" ? "injection" : "removal"; - super(`CSS delivery failed in frame ${frameId} of tab ${tabId}: ${message}`); + super(`CSS ${action} failed in frame ${frameId} of tab ${tabId}: ${message}`); this.name = "InjectCssFrameDeliveryError"; this.tabId = tabId; this.frameId = frameId; + this.operation = operation; this.cause = cause; } } diff --git a/src/index.ts b/src/index.ts index 632a142..e1e418f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { InvalidInjectCssFilesError, InvalidInjectCssOptionsError, InvalidInjectCssTargetError, + UnsupportedInjectCssOperationError, UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError, } from "./errors"; @@ -23,6 +24,7 @@ export type { InjectCssExecutionOptions, InjectCssExecutionOptionsPatch, InjectCssFramesTarget, + InjectCssOperation, InjectCssOptions, InjectCssOrigin, InjectCssTarget, diff --git a/src/types.ts b/src/types.ts index f7ffe5a..599c6fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ type StyleOrigin = chrome.scripting.StyleOrigin; export type NonEmptyReadonlyArray = readonly [T, ...T[]]; export type InjectCssOrigin = StyleOrigin | `${StyleOrigin}`; +export type InjectCssOperation = "insert" | "remove"; export interface InjectCssTopFrameTarget { tabId: number; @@ -59,6 +60,10 @@ export interface InjectCssContract { file(files: string | NonEmptyReadonlyArray): Promise; + remove(css: string): Promise; + + removeFile(files: string | NonEmptyReadonlyArray): Promise; + target(target: InjectCssTarget): this; options(options: InjectCssExecutionOptionsPatch): this; diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs index 1c4da04..2e0d41b 100644 --- a/tests/inject-css.test.cjs +++ b/tests/inject-css.test.cjs @@ -10,6 +10,7 @@ const { InvalidInjectCssOptionsError, InvalidInjectCssTargetError, UnsupportedInjectCssOptionError, + UnsupportedInjectCssOperationError, UnsupportedInjectCssTargetError, } = require("../dist/index.cjs"); @@ -114,6 +115,14 @@ describe("InjectCss target and execution options", () => { } ); + test("validates removal sources before checking native removal support", async () => { + global.chrome = {runtime: createRuntime(3), scripting: {}}; + const injector = injectCss({target: {tabId: 1}}); + + await expect(injector.remove(" ")).rejects.toBeInstanceOf(InvalidInjectCssCodeError); + await expect(injector.removeFile([])).rejects.toBeInstanceOf(InvalidInjectCssFilesError); + }); + test("copies target arrays instead of retaining caller-owned state", async () => { const calls = []; const frameIds = [1]; @@ -243,6 +252,138 @@ describe("MV3 adapter", () => { ]); }); + test("removes code and ordered files with the exact MV3 source, target, and origin", async () => { + const calls = []; + + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (details, callback) => { + calls.push(details); + callback(); + }, + }, + }; + + const injector = injectCss({target: {tabId: 7, documentIds: ["doc"]}, origin: "USER"}); + + await expect(injector.remove("body { color: red; }")).resolves.toBeUndefined(); + await expect(injector.removeFile(["/first.css", "/second.css"])).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + target: {tabId: 7, documentIds: ["doc"]}, + css: "body { color: red; }", + origin: "USER", + }, + { + target: {tabId: 7, documentIds: ["doc"]}, + files: ["/first.css", "/second.css"], + origin: "USER", + }, + ]); + }); + + test("reports missing MV3 removal capability with a typed operation error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + insertCSS: (_details, callback) => callback(), + }, + }; + + const injector = injectCss({target: {tabId: 7}}); + + await expect(injector.insert("body { color: red; }")).resolves.toBeUndefined(); + + const error = await injector.remove("body { color: red; }").catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + }); + }); + + test("normalizes a native MV3 removal capability error", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "scripting.removeCSS is not supported"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + cause: expect.any(Error), + }); + }); + + test("reports MV3 removal delivery failures with the operation", async () => { + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 7, frameIds: [2]}; + const error = await injectCss({target}) + .remove("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + operation: "remove", + cause: expect.any(Error), + }); + expect(error.message).toContain("CSS removal failed"); + }); + + test("reports MV3 removal timeouts with the operation", async () => { + jest.useFakeTimers(); + + global.chrome = { + runtime: createRuntime(3), + scripting: { + removeCSS: () => {}, + }, + }; + + const target = {tabId: 7, allFrames: true}; + const pending = injectCss({target, timeoutMs: 5}) + .removeFile("/content.css") + .catch(cause => cause); + + jest.advanceTimersByTime(5); + const error = await pending; + + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_TIMEOUT", + target, + timeoutMs: 5, + operation: "remove", + }); + expect(error.message).toContain("CSS removal timed out"); + }); + test("does not materialize an omitted origin", async () => { const calls = []; @@ -491,6 +632,163 @@ describe("MV2 adapter", () => { expect(calls).toEqual([{tabId: 12, details: {code: "body { color: red; }"}}]); }); + test("removes MV2 code from explicit frames without forwarding runAt", async () => { + const calls = []; + + global.browser = { + runtime: createRuntime(2), + tabs: { + removeCSS: (tabId, details) => { + calls.push({tabId, details}); + return Promise.resolve(); + }, + }, + }; + + await expect( + injectCss({ + target: {tabId: 12, frameIds: [0, 3]}, + origin: "USER", + matchAboutBlank: true, + runAt: "document_start", + }).remove("body { color: red; }") + ).resolves.toBeUndefined(); + + expect(calls).toEqual([ + { + tabId: 12, + details: { + code: "body { color: red; }", + cssOrigin: "user", + matchAboutBlank: true, + frameId: 0, + }, + }, + { + tabId: 12, + details: { + code: "body { color: red; }", + cssOrigin: "user", + matchAboutBlank: true, + frameId: 3, + }, + }, + ]); + }); + + test("reports missing MV2 removal capability with a typed operation error", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, _details, callback) => callback(), + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + }); + }); + + test("normalizes a native MV2 removal capability error", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message: "tabs.removeCSS is not supported"}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const error = await injectCss({target: {tabId: 7}}) + .remove("body { color: red; }") + .catch(cause => cause); + + expect(error).toBeInstanceOf(UnsupportedInjectCssOperationError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPERATION", + operation: "remove", + cause: expect.any(Error), + }); + }); + + test("removes MV2 files sequentially while dispatching each file to frames in parallel", async () => { + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, + }, + }; + + const pending = injectCss({target: {tabId: 6, frameIds: [0, 3]}}).removeFile(["/first.css", "/second.css"]); + + expect(calls.map(call => call.details)).toEqual([ + {file: "/first.css", frameId: 0}, + {file: "/first.css", frameId: 3}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await flushAsync(); + + expect(calls.map(call => call.details)).toEqual([ + {file: "/first.css", frameId: 0}, + {file: "/first.css", frameId: 3}, + {file: "/second.css", frameId: 0}, + {file: "/second.css", frameId: 3}, + ]); + + callbacks.splice(0).forEach(callback => { + callback(); + }); + await expect(pending).resolves.toBeUndefined(); + }); + + test("retains the failed MV2 removal frame and operation", async () => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + removeCSS: (_tabId, details, callback) => { + if (details.frameId === 3) { + global.chrome.runtime.lastError = {message: "Frame 3 is unavailable"}; + } + + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const target = {tabId: 2, frameIds: [0, 3]}; + const error = await injectCss({target}) + .removeFile("/content.css") + .catch(cause => cause); + + expect(error).toBeInstanceOf(InjectCssDeliveryError); + expect(error).toMatchObject({target, operation: "remove"}); + expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toMatchObject({ + tabId: 2, + frameId: 3, + operation: "remove", + cause: expect.objectContaining({message: "Frame 3 is unavailable"}), + }); + }); + test("maps canonical origin to MV2 casing and preserves supported execution options", async () => { const calls = []; diff --git a/tests/types.test.ts b/tests/types.test.ts index 782cfbe..20d5d33 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -2,6 +2,7 @@ import injectCss, { type InjectCssErrorCode, type InjectCssExecutionOptions, type InjectCssExecutionOptionsPatch, + type InjectCssOperation, type InjectCssOrigin, type InjectCssTarget, type NonEmptyReadonlyArray, @@ -49,6 +50,9 @@ const inserted: Promise = topFrame.insert("body { color: red; }"); const singleFile: Promise = topFrame.file("/content.css"); const files: NonEmptyReadonlyArray = ["/first.css", "/second.css"]; const multipleFiles: Promise = topFrame.file(files); +const removed: Promise = topFrame.remove("body { color: red; }"); +const removedFile: Promise = topFrame.removeFile("/content.css"); +const removedFiles: Promise = topFrame.removeFile(files); topFrame.file(["/content.css"]); @@ -58,9 +62,18 @@ topFrame.file([]); // @ts-expect-error every file must be a string topFrame.file([1]); +// @ts-expect-error at least one file is required +topFrame.removeFile([]); + +// @ts-expect-error every file must be a string +topFrame.removeFile([1]); + // @ts-expect-error CSS code must be a string topFrame.insert(42); +// @ts-expect-error CSS code must be a string +topFrame.remove(42); + topFrame.target({tabId, frameIds: [frameId]}).options({origin: "USER", timeoutMs: 100}); topFrame.options({matchAboutBlank: undefined, runAt: undefined, origin: undefined, timeoutMs: undefined}); @@ -82,12 +95,19 @@ const executionOptionsPatch: InjectCssExecutionOptionsPatch = { }; const target: InjectCssTarget = {tabId, documentIds: [documentId]}; const errorCode: InjectCssErrorCode = "ERR_INJECT_CSS_DELIVERY"; +const unsupportedOperationCode: InjectCssErrorCode = "ERR_INJECT_CSS_UNSUPPORTED_OPERATION"; +const operation: InjectCssOperation = "remove"; topFrame.options(executionOptions); topFrame.options(executionOptionsPatch); topFrame.target(target); errorCode.toUpperCase(); +unsupportedOperationCode.toUpperCase(); +operation.toUpperCase(); void inserted; void singleFile; void multipleFiles; +void removed; +void removedFile; +void removedFiles; From 785b546039cf81566411c9b2672df44302955f63 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:02:15 +0300 Subject: [PATCH 09/12] fix: normalize nested CSS delivery errors --- README.md | 2 +- src/errors.ts | 37 +++++++++++++++++++++---------------- tests/inject-css.test.cjs | 12 +++++++++++- tests/types.test.ts | 2 ++ 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6c32e65..19ad7ed 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ try { } ``` -Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target and expose `operation` as `"insert"` or `"remove"`. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with the failed `tabId`, `frameId`, operation, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. +Every rejected package operation exposes an error derived from `InjectCssBaseError` with a stable `code`. Delivery and timeout errors also retain the request target and expose `operation` as `"insert"` or `"remove"`. For explicit MV2 frame targets, a delivery error may contain an `InjectCssFrameDeliveryError` cause with code `ERR_INJECT_CSS_FRAME_DELIVERY`, the failed `tabId`, `frameId`, operation, and native cause. Prefer `code` when errors may cross realms or multiple copies of the dependency may exist. Known validation and adapter incompatibilities fail before delivery. Browser capabilities discovered only by a native call are normalized after that call. diff --git a/src/errors.ts b/src/errors.ts index 9534635..8507f7a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2,6 +2,7 @@ import type {InjectCssOperation, InjectCssTarget} from "./types"; export type InjectCssErrorCode = | "ERR_INJECT_CSS_DELIVERY" + | "ERR_INJECT_CSS_FRAME_DELIVERY" | "ERR_INJECT_CSS_INVALID_CODE" | "ERR_INJECT_CSS_INVALID_FILES" | "ERR_INJECT_CSS_INVALID_OPTIONS" @@ -105,35 +106,39 @@ export class InjectCssTimeoutError extends InjectCssBaseError { } } -export class InjectCssDeliveryError extends InjectCssBaseError { - public readonly target: InjectCssTarget; +export class InjectCssFrameDeliveryError extends InjectCssBaseError { + public readonly tabId: number; + public readonly frameId: number; public readonly operation: InjectCssOperation; - public constructor(target: InjectCssTarget, cause: unknown, operation: InjectCssOperation = "insert") { + public constructor(tabId: number, frameId: number, cause: unknown, operation: InjectCssOperation = "insert") { const message = cause instanceof Error ? cause.message : String(cause); const action = operation === "insert" ? "injection" : "removal"; - super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", `CSS ${action} failed: ${message}`, cause); - this.target = target; + super( + "InjectCssFrameDeliveryError", + "ERR_INJECT_CSS_FRAME_DELIVERY", + `CSS ${action} failed in frame ${frameId} of tab ${tabId}: ${message}`, + cause + ); + this.tabId = tabId; + this.frameId = frameId; this.operation = operation; } } -export class InjectCssFrameDeliveryError extends Error { - public readonly tabId: number; - public readonly frameId: number; +export class InjectCssDeliveryError extends InjectCssBaseError { + public readonly target: InjectCssTarget; public readonly operation: InjectCssOperation; - public override readonly cause: unknown; - public constructor(tabId: number, frameId: number, cause: unknown, operation: InjectCssOperation = "insert") { - const message = cause instanceof Error ? cause.message : String(cause); + public constructor(target: InjectCssTarget, cause: unknown, operation: InjectCssOperation = "insert") { + const causeMessage = cause instanceof Error ? cause.message : String(cause); const action = operation === "insert" ? "injection" : "removal"; + const message = + cause instanceof InjectCssFrameDeliveryError ? causeMessage : `CSS ${action} failed: ${causeMessage}`; - super(`CSS ${action} failed in frame ${frameId} of tab ${tabId}: ${message}`); - this.name = "InjectCssFrameDeliveryError"; - this.tabId = tabId; - this.frameId = frameId; + super("InjectCssDeliveryError", "ERR_INJECT_CSS_DELIVERY", message, cause); + this.target = target; this.operation = operation; - this.cause = cause; } } diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs index 2e0d41b..a095167 100644 --- a/tests/inject-css.test.cjs +++ b/tests/inject-css.test.cjs @@ -779,9 +779,16 @@ describe("MV2 adapter", () => { .catch(cause => cause); expect(error).toBeInstanceOf(InjectCssDeliveryError); - expect(error).toMatchObject({target, operation: "remove"}); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_DELIVERY", + target, + operation: "remove", + message: "CSS removal failed in frame 3 of tab 2: Frame 3 is unavailable", + }); expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toBeInstanceOf(InjectCssBaseError); expect(error.cause).toMatchObject({ + code: "ERR_INJECT_CSS_FRAME_DELIVERY", tabId: 2, frameId: 3, operation: "remove", @@ -948,8 +955,11 @@ describe("MV2 adapter", () => { expect(error).toBeInstanceOf(InjectCssDeliveryError); expect(error.target).toEqual(target); + expect(error.message).toBe("CSS injection failed in frame 3 of tab 2: Frame 3 is unavailable"); expect(error.cause).toBeInstanceOf(InjectCssFrameDeliveryError); + expect(error.cause).toBeInstanceOf(InjectCssBaseError); expect(error.cause).toMatchObject({ + code: "ERR_INJECT_CSS_FRAME_DELIVERY", tabId: 2, frameId: 3, cause: expect.objectContaining({message: "Frame 3 is unavailable"}), diff --git a/tests/types.test.ts b/tests/types.test.ts index 20d5d33..8d9c69f 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -95,6 +95,7 @@ const executionOptionsPatch: InjectCssExecutionOptionsPatch = { }; const target: InjectCssTarget = {tabId, documentIds: [documentId]}; const errorCode: InjectCssErrorCode = "ERR_INJECT_CSS_DELIVERY"; +const frameDeliveryCode: InjectCssErrorCode = "ERR_INJECT_CSS_FRAME_DELIVERY"; const unsupportedOperationCode: InjectCssErrorCode = "ERR_INJECT_CSS_UNSUPPORTED_OPERATION"; const operation: InjectCssOperation = "remove"; @@ -102,6 +103,7 @@ topFrame.options(executionOptions); topFrame.options(executionOptionsPatch); topFrame.target(target); errorCode.toUpperCase(); +frameDeliveryCode.toUpperCase(); unsupportedOperationCode.toUpperCase(); operation.toUpperCase(); From b5007386dc81bde61e19e5476b5fa5330d69bf8b Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:05:33 +0300 Subject: [PATCH 10/12] refactor: consolidate native CSS delivery handling --- src/InjectCssV2.ts | 104 ++++++++++---------------- src/InjectCssV3.ts | 68 +++++------------ src/native-errors.ts | 29 ++++++++ tests/inject-css.test.cjs | 152 ++++++++++++++++++++------------------ 4 files changed, 172 insertions(+), 181 deletions(-) create mode 100644 src/native-errors.ts diff --git a/src/InjectCssV2.ts b/src/InjectCssV2.ts index 6a7b6f7..a6bf4ec 100644 --- a/src/InjectCssV2.ts +++ b/src/InjectCssV2.ts @@ -6,6 +6,7 @@ import { UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError, } from "./errors"; +import {isUnsupportedOriginCapabilityError, isUnsupportedRemovalCapabilityError} from "./native-errors"; import type { InjectCssExecutionOptions, InjectCssOperation, @@ -34,33 +35,14 @@ export default class extends AbstractInjectCss { try { await this.withTimeout(this.execute("insert", target, details), target, timeoutMs); } catch (error) { - this.throwUnsupportedOriginCapability(execution, error); - throw this.deliveryError(target, error); + this.throwDeliveryError("insert", target, execution, error); } } 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; - - const task = (async (): Promise => { - for (const file of fileList) { - if (stopped) return; - await this.execute("insert", target, this.createDetails("insert", execution, {file})); - } - })(); - - try { - await this.withTimeout(task, target, timeoutMs); - } catch (error) { - stopped = true; - this.throwUnsupportedOriginCapability(execution, error); - throw this.deliveryError(target, error); - } + await this.deliverFiles("insert", fileList); } public async remove(css: string): Promise { @@ -71,29 +53,8 @@ export default class extends AbstractInjectCss { public async removeFile(files: string | NonEmptyReadonlyArray): Promise { const fileList = this.normalizeFiles(files); - const target = this.snapshotTarget(); - const execution = this.snapshotExecution(); - const timeoutMs = this.timeoutMs; - let stopped = false; - this.assertRemovalSupport(); - - const task = (async (): Promise => { - for (const file of fileList) { - if (stopped) return; - - await this.execute("remove", target, this.createDetails("remove", execution, {file})); - } - })(); - - try { - await this.withTimeout(task, target, timeoutMs, "remove"); - } catch (error) { - stopped = true; - this.throwUnsupportedRemovalCapability(error); - this.throwUnsupportedOriginCapability(execution, error); - throw this.deliveryError(target, error, "remove"); - } + await this.deliverFiles("remove", fileList); } protected assertAdapterSupport(target: InjectCssTarget, _execution: InjectCssExecutionOptions): void { @@ -169,9 +130,33 @@ export default class extends AbstractInjectCss { "remove" ); } catch (error) { - this.throwUnsupportedRemovalCapability(error); - this.throwUnsupportedOriginCapability(execution, error); - throw this.deliveryError(target, error, "remove"); + this.throwDeliveryError("remove", target, execution, error); + } + } + + private async deliverFiles(operation: InjectCssOperation, fileList: readonly string[]): Promise { + const target = this.snapshotTarget(); + const execution = this.snapshotExecution(); + const timeoutMs = this.timeoutMs; + let stopped = false; + + if (operation === "remove") { + this.assertRemovalSupport(); + } + + const task = (async (): Promise => { + for (const file of fileList) { + if (stopped) return; + + await this.execute(operation, target, this.createDetails(operation, execution, {file})); + } + })(); + + try { + await this.withTimeout(task, target, timeoutMs, operation); + } catch (error) { + stopped = true; + this.throwDeliveryError(operation, target, execution, error); } } @@ -184,31 +169,24 @@ export default class extends AbstractInjectCss { } } - private throwUnsupportedRemovalCapability(error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - - if ( - /remove\s*css/i.test(message) && - /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i.test(message) - ) { + private throwDeliveryError( + operation: InjectCssOperation, + target: InjectCssTarget, + execution: InjectCssExecutionOptions, + error: unknown + ): never { + if (operation === "remove" && isUnsupportedRemovalCapabilityError(error)) { throw new UnsupportedInjectCssOperationError( "remove", 'the current MV2 browser does not support "tabs.removeCSS".', error ); } - } - private throwUnsupportedOriginCapability(execution: InjectCssExecutionOptions, error: unknown): void { - if (execution.origin === undefined) return; - - const message = error instanceof Error ? error.message : String(error); - - if ( - /\b(?:css[\s_-]*)?origin\b/i.test(message) && - /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i.test(message) - ) { + if (execution.origin !== undefined && isUnsupportedOriginCapabilityError(error)) { throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); } + + throw this.deliveryError(target, error, operation); } } diff --git a/src/InjectCssV3.ts b/src/InjectCssV3.ts index 211e198..10b3553 100644 --- a/src/InjectCssV3.ts +++ b/src/InjectCssV3.ts @@ -5,6 +5,11 @@ import { UnsupportedInjectCssOptionError, UnsupportedInjectCssTargetError, } from "./errors"; +import { + isUnsupportedDocumentTargetCapabilityError, + isUnsupportedOriginCapabilityError, + isUnsupportedRemovalCapabilityError, +} from "./native-errors"; import type { InjectCssExecutionOptions, InjectCssOperation, @@ -96,18 +101,29 @@ export default class extends AbstractInjectCss { await this.withTimeout(task, target, timeoutMs, operation); } catch (error) { - if (this.isUnsupportedDocumentTargetError(target, error)) { + if ( + "documentIds" in target && + target.documentIds !== undefined && + isUnsupportedDocumentTargetCapabilityError(error) + ) { throw new UnsupportedInjectCssTargetError( '"documentIds" are not supported by the current browser.', error ); } - if (operation === "remove") { - this.throwUnsupportedRemovalCapability(error); + if (operation === "remove" && isUnsupportedRemovalCapabilityError(error)) { + throw new UnsupportedInjectCssOperationError( + "remove", + 'the current MV3 browser does not support "scripting.removeCSS".', + error + ); + } + + if (execution.origin !== undefined && isUnsupportedOriginCapabilityError(error)) { + throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); } - this.throwUnsupportedOriginCapability(execution, error); throw this.deliveryError(target, error, operation); } } @@ -141,21 +157,6 @@ export default class extends AbstractInjectCss { } } - private throwUnsupportedRemovalCapability(error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - - if ( - /remove\s*css/i.test(message) && - /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i.test(message) - ) { - throw new UnsupportedInjectCssOperationError( - "remove", - 'the current MV3 browser does not support "scripting.removeCSS".', - error - ); - } - } - private toNativeTarget(target: InjectCssTarget): InjectionTarget { if ("frameIds" in target && target.frameIds !== undefined) { return {tabId: target.tabId, frameIds: [...target.frameIds]}; @@ -171,33 +172,4 @@ export default class extends AbstractInjectCss { return {tabId: target.tabId}; } - - private isUnsupportedDocumentTargetError(target: InjectCssTarget, error: unknown): boolean { - if (!("documentIds" in target) || target.documentIds === undefined) { - return false; - } - - const message = error instanceof Error ? error.message : String(error); - - return ( - /documentIds?/i.test(message) && - /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i.test(message) - ); - } - - private throwUnsupportedOriginCapability(execution: InjectCssExecutionOptions, error: unknown): void { - if (execution.origin === undefined) return; - - const message = error instanceof Error ? error.message : String(error); - - if (/\b(?:(?:css|style)[\s_-]*)?origin\b/i.test(message) && this.isUnsupportedCapabilityMessage(message)) { - throw new UnsupportedInjectCssOptionError('"origin" is not supported by the current browser.', error); - } - } - - 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/native-errors.ts b/src/native-errors.ts new file mode 100644 index 0000000..2855ac4 --- /dev/null +++ b/src/native-errors.ts @@ -0,0 +1,29 @@ +const UNSUPPORTED_API_PATTERN = /(not supported|unsupported|not (?:available|implemented)|is not a function)\b/i; +const UNSUPPORTED_FIELD_PATTERN = + /(not supported|unsupported|unexpected|unknown|unrecognized|invalid|not (?:a )?valid)\b/i; +const UNSUPPORTED_TARGET_FIELD_PATTERN = /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i; + +export const getNativeErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; + +export const isUnsupportedRemovalCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + return /remove\s*css/i.test(message) && UNSUPPORTED_API_PATTERN.test(message); +}; + +export const isUnsupportedOriginCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + // MV2 usually names the field "cssOrigin", while MV3 uses "origin" and some browsers describe it as a + // "style origin". Treat those spellings as the same package capability. + return /\b(?:(?:css|style)[\s_-]*)?origin\b/i.test(message) && UNSUPPORTED_FIELD_PATTERN.test(message); +}; + +export const isUnsupportedDocumentTargetCapabilityError = (error: unknown): boolean => { + const message = getNativeErrorMessage(error); + + // Keep this intentionally narrower than option detection: "Invalid documentId" can mean a stale target. + return /documentIds?/i.test(message) && UNSUPPORTED_TARGET_FIELD_PATTERN.test(message); +}; diff --git a/tests/inject-css.test.cjs b/tests/inject-css.test.cjs index a095167..81e4bbb 100644 --- a/tests/inject-css.test.cjs +++ b/tests/inject-css.test.cjs @@ -498,30 +498,33 @@ describe("MV3 adapter", () => { }); }); - test("normalizes a native origin capability error", async () => { - const runtime = createRuntime(3); - - global.chrome = { - runtime, - scripting: { - insertCSS: (_details, callback) => { - global.chrome.runtime.lastError = {message: 'Unexpected property "origin"'}; - callback(); - global.chrome.runtime.lastError = undefined; + test.each(['Unexpected property "origin"', "Invalid style origin"])( + "normalizes an MV3 origin capability error: %s", + async message => { + const runtime = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + insertCSS: (_details, callback) => { + global.chrome.runtime.lastError = {message}; + callback(); + global.chrome.runtime.lastError = undefined; + }, }, - }, - }; + }; - const error = await injectCss({target: {tabId: 2}, origin: "USER"}) - .insert("body { color: red; }") - .catch(cause => cause); + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .insert("body { color: red; }") + .catch(cause => cause); - expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); - expect(error).toMatchObject({ - code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", - cause: expect.any(Error), - }); - }); + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + } + ); test("reports native delivery failures with the target and cause", async () => { const runtime = createRuntime(3); @@ -966,28 +969,31 @@ describe("MV2 adapter", () => { }); }); - test("normalizes an MV2 cssOrigin capability error", async () => { - global.chrome = { - runtime: createRuntime(2), - tabs: { - insertCSS: (_tabId, _details, callback) => { - global.chrome.runtime.lastError = {message: 'Unexpected property "cssOrigin"'}; - callback(); - global.chrome.runtime.lastError = undefined; + test.each(['Unexpected property "cssOrigin"', "Invalid style origin"])( + "normalizes an MV2 origin capability error: %s", + async message => { + global.chrome = { + runtime: createRuntime(2), + tabs: { + insertCSS: (_tabId, _details, callback) => { + global.chrome.runtime.lastError = {message}; + callback(); + global.chrome.runtime.lastError = undefined; + }, }, - }, - }; + }; - const error = await injectCss({target: {tabId: 2}, origin: "USER"}) - .file("/content.css") - .catch(cause => cause); + const error = await injectCss({target: {tabId: 2}, origin: "USER"}) + .file("/content.css") + .catch(cause => cause); - expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); - expect(error).toMatchObject({ - code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", - cause: expect.any(Error), - }); - }); + expect(error).toBeInstanceOf(UnsupportedInjectCssOptionError); + expect(error).toMatchObject({ + code: "ERR_INJECT_CSS_UNSUPPORTED_OPTION", + cause: expect.any(Error), + }); + } + ); test("injects files sequentially while dispatching each file to frames in parallel", async () => { const calls = []; @@ -1029,41 +1035,47 @@ describe("MV2 adapter", () => { await expect(pending).resolves.toBeUndefined(); }); - test("does not start a later MV2 file after the operation times out", async () => { - jest.useFakeTimers(); - - const calls = []; - const callbacks = []; - - global.chrome = { - runtime: createRuntime(2), - tabs: { - insertCSS: (tabId, details, callback) => { - calls.push({tabId, details}); - callbacks.push(callback); + test.each([ + ["insertion", "insertCSS", "file", "insert"], + ["removal", "removeCSS", "removeFile", "remove"], + ])( + "does not start a later MV2 file after %s times out", + async (_label, nativeMethod, contractMethod, operation) => { + jest.useFakeTimers(); + + const calls = []; + const callbacks = []; + + global.chrome = { + runtime: createRuntime(2), + tabs: { + [nativeMethod]: (tabId, details, callback) => { + calls.push({tabId, details}); + callbacks.push(callback); + }, }, - }, - }; + }; - const target = {tabId: 6, frameIds: [0, 3]}; - const pending = injectCss({target, timeoutMs: 5}) - .file(["/first.css", "/second.css"]) - .catch(error => error); + const target = {tabId: 6, frameIds: [0, 3]}; + const pending = injectCss({target, timeoutMs: 5}) + [contractMethod](["/first.css", "/second.css"]) + .catch(error => error); - expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); - jest.advanceTimersByTime(5); - const error = await pending; + jest.advanceTimersByTime(5); + const error = await pending; - expect(error).toBeInstanceOf(InjectCssTimeoutError); - expect(error).toMatchObject({target, timeoutMs: 5}); + expect(error).toBeInstanceOf(InjectCssTimeoutError); + expect(error).toMatchObject({target, timeoutMs: 5, operation}); - callbacks.forEach(callback => { - callback(); - }); - await Promise.resolve(); - await Promise.resolve(); + callbacks.forEach(callback => { + callback(); + }); + await Promise.resolve(); + await Promise.resolve(); - expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); - }); + expect(calls.map(call => call.details.file)).toEqual(["/first.css", "/first.css"]); + } + ); }); From 84aa60a9b48b798a26dadbd642a3a927b494216c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:46:33 +0300 Subject: [PATCH 11/12] build: repair npm lockfile --- package-lock.json | 623 +++++++++++++++++----------------------------- 1 file changed, 226 insertions(+), 397 deletions(-) diff --git a/package-lock.json b/package-lock.json index cad357d..fde45d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1457,16 +1457,6 @@ "node": ">=18" } }, - "node_modules/@hutson/parse-repository-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-5.0.0.tgz", - "integrity": "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", @@ -2754,25 +2744,99 @@ } }, "node_modules/@release-it/conventional-changelog": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.1.tgz", - "integrity": "sha512-Qp+eyMGCPyq5xiWoNK91cWVIR/6HD1QAUNeG6pV2G4kxotWl81k/KDQqDNvrNVmr9+zDp53jI7pVVYQp6mi4zA==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.6.tgz", + "integrity": "sha512-aUb0IkcsBTMcOH5PPQ9Jv9lEOOVu2+rSgkE1ny+dzsTziQm2BhDRAtaFK/dw/HflthuXMWrqhhyfJhAV1AOEPQ==", "dev": true, "license": "MIT", "dependencies": { + "@conventional-changelog/git-client": "^2.6.0", "concat-stream": "^2.0.0", - "conventional-changelog": "^6.0.0", - "conventional-recommended-bump": "^10.0.0", - "git-semver-tags": "^8.0.0", - "semver": "^7.6.3" + "conventional-changelog": "^7.2.0", + "conventional-changelog-angular": "^8.3.0", + "conventional-changelog-conventionalcommits": "^9.3.0", + "conventional-recommended-bump": "^11.2.0", + "semver": "^7.7.4" }, "engines": { - "node": "^20.9.0 || >=22.0.0" + "node": "^20.12.0 || >=22.0.0" }, "peerDependencies": { "release-it": "^18.0.0 || ^19.0.0" } }, + "node_modules/@release-it/conventional-changelog/node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.4.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@release-it/conventional-changelog/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", @@ -3081,6 +3145,48 @@ "win32" ] }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/hosted-git-info": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/hosted-git-info/-/hosted-git-info-1.0.2.tgz", + "integrity": "sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -3281,13 +3387,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3601,13 +3700,6 @@ "node": ">=0.4.0" } }, - "node_modules/add-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", - "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4433,23 +4525,24 @@ } }, "node_modules/conventional-changelog": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-6.0.0.tgz", - "integrity": "sha512-tuUH8H/19VjtD9Ig7l6TQRh+Z0Yt0NZ6w/cCkkyzUbGQTnUEmKfGtkC9gGfVgCfOL1Rzno5NgNF4KY8vR+Jo3w==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-7.2.1.tgz", + "integrity": "sha512-smcpqCeNRCAoL586/Iql8GfVsduP1JDrr7SAzKHWM5C5voDzKhc3QtqwVWeKMhvVw4iYfAr+znz1TF4kPXVm3A==", "dev": true, "license": "MIT", "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-atom": "^5.0.0", - "conventional-changelog-codemirror": "^5.0.0", - "conventional-changelog-conventionalcommits": "^8.0.0", - "conventional-changelog-core": "^8.0.0", - "conventional-changelog-ember": "^5.0.0", - "conventional-changelog-eslint": "^6.0.0", - "conventional-changelog-express": "^5.0.0", - "conventional-changelog-jquery": "^6.0.0", - "conventional-changelog-jshint": "^5.0.0", - "conventional-changelog-preset-loader": "^5.0.0" + "@conventional-changelog/git-client": "^2.7.0", + "@simple-libs/hosted-git-info": "^1.0.2", + "@types/normalize-package-data": "^2.4.4", + "conventional-changelog-preset-loader": "^5.0.0", + "conventional-changelog-writer": "^8.4.0", + "conventional-commits-parser": "^6.4.0", + "fd-package-json": "^2.0.0", + "meow": "^13.0.0", + "normalize-package-data": "^7.0.0" + }, + "bin": { + "conventional-changelog": "dist/cli/index.js" }, "engines": { "node": ">=18" @@ -4468,26 +4561,6 @@ "node": ">=16" } }, - "node_modules/conventional-changelog-atom": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-atom/-/conventional-changelog-atom-5.0.0.tgz", - "integrity": "sha512-WfzCaAvSCFPkznnLgLnfacRAzjgqjLUjvf3MftfsJzQdDICqkOOpcMtdJF3wTerxSpv2IAAjX8doM3Vozqle3g==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-codemirror": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-codemirror/-/conventional-changelog-codemirror-5.0.0.tgz", - "integrity": "sha512-8gsBDI5Y3vrKUCxN6Ue8xr6occZ5nsDEc4C7jO/EovFGozx8uttCAyfhRrvoUAWi2WMm3OmYs+0mPJU7kQdYWQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, "node_modules/conventional-changelog-conventionalcommits": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", @@ -4501,140 +4574,6 @@ "node": ">=16" } }, - "node_modules/conventional-changelog-core": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-8.0.0.tgz", - "integrity": "sha512-EATUx5y9xewpEe10UEGNpbSHRC6cVZgO+hXQjofMqpy+gFIrcGvH3Fl6yk2VFKh7m+ffenup2N7SZJYpyD9evw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hutson/parse-repository-url": "^5.0.0", - "add-stream": "^1.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-parser": "^6.0.0", - "git-raw-commits": "^5.0.0", - "git-semver-tags": "^8.0.0", - "hosted-git-info": "^7.0.0", - "normalize-package-data": "^6.0.0", - "read-package-up": "^11.0.0", - "read-pkg": "^9.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-core/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/semver": "^7.5.5", - "semver": "^7.5.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" - }, - "peerDependenciesMeta": { - "conventional-commits-filter": { - "optional": true - }, - "conventional-commits-parser": { - "optional": true - } - } - }, - "node_modules/conventional-changelog-core/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", - "dev": true, - "license": "MIT", - "dependencies": { - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-core/node_modules/git-raw-commits": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.0.tgz", - "integrity": "sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", - "meow": "^13.0.0" - }, - "bin": { - "git-raw-commits": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-ember": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-ember/-/conventional-changelog-ember-5.0.0.tgz", - "integrity": "sha512-RPflVfm5s4cSO33GH/Ey26oxhiC67akcxSKL8CLRT3kQX2W3dbE19sSOM56iFqUJYEwv9mD9r6k79weWe1urfg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-eslint": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-eslint/-/conventional-changelog-eslint-6.0.0.tgz", - "integrity": "sha512-eiUyULWjzq+ybPjXwU6NNRflApDWlPEQEHvI8UAItYW/h22RKkMnOAtfCZxMmrcMO1OKUWtcf2MxKYMWe9zJuw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-express": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-express/-/conventional-changelog-express-5.0.0.tgz", - "integrity": "sha512-D8Q6WctPkQpvr2HNCCmwU5GkX22BVHM0r4EW8vN0230TSyS/d6VQJDAxGb84lbg0dFjpO22MwmsikKL++Oo/oQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-jquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-jquery/-/conventional-changelog-jquery-6.0.0.tgz", - "integrity": "sha512-2kxmVakyehgyrho2ZHBi90v4AHswkGzHuTaoH40bmeNqUt20yEkDOSpw8HlPBfvEQBwGtbE+5HpRwzj6ac2UfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-jshint": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-jshint/-/conventional-changelog-jshint-5.0.0.tgz", - "integrity": "sha512-gGNphSb/opc76n2eWaO6ma4/Wqu3tpa2w7i9WYqI6Cs2fncDSI2/ihOfMvXveeTTeld0oFvwMVNV+IYQIk3F3g==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/conventional-changelog-preset-loader": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-5.0.0.tgz", @@ -4646,12 +4585,13 @@ } }, "node_modules/conventional-changelog-writer": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.2.0.tgz", - "integrity": "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", @@ -4664,27 +4604,45 @@ "node": ">=18" } }, - "node_modules/conventional-changelog/node_modules/conventional-changelog-angular": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", - "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", + "node_modules/conventional-changelog/node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "compare-func": "^2.0.0" + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.4.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } } }, - "node_modules/conventional-changelog/node_modules/conventional-changelog-conventionalcommits": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-8.0.0.tgz", - "integrity": "sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA==", + "node_modules/conventional-changelog/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "compare-func": "^2.0.0" + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" }, "engines": { "node": ">=18" @@ -4733,16 +4691,16 @@ } }, "node_modules/conventional-recommended-bump": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-10.0.0.tgz", - "integrity": "sha512-RK/fUnc2btot0oEVtrj3p2doImDSs7iiz/bftFCDzels0Qs1mxLghp+DFHMaOC0qiCI6sWzlTDyBFSYuot6pRA==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-11.2.0.tgz", + "integrity": "sha512-lqIdmw330QdMBgfL0e6+6q5OMKyIpy4OZNmepit6FS3GldhkG+70drZjuZ0A5NFpze5j85dlYs3GabQXl6sMHw==", "dev": true, "license": "MIT", "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", + "@conventional-changelog/git-client": "^2.5.1", "conventional-changelog-preset-loader": "^5.0.0", "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", + "conventional-commits-parser": "^6.1.0", "meow": "^13.0.0" }, "bin": { @@ -4753,13 +4711,14 @@ } }, "node_modules/conventional-recommended-bump/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", "dev": true, "license": "MIT", "dependencies": { - "@types/semver": "^7.5.5", + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "engines": { @@ -4767,7 +4726,7 @@ }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" + "conventional-commits-parser": "^6.4.0" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -4779,12 +4738,13 @@ } }, "node_modules/conventional-recommended-bump/node_modules/conventional-commits-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.0.tgz", - "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { @@ -5350,6 +5310,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5381,19 +5351,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -5548,49 +5505,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-semver-tags": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-8.0.0.tgz", - "integrity": "sha512-N7YRIklvPH3wYWAR2vysaqGLPRcpwQ0GKdlqTiVN5w1UmCdaeY3K8s6DMKRCh54DDdzyt/OAB6C8jgVtb7Y2Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@conventional-changelog/git-client": "^1.0.0", - "meow": "^13.0.0" - }, - "bin": { - "git-semver-tags": "src/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/git-semver-tags/node_modules/@conventional-changelog/git-client": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-1.0.1.tgz", - "integrity": "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/semver": "^7.5.5", - "semver": "^7.5.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0" - }, - "peerDependenciesMeta": { - "conventional-commits-filter": { - "optional": true - }, - "conventional-commits-parser": { - "optional": true - } - } - }, "node_modules/git-up": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/git-up/-/git-up-8.1.1.tgz", @@ -5655,9 +5569,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5687,16 +5601,16 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/html-escaper": { @@ -5845,19 +5759,6 @@ "node": ">=0.8.19" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8070,18 +7971,18 @@ "license": "MIT" }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", + "integrity": "sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^8.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/normalize-path": { @@ -8700,88 +8601,6 @@ "dev": true, "license": "MIT" }, - "node_modules/read-package-up": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", - "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0", - "read-pkg": "^9.0.0", - "type-fest": "^4.6.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9082,9 +8901,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9273,9 +9092,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -9952,6 +9771,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", From ee1f478afb4ea0b122720729394489b94b683558 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:53:55 +0300 Subject: [PATCH 12/12] docs: remove obsolete migration guide --- README.md | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/README.md b/README.md index 19ad7ed..10262a0 100644 --- a/README.md +++ b/README.md @@ -211,40 +211,6 @@ A resolved promise means the native operation completed. It does not prove that In MV2, a timeout stops the package from starting later files in a sequential insertion or removal batch. In MV3, the complete file list is handed to the browser in one native call before a timeout can occur. In either adapter, a timeout cannot cancel a native browser operation that is already in progress. -## Migrating from 0.3.x - -Targets now live under the required `target` field: - -```ts -// Before -const injector = injectCss({ - tabId: 123, - frameId: [1, 2], - origin: "USER", -}); - -injector.options({frameId: true}); - -// Now -const injector = injectCss({ - target: {tabId: 123, frameIds: [1, 2]}, - origin: "USER", -}); - -injector.target({tabId: 123, allFrames: true}); -``` - -Migration map: - -- `{tabId}` becomes `{target: {tabId}}`. -- `frameId: false` becomes a top-frame target with no selector. -- `frameId: 7` becomes `frameIds: [7]`. -- `frameId: [2, 7]` becomes `frameIds: [2, 7]`. -- `documentId: "document-a"` becomes `documentIds: ["document-a"]`. -- Target changes move from `.options()` to `.target()`. -- `file([])` is now a compile-time and runtime error. -- Code relying on the old implicit `matchAboutBlank: true` must set it explicitly in MV2. - ## API reference The factory is available as both a default and named export: