diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 299825e..20ab225 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,8 @@ Keep changes focused and preserve the package's public design: - Every operation has one explicit `target`. - Target selectors remain mutually exclusive. - Unsupported targets and options fail explicitly; they are never removed silently. -- `run()` returns package-owned per-frame outcomes instead of raw browser results. +- `run()` uses best-effort target isolation and returns package-owned success/failure outcomes instead of raw browser results. +- Explicit target calls start synchronously before the first result is awaited so user activation is preserved. - Callback arguments and results remain strictly JSON-compatible. - MV2 injected code must stay self-contained because imports and caller closures do not cross the injection boundary. - Runtime code must not introduce `eval` or `new Function`. @@ -81,7 +82,8 @@ For adapter changes, cover the affected combinations where relevant: - Manifest V2 and Manifest V3; - callback-based `global.chrome` and Promise-based `global.browser`; - top frame, `allFrames`, explicit `frameIds`, and `documentIds`; -- `fulfilled`, `rejected`, and `unknown` outcomes; +- success and failure outcomes, including execution, delivery, timeout, and target-gone errors; +- input ordering and synchronous initiation of explicit target batches; - valid JSON data and runtime-only invalid values; - preparation errors, native delivery failures, and timeouts. diff --git a/README.md b/README.md index a61658c..aca4d58 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ Run typed functions or inject script files into browser extension tabs with one API for Manifest V2 and Manifest V3. -`@addon-core/inject-script` selects the correct browser adapter, translates explicit frame and document targets, and turns native browser responses into predictable per-frame outcomes. You write the callback and choose the target; the package handles the manifest-specific execution path. +`@addon-core/inject-script` selects the correct browser adapter, translates explicit frame and document targets, and turns native browser responses into predictable outcomes. You write the callback and choose the target; the package handles the manifest-specific execution path. - One target model for the top frame, all frames, selected frames, or selected documents - Typed synchronous and asynchronous callbacks with explicit arguments -- Structured `fulfilled`, `rejected`, and `unknown` outcomes +- Best-effort batches: one unavailable target does not discard successful results +- Typed `success`/`failure` outcomes with delivery, execution, timeout, target-gone, and unobservable errors - Strict JSON-compatible data validation with actionable error paths - No `eval`, no `new Function`, and no extra frame-enumeration permissions @@ -32,28 +33,14 @@ Your extension still needs the native permissions required for script injection, ```ts import injectScript from "@addon-core/inject-script"; -const outcomes = await injectScript({ - target: { - tabId: 123, - allFrames: true, - }, - timeoutMs: 5_000, -}).run( - (selector: string) => ({ - href: location.href, - text: document.querySelector(selector)?.textContent ?? null, - }), - ["h1"], -); +const [outcome] = await injectScript({ + target: {tabId: 123}, +}).run(() => document.title); -for (const outcome of outcomes) { - if (outcome.status === "fulfilled") { - console.log(outcome.target.frameId, outcome.result); - } else if (outcome.status === "rejected") { - console.error(outcome.target.frameId, outcome.error); - } else { - console.warn(outcome.target.frameId, "No result or error was exposed"); - } +if (outcome.success) { + console.log(outcome.value); +} else { + console.error(outcome.error.kind, outcome.error.message); } ``` @@ -103,11 +90,13 @@ const injector = injectScript({target}); ### Observed results, not frame discovery -An `allFrames` call is one native browser operation. It returns outcomes for the frames the browser reports as executed; it is not a frame snapshot or an exhaustive RPC fan-out. +An `allFrames` call is one native browser operation. It returns outcomes for the frames the browser reports as executed; it is not a frame snapshot or an exhaustive RPC fan-out. If the whole native operation fails, the returned failure uses `target: {tabId, allFrames: true}` without inventing a frame ID. -With explicit `frameIds`, a normal execution returns one outcome per requested frame. MV2 can mark a known frame that did not answer as `unknown`. MV3 returns the native outcomes exposed by the browser and does not fabricate missing frame results. +Explicit `frameIds` and `documentIds` are different: `run()` starts one native call per requested target. All calls are initiated before the first result is awaited, which preserves user activation for browser APIs that require a user gesture. The returned array always follows the input order, and a delivery error or timeout for one target does not discard the others. -If an application requires exactly one outcome for every previously discovered frame, enumerate those frames in the application layer and call them through explicit `frameIds` targets. +This isolation has a linear cost: `N` explicit targets create `N` native injection calls. MV2 uses one temporary message listener for the batch and keeps `N` independent timeout timers. The package deliberately starts the native calls without an internal concurrency limit because awaiting a previous chunk could lose user activation. Prefer `allFrames` when one native operation is sufficient; use explicit IDs when independent outcomes are more important. + +If an application requires exactly one outcome for every previously discovered frame, enumerate those frames in the application layer and pass that snapshot through explicit `frameIds` targets. ## Run a function @@ -155,52 +144,83 @@ Type-only annotations are safe because they disappear during compilation. Import ## Work with outcomes -When the browser request itself succeeds, `run()` resolves to an array of package-owned outcomes: +`run()` is best-effort by default. For a valid request, target-level execution, delivery, and timeout errors are values in the resolved array rather than reasons to reject the whole promise: ```ts type InjectScriptResult = | { - target: {tabId: number; frameId: number; documentId?: string}; - status: "fulfilled"; - result: T; + success: true; + target: InjectScriptResultTarget; + value: T; } | { - target: {tabId: number; frameId: number; documentId?: string}; - status: "rejected"; - error: {name: string; message: string; stack?: string}; + success: false; + target: InjectScriptResultTarget; + error: InjectScriptTargetError; + }; + +type InjectScriptTargetError = + | { + kind: "execution" | "delivery" | "target-gone" | "unobservable"; + name: string; + message: string; + stack?: string; } | { - target: {tabId: number; frameId: number; documentId?: string}; - status: "unknown"; + kind: "timeout"; + name: string; + message: string; + stack?: string; + timeoutMs: number; + missingCount?: number; }; + +type InjectScriptResultTarget = + | {tabId: number; frameId: number; documentId?: string} + | {tabId: number; documentId: string; frameId?: number} + | {tabId: number; allFrames: true}; ``` -- `fulfilled` means the browser exposed a valid callback result. -- `rejected` means a frame-level callback or result-validation error was available. -- `unknown` means the browser reported the frame but exposed neither a result nor an error. +- `success: true` contains the JSON-compatible callback value. +- `success: false` contains the affected target and a normalized error. +- A document delivery failure can be identified by `documentId` alone; the package never adds a fake `frameId`. -`target` describes the actual execution context reported by the browser. Results are sorted by `frameId`, with the main frame (`frameId: 0`) first, and preserve `documentId` when available. +Explicit-target results follow the requested `frameIds` or `documentIds` order. Successful `allFrames` results are sorted by `frameId`, with the main frame (`frameId: 0`) first, and preserve `documentId` when available. -A rejected frame does not discard successful results from other frames. +A failed target does not discard successful results from other targets: ```ts for (const outcome of outcomes) { - switch (outcome.status) { - case "fulfilled": - useValue(outcome.target, outcome.result); - break; - - case "rejected": - reportFrameError(outcome.target, outcome.error); - break; - - case "unknown": - reportMissingOutcome(outcome.target); - break; + if (outcome.success) { + useValue(outcome.target, outcome.value); + } else { + reportTargetError(outcome.target, outcome.error); } } ``` +Error kinds are stable and exported as `InjectScriptTargetErrorKind`: + +| Kind | Meaning | +| --- | --- | +| `Execution` (`"execution"`) | The callback ran but failed, or its result violated the data contract | +| `Delivery` (`"delivery"`) | The browser could not deliver or observe the injection | +| `Timeout` (`"timeout"`) | This target, or an `allFrames` operation, did not finish in time | +| `TargetGone` (`"target-gone"`) | The browser reported that the requested tab, frame, or document disappeared | +| `Unobservable` (`"unobservable"`) | The browser exposed neither a usable callback result nor an observable callback error | + +`InjectScriptTargetErrorKind` is a string enum. Its template-literal form can be used when an application needs to assign literal strings: + +```ts +const retryKinds: `${InjectScriptTargetErrorKind}`[] = ["timeout", "target-gone"]; + +if (!outcome.success && outcome.error.kind === InjectScriptTargetErrorKind.Timeout) { + // ... +} +``` + +A timeout failure always includes `timeoutMs`. For an MV2 `allFrames` timeout, `missingCount` reports how many injected frames did not answer. Partial per-frame outcomes remain ordinary sibling elements in the returned result array instead of being duplicated inside the timeout error. + ## Return application-level errors as data Package outcomes describe injection and frame execution. If your callback is acting like an RPC method and needs a guaranteed business-level result, return an explicit JSON-compatible envelope: @@ -243,8 +263,8 @@ const outcomes = await injector.run( This produces two intentionally separate levels: ```text -InjectScriptResult.status -> Was the frame execution observable and valid? -RemoteResult.ok -> Did the application operation succeed? +InjectScriptResult.success -> Did injection and callback execution succeed? +RemoteResult.ok -> Did the application operation succeed? ``` ## Pass and return plain data @@ -369,28 +389,45 @@ const injector = isManifestVersion3() }); ``` -## Handle operation failures +## Handle failures -Frame-level failures belong in the resolved outcome array. Preparation, delivery, capability, and overall timeout failures reject the operation. +Target-level failures from `run()` belong in the resolved outcome array: ```ts -import { - InjectScriptBaseError, - InjectScriptTimeoutError, -} from "@addon-core/inject-script"; +import {InjectScriptTargetErrorKind} from "@addon-core/inject-script"; + +const outcomes = await injectScript({ + target: {tabId: 123, frameIds: [7, 2, 9]}, + timeoutMs: 5_000, +}).run(() => location.href); + +for (const outcome of outcomes) { + if (outcome.success) { + console.log(outcome.target, outcome.value); + continue; + } + + if (outcome.error.kind === InjectScriptTargetErrorKind.TargetGone) { + console.warn("Target disappeared", outcome.target); + } else { + console.error(outcome.target, outcome.error); + } +} +``` + +`run()` can still reject when the request itself is invalid or unsupported, for example because arguments are not JSON-compatible or MV2 receives `documentIds`. These are preparation or capability errors, not failures of one requested target. + +Known adapter limitations are rejected before injection. A browser-specific capability error discovered only from the native MV3 call is different: all explicit calls have already been started to preserve user activation, so callbacks may have completed in some targets before `run()` rejects. For callbacks with side effects, do not interpret such a rejection as proof that nothing executed. + +`file()` remains a strict `Promise` operation because native browser APIs do not expose a portable per-target file result. Its delivery and timeout failures reject: + +```ts +import {InjectScriptBaseError} from "@addon-core/inject-script"; try { - const outcomes = await injector.run(() => document.title); - consume(outcomes); + await injector.file("scripts/content.js"); } catch (error) { - if (error instanceof InjectScriptTimeoutError) { - console.error("Injection timed out", { - target: error.target, - timeoutMs: error.timeoutMs, - partialResults: error.partialResults, - missingCount: error.missingCount, - }); - } else if (error instanceof InjectScriptBaseError) { + if (error instanceof InjectScriptBaseError) { console.error(error.code, error.message, error.cause); } else { throw error; @@ -402,12 +439,12 @@ Every package error extends `InjectScriptBaseError` and exposes a stable `code`. ### Cross-browser outcome details -- Firefox can expose a literal `throw undefined` as an existing `error` property whose value is `undefined`. The package preserves it as `rejected`. +- Firefox can expose a literal `throw undefined` as an existing `error` property whose value is `undefined`. The package preserves it as an `Execution` failure. - A defined `result` takes precedence over an `error: undefined` placeholder. -- MV2 can identify an unsupported callback result of `undefined` and returns a frame-level `TypeError`. -- If MV3 exposes neither `result` nor `error`, the outcome is `unknown`; the package does not guess whether the callback returned nothing or the browser omitted an exception. -- For MV2 top-frame and explicit `frameIds` calls, a known frame that does not answer before `timeoutMs` becomes `unknown`. -- For MV2 `allFrames`, a missing response cannot be assigned to a frame without another permission-dependent API. The operation rejects with `InjectScriptTimeoutError` and preserves `partialResults` and `missingCount`. +- MV2 can identify an unsupported callback result of `undefined` and returns an `Execution` failure with `TypeError`. +- If MV3 exposes neither a usable `result` nor an observable error, the package returns an `Unobservable` failure. Chrome MV3 does not always expose the exact callback exception, so exact automatic exception serialization cannot be guaranteed there. +- For top-frame and explicit-target calls, a target that does not answer before `timeoutMs` returns a `Timeout` failure for that target. +- For MV2 `allFrames`, a missing response cannot be assigned to a frame without another permission-dependent API. The result keeps every observed per-frame outcome and adds one `Timeout` failure with `target: {tabId, allFrames: true}`. Return `null` or an explicit application envelope when the caller must distinguish a successful no-value result from an unavailable native outcome. @@ -461,6 +498,7 @@ interface InjectScriptOptions { ```ts injectScript +InjectScriptTargetErrorKind InjectScriptBaseError InjectScriptDeliveryError InjectScriptTimeoutError @@ -483,9 +521,12 @@ InjectScriptExecutionOptions InjectScriptTarget InjectScriptResult InjectScriptResultTarget +InjectScriptTargetError +InjectScriptTargetFailure +InjectScriptTargetSuccess +InjectScriptTargetTimeoutError SerializedInjectScriptError InjectScriptErrorCode -InjectScriptTimeoutDetails ``` Advanced target and JSON types: diff --git a/src/InjectScriptV2.ts b/src/InjectScriptV2.ts index 36f6602..9cd496b 100644 --- a/src/InjectScriptV2.ts +++ b/src/InjectScriptV2.ts @@ -1,13 +1,23 @@ import {executeScriptTab, onMessage} from "@addon-core/browser"; import AbstractInjectScript from "./AbstractInjectScript"; import { - InjectScriptDeliveryError, InjectScriptTimeoutError, UnsupportedInjectScriptOptionError, UnsupportedInjectScriptTargetError, } from "./errors"; import {createRequestId} from "./requestId"; -import {createResultTarget, normalizeInjectionError, sortInjectionResults} from "./results"; +import { + classifyTargetDeliveryError, + createAllFramesResultTarget, + createFrameResultTarget, + createTargetFailure, + createTargetSuccess, + createTargetTimeoutFailure, + normalizeInjectionError, + sortInjectionResults, + withTargetErrorKind, +} from "./results"; +import {InjectScriptTargetErrorKind} from "./types"; import {findJsonCompatibilityIssue} from "./validation"; import type { InjectScriptExecutionOptions, @@ -23,6 +33,14 @@ type InjectDetails = chrome.extensionTypes.InjectDetails; type InjectedOutcome = {status: "fulfilled"; result: T} | {status: "rejected"; error: SerializedInjectScriptError}; +interface PendingExplicitFrame { + frameId: number; + messageType: string; + promise: Promise>; + resolve: (result: InjectScriptResult) => void; + timeoutId?: ReturnType; +} + export default class extends AbstractInjectScript { public constructor(options: InjectScriptOptions) { super(options); @@ -38,35 +56,249 @@ export default class extends AbstractInjectScript { const target = this.snapshotTarget(); const execution = this.snapshotExecution(); const timeoutMs = this.timeoutMs; + const createCode = this.createCodeBuilder(func, args); + + if ("allFrames" in target && target.allFrames === true) { + return this.runAllFrames(target, execution, timeoutMs, createCode); + } + + const frameIds = "frameIds" in target && target.frameIds !== undefined ? target.frameIds : ([0] as const); + const baseDetails = this.createRunDetails(execution); + + return this.runExplicitFrames>(target.tabId, frameIds, baseDetails, timeoutMs, createCode); + } + + 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; + + const details: InjectDetails = { + file, + ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), + }; + + await this.executeFile(target, details); + } + })(); + + try { + await this.withTimeout(task, target, timeoutMs); + } catch (error) { + stopped = true; + throw this.deliveryError(target, error); + } + } - return new Promise>[]>((resolve, reject) => { + protected assertAdapterSupport(target: InjectScriptTarget, execution: InjectScriptExecutionOptions): void { + if ("documentIds" in target && target.documentIds !== undefined) { + throw new UnsupportedInjectScriptTargetError('"documentIds" are not supported by the MV2 adapter.'); + } + + if (execution.world !== undefined && execution.world !== "ISOLATED") { + throw new UnsupportedInjectScriptOptionError('"world: MAIN" is not supported by the MV2 adapter.'); + } + } + + private runExplicitFrames( + tabId: number, + frameIds: NonEmptyReadonlyArray, + baseDetails: Partial>, + timeoutMs: number, + createCode: (messageType: string) => string + ): Promise[]> { + const pending = new Map>(); + const requests = frameIds.map>(frameId => { const messageType = createRequestId(); - const results = new Map>>(); - const knownFrameIds = this.getKnownFrameIds(target); + let resolveResult!: (result: InjectScriptResult) => void; + const promise = new Promise>(resolve => { + resolveResult = resolve; + }); + + const request: PendingExplicitFrame = { + frameId, + messageType, + promise, + resolve: resolveResult, + }; + + pending.set(messageType, request); + + return request; + }); + + let unsubscribe = (): void => {}; + + const finish = (request: PendingExplicitFrame, result: InjectScriptResult): void => { + if (pending.get(request.messageType) !== request) return; + + pending.delete(request.messageType); + + if (request.timeoutId !== undefined) { + clearTimeout(request.timeoutId); + } + + request.resolve(result); + + if (pending.size === 0) { + unsubscribe(); + } + }; + + const listener = (message: unknown, sender: MessageSender): void => { + if (typeof message !== "object" || message === null) return; + + const messageType = (message as {type?: unknown}).type; + + if (typeof messageType !== "string") return; + + const request = pending.get(messageType); + + if (!request || !this.isInjectedResponse(message, messageType)) return; + if (sender.tab?.id !== tabId) return; + + const resultTarget = createFrameResultTarget(tabId, request.frameId); + + if (sender.frameId === undefined) { + finish( + request, + createTargetFailure( + resultTarget, + InjectScriptTargetErrorKind.Delivery, + new Error("The injected response did not include a frame ID.") + ) + ); + return; + } + + if (sender.frameId !== request.frameId) return; + + const observedTarget = createFrameResultTarget(tabId, request.frameId, sender.documentId); + const outcome = message.data; + + finish( + request, + outcome.status === "fulfilled" + ? createTargetSuccess(observedTarget, outcome.result as T) + : { + success: false, + target: observedTarget, + error: withTargetErrorKind( + InjectScriptTargetErrorKind.Execution, + normalizeInjectionError(outcome.error) + ), + } + ); + }; + + try { + unsubscribe = onMessage(listener); + } catch (error) { + for (const request of requests) { + finish( + request, + createTargetFailure( + createFrameResultTarget(tabId, request.frameId), + InjectScriptTargetErrorKind.Delivery, + error + ) + ); + } + + return Promise.all(requests.map(request => request.promise)); + } + + // Preserve user activation: every native call is initiated before the first await. + for (const request of requests) { + const resultTarget = createFrameResultTarget(tabId, request.frameId); + const requestTarget: InjectScriptTarget = {tabId, frameIds: [request.frameId]}; + + request.timeoutId = setTimeout(() => { + finish( + request, + createTargetTimeoutFailure( + resultTarget, + timeoutMs, + new InjectScriptTimeoutError(requestTarget, timeoutMs) + ) + ); + }, timeoutMs); + + try { + const details: InjectDetails = { + ...baseDetails, + code: createCode(request.messageType), + frameId: request.frameId, + }; + + void executeScriptTab(tabId, details) + .then(nativeResults => { + if (!pending.has(request.messageType)) return; + + try { + if (this.getNativeResultCount(nativeResults) === 0) { + finish( + request, + createTargetFailure( + resultTarget, + InjectScriptTargetErrorKind.Delivery, + new Error("The browser did not execute the script in the requested frame.") + ) + ); + } + } catch (error) { + finish( + request, + createTargetFailure(resultTarget, InjectScriptTargetErrorKind.Delivery, error) + ); + } + }) + .catch(error => { + finish(request, createTargetFailure(resultTarget, classifyTargetDeliveryError(error), error)); + }); + } catch (error) { + finish(request, createTargetFailure(resultTarget, classifyTargetDeliveryError(error), error)); + } + } + + return Promise.all(requests.map(request => request.promise)); + } + + private runAllFrames( + target: InjectScriptTarget, + execution: InjectScriptExecutionOptions, + timeoutMs: number, + createCode: (messageType: string) => string + ): Promise[]> { + return new Promise[]>(resolve => { + const messageType = createRequestId(); + const results = new Map>(); + const operationTarget = createAllFramesResultTarget(target.tabId); let expectedCount: number | undefined; let deliveryCompleted = false; let settled = false; - const finish = (callback: () => void): void => { + const finish = (outcomes: InjectScriptResult[]): void => { if (settled) return; settled = true; unsubscribe(); clearTimeout(timeoutId); - callback(); + resolve(sortInjectionResults(outcomes)); }; const maybeResolve = (): void => { - if (!deliveryCompleted) return; - - if (knownFrameIds) { - if (knownFrameIds.some(frameId => !results.has(frameId))) return; - } else if (expectedCount === undefined || results.size < expectedCount) { - return; - } + if (!deliveryCompleted || expectedCount === undefined || results.size < expectedCount) return; - finish(() => resolve(sortInjectionResults([...results.values()]))); + finish([...results.values()]); }; const listener = (message: unknown, sender: MessageSender): void => { @@ -76,35 +308,33 @@ export default class extends AbstractInjectScript { const {frameId, documentId} = sender; if (frameId === undefined) { - finish(() => - reject( - new InjectScriptDeliveryError( - target, - new Error("The injected response did not include a frame ID.") - ) - ) - ); + finish([ + ...results.values(), + createTargetFailure( + operationTarget, + InjectScriptTargetErrorKind.Delivery, + new Error("The injected response did not include a frame ID.") + ), + ]); return; } - if (!this.isExpectedFrame(target, frameId)) return; if (results.has(frameId)) return; - const resultTarget = createResultTarget(target.tabId, frameId, documentId); + const resultTarget = createFrameResultTarget(target.tabId, frameId, documentId); const outcome = message.data; results.set( frameId, outcome.status === "fulfilled" - ? { - target: resultTarget, - status: "fulfilled", - result: outcome.result as Awaited, - } + ? createTargetSuccess(resultTarget, outcome.result as T) : { + success: false, target: resultTarget, - status: "rejected", - error: normalizeInjectionError(outcome.error), + error: withTargetErrorKind( + InjectScriptTargetErrorKind.Execution, + normalizeInjectionError(outcome.error) + ), } ); @@ -112,105 +342,53 @@ export default class extends AbstractInjectScript { }; const unsubscribe = onMessage(listener); - const timeoutId = setTimeout(() => { const partialResults = sortInjectionResults([...results.values()]); - - if (deliveryCompleted && knownFrameIds) { - for (const frameId of knownFrameIds) { - if (results.has(frameId)) continue; - - results.set(frameId, { - target: createResultTarget(target.tabId, frameId), - status: "unknown", - }); - } - - finish(() => resolve(sortInjectionResults([...results.values()]))); - return; - } - const missingCount = expectedCount === undefined ? undefined : Math.max(0, expectedCount - results.size); + const timeoutError = new InjectScriptTimeoutError(target, timeoutMs); - finish(() => reject(new InjectScriptTimeoutError(target, timeoutMs, {partialResults, missingCount}))); + finish([ + ...partialResults, + createTargetTimeoutFailure(operationTarget, timeoutMs, timeoutError, missingCount), + ]); }, timeoutMs); - const details: InjectDetails = { - code: this.getCode(messageType, func, args), - ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), - ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), + ...this.createRunDetails(execution), + code: createCode(messageType), + allFrames: true, }; - void this.executeRun(target, details) - .then(count => { + void executeScriptTab(target.tabId, details) + .then(nativeResults => { deliveryCompleted = true; - expectedCount = count; - maybeResolve(); + + try { + expectedCount = this.getNativeResultCount(nativeResults); + maybeResolve(); + } catch (error) { + finish([ + ...results.values(), + createTargetFailure(operationTarget, InjectScriptTargetErrorKind.Delivery, error), + ]); + } }) .catch(error => { - finish(() => reject(this.deliveryError(target, error))); + finish([ + ...results.values(), + createTargetFailure(operationTarget, InjectScriptTargetErrorKind.Delivery, 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; - - const details: InjectDetails = { - file, - ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), - ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), - }; - - await this.executeFile(target, details); - } - })(); - - try { - await this.withTimeout(task, target, timeoutMs); - } catch (error) { - stopped = true; - throw this.deliveryError(target, error); - } - } - - protected assertAdapterSupport(target: InjectScriptTarget, execution: InjectScriptExecutionOptions): void { - if ("documentIds" in target && target.documentIds !== undefined) { - throw new UnsupportedInjectScriptTargetError('"documentIds" are not supported by the MV2 adapter.'); - } - - if (execution.world !== undefined && execution.world !== "ISOLATED") { - throw new UnsupportedInjectScriptOptionError('"world: MAIN" is not supported by the MV2 adapter.'); - } - } - - private async executeRun(target: InjectScriptTarget, details: InjectDetails): Promise { - if ("allFrames" in target && target.allFrames === true) { - const nativeResults = await executeScriptTab(target.tabId, {...details, allFrames: true}); - - return this.getNativeResultCount(nativeResults); - } - - if ("frameIds" in target && target.frameIds !== undefined) { - const nativeResults = await Promise.all( - target.frameIds.map(frameId => executeScriptTab(target.tabId, {...details, frameId})) - ); - - return nativeResults.reduce((count, result) => count + this.getNativeResultCount(result), 0); - } - - const nativeResults = await executeScriptTab(target.tabId, details); - - return this.getNativeResultCount(nativeResults); + private createRunDetails( + execution: InjectScriptExecutionOptions + ): Partial> { + return { + ...(execution.runAt !== undefined ? {runAt: execution.runAt} : {}), + ...(execution.matchAboutBlank !== undefined ? {matchAboutBlank: execution.matchAboutBlank} : {}), + }; } private async executeFile(target: InjectScriptTarget, details: InjectDetails): Promise { @@ -252,27 +430,20 @@ export default class extends AbstractInjectScript { return outcome.status === "fulfilled" || outcome.status === "rejected"; } - private getKnownFrameIds(target: InjectScriptTarget): readonly number[] | undefined { - if ("allFrames" in target && target.allFrames === true) return undefined; - if ("frameIds" in target && target.frameIds !== undefined) return target.frameIds; - - return [0]; - } - - private isExpectedFrame(target: InjectScriptTarget, frameId: number): boolean { - const knownFrameIds = this.getKnownFrameIds(target); - - return knownFrameIds === undefined || knownFrameIds.includes(frameId); - } - - private getCode(messageType: string, func: (...args: A) => R, args?: A): string { + private createCodeBuilder( + func: (...args: A) => R, + args?: A + ): (messageType: string) => string { const codeSource = this.generateCode().toString(); const funcSource = func.toString(); const validatorSource = findJsonCompatibilityIssue.toString(); - const serializedType = JSON.stringify(messageType); const serializedArgs = JSON.stringify(args ?? []); - return `(${codeSource})(${serializedType}, ${funcSource}, ${serializedArgs}, ${validatorSource})`; + return messageType => { + const serializedType = JSON.stringify(messageType); + + return `(${codeSource})(${serializedType}, ${funcSource}, ${serializedArgs}, ${validatorSource})`; + }; } private generateCode(): ( diff --git a/src/InjectScriptV3.ts b/src/InjectScriptV3.ts index af1acc6..64341b9 100644 --- a/src/InjectScriptV3.ts +++ b/src/InjectScriptV3.ts @@ -1,17 +1,39 @@ import {executeScript} from "@addon-core/browser"; import AbstractInjectScript from "./AbstractInjectScript"; -import {UnsupportedInjectScriptOptionError, UnsupportedInjectScriptTargetError} from "./errors"; -import {normalizeNativeInjectionResult, sortInjectionResults} from "./results"; +import { + InjectScriptTimeoutError, + UnsupportedInjectScriptOptionError, + UnsupportedInjectScriptTargetError, +} from "./errors"; +import { + classifyTargetDeliveryError, + createAllFramesResultTarget, + createDocumentResultTarget, + createFrameResultTarget, + createTargetFailure, + createTargetTimeoutFailure, + normalizeNativeInjectionResult, + sortInjectionResults, +} from "./results"; +import {InjectScriptTargetErrorKind} from "./types"; import type { InjectScriptExecutionOptions, InjectScriptOptions, InjectScriptResult, + InjectScriptResultTarget, InjectScriptTarget, JsonValue, NonEmptyReadonlyArray, } from "./types"; type InjectionTarget = chrome.scripting.InjectionTarget; +type NativeInjectionResult = chrome.scripting.InjectionResult; + +interface ExplicitExecutionTarget { + nativeTarget: InjectionTarget; + requestTarget: InjectScriptTarget; + resultTarget: InjectScriptResultTarget; +} export default class extends AbstractInjectScript { public constructor(options: InjectScriptOptions) { @@ -29,22 +51,45 @@ export default class extends AbstractInjectScript { const execution = this.snapshotExecution(); const timeoutMs = this.timeoutMs; + if ("frameIds" in target && target.frameIds !== undefined) { + const targets: ExplicitExecutionTarget[] = target.frameIds.map(frameId => ({ + nativeTarget: {tabId: target.tabId, frameIds: [frameId]}, + requestTarget: {tabId: target.tabId, frameIds: [frameId]}, + resultTarget: createFrameResultTarget(target.tabId, frameId), + })); + + return this.runExplicitTargets(targets, func, args, execution, timeoutMs); + } + + if ("documentIds" in target && target.documentIds !== undefined) { + const targets: ExplicitExecutionTarget[] = target.documentIds.map(documentId => ({ + nativeTarget: {tabId: target.tabId, documentIds: [documentId]}, + requestTarget: {tabId: target.tabId, documentIds: [documentId]}, + resultTarget: createDocumentResultTarget(target.tabId, documentId), + })); + + return this.runExplicitTargets(targets, func, args, execution, timeoutMs); + } + + const resultTarget = + "allFrames" in target && target.allFrames === true + ? createAllFramesResultTarget(target.tabId) + : createFrameResultTarget(target.tabId, 0); + try { const nativeResults = await this.withTimeout( - executeScript({ - target: this.toNativeTarget(target), - func: func as unknown as (...args: JsonValue[]) => R, - ...(execution.world !== undefined ? {world: execution.world} : {}), - ...(execution.runAt === "document_start" ? {injectImmediately: true} : {}), - ...(args ? {args: [...args] as JsonValue[]} : {}), - }), + this.executeFunction(this.toNativeTarget(target), func, args, execution), target, timeoutMs ); - return sortInjectionResults( - nativeResults.map(result => normalizeNativeInjectionResult>(target.tabId, result)) - ); + if ("allFrames" in target && target.allFrames === true) { + return sortInjectionResults( + nativeResults.map(result => normalizeNativeInjectionResult>(target.tabId, result)) + ); + } + + return [this.normalizeSingleNativeResult(target.tabId, resultTarget, nativeResults)]; } catch (error) { if (this.isUnsupportedDocumentTargetError(target, error)) { throw new UnsupportedInjectScriptTargetError( @@ -55,7 +100,19 @@ export default class extends AbstractInjectScript { this.throwUnsupportedExecutionCapability(execution, error); - throw this.deliveryError(target, error); + if (error instanceof InjectScriptTimeoutError) { + return [createTargetTimeoutFailure(resultTarget, timeoutMs, error)]; + } + + return [ + createTargetFailure( + resultTarget, + "allFrames" in target && target.allFrames === true + ? InjectScriptTargetErrorKind.Delivery + : classifyTargetDeliveryError(error), + error + ), + ]; } } @@ -102,6 +159,100 @@ export default class extends AbstractInjectScript { } } + private async runExplicitTargets( + targets: readonly ExplicitExecutionTarget[], + func: (...args: A) => R, + args: A | undefined, + execution: InjectScriptExecutionOptions, + timeoutMs: number + ): Promise>[]> { + // Preserve user activation: every native call is initiated before the first await. + const executions = targets.map(target => ({ + target, + promise: this.withTimeout( + this.executeFunction(target.nativeTarget, func, args, execution), + target.requestTarget, + timeoutMs + ), + })); + const settled = await Promise.allSettled(executions.map(executionItem => executionItem.promise)); + + for (let index = 0; index < settled.length; index += 1) { + const outcome = settled[index]; + + if (outcome.status === "fulfilled") continue; + + const target = executions[index].target.requestTarget; + + if (this.isUnsupportedDocumentTargetError(target, outcome.reason)) { + throw new UnsupportedInjectScriptTargetError( + '"documentIds" are not supported by the current browser.', + outcome.reason + ); + } + + this.throwUnsupportedExecutionCapability(execution, outcome.reason); + } + + return settled.map((outcome, index) => { + const {resultTarget} = executions[index].target; + + if (outcome.status === "fulfilled") { + return this.normalizeSingleNativeResult( + targets[index].requestTarget.tabId, + resultTarget, + outcome.value + ); + } + + if (outcome.reason instanceof InjectScriptTimeoutError) { + return createTargetTimeoutFailure(resultTarget, timeoutMs, outcome.reason); + } + + return createTargetFailure(resultTarget, classifyTargetDeliveryError(outcome.reason), outcome.reason); + }); + } + + private executeFunction( + target: InjectionTarget, + func: (...args: A) => R, + args: A | undefined, + execution: InjectScriptExecutionOptions + ): Promise[]> { + return executeScript({ + target, + func: func as unknown as (...args: JsonValue[]) => R, + ...(execution.world !== undefined ? {world: execution.world} : {}), + ...(execution.runAt === "document_start" ? {injectImmediately: true} : {}), + ...(args ? {args: [...args] as JsonValue[]} : {}), + }) as unknown as Promise[]>; + } + + private normalizeSingleNativeResult( + tabId: number, + requestedTarget: InjectScriptResultTarget, + nativeResults: NativeInjectionResult[] + ): InjectScriptResult { + const exactResult = nativeResults.find(result => { + if ("documentId" in requestedTarget && requestedTarget.documentId !== undefined) { + return result.documentId === requestedTarget.documentId; + } + + return "frameId" in requestedTarget && result.frameId === requestedTarget.frameId; + }); + const nativeResult = exactResult ?? (nativeResults.length === 1 ? nativeResults[0] : undefined); + + if (!nativeResult) { + return createTargetFailure( + requestedTarget, + InjectScriptTargetErrorKind.Delivery, + new Error("The browser did not return an injection result for the requested target.") + ); + } + + return normalizeNativeInjectionResult(tabId, nativeResult, requestedTarget); + } + private toNativeTarget(target: InjectScriptTarget): InjectionTarget { if ("frameIds" in target && target.frameIds !== undefined) { return {tabId: target.tabId, frameIds: [...target.frameIds]}; @@ -125,7 +276,10 @@ export default class extends AbstractInjectScript { const message = error instanceof Error ? error.message : String(error); - return /documentIds?/i.test(message) && this.isUnsupportedCapabilityMessage(message); + return ( + /documentIds?/i.test(message) && + /(not supported|unsupported|unexpected|unknown|unrecognized)\b/i.test(message) + ); } private throwUnsupportedExecutionCapability(execution: InjectScriptExecutionOptions, error: unknown): void { diff --git a/src/errors.ts b/src/errors.ts index 51a6b6d..2204a19 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import type {InjectScriptResult, InjectScriptTarget} from "./types"; +import type {InjectScriptTarget} from "./types"; export type InjectScriptErrorCode = | "ERR_INJECT_SCRIPT_DELIVERY" @@ -87,18 +87,11 @@ export class InvalidInjectScriptFilesError extends InjectScriptBaseError { } } -export interface InjectScriptTimeoutDetails { - missingCount?: number; - partialResults?: readonly InjectScriptResult[]; -} - export class InjectScriptTimeoutError extends InjectScriptBaseError { public readonly target: InjectScriptTarget; public readonly timeoutMs: number; - public readonly partialResults: readonly InjectScriptResult[]; - public readonly missingCount?: number; - public constructor(target: InjectScriptTarget, timeoutMs: number, details: InjectScriptTimeoutDetails = {}) { + public constructor(target: InjectScriptTarget, timeoutMs: number) { super( "InjectScriptTimeoutError", "ERR_INJECT_SCRIPT_TIMEOUT", @@ -106,8 +99,6 @@ export class InjectScriptTimeoutError extends InjectScriptBaseError { ); this.target = target; this.timeoutMs = timeoutMs; - this.partialResults = [...(details.partialResults ?? [])]; - this.missingCount = details.missingCount; } } diff --git a/src/index.ts b/src/index.ts index 9f76a7a..4a3a10d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,8 @@ export { UnsupportedInjectScriptOptionError, UnsupportedInjectScriptTargetError, } from "./errors"; -export type {InjectScriptErrorCode, InjectScriptTimeoutDetails} from "./errors"; +export {InjectScriptTargetErrorKind} from "./types"; +export type {InjectScriptErrorCode} from "./errors"; export type { InjectScriptAllFramesTarget, InjectScriptContract, @@ -26,6 +27,10 @@ export type { InjectScriptResult, InjectScriptResultTarget, InjectScriptTarget, + InjectScriptTargetError, + InjectScriptTargetFailure, + InjectScriptTargetSuccess, + InjectScriptTargetTimeoutError, InjectScriptTopFrameTarget, JsonCompatible, JsonPrimitive, diff --git a/src/results.ts b/src/results.ts index 0e2df38..e770907 100644 --- a/src/results.ts +++ b/src/results.ts @@ -1,5 +1,13 @@ +import { + type InjectScriptResult, + type InjectScriptResultTarget, + type InjectScriptTargetError, + InjectScriptTargetErrorKind, + type SerializedInjectScriptError, +} from "./types"; import {findJsonCompatibilityIssue} from "./validation"; -import type {InjectScriptResult, InjectScriptResultTarget, SerializedInjectScriptError} from "./types"; + +type NonTimeoutTargetErrorKind = Exclude<`${InjectScriptTargetErrorKind}`, `${InjectScriptTargetErrorKind.Timeout}`>; interface NativeInjectionResult { frameId: number; @@ -30,7 +38,11 @@ const serializeError = (value: unknown): SerializedInjectScriptError => { return {name: "Error", message: String(value)}; }; -export const createResultTarget = (tabId: number, frameId: number, documentId?: string): InjectScriptResultTarget => { +export const createFrameResultTarget = ( + tabId: number, + frameId: number, + documentId?: string +): InjectScriptResultTarget => { return { tabId, frameId, @@ -38,46 +50,137 @@ export const createResultTarget = (tabId: number, frameId: number, documentId?: }; }; -export const normalizeNativeInjectionResult = ( +export const createDocumentResultTarget = ( + tabId: number, + documentId: string, + frameId?: number +): InjectScriptResultTarget => { + return { + tabId, + documentId, + ...(frameId !== undefined ? {frameId} : {}), + }; +}; + +export const createAllFramesResultTarget = (tabId: number): InjectScriptResultTarget => { + return {tabId, allFrames: true}; +}; + +export const createTargetFailure = ( + target: InjectScriptResultTarget, + kind: NonTimeoutTargetErrorKind, + error: unknown +): InjectScriptResult => { + return { + success: false, + target, + error: { + kind, + ...serializeError(error), + }, + }; +}; + +export const createTargetTimeoutFailure = ( + target: InjectScriptResultTarget, + timeoutMs: number, + error: unknown, + missingCount?: number +): InjectScriptResult => { + return { + success: false, + target, + error: { + kind: InjectScriptTargetErrorKind.Timeout, + ...serializeError(error), + timeoutMs, + ...(missingCount !== undefined ? {missingCount} : {}), + }, + }; +}; + +export const createTargetSuccess = (target: InjectScriptResultTarget, value: T): InjectScriptResult => { + return {success: true, target, value}; +}; + +export const classifyTargetDeliveryError = ( + error: unknown +): typeof InjectScriptTargetErrorKind.Delivery | typeof InjectScriptTargetErrorKind.TargetGone => { + const message = error instanceof Error ? error.message : String(error); + + return /\bNo (?:frame|document|tab) with id\b|\b(?:frame|document|tab) (?:was )?(?:removed|not found|does not exist)\b/i.test( + message + ) + ? InjectScriptTargetErrorKind.TargetGone + : InjectScriptTargetErrorKind.Delivery; +}; + +const mergeObservedTarget = ( + requestedTarget: InjectScriptResultTarget | undefined, tabId: number, nativeResult: NativeInjectionResult +): InjectScriptResultTarget => { + if (requestedTarget && "documentId" in requestedTarget && requestedTarget.documentId !== undefined) { + return createDocumentResultTarget(tabId, requestedTarget.documentId, nativeResult.frameId); + } + + return createFrameResultTarget(tabId, nativeResult.frameId, nativeResult.documentId); +}; + +export const normalizeNativeInjectionResult = ( + tabId: number, + nativeResult: NativeInjectionResult, + requestedTarget?: InjectScriptResultTarget ): InjectScriptResult => { - const target = createResultTarget(tabId, nativeResult.frameId, nativeResult.documentId); + const target = mergeObservedTarget(requestedTarget, tabId, nativeResult); if ("result" in nativeResult && nativeResult.result !== undefined) { const issue = findJsonCompatibilityIssue(nativeResult.result, "result"); if (issue) { - return { + return createTargetFailure( target, - status: "rejected", - error: { - name: "TypeError", - message: `Injected function result is not JSON-compatible: ${issue.path} ${issue.reason}`, - }, - }; + InjectScriptTargetErrorKind.Execution, + new TypeError(`Injected function result is not JSON-compatible: ${issue.path} ${issue.reason}`) + ); } - return {target, status: "fulfilled", result: nativeResult.result as T}; + return createTargetSuccess(target, nativeResult.result as T); } if ("error" in nativeResult) { - return {target, status: "rejected", error: serializeError(nativeResult.error)}; + return createTargetFailure(target, InjectScriptTargetErrorKind.Execution, nativeResult.error); } - return {target, status: "unknown"}; + return createTargetFailure( + target, + InjectScriptTargetErrorKind.Unobservable, + new Error("The browser did not expose an observable injected function result.") + ); }; export const sortInjectionResults = (results: InjectScriptResult[]): InjectScriptResult[] => { return [...results].sort((left, right) => { - const frameOrder = left.target.frameId - right.target.frameId; + const leftFrameId = "frameId" in left.target ? left.target.frameId : undefined; + const rightFrameId = "frameId" in right.target ? right.target.frameId : undefined; + const frameOrder = (leftFrameId ?? Number.MAX_SAFE_INTEGER) - (rightFrameId ?? Number.MAX_SAFE_INTEGER); if (frameOrder !== 0) { return frameOrder; } - return (left.target.documentId ?? "").localeCompare(right.target.documentId ?? ""); + const leftDocumentId = "documentId" in left.target ? (left.target.documentId ?? "") : ""; + const rightDocumentId = "documentId" in right.target ? (right.target.documentId ?? "") : ""; + + return leftDocumentId.localeCompare(rightDocumentId); }); }; export const normalizeInjectionError = serializeError; + +export const withTargetErrorKind = ( + kind: NonTimeoutTargetErrorKind, + error: SerializedInjectScriptError +): InjectScriptTargetError => { + return {kind, ...error}; +}; diff --git a/src/types.ts b/src/types.ts index fd81678..c4b8157 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,11 +67,25 @@ export interface InjectScriptOptions extends InjectScriptExecutionOptions { target: InjectScriptTarget; } -export interface InjectScriptResultTarget { - tabId: number; - frameId: number; - documentId?: string; -} +export type InjectScriptResultTarget = + | { + tabId: number; + frameId: number; + documentId?: string; + allFrames?: never; + } + | { + tabId: number; + documentId: string; + frameId?: number; + allFrames?: never; + } + | { + tabId: number; + allFrames: true; + frameId?: never; + documentId?: never; + }; export interface SerializedInjectScriptError { name: string; @@ -79,21 +93,42 @@ export interface SerializedInjectScriptError { stack?: string; } -export type InjectScriptResult = - | { - target: InjectScriptResultTarget; - status: "fulfilled"; - result: T; - } - | { - target: InjectScriptResultTarget; - status: "rejected"; - error: SerializedInjectScriptError; - } - | { - target: InjectScriptResultTarget; - status: "unknown"; - }; +export enum InjectScriptTargetErrorKind { + Execution = "execution", + Delivery = "delivery", + Timeout = "timeout", + TargetGone = "target-gone", + Unobservable = "unobservable", +} + +type InjectScriptTargetErrorKindValue = `${InjectScriptTargetErrorKind}`; +type InjectScriptTargetTimeoutKind = `${InjectScriptTargetErrorKind.Timeout}`; + +export interface InjectScriptTargetTimeoutError extends SerializedInjectScriptError { + kind: InjectScriptTargetTimeoutKind; + timeoutMs: number; + missingCount?: number; +} + +export type InjectScriptTargetError = + | InjectScriptTargetTimeoutError + | (SerializedInjectScriptError & { + kind: Exclude; + }); + +export interface InjectScriptTargetSuccess { + success: true; + target: InjectScriptResultTarget; + value: T; +} + +export interface InjectScriptTargetFailure { + success: false; + target: InjectScriptResultTarget; + error: InjectScriptTargetError; +} + +export type InjectScriptResult = InjectScriptTargetSuccess | InjectScriptTargetFailure; export type InjectScriptFunctionResult = T | PromiseLike; diff --git a/tests/inject-script.test.cjs b/tests/inject-script.test.cjs index ae054e0..ec71f36 100644 --- a/tests/inject-script.test.cjs +++ b/tests/inject-script.test.cjs @@ -8,6 +8,7 @@ const { injectScript: namedInjectScript, InjectScriptBaseError, InjectScriptDeliveryError, + InjectScriptTargetErrorKind, InjectScriptTimeoutError, InvalidInjectScriptArgumentsError, InvalidInjectScriptFilesError, @@ -169,7 +170,7 @@ describe("MV3 adapter", () => { delete global.browser; }); - test("translates targets and returns sorted observed outcomes", async () => { + test("runs explicit frame targets independently and preserves input order", async () => { const {runtime} = createRuntime(3); const calls = []; @@ -178,13 +179,19 @@ describe("MV3 adapter", () => { scripting: { executeScript: (details, callback) => { calls.push(details); - callback([ - {frameId: 8, documentId: "doc-8", error: {name: "Error", message: "failed"}}, - {frameId: 9, documentId: "doc-9", error: undefined}, - {frameId: 10, documentId: "doc-10", result: "ok", error: undefined}, - {frameId: 0, documentId: "doc-0", result: "top"}, - {frameId: 3, documentId: "doc-3", result: undefined}, - ]); + const frameId = details.target.frameIds[0]; + const result = + frameId === 8 + ? {frameId, documentId: "doc-8", error: {name: "Error", message: "failed"}} + : frameId === 9 + ? {frameId, documentId: "doc-9", error: undefined} + : frameId === 10 + ? {frameId, documentId: "doc-10", result: "ok", error: undefined} + : frameId === 0 + ? {frameId, documentId: "doc-0", result: "top"} + : {frameId, documentId: "doc-3", result: undefined}; + + callback([result]); }, }, }; @@ -195,22 +202,206 @@ describe("MV3 adapter", () => { world: "MAIN", }).run(() => "value"); - expect(calls[0].target).toEqual({tabId: 5, frameIds: [8, 0, 3, 9, 10]}); - expect(calls[0].injectImmediately).toBe(true); + expect(calls.map(call => call.target)).toEqual([ + {tabId: 5, frameIds: [8]}, + {tabId: 5, frameIds: [0]}, + {tabId: 5, frameIds: [3]}, + {tabId: 5, frameIds: [9]}, + {tabId: 5, frameIds: [10]}, + ]); + expect(calls.every(call => call.injectImmediately === true)).toBe(true); expect(results).toEqual([ - {target: {tabId: 5, frameId: 0, documentId: "doc-0"}, status: "fulfilled", result: "top"}, - {target: {tabId: 5, frameId: 3, documentId: "doc-3"}, status: "unknown"}, { target: {tabId: 5, frameId: 8, documentId: "doc-8"}, - status: "rejected", - error: {name: "Error", message: "failed"}, + success: false, + error: {kind: InjectScriptTargetErrorKind.Execution, name: "Error", message: "failed"}, + }, + {target: {tabId: 5, frameId: 0, documentId: "doc-0"}, success: true, value: "top"}, + { + target: {tabId: 5, frameId: 3, documentId: "doc-3"}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.Unobservable, + name: "Error", + message: "The browser did not expose an observable injected function result.", + }), }, { target: {tabId: 5, frameId: 9, documentId: "doc-9"}, - status: "rejected", - error: {name: "Error", message: "undefined"}, + success: false, + error: {kind: InjectScriptTargetErrorKind.Execution, name: "Error", message: "undefined"}, + }, + {target: {tabId: 5, frameId: 10, documentId: "doc-10"}, success: true, value: "ok"}, + ]); + }); + + test("starts every explicit frame call before awaiting and isolates a removed frame", async () => { + const {runtime} = createRuntime(3); + const calls = []; + const callbacks = new Map(); + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + const frameId = details.target.frameIds[0]; + calls.push(frameId); + callbacks.set(frameId, callback); + }, + }, + }; + + const pending = injectScript({target: {tabId: 5, frameIds: [7, 2, 9]}}).run(() => "value"); + + expect(calls).toEqual([7, 2, 9]); + + callbacks.get(9)([{frameId: 9, result: "nine"}]); + global.chrome.runtime.lastError = {message: "No frame with id 2 in tab with id 5"}; + callbacks.get(2)(); + global.chrome.runtime.lastError = undefined; + callbacks.get(7)([{frameId: 7, result: "seven"}]); + + await expect(pending).resolves.toEqual([ + {target: {tabId: 5, frameId: 7}, success: true, value: "seven"}, + { + target: {tabId: 5, frameId: 2}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.TargetGone, + message: "No frame with id 2 in tab with id 5", + }), + }, + {target: {tabId: 5, frameId: 9}, success: true, value: "nine"}, + ]); + }); + + test("returns one failure for every unavailable explicit frame", async () => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + const frameId = details.target.frameIds[0]; + global.chrome.runtime.lastError = {message: `No frame with id ${frameId} in tab with id 5`}; + callback(); + global.chrome.runtime.lastError = undefined; + }, + }, + }; + + const results = await injectScript({target: {tabId: 5, frameIds: [4, 6]}}).run(() => "value"); + + expect(results).toHaveLength(2); + expect(results.map(result => result.target)).toEqual([ + {tabId: 5, frameId: 4}, + {tabId: 5, frameId: 6}, + ]); + expect( + results.every(result => !result.success && result.error.kind === InjectScriptTargetErrorKind.TargetGone) + ).toBe(true); + }); + + test("runs document targets independently without inventing a frame ID for delivery failures", async () => { + const {runtime} = createRuntime(3); + const calls = []; + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + const documentId = details.target.documentIds[0]; + calls.push(documentId); + + if (documentId === "doc-b") { + global.chrome.runtime.lastError = {message: "No document with id doc-b"}; + callback(); + global.chrome.runtime.lastError = undefined; + return; + } + + callback([{frameId: 7, documentId, result: "document-a"}]); + }, + }, + }; + + await expect( + injectScript({target: {tabId: 5, documentIds: ["doc-a", "doc-b"]}}).run(() => "value") + ).resolves.toEqual([ + {target: {tabId: 5, documentId: "doc-a", frameId: 7}, success: true, value: "document-a"}, + { + target: {tabId: 5, documentId: "doc-b"}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.TargetGone}), + }, + ]); + expect(calls).toEqual(["doc-a", "doc-b"]); + }); + + test("returns per-frame allFrames results and an operation failure when native delivery fails", async () => { + const {runtime} = createRuntime(3); + let failDelivery = false; + + global.chrome = { + runtime, + scripting: { + executeScript: (_details, callback) => { + if (failDelivery) { + global.chrome.runtime.lastError = {message: "Missing host permission"}; + callback(); + global.chrome.runtime.lastError = undefined; + return; + } + + callback([ + {frameId: 4, result: "child"}, + {frameId: 0, result: "top"}, + ]); + }, + }, + }; + + const injector = injectScript({target: {tabId: 5, allFrames: true}}); + + await expect(injector.run(() => "value")).resolves.toEqual([ + {target: {tabId: 5, frameId: 0}, success: true, value: "top"}, + {target: {tabId: 5, frameId: 4}, success: true, value: "child"}, + ]); + + failDelivery = true; + + await expect(injector.run(() => "value")).resolves.toEqual([ + { + target: {tabId: 5, allFrames: true}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.Delivery}), + }, + ]); + }); + + test("times out only the unresponsive explicit MV3 target", async () => { + const {runtime} = createRuntime(3); + + global.chrome = { + runtime, + scripting: { + executeScript: (details, callback) => { + const frameId = details.target.frameIds[0]; + + if (frameId === 1) callback([{frameId, result: "one"}]); + }, + }, + }; + + await expect( + injectScript({target: {tabId: 5, frameIds: [1, 2]}, timeoutMs: 5}).run(() => "value") + ).resolves.toEqual([ + {target: {tabId: 5, frameId: 1}, success: true, value: "one"}, + { + target: {tabId: 5, frameId: 2}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.Timeout, timeoutMs: 5}), }, - {target: {tabId: 5, frameId: 10, documentId: "doc-10"}, status: "fulfilled", result: "ok"}, ]); }); @@ -258,45 +449,64 @@ describe("MV3 adapter", () => { const byFrame = new Map(results.map(result => [result.target.frameId, result])); expect(results.slice(0, 4)).toEqual([ - {target: {tabId: 5, frameId: 0}, status: "fulfilled", result: null}, - {target: {tabId: 5, frameId: 1}, status: "fulfilled", result: true}, - {target: {tabId: 5, frameId: 2}, status: "fulfilled", result: 42}, - {target: {tabId: 5, frameId: 3}, status: "fulfilled", result: {nested: ["ok"]}}, + {target: {tabId: 5, frameId: 0}, success: true, value: null}, + {target: {tabId: 5, frameId: 1}, success: true, value: true}, + {target: {tabId: 5, frameId: 2}, success: true, value: 42}, + {target: {tabId: 5, frameId: 3}, success: true, value: {nested: ["ok"]}}, ]); expect(byFrame.get(4)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result is a Date instance")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + name: "TypeError", + message: expect.stringContaining("result is a Date instance"), + }, }); expect(byFrame.get(5)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result is a Map instance")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + message: expect.stringContaining("result is a Map instance"), + }, }); expect(byFrame.get(6)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result is NaN")}, + success: false, + error: {kind: InjectScriptTargetErrorKind.Execution, message: expect.stringContaining("result is NaN")}, }); expect(byFrame.get(7)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result.self contains a circular reference")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + message: expect.stringContaining("result.self contains a circular reference"), + }, }); expect(byFrame.get(8)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result[0] is missing")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + message: expect.stringContaining("result[0] is missing"), + }, }); expect(byFrame.get(9)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("enumerable symbol-keyed property")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + message: expect.stringContaining("enumerable symbol-keyed property"), + }, }); expect(byFrame.get(10)).toMatchObject({ - status: "rejected", + success: false, error: { - name: "TypeError", + kind: InjectScriptTargetErrorKind.Execution, message: expect.stringContaining("result.metadata is an additional array property"), }, }); expect(byFrame.get(11)).toMatchObject({ - status: "rejected", - error: {name: "TypeError", message: expect.stringContaining("result is a CustomArray instance")}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + message: expect.stringContaining("result is a CustomArray instance"), + }, }); }); @@ -317,8 +527,8 @@ describe("MV3 adapter", () => { await expect(injectScript({target: {tabId: 12}}).run(() => "value")).resolves.toEqual([ { target: {tabId: 12, frameId: 0}, - status: "fulfilled", - result: {namespace: "browser"}, + success: true, + value: {namespace: "browser"}, }, ]); expect(calls).toHaveLength(1); @@ -345,20 +555,25 @@ describe("MV3 adapter", () => { }); test.each([ - [{tabId: 7}, {tabId: 7}], - [ - {tabId: 7, allFrames: true}, - {tabId: 7, allFrames: true}, - ], + [{tabId: 7}, [{tabId: 7}], {tabId: 7}], + [{tabId: 7, allFrames: true}, [{tabId: 7, allFrames: true}], {tabId: 7, allFrames: true}], [ {tabId: 7, frameIds: [0, 2]}, + [ + {tabId: 7, frameIds: [0]}, + {tabId: 7, frameIds: [2]}, + ], {tabId: 7, frameIds: [0, 2]}, ], [ {tabId: 7, documentIds: ["doc-a", "doc-b"]}, + [ + {tabId: 7, documentIds: ["doc-a"]}, + {tabId: 7, documentIds: ["doc-b"]}, + ], {tabId: 7, documentIds: ["doc-a", "doc-b"]}, ], - ])("uses the same target translation for run and file %#", async (target, nativeTarget) => { + ])("isolates explicit run targets while keeping file targets native %#", async (target, runTargets, fileTarget) => { const {runtime} = createRuntime(3); const calls = []; @@ -377,7 +592,7 @@ describe("MV3 adapter", () => { await injector.run(() => null); await injector.file("/content.js"); - expect(calls).toEqual([nativeTarget, nativeTarget]); + expect(calls).toEqual([...runTargets, fileTarget]); }); test("updates execution options without changing the target", async () => { @@ -424,7 +639,7 @@ describe("MV3 adapter", () => { }; await expect(injectScript({target: {tabId: 7}}).run(func)).resolves.toEqual([ - {target: {tabId: 7, frameId: 0}, status: "fulfilled", result: expected}, + {target: {tabId: 7, frameId: 0}, success: true, value: expected}, ]); }); @@ -509,7 +724,7 @@ describe("MV3 adapter", () => { ); }); - test("times out run() with structured timeout details", async () => { + test("returns a structured target timeout from run()", async () => { const {runtime} = createRuntime(3); global.chrome = { @@ -519,12 +734,17 @@ describe("MV3 adapter", () => { }, }; - await expect(injectScript({target: {tabId: 2}, timeoutMs: 5}).run(() => "late")).rejects.toMatchObject({ - code: "ERR_INJECT_SCRIPT_TIMEOUT", - timeoutMs: 5, - partialResults: [], - target: {tabId: 2}, - }); + await expect(injectScript({target: {tabId: 2}, timeoutMs: 5}).run(() => "late")).resolves.toEqual([ + { + target: {tabId: 2, frameId: 0}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.Timeout, + name: "InjectScriptTimeoutError", + timeoutMs: 5, + }), + }, + ]); }); }); @@ -581,10 +801,14 @@ describe("MV2 adapter", () => { expect(results).toEqual([ { target: {tabId: 3, frameId: 0}, - status: "rejected", - error: {name: "Error", message: "top failed"}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + name: "Error", + message: "top failed", + }, }, - {target: {tabId: 3, frameId: 4}, status: "fulfilled", result: "child"}, + {target: {tabId: 3, frameId: 4}, success: true, value: "child"}, ]); expect(listeners.size).toBe(0); }); @@ -617,7 +841,90 @@ describe("MV2 adapter", () => { const results = await injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => 1); expect(frameCalls).toEqual([2, 0]); - expect(results.map(result => result.target.frameId)).toEqual([0, 2]); + expect(results.map(result => result.target.frameId)).toEqual([2, 0]); + }); + + test("uses one temporary listener for an explicit frame batch", async () => { + jest.useFakeTimers(); + + try { + const {runtime, listeners} = createRuntime(2); + const frameIds = Array.from({length: 20}, (_, frameId) => frameId); + const frameCalls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (_tabId, details, callback) => { + frameCalls.push(details.frameId); + callback([undefined]); + }, + }, + }; + + const pending = injectScript({target: {tabId: 6, frameIds}, timeoutMs: 10}).run(() => "value"); + + expect(frameCalls).toEqual(frameIds); + expect(listeners.size).toBe(1); + expect(jest.getTimerCount()).toBe(20); + + jest.advanceTimersByTime(10); + + const results = await pending; + + expect(results).toHaveLength(20); + expect( + results.every(result => !result.success && result.error.kind === InjectScriptTargetErrorKind.Timeout) + ).toBe(true); + expect(listeners.size).toBe(0); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + test("dispatches explicit responses by request and isolates parallel batches", async () => { + const {runtime, listeners} = createRuntime(2); + const calls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + calls.push({tabId, frameId: details.frameId, type: getMessageType(details.code)}); + callback([undefined]); + }, + }, + }; + + const first = injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => "first"); + const second = injectScript({target: {tabId: 7, frameIds: [4, 1]}}).run(() => "second"); + + expect(listeners.size).toBe(2); + + const dispatch = (type, tabId, frameId, result) => { + for (const listener of [...listeners]) { + listener({type, data: {status: "fulfilled", result}}, {tab: {id: tabId}, frameId}); + } + }; + + dispatch("unrelated-message", 6, 2, "ignored"); + dispatch(calls.find(call => call.tabId === 6 && call.frameId === 2).type, 6, 99, "wrong frame"); + + dispatch(calls.find(call => call.tabId === 7 && call.frameId === 1).type, 7, 1, "second-1"); + dispatch(calls.find(call => call.tabId === 6 && call.frameId === 0).type, 6, 0, "first-0"); + dispatch(calls.find(call => call.tabId === 7 && call.frameId === 4).type, 7, 4, "second-4"); + dispatch(calls.find(call => call.tabId === 6 && call.frameId === 2).type, 6, 2, "first-2"); + + await expect(first).resolves.toEqual([ + {target: {tabId: 6, frameId: 2}, success: true, value: "first-2"}, + {target: {tabId: 6, frameId: 0}, success: true, value: "first-0"}, + ]); + await expect(second).resolves.toEqual([ + {target: {tabId: 7, frameId: 4}, success: true, value: "second-4"}, + {target: {tabId: 7, frameId: 1}, success: true, value: "second-1"}, + ]); + expect(listeners.size).toBe(0); }); test("collects fulfilled and rejected outcomes from explicit frames", async () => { @@ -646,12 +953,61 @@ describe("MV2 adapter", () => { }; await expect(injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => "value")).resolves.toEqual([ - {target: {tabId: 6, frameId: 0}, status: "fulfilled", result: "top"}, { target: {tabId: 6, frameId: 2}, - status: "rejected", - error: {name: "Error", message: "failed"}, + success: false, + error: { + kind: InjectScriptTargetErrorKind.Execution, + name: "Error", + message: "failed", + }, + }, + {target: {tabId: 6, frameId: 0}, success: true, value: "top"}, + ]); + }); + + test("keeps successful MV2 frames when another native delivery fails", async () => { + const {runtime, listeners} = createRuntime(2); + const frameCalls = []; + + global.chrome = { + runtime, + tabs: { + executeScript: (tabId, details, callback) => { + frameCalls.push(details.frameId); + + if (details.frameId === 2) { + global.chrome.runtime.lastError = {message: "No frame with id 2 in tab with id 6"}; + callback(); + global.chrome.runtime.lastError = undefined; + return; + } + + const type = getMessageType(details.code); + + queueMicrotask(() => { + for (const listener of listeners) { + listener( + {type, data: {status: "fulfilled", result: "top"}}, + {tab: {id: tabId}, frameId: details.frameId} + ); + } + }); + callback([undefined]); + }, + }, + }; + + const pending = injectScript({target: {tabId: 6, frameIds: [2, 0]}}).run(() => "value"); + + expect(frameCalls).toEqual([2, 0]); + await expect(pending).resolves.toEqual([ + { + target: {tabId: 6, frameId: 2}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.TargetGone}), }, + {target: {tabId: 6, frameId: 0}, success: true, value: "top"}, ]); }); @@ -660,7 +1016,7 @@ describe("MV2 adapter", () => { "chrome", async value => ({value}), ["async"], - {target: {tabId: 8, frameId: 0}, status: "fulfilled", result: {value: "async"}}, + {target: {tabId: 8, frameId: 0}, success: true, value: {value: "async"}}, ], [ "browser", @@ -670,7 +1026,7 @@ describe("MV2 adapter", () => { [], { target: {tabId: 8, frameId: 0}, - status: "rejected", + success: false, error: expect.objectContaining({name: "Error", message: "frame failed"}), }, ], @@ -680,7 +1036,7 @@ describe("MV2 adapter", () => { [], { target: {tabId: 8, frameId: 0}, - status: "rejected", + success: false, error: expect.objectContaining({ name: "TypeError", message: @@ -694,7 +1050,7 @@ describe("MV2 adapter", () => { [], { target: {tabId: 8, frameId: 0}, - status: "rejected", + success: false, error: expect.objectContaining({ name: "TypeError", message: @@ -708,7 +1064,7 @@ describe("MV2 adapter", () => { [], { target: {tabId: 8, frameId: 0}, - status: "rejected", + success: false, error: expect.objectContaining({ name: "TypeError", message: @@ -763,8 +1119,8 @@ describe("MV2 adapter", () => { await expect(injectScript({target: {tabId: 12}}).run(() => "value")).resolves.toEqual([ { target: {tabId: 12, frameId: 0}, - status: "fulfilled", - result: {namespace: "browser"}, + success: true, + value: {namespace: "browser"}, }, ]); }); @@ -891,7 +1247,7 @@ describe("MV2 adapter", () => { expect(() => injectScript({target: {tabId: 1}, world: "MAIN"})).toThrow(UnsupportedInjectScriptOptionError); }); - test("rejects non-JSON arguments and empty files, but marks a known missing response as unknown", async () => { + test("rejects invalid input before injection and returns a timeout for a missing response", async () => { const {runtime} = createRuntime(2); global.chrome = { @@ -914,7 +1270,15 @@ describe("MV2 adapter", () => { await expect(injector.run(value => value, [cyclic])).rejects.toThrow(InvalidInjectScriptArgumentsError); await expect(injector.file([])).rejects.toThrow(InvalidInjectScriptFilesError); await expect(injector.run(() => "never delivered")).resolves.toEqual([ - {target: {tabId: 1, frameId: 0}, status: "unknown"}, + { + target: {tabId: 1, frameId: 0}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.Timeout, + name: "InjectScriptTimeoutError", + timeoutMs: 5, + }), + }, ]); }); @@ -986,7 +1350,7 @@ describe("MV2 adapter", () => { ); }); - test("preserves known frame results and marks only missing explicit frames as unknown", async () => { + test("preserves known frame results and times out only the missing explicit frame", async () => { const {runtime, listeners} = createRuntime(2); global.chrome = { @@ -1014,8 +1378,16 @@ describe("MV2 adapter", () => { await expect( injectScript({target: {tabId: 1, frameIds: [2, 0]}, timeoutMs: 5}).run(() => "value") ).resolves.toEqual([ - {target: {tabId: 1, frameId: 0}, status: "fulfilled", result: "top"}, - {target: {tabId: 1, frameId: 2}, status: "unknown"}, + { + target: {tabId: 1, frameId: 2}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.Timeout, + name: "InjectScriptTimeoutError", + timeoutMs: 5, + }), + }, + {target: {tabId: 1, frameId: 0}, success: true, value: "top"}, ]); }); @@ -1044,12 +1416,19 @@ describe("MV2 adapter", () => { await expect( injectScript({target: {tabId: 1, allFrames: true}, timeoutMs: 5}).run(() => "value") - ).rejects.toMatchObject({ - code: "ERR_INJECT_SCRIPT_TIMEOUT", - timeoutMs: 5, - missingCount: 1, - partialResults: [{target: {tabId: 1, frameId: 0}, status: "fulfilled", result: "top"}], - }); + ).resolves.toEqual([ + {target: {tabId: 1, frameId: 0}, success: true, value: "top"}, + { + target: {tabId: 1, allFrames: true}, + success: false, + error: expect.objectContaining({ + kind: InjectScriptTargetErrorKind.Timeout, + name: "InjectScriptTimeoutError", + timeoutMs: 5, + missingCount: 1, + }), + }, + ]); }); test("cleans up listeners and timers after delivery errors and timeouts", async () => { @@ -1069,18 +1448,30 @@ describe("MV2 adapter", () => { }, }; - await expect(injectScript({target: {tabId: 1}}).run(() => null)).rejects.toThrow(InjectScriptDeliveryError); + await expect(injectScript({target: {tabId: 1}}).run(() => null)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.Delivery}), + }, + ]); expect(listeners.size).toBe(0); expect(jest.getTimerCount()).toBe(0); global.chrome.tabs.executeScript = () => {}; const pending = injectScript({target: {tabId: 1}, timeoutMs: 10}).run(() => null); - const rejection = expect(pending).rejects.toThrow(InjectScriptTimeoutError); + const result = expect(pending).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0}, + success: false, + error: expect.objectContaining({kind: InjectScriptTargetErrorKind.Timeout}), + }, + ]); expect(listeners.size).toBe(1); jest.advanceTimersByTime(10); - await rejection; + await result; expect(listeners.size).toBe(0); expect(jest.getTimerCount()).toBe(0); diff --git a/tests/types.test.ts b/tests/types.test.ts index 9a4c892..d17d9dd 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -1,7 +1,7 @@ import injectScript, { type InjectScriptErrorCode, type InjectScriptResult, - type InjectScriptTimeoutDetails, + type InjectScriptTargetErrorKind, type JsonValue, injectScript as namedInjectScript, type SerializedInjectScriptError, @@ -71,23 +71,31 @@ topFrame.run(() => document.body); declare const result: InjectScriptResult; -if (result.status === "fulfilled") { - result.result.toUpperCase(); -} - -if (result.status === "rejected") { +if (result.success) { + result.value.toUpperCase(); + // @ts-expect-error successful outcomes intentionally expose no error + result.error; +} else { result.error.message.toUpperCase(); -} - -if (result.status === "unknown") { - // @ts-expect-error unknown outcomes intentionally expose no result - result.result; + result.error.kind satisfies `${InjectScriptTargetErrorKind}`; + // @ts-expect-error failed outcomes intentionally expose no value + result.value; } declare const serializedError: SerializedInjectScriptError; declare const errorCode: InjectScriptErrorCode; -declare const timeoutDetails: InjectScriptTimeoutDetails; +const literalKind: `${InjectScriptTargetErrorKind}` = "delivery"; +const retryKinds: `${InjectScriptTargetErrorKind}`[] = ["timeout", "target-gone", "unobservable"]; serializedError.message.toUpperCase(); errorCode.toUpperCase(); -timeoutDetails.partialResults?.map(item => item.status); +literalKind.toUpperCase(); +retryKinds.map(kind => kind.toUpperCase()); + +if (!result.success && result.error.kind === "timeout") { + result.error.timeoutMs.toFixed(); + result.error.missingCount?.toFixed(); +} else if (!result.success) { + // @ts-expect-error timeout metadata is available only for timeout failures + result.error.timeoutMs; +}