From f13bc6f19308cacf7d01dcbc5462a09e204ee93c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:09 +0200 Subject: [PATCH 1/6] feat(availability): expose typed native method checks Accept optional native method names in every availability helper and export ApiMethodName for consumer wrappers. Keep no-argument namespace checks. --- README.md | 15 ++++++++++ codegen/availability/generate.mjs | 9 ++++-- codegen/availability/templates/action.mjs | 1 + codegen/availability/templates/basic.mjs | 1 + codegen/availability/templates/sidebar.mjs | 1 + docs/action.md | 9 ++++-- docs/alarms.md | 9 ++++-- docs/audio.md | 9 ++++-- docs/bookmarks.md | 6 +++- docs/browsing-data.md | 9 ++++-- docs/commands.md | 9 ++++-- docs/context-menus.md | 9 ++++-- docs/cookies.md | 9 ++++-- docs/declarative-net-request.md | 6 +++- docs/document-scan.md | 9 ++++-- docs/downloads.md | 9 ++++-- docs/extension.md | 9 ++++-- docs/history.md | 9 ++++-- docs/i18n.md | 9 ++++-- docs/identity.md | 9 ++++-- docs/idle.md | 9 ++++-- docs/management.md | 9 ++++-- docs/notifications.md | 9 ++++-- docs/offscreen.md | 9 ++++-- docs/permissions.md | 9 ++++-- docs/runtime.md | 9 ++++-- docs/safari.md | 28 +++++++++++++++++ docs/scripting.md | 9 ++++-- docs/search.md | 6 +++- docs/sidebar.md | 6 +++- docs/tab-capture.md | 9 ++++-- docs/tab-groups.md | 6 +++- docs/tabs.md | 6 +++- docs/top-sites.md | 8 +++-- docs/user-scripts.md | 9 ++++-- docs/web-navigation.md | 9 ++++-- docs/web-request.md | 9 ++++-- docs/windows.md | 9 ++++-- scripts/verify-build.mjs | 6 ++-- src/api/action/availability.test.ts | 18 +++++++++++ src/api/action/availability.ts | 7 +++-- src/api/alarms/availability.ts | 7 +++-- src/api/audio/availability.ts | 7 +++-- src/api/bookmarks/availability.ts | 7 +++-- src/api/browsing-data/availability.ts | 7 +++-- src/api/commands/availability.ts | 7 +++-- src/api/context-menus/availability.ts | 7 +++-- src/api/cookies/availability.ts | 7 +++-- .../declarative-net-request/availability.ts | 7 +++-- src/api/document-scan/availability.ts | 7 +++-- src/api/downloads/availability.ts | 7 +++-- src/api/extension/availability.ts | 7 +++-- src/api/history/availability.ts | 7 +++-- src/api/i18n/availability.ts | 7 +++-- src/api/identity/availability.ts | 7 +++-- src/api/idle/availability.ts | 7 +++-- src/api/management/availability.ts | 7 +++-- src/api/notifications/availability.ts | 7 +++-- src/api/offscreen/availability.ts | 7 +++-- src/api/permissions/availability.ts | 7 +++-- src/api/runtime/availability.ts | 7 +++-- src/api/scripting/availability.ts | 7 +++-- src/api/search/availability.ts | 7 +++-- src/api/sidebar/availability.test.ts | 11 +++++++ src/api/sidebar/availability.ts | 7 +++-- src/api/tab-capture/availability.ts | 7 +++-- src/api/tab-groups/availability.ts | 7 +++-- src/api/tabs/availability.ts | 7 +++-- src/api/top-sites/availability.ts | 7 +++-- src/api/user-scripts/availability.ts | 7 +++-- src/api/web-navigation/availability.ts | 7 +++-- src/api/web-request/availability.ts | 7 +++-- src/api/windows/availability.ts | 7 +++-- src/index.ts | 1 + src/internal/api-method.ts | 3 ++ src/testing/coverage/public-exports.ts | 7 +++-- tests/api/availability.ts | 30 +++++++++++++++++++ tests/consumer-types/availability-types.ts | 30 ++++++++++++++++--- tests/consumer-types/availability.cjs | 8 +++++ .../consumer-types/bookmarks-methods-types.ts | 2 +- .../tab-groups-methods-types.ts | 2 +- .../consumer-types/top-sites-methods-types.ts | 2 +- tests/tooling/codegen.test.mjs | 2 +- 83 files changed, 531 insertions(+), 139 deletions(-) create mode 100644 docs/safari.md create mode 100644 src/internal/api-method.ts diff --git a/README.md b/README.md index 70f0562..03c6197 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,21 @@ Explore the API guides for available helpers, examples, and browser-specific det - Firefox — partial support via compatible helpers (e.g., `sidebarAction`, `runtime.getBrowserInfo`) - Apple Safari — limited WebExtensions support; many Chromium-specific APIs are not available, so some helpers won’t work. +All `isAvailable…()` helpers accept an optional **native method name**. A namespace can exist while an individual +method is missing, especially in Safari: + +```ts +import {getBadgeTextColor, isAvailableAction} from "@addon-core/browser"; + +if (isAvailableAction("getBadgeTextColor")) { + const color = await getBadgeTextColor(); +} +``` + +The check is synchronous and does not invoke the method. It checks callable presence; permissions, valid arguments +and successful execution still depend on the browser. Calls without an argument retain their namespace check. +See [Safari compatibility and migration notes](docs/safari.md) for native differences and changed result types. + ## TypeScript compatibility The package depends on `@types/chrome@^0.3.5`, which owns the global `browser` alias diff --git a/codegen/availability/generate.mjs b/codegen/availability/generate.mjs index c496248..7f0c23c 100644 --- a/codegen/availability/generate.mjs +++ b/codegen/availability/generate.mjs @@ -28,7 +28,7 @@ export function generateAvailability(specs) { } const exportName = `isAvailable${alias ?? namespace[0].toUpperCase() + namespace.slice(1)}`; - const {imports, expression} = render({namespace}); + const {imports, expression, methodType} = render({namespace}); return { namespace, @@ -37,11 +37,14 @@ export function generateAvailability(specs) { source: [ "// Edit codegen/availability/apis.mjs and run npm run generate.", "", + 'import type {ApiMethodName} from "../../internal/api-method";', ...imports, "", - `export const ${exportName} = (): boolean => {`, + `export const ${exportName} = (method?: ApiMethodName<${methodType}>): boolean => {`, " try {", - ` return Boolean(${expression});`, + ` const api = ${expression};`, + "", + ' return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function";', " } catch {", " return false;", " }", diff --git a/codegen/availability/templates/action.mjs b/codegen/availability/templates/action.mjs index faffa1e..ff549c5 100644 --- a/codegen/availability/templates/action.mjs +++ b/codegen/availability/templates/action.mjs @@ -2,5 +2,6 @@ export function renderActionAvailability() { return { imports: ['import {action} from "./api";'], expression: "action()", + methodType: "typeof chrome.action | typeof chrome.browserAction", }; } diff --git a/codegen/availability/templates/basic.mjs b/codegen/availability/templates/basic.mjs index b4997a6..8ab4044 100644 --- a/codegen/availability/templates/basic.mjs +++ b/codegen/availability/templates/basic.mjs @@ -2,5 +2,6 @@ export function renderBasicAvailability({namespace}) { return { imports: ['import {browser} from "../browser";'], expression: `browser().${namespace}`, + methodType: `typeof chrome.${namespace}`, }; } diff --git a/codegen/availability/templates/sidebar.mjs b/codegen/availability/templates/sidebar.mjs index 654cf25..6c7a889 100644 --- a/codegen/availability/templates/sidebar.mjs +++ b/codegen/availability/templates/sidebar.mjs @@ -2,5 +2,6 @@ export function renderSidebarAvailability() { return { imports: ['import {sidebarAction, sidePanel} from "./api";'], expression: "sidePanel() || sidebarAction()", + methodType: "NonNullable | ReturnType>", }; } diff --git a/docs/action.md b/docs/action.md index 6e30b05..7c11a1c 100644 --- a/docs/action.md +++ b/docs/action.md @@ -38,13 +38,18 @@ A unified, promise-based wrapper around the Chrome Action API. It transparently ### isAvailableAction ```ts -isAvailableAction(): boolean +isAvailableAction(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableAction("isEnabled")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks the same API selected by action methods and events: `action` for Manifest V3 and `browserAction` for Manifest V2. Does not fall back to the other namespace if the selected one is missing. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableAction} from "@addon-core/browser"; diff --git a/docs/alarms.md b/docs/alarms.md index 88da03c..d9fb82c 100644 --- a/docs/alarms.md +++ b/docs/alarms.md @@ -25,13 +25,18 @@ A promise-based wrapper for the Chrome `alarms` API. ### isAvailableAlarms ```ts -isAvailableAlarms(): boolean +isAvailableAlarms(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableAlarms("clear")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `alarms` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableAlarms} from "@addon-core/browser"; diff --git a/docs/audio.md b/docs/audio.md index e3a59d6..d935735 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -26,13 +26,18 @@ A promise-based wrapper for the Chrome `audio` API. ### isAvailableAudio ```ts -isAvailableAudio(): boolean +isAvailableAudio(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableAudio("getDevices")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `audio` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableAudio} from "@addon-core/browser"; diff --git a/docs/bookmarks.md b/docs/bookmarks.md index 9a626d0..827cc84 100644 --- a/docs/bookmarks.md +++ b/docs/bookmarks.md @@ -81,9 +81,13 @@ All methods reject on native errors instead of converting failures into empty ar ### isAvailableBookmarks ```ts -isAvailableBookmarks(): boolean +isAvailableBookmarks(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableBookmarks("getTree")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks the current API selected by `browser()` on every call. Returns `false` if the namespace or extension environment is missing, or access throws. It does not log or guarantee that a later operation succeeds. diff --git a/docs/browsing-data.md b/docs/browsing-data.md index c5f51f2..3bb78cb 100644 --- a/docs/browsing-data.md +++ b/docs/browsing-data.md @@ -30,13 +30,18 @@ A promise-based wrapper for the Chrome `browsingData` API. ### isAvailableBrowsingData ```ts -isAvailableBrowsingData(): boolean +isAvailableBrowsingData(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableBrowsingData("remove")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `browsingData` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableBrowsingData} from "@addon-core/browser"; diff --git a/docs/commands.md b/docs/commands.md index c29ed4a..d619dac 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -22,13 +22,18 @@ A promise-based wrapper for the Chrome `commands` API. ### isAvailableCommands ```ts -isAvailableCommands(): boolean +isAvailableCommands(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableCommands("getAll")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `commands` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableCommands} from "@addon-core/browser"; diff --git a/docs/context-menus.md b/docs/context-menus.md index 5ee263f..09456f7 100644 --- a/docs/context-menus.md +++ b/docs/context-menus.md @@ -24,13 +24,18 @@ A promise-based wrapper for the Chrome `contextMenus` API. ### isAvailableContextMenus ```ts -isAvailableContextMenus(): boolean +isAvailableContextMenus(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableContextMenus("create")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `contextMenus` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableContextMenus} from "@addon-core/browser"; diff --git a/docs/cookies.md b/docs/cookies.md index 37f5688..73979b0 100644 --- a/docs/cookies.md +++ b/docs/cookies.md @@ -23,13 +23,18 @@ A promise-based wrapper for the Chrome `cookies` API. ### isAvailableCookies ```ts -isAvailableCookies(): boolean +isAvailableCookies(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableCookies("get")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `cookies` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableCookies} from "@addon-core/browser"; diff --git a/docs/declarative-net-request.md b/docs/declarative-net-request.md index 1c04268..548caff 100644 --- a/docs/declarative-net-request.md +++ b/docs/declarative-net-request.md @@ -92,9 +92,13 @@ for rule matching and limits rather than assuming identical behavior across brow ### isAvailableDnr ```ts -isAvailableDnr(): boolean +isAvailableDnr(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableDnr("getDynamicRules")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks the current native namespace on each call. Returns `false` if the environment or namespace is missing or access throws. It does not log, probe native operations or cache results. diff --git a/docs/document-scan.md b/docs/document-scan.md index b50fc43..b3f64ea 100644 --- a/docs/document-scan.md +++ b/docs/document-scan.md @@ -24,13 +24,18 @@ A promise-based wrapper for the Chrome `documentScan` API. ### isAvailableDocumentScan ```ts -isAvailableDocumentScan(): boolean +isAvailableDocumentScan(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableDocumentScan("scan")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `documentScan` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableDocumentScan} from "@addon-core/browser"; diff --git a/docs/downloads.md b/docs/downloads.md index a85488e..dddd49d 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -37,13 +37,18 @@ A promise-based wrapper for the Chrome `downloads` API. ### isAvailableDownloads ```ts -isAvailableDownloads(): boolean +isAvailableDownloads(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableDownloads("search")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `downloads` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableDownloads} from "@addon-core/browser"; diff --git a/docs/extension.md b/docs/extension.md index 93ab4b5..5fc8d3c 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -20,13 +20,18 @@ A promise-based wrapper for the Chrome `extension` API. ### isAvailableExtension ```ts -isAvailableExtension(): boolean +isAvailableExtension(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableExtension("getViews")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `extension` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableExtension} from "@addon-core/browser"; diff --git a/docs/history.md b/docs/history.md index 9f98d29..75055bc 100644 --- a/docs/history.md +++ b/docs/history.md @@ -26,13 +26,18 @@ A promise-based wrapper for the Chrome `history` API to manage browser history. ### isAvailableHistory ```ts -isAvailableHistory(): boolean +isAvailableHistory(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableHistory("search")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `history` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableHistory} from "@addon-core/browser"; diff --git a/docs/i18n.md b/docs/i18n.md index e36b3d9..3e23fd6 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -20,13 +20,18 @@ A promise-based wrapper for the Chrome `i18n` API. ### isAvailableI18n ```ts -isAvailableI18n(): boolean +isAvailableI18n(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableI18n("getMessage")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `i18n` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableI18n} from "@addon-core/browser"; diff --git a/docs/identity.md b/docs/identity.md index b80e8ef..89fd700 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -62,13 +62,18 @@ Interactive authorization flows should be started from a user action, such as a ### isAvailableIdentity ```ts -isAvailableIdentity(): boolean +isAvailableIdentity(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableIdentity("launchWebAuthFlow")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `identity` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableIdentity} from "@addon-core/browser"; diff --git a/docs/idle.md b/docs/idle.md index 2dc846c..d8b93b0 100644 --- a/docs/idle.md +++ b/docs/idle.md @@ -22,13 +22,18 @@ A promise-based wrapper for the Chrome `idle` API to monitor user idle state. ### isAvailableIdle ```ts -isAvailableIdle(): boolean +isAvailableIdle(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableIdle("queryState")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `idle` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableIdle} from "@addon-core/browser"; diff --git a/docs/management.md b/docs/management.md index 5e64aee..caf2d7f 100644 --- a/docs/management.md +++ b/docs/management.md @@ -34,13 +34,18 @@ A promise-based wrapper for the Chrome `management` API to manage extensions and ### isAvailableManagement ```ts -isAvailableManagement(): boolean +isAvailableManagement(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableManagement("getSelf")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `management` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableManagement} from "@addon-core/browser"; diff --git a/docs/notifications.md b/docs/notifications.md index 66f9517..068155f 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -89,13 +89,18 @@ Updates an existing notification with new options, resolving to `true` if the no ### isAvailableNotifications ```ts -isAvailableNotifications(): boolean +isAvailableNotifications(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableNotifications("create")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `notifications` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableNotifications} from "@addon-core/browser"; diff --git a/docs/offscreen.md b/docs/offscreen.md index 03385f6..9c24247 100644 --- a/docs/offscreen.md +++ b/docs/offscreen.md @@ -23,13 +23,18 @@ A promise-based wrapper for the Chrome `offscreen` API to create and manage offs ### isAvailableOffscreen ```ts -isAvailableOffscreen(): boolean +isAvailableOffscreen(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableOffscreen("hasDocument")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `offscreen` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableOffscreen} from "@addon-core/browser"; diff --git a/docs/permissions.md b/docs/permissions.md index efbfe7d..9eac4fa 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -26,13 +26,18 @@ A promise-based wrapper for the Chrome `permissions` API to request and manage e ### isAvailablePermissions ```ts -isAvailablePermissions(): boolean +isAvailablePermissions(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailablePermissions("contains")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `permissions` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailablePermissions} from "@addon-core/browser"; diff --git a/docs/runtime.md b/docs/runtime.md index 930e5ef..f899481 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -48,13 +48,18 @@ A wrapper for the Chrome `runtime` API, including messaging, updates, and lifecy ### isAvailableRuntime ```ts -isAvailableRuntime(): boolean +isAvailableRuntime(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableRuntime("getPlatformInfo")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `runtime` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableRuntime} from "@addon-core/browser"; diff --git a/docs/safari.md b/docs/safari.md new file mode 100644 index 0000000..6314842 --- /dev/null +++ b/docs/safari.md @@ -0,0 +1,28 @@ +# Safari compatibility + +The package's TypeScript declarations cover several browser APIs. Individual methods can be missing even when their +namespace is exposed. Use a native method name with any `isAvailable…` helper: + +```ts +isAvailableAction("getBadgeTextColor"); +isAvailableTabs("discard"); +isAvailableCookies("getPartitionKey"); +isAvailableDnr("testMatchOutcome"); +``` + +Only native function names in that namespace are accepted by TypeScript. The check does not invoke the method, +request permissions, validate arguments or promise that the operation will succeed. It returns `false` if the API +is absent or accessing it throws. A call without an argument still checks namespace presence only. Action and sidebar +checks use the same manifest/API selection as the corresponding wrappers. + +## Method names + +`ApiMethodName` is a public type exported by `@addon-core/browser`. It extracts callable property names, including +optional methods and methods declared by either member of a union, while excluding event objects and constants: + +```ts +import type {ApiMethodName} from "@addon-core/browser"; + +const method: ApiMethodName = "query"; +``` + diff --git a/docs/scripting.md b/docs/scripting.md index 9edcc58..dc64baa 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -102,13 +102,18 @@ Updates existing content scripts with new definitions. ### isAvailableScripting ```ts -isAvailableScripting(): boolean +isAvailableScripting(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableScripting("executeScript")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `scripting` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableScripting} from "@addon-core/browser"; diff --git a/docs/search.md b/docs/search.md index 605bd09..d9a34a8 100644 --- a/docs/search.md +++ b/docs/search.md @@ -79,9 +79,13 @@ The Firefox additions to the global declarations are a type superset, not a runt ### isAvailableSearch ```ts -isAvailableSearch(): boolean +isAvailableSearch(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableSearch("query")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns whether the `search` namespace exists on the API selected by `browser()`. For example, older Firefox versions can expose the namespace without supporting `query`. diff --git a/docs/sidebar.md b/docs/sidebar.md index b52a8a9..7194432 100644 --- a/docs/sidebar.md +++ b/docs/sidebar.md @@ -168,9 +168,13 @@ Badge setters accept a color string or `chrome.extensionTypes.ColorArray`, an RG ### isAvailableSidebar ```ts -isAvailableSidebar(): boolean +isAvailableSidebar(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableSidebar("open")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks for `sidePanel` on the API selected by `browser()`, then for the selected `sidebarAction` namespace. Returns `false` if no namespace is available or accessing the environment throws. It checks namespace presence only; use the `can…` functions for the operations they cover. diff --git a/docs/tab-capture.md b/docs/tab-capture.md index f6490ec..1d3da16 100644 --- a/docs/tab-capture.md +++ b/docs/tab-capture.md @@ -22,13 +22,18 @@ A promise-based wrapper for the Chrome `tabCapture` API to capture and retrieve ### isAvailableTabCapture ```ts -isAvailableTabCapture(): boolean +isAvailableTabCapture(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableTabCapture("capture")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `tabCapture` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableTabCapture} from "@addon-core/browser"; diff --git a/docs/tab-groups.md b/docs/tab-groups.md index 0e03081..dabdfc9 100644 --- a/docs/tab-groups.md +++ b/docs/tab-groups.md @@ -93,9 +93,13 @@ All methods preserve native errors, including errors for missing group IDs. ### isAvailableTabGroups ```ts -isAvailableTabGroups(): boolean +isAvailableTabGroups(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableTabGroups("get")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks the API selected by `browser()` on every call. Returns `false` when the namespace or extension environment is missing, or access throws. It does not log or guarantee that a later operation succeeds. diff --git a/docs/tabs.md b/docs/tabs.md index 07fdc76..d231c4e 100644 --- a/docs/tabs.md +++ b/docs/tabs.md @@ -136,9 +136,13 @@ must match, use `openOrCreateTab()`. Both reuse paths delegate to native highlig ### isAvailableTabs ```ts -isAvailableTabs(): boolean +isAvailableTabs(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableTabs("query")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Checks the namespace selected by `browser()` on each call. Returns `false` if the extension environment or namespace is missing, or if accessing it throws. The check is synchronous, silent and uncached. It does not establish permissions, support for individual methods, or success of a later operation. diff --git a/docs/top-sites.md b/docs/top-sites.md index 67169d8..dba358f 100644 --- a/docs/top-sites.md +++ b/docs/top-sites.md @@ -95,13 +95,17 @@ preferences and ranking decisions remain owned by the browser. ### isAvailableTopSites ```ts -isAvailableTopSites(): boolean +isAvailableTopSites(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableTopSites("get")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `topSites` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee support for Firefox options, a nonempty list, +Without an argument, it checks namespace presence only; it does not guarantee support for Firefox options, a nonempty list, or a successful operation. ```ts diff --git a/docs/user-scripts.md b/docs/user-scripts.md index c218e7c..46bfdb2 100644 --- a/docs/user-scripts.md +++ b/docs/user-scripts.md @@ -103,13 +103,18 @@ Updates previously registered user scripts with new definitions. ### isAvailableUserScripts ```ts -isAvailableUserScripts(): boolean +isAvailableUserScripts(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableUserScripts("getScripts")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `userScripts` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). In Chrome, the namespace can remain defined after user-script access is revoked until the context reloads. This check does not call `getScripts()` to probe that access. See [Chrome’s availability guidance](https://developer.chrome.com/docs/extensions/reference/api/userScripts#check_for_api_availability). diff --git a/docs/web-navigation.md b/docs/web-navigation.md index 9c4f655..bc5dcd5 100644 --- a/docs/web-navigation.md +++ b/docs/web-navigation.md @@ -29,13 +29,18 @@ A promise-based wrapper for the Chrome `webNavigation` API, providing methods to ### isAvailableWebNavigation ```ts -isAvailableWebNavigation(): boolean +isAvailableWebNavigation(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableWebNavigation("getFrame")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `webNavigation` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableWebNavigation} from "@addon-core/browser"; diff --git a/docs/web-request.md b/docs/web-request.md index 2e30d21..e47873d 100644 --- a/docs/web-request.md +++ b/docs/web-request.md @@ -28,13 +28,18 @@ A promise-based wrapper for the Chrome `webRequest` API, providing methods to ob ### isAvailableWebRequest ```ts -isAvailableWebRequest(): boolean +isAvailableWebRequest(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableWebRequest("handlerBehaviorChanged")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `webRequest` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableWebRequest} from "@addon-core/browser"; diff --git a/docs/windows.md b/docs/windows.md index e421e08..546861b 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -29,13 +29,18 @@ A promise-based wrapper for the Chrome `windows` API to create, query, update, a ### isAvailableWindows ```ts -isAvailableWindows(): boolean +isAvailableWindows(method?: ApiMethodName): boolean ``` +The optional `method` is a native function name, for example `isAvailableWindows("getAll")`. +Import the public `ApiMethodName` type from `@addon-core/browser` for your own typed helpers. +This checks callable presence without invoking the method; permissions and argument support still require handling. + Returns `true` when the `windows` namespace is present on the API selected by `browser()` in the current context. Returns `false` if the namespace or WebExtension environment is absent, or if accessing it throws. The check is synchronous, does not log, and reads the current API on every call without caching. -It checks namespace presence only; it does not guarantee individual methods, permissions, or a successful operation. +Without an argument, it checks namespace presence only. Pass a native method name to also check that it is callable. +Neither form verifies permissions, argument support, or a successful operation. See [method availability](safari.md). ```ts import {isAvailableWindows} from "@addon-core/browser"; diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index 781d9f7..a830a74 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -52,7 +52,7 @@ const declarationExports = getModuleExports(declarationEntry, { types: ["chrome"], }); -assert.equal(sourceExports.length, 430, "The source public-export baseline changed; update the coverage matrix first"); +assert.equal(sourceExports.length, 431, "The source public-export baseline changed; update the coverage matrix first"); assert.equal( sourceExports.filter(value => value.hasValue).length, @@ -62,7 +62,7 @@ assert.equal( assert.deepEqual(sortNames(declarationExports), sortNames(sourceExports), "Source and declaration exports differ"); -const expectedTypeOnly = ["BrowserGuess", "DnrRulesMatchedDetails", "DnrTestMatchOptions", "LaunchWebAuthFlowDetails", "SearchEngine", "SearchTargetOptions", "TabGroup", "TabGroupRemoveInfo", "TopSite", "TopSitesOptions", "WindowEventFilter"]; +const expectedTypeOnly = ["ApiMethodName", "BrowserGuess", "DnrRulesMatchedDetails", "DnrTestMatchOptions", "LaunchWebAuthFlowDetails", "SearchEngine", "SearchTargetOptions", "TabGroup", "TabGroupRemoveInfo", "TopSite", "TopSitesOptions", "WindowEventFilter"]; assert.deepEqual( sourceExports @@ -170,4 +170,4 @@ for (const {file, source} of testingRuntimeSources) { ); } -console.log("Verified 430 TypeScript exports, 419 ESM/CJS runtime exports, and isolated testing bundles."); +console.log("Verified 431 TypeScript exports, 419 ESM/CJS runtime exports, and isolated testing bundles."); diff --git a/src/api/action/availability.test.ts b/src/api/action/availability.test.ts index 676243a..eba36f0 100644 --- a/src/api/action/availability.test.ts +++ b/src/api/action/availability.test.ts @@ -67,3 +67,21 @@ describe("action availability", () => { expect(isAvailableAction()).toBe(false); }); }); + +test.each([2, 3])("method availability observes the API selected by MV%s", version => { + const method = jest.fn(); + + const restore = installAvailabilityGlobals({chrome: { + runtime: {getManifest: () => ({manifest_version: version})}, + action: {isEnabled: method}, browserAction: {}, + }}); + + try { + expect(isAvailableAction()).toBe(true); + expect(isAvailableAction("isEnabled")).toBe(version === 3); + expect(isAvailableAction("getUserSettings")).toBe(false); + expect(method).not.toHaveBeenCalled(); + } finally { + restore(); + } +}); diff --git a/src/api/action/availability.ts b/src/api/action/availability.ts index bc282bf..04d0e1c 100644 --- a/src/api/action/availability.ts +++ b/src/api/action/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {action} from "./api"; -export const isAvailableAction = (): boolean => { +export const isAvailableAction = (method?: ApiMethodName): boolean => { try { - return Boolean(action()); + const api = action(); + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/alarms/availability.ts b/src/api/alarms/availability.ts index 9b935cc..e5f6324 100644 --- a/src/api/alarms/availability.ts +++ b/src/api/alarms/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableAlarms = (): boolean => { +export const isAvailableAlarms = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().alarms); + const api = browser().alarms; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/audio/availability.ts b/src/api/audio/availability.ts index 789b80d..43feec6 100644 --- a/src/api/audio/availability.ts +++ b/src/api/audio/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableAudio = (): boolean => { +export const isAvailableAudio = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().audio); + const api = browser().audio; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/bookmarks/availability.ts b/src/api/bookmarks/availability.ts index 915f1eb..1489785 100644 --- a/src/api/bookmarks/availability.ts +++ b/src/api/bookmarks/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableBookmarks = (): boolean => { +export const isAvailableBookmarks = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().bookmarks); + const api = browser().bookmarks; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/browsing-data/availability.ts b/src/api/browsing-data/availability.ts index b07269f..923f195 100644 --- a/src/api/browsing-data/availability.ts +++ b/src/api/browsing-data/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableBrowsingData = (): boolean => { +export const isAvailableBrowsingData = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().browsingData); + const api = browser().browsingData; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/commands/availability.ts b/src/api/commands/availability.ts index 82a9dc1..51fcc66 100644 --- a/src/api/commands/availability.ts +++ b/src/api/commands/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableCommands = (): boolean => { +export const isAvailableCommands = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().commands); + const api = browser().commands; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/context-menus/availability.ts b/src/api/context-menus/availability.ts index eefd2ad..b56569f 100644 --- a/src/api/context-menus/availability.ts +++ b/src/api/context-menus/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableContextMenus = (): boolean => { +export const isAvailableContextMenus = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().contextMenus); + const api = browser().contextMenus; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/cookies/availability.ts b/src/api/cookies/availability.ts index ab558de..3d06ab3 100644 --- a/src/api/cookies/availability.ts +++ b/src/api/cookies/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableCookies = (): boolean => { +export const isAvailableCookies = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().cookies); + const api = browser().cookies; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/declarative-net-request/availability.ts b/src/api/declarative-net-request/availability.ts index 85b03a9..00473ac 100644 --- a/src/api/declarative-net-request/availability.ts +++ b/src/api/declarative-net-request/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableDnr = (): boolean => { +export const isAvailableDnr = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().declarativeNetRequest); + const api = browser().declarativeNetRequest; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/document-scan/availability.ts b/src/api/document-scan/availability.ts index 9105191..c92b6c7 100644 --- a/src/api/document-scan/availability.ts +++ b/src/api/document-scan/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableDocumentScan = (): boolean => { +export const isAvailableDocumentScan = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().documentScan); + const api = browser().documentScan; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/downloads/availability.ts b/src/api/downloads/availability.ts index 8809a75..fe999bc 100644 --- a/src/api/downloads/availability.ts +++ b/src/api/downloads/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableDownloads = (): boolean => { +export const isAvailableDownloads = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().downloads); + const api = browser().downloads; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/extension/availability.ts b/src/api/extension/availability.ts index 69ba9f1..47242e4 100644 --- a/src/api/extension/availability.ts +++ b/src/api/extension/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableExtension = (): boolean => { +export const isAvailableExtension = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().extension); + const api = browser().extension; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/history/availability.ts b/src/api/history/availability.ts index 11f33dd..808758c 100644 --- a/src/api/history/availability.ts +++ b/src/api/history/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableHistory = (): boolean => { +export const isAvailableHistory = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().history); + const api = browser().history; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/i18n/availability.ts b/src/api/i18n/availability.ts index 10406de..73b586c 100644 --- a/src/api/i18n/availability.ts +++ b/src/api/i18n/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableI18n = (): boolean => { +export const isAvailableI18n = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().i18n); + const api = browser().i18n; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/identity/availability.ts b/src/api/identity/availability.ts index 605091a..1bfe02d 100644 --- a/src/api/identity/availability.ts +++ b/src/api/identity/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableIdentity = (): boolean => { +export const isAvailableIdentity = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().identity); + const api = browser().identity; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/idle/availability.ts b/src/api/idle/availability.ts index 37bc245..83cbb5e 100644 --- a/src/api/idle/availability.ts +++ b/src/api/idle/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableIdle = (): boolean => { +export const isAvailableIdle = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().idle); + const api = browser().idle; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/management/availability.ts b/src/api/management/availability.ts index 1e4abb9..e1e0d6f 100644 --- a/src/api/management/availability.ts +++ b/src/api/management/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableManagement = (): boolean => { +export const isAvailableManagement = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().management); + const api = browser().management; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/notifications/availability.ts b/src/api/notifications/availability.ts index db96821..ede8314 100644 --- a/src/api/notifications/availability.ts +++ b/src/api/notifications/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableNotifications = (): boolean => { +export const isAvailableNotifications = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().notifications); + const api = browser().notifications; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/offscreen/availability.ts b/src/api/offscreen/availability.ts index be68ba2..1620c77 100644 --- a/src/api/offscreen/availability.ts +++ b/src/api/offscreen/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableOffscreen = (): boolean => { +export const isAvailableOffscreen = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().offscreen); + const api = browser().offscreen; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/permissions/availability.ts b/src/api/permissions/availability.ts index 7db0919..132fea1 100644 --- a/src/api/permissions/availability.ts +++ b/src/api/permissions/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailablePermissions = (): boolean => { +export const isAvailablePermissions = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().permissions); + const api = browser().permissions; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/runtime/availability.ts b/src/api/runtime/availability.ts index 0543421..1d9c13e 100644 --- a/src/api/runtime/availability.ts +++ b/src/api/runtime/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableRuntime = (): boolean => { +export const isAvailableRuntime = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().runtime); + const api = browser().runtime; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/scripting/availability.ts b/src/api/scripting/availability.ts index ea1b459..ab58326 100644 --- a/src/api/scripting/availability.ts +++ b/src/api/scripting/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableScripting = (): boolean => { +export const isAvailableScripting = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().scripting); + const api = browser().scripting; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/search/availability.ts b/src/api/search/availability.ts index 6712f0e..33bc73b 100644 --- a/src/api/search/availability.ts +++ b/src/api/search/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableSearch = (): boolean => { +export const isAvailableSearch = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().search); + const api = browser().search; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/sidebar/availability.test.ts b/src/api/sidebar/availability.test.ts index ab0670f..34c347f 100644 --- a/src/api/sidebar/availability.test.ts +++ b/src/api/sidebar/availability.test.ts @@ -72,3 +72,14 @@ describe("sidebar availability", () => { expect(warn).not.toHaveBeenCalled(); }); }); + +test("method availability checks the selected sidebar API without falling back", () => { + const restore = installAvailabilityGlobals({chrome: {sidePanel: {getOptions: () => undefined}}, opr: {sidebarAction: {toggle: () => undefined}}}); + + try { + expect(isAvailableSidebar("getOptions")).toBe(true); + expect(isAvailableSidebar("toggle")).toBe(false); + } finally { + restore(); + } +}); diff --git a/src/api/sidebar/availability.ts b/src/api/sidebar/availability.ts index a1ee760..540f5e1 100644 --- a/src/api/sidebar/availability.ts +++ b/src/api/sidebar/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {sidebarAction, sidePanel} from "./api"; -export const isAvailableSidebar = (): boolean => { +export const isAvailableSidebar = (method?: ApiMethodName | ReturnType>>): boolean => { try { - return Boolean(sidePanel() || sidebarAction()); + const api = sidePanel() || sidebarAction(); + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/tab-capture/availability.ts b/src/api/tab-capture/availability.ts index 3d9e1b0..34addf8 100644 --- a/src/api/tab-capture/availability.ts +++ b/src/api/tab-capture/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableTabCapture = (): boolean => { +export const isAvailableTabCapture = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().tabCapture); + const api = browser().tabCapture; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/tab-groups/availability.ts b/src/api/tab-groups/availability.ts index 79d3550..b0aaf5e 100644 --- a/src/api/tab-groups/availability.ts +++ b/src/api/tab-groups/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableTabGroups = (): boolean => { +export const isAvailableTabGroups = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().tabGroups); + const api = browser().tabGroups; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/tabs/availability.ts b/src/api/tabs/availability.ts index e58ab79..6b466f0 100644 --- a/src/api/tabs/availability.ts +++ b/src/api/tabs/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableTabs = (): boolean => { +export const isAvailableTabs = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().tabs); + const api = browser().tabs; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/top-sites/availability.ts b/src/api/top-sites/availability.ts index a4fc0b5..d817a4a 100644 --- a/src/api/top-sites/availability.ts +++ b/src/api/top-sites/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableTopSites = (): boolean => { +export const isAvailableTopSites = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().topSites); + const api = browser().topSites; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/user-scripts/availability.ts b/src/api/user-scripts/availability.ts index 1e3b9d6..8c4bb29 100644 --- a/src/api/user-scripts/availability.ts +++ b/src/api/user-scripts/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableUserScripts = (): boolean => { +export const isAvailableUserScripts = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().userScripts); + const api = browser().userScripts; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/web-navigation/availability.ts b/src/api/web-navigation/availability.ts index df99328..9b86928 100644 --- a/src/api/web-navigation/availability.ts +++ b/src/api/web-navigation/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableWebNavigation = (): boolean => { +export const isAvailableWebNavigation = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().webNavigation); + const api = browser().webNavigation; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/web-request/availability.ts b/src/api/web-request/availability.ts index 72b3639..9615a69 100644 --- a/src/api/web-request/availability.ts +++ b/src/api/web-request/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableWebRequest = (): boolean => { +export const isAvailableWebRequest = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().webRequest); + const api = browser().webRequest; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/api/windows/availability.ts b/src/api/windows/availability.ts index f1f2256..51ea792 100644 --- a/src/api/windows/availability.ts +++ b/src/api/windows/availability.ts @@ -1,11 +1,14 @@ // Generated by codegen/generate.mjs. Do not edit. // Edit codegen/availability/apis.mjs and run npm run generate. +import type {ApiMethodName} from "../../internal/api-method"; import {browser} from "../browser"; -export const isAvailableWindows = (): boolean => { +export const isAvailableWindows = (method?: ApiMethodName): boolean => { try { - return Boolean(browser().windows); + const api = browser().windows; + + return method === undefined ? Boolean(api) : api != null && typeof Reflect.get(api, method) === "function"; } catch { return false; } diff --git a/src/index.ts b/src/index.ts index d158c57..67c1ca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./api"; +export type {ApiMethodName} from "./internal/api-method"; diff --git a/src/internal/api-method.ts b/src/internal/api-method.ts new file mode 100644 index 0000000..030e4b9 --- /dev/null +++ b/src/internal/api-method.ts @@ -0,0 +1,3 @@ +export type ApiMethodName = T extends unknown ? { + [Name in keyof T]-?: Extract any> extends never ? never : Name; +}[keyof T] : never; diff --git a/src/testing/coverage/public-exports.ts b/src/testing/coverage/public-exports.ts index e9506b8..cd3df6e 100644 --- a/src/testing/coverage/public-exports.ts +++ b/src/testing/coverage/public-exports.ts @@ -1,4 +1,4 @@ -export type PublicExportKind = "method-wrapper" | "event-wrapper" | "class" | "enum" | "interface"; +export type PublicExportKind = "method-wrapper" | "event-wrapper" | "class" | "enum" | "interface" | "type"; export type PublicExportCoverage = "stateful" | "configurable" | "event" | "behavioral" | "declaration" | "unsupported"; @@ -24,6 +24,7 @@ const entries = ( * deliberate test failure instead of an implicit fake implementation. */ export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [ + ...entries("availability", "type", "declaration", ["ApiMethodName"]), ...entries("tabGroups", "method-wrapper", "behavioral", ["isAvailableTabGroups"]), ...entries("tabGroups", "interface", "declaration", ["TabGroup", "TabGroupRemoveInfo"]), ...entries("tabGroups", "method-wrapper", "configurable", [ @@ -518,9 +519,9 @@ export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [ ]), ] as const; -export const TYPE_ONLY_ROOT_EXPORTS = ["BrowserGuess", "DnrRulesMatchedDetails", "DnrTestMatchOptions", "LaunchWebAuthFlowDetails", "SearchEngine", "SearchTargetOptions", "TabGroup", "TabGroupRemoveInfo", "TopSite", "TopSitesOptions", "WindowEventFilter"] as const; +export const TYPE_ONLY_ROOT_EXPORTS = ["ApiMethodName", "BrowserGuess", "DnrRulesMatchedDetails", "DnrTestMatchOptions", "LaunchWebAuthFlowDetails", "SearchEngine", "SearchTargetOptions", "TabGroup", "TabGroupRemoveInfo", "TopSite", "TopSitesOptions", "WindowEventFilter"] as const; -export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 430; +export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 431; export const EXPECTED_ROOT_RUNTIME_EXPORT_COUNT = 419; export const getPublicExportCoverage = (name: string): PublicExportCoverageEntry | undefined => diff --git a/tests/api/availability.ts b/tests/api/availability.ts index 2c05842..6b0af6e 100644 --- a/tests/api/availability.ts +++ b/tests/api/availability.ts @@ -49,6 +49,36 @@ export function describeNamespaceAvailability(namespace: string, isAvailable: () expect(console.warn).not.toHaveBeenCalled(); }); + test("checks a method without invoking it and observes its removal", () => { + const method = jest.fn(); + const api: Record = {testMethod: method, notAMethod: true}; + const check = isAvailable as (method: string) => boolean; + useGlobals({chrome: {[namespace]: api}}); + + expect(check("testMethod")).toBe(true); + expect(check("notAMethod")).toBe(false); + expect(check("missing")).toBe(false); + delete api.testMethod; + expect(check("testMethod")).toBe(false); + api.testMethod = method; + expect(check("testMethod")).toBe(true); + expect(method).not.toHaveBeenCalled(); + }); + + test("contains method access errors and missing namespaces", () => { + const check = isAvailable as (method: string) => boolean; + expect(check("testMethod")).toBe(false); + + const api = Object.defineProperty({}, "testMethod", {get: () => { + throw new Error("Unavailable method"); + }}); + + useGlobals({chrome: {[namespace]: api}}); + expect(isAvailable()).toBe(true); + expect(check("testMethod")).toBe(false); + expect(console.error).not.toHaveBeenCalled(); + }); + test.each([undefined, null])("returns false when the namespace is %s", value => { useGlobals({chrome: {[namespace]: value}}); expect(isAvailable()).toBe(false); diff --git a/tests/consumer-types/availability-types.ts b/tests/consumer-types/availability-types.ts index 0eb5f65..aec24dd 100644 --- a/tests/consumer-types/availability-types.ts +++ b/tests/consumer-types/availability-types.ts @@ -1,4 +1,5 @@ import { + type ApiMethodName, isAvailableAction, isAvailableAlarms, isAvailableAudio, @@ -69,17 +70,38 @@ const checks = { }; type Expected = {[Name in keyof typeof checks]: () => boolean}; -type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -const exactSignatures: Equal = true; -void exactSignatures; +const namespaceChecks: Expected = checks; +void namespaceChecks; + +const nativeMethod: ApiMethodName = "query"; +const optionalMethod: ApiMethodName<{run?: () => void; label: string; missing?: undefined}> = "run"; +const unionMethod: ApiMethodName<{first(): void} | {second(): void}> = "second"; +void [nativeMethod, optionalMethod, unionMethod]; +// @ts-expect-error Event properties are not native methods. +const eventName: ApiMethodName = "onUpdated"; +// @ts-expect-error An absent property is not a method. +const missingMethod: ApiMethodName<{missing?: undefined}> = "missing"; +void [eventName, missingMethod]; for (const check of Object.values(checks)) { const result: boolean = check(); void result; } -// @ts-expect-error Availability checks do not take namespace or method arguments. isAvailableTabs("query"); +isAvailableAction("getBadgeTextColor"); +isAvailableAction("isEnabled"); +isAvailableCookies("getPartitionKey"); +isAvailablePermissions("addHostAccessRequest"); +isAvailableDnr("testMatchOutcome"); +isAvailableSidebar("isOpen"); +isAvailableSidebar("getOptions"); +// @ts-expect-error Only native method names in the selected namespace are accepted. +isAvailableTabs("getBadgeText"); +// @ts-expect-error Events are not methods. +isAvailableTabs("onUpdated"); +// @ts-expect-error Constants are not methods. +isAvailableTabs("TAB_ID_NONE"); // @ts-expect-error Availability checks are synchronous. const pending: Promise = isAvailableUserScripts(); void pending; diff --git a/tests/consumer-types/availability.cjs b/tests/consumer-types/availability.cjs index ddc3aea..59fbb72 100644 --- a/tests/consumer-types/availability.cjs +++ b/tests/consumer-types/availability.cjs @@ -22,8 +22,16 @@ const names = [ assert.equal(api.isAvailableNotifications(), true); assert.equal(api.isAvailableScripting(), false); assert.equal(api.isAvailableUserScripts(), true); + + globalThis.chrome.tabs.query = () => { + throw new Error("Availability must not invoke methods"); + }; + + assert.equal(api.isAvailableTabs("query"), true); + assert.equal(api.isAvailableTabs("discard"), false); delete globalThis.chrome.tabs; assert.equal(api.isAvailableTabs(), false); + assert.equal(api.isAvailableTabs("query"), false); Object.defineProperty(globalThis.chrome, "userScripts", {get() { throw new Error("Access denied"); diff --git a/tests/consumer-types/bookmarks-methods-types.ts b/tests/consumer-types/bookmarks-methods-types.ts index e3ef2c2..80148e9 100644 --- a/tests/consumer-types/bookmarks-methods-types.ts +++ b/tests/consumer-types/bookmarks-methods-types.ts @@ -7,7 +7,7 @@ import { type Node = chrome.bookmarks.BookmarkTreeNode; type Expected = { - isAvailableBookmarks: () => boolean; + isAvailableBookmarks: (method?: "get" | "getTree" | "getSubTree" | "getChildren" | "getRecent" | "search" | "create" | "update" | "move" | "remove" | "removeTree") => boolean; getBookmarks: (ids: string | [string, ...string[]]) => Promise; getBookmarkTree: () => Promise; getBookmarkSubTree: (id: string) => Promise; diff --git a/tests/consumer-types/tab-groups-methods-types.ts b/tests/consumer-types/tab-groups-methods-types.ts index 43d5323..144c429 100644 --- a/tests/consumer-types/tab-groups-methods-types.ts +++ b/tests/consumer-types/tab-groups-methods-types.ts @@ -3,7 +3,7 @@ import {getTabGroup, isAvailableTabGroups, moveTabGroup, queryTabGroups, type Ta type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; const signatures: [ - Equal boolean>, + Equal boolean>, Equal Promise>, Equal Promise>, Equal Promise>, diff --git a/tests/consumer-types/top-sites-methods-types.ts b/tests/consumer-types/top-sites-methods-types.ts index d51c920..c4694eb 100644 --- a/tests/consumer-types/top-sites-methods-types.ts +++ b/tests/consumer-types/top-sites-methods-types.ts @@ -3,7 +3,7 @@ import {createBrowserHarness} from "@addon-core/browser/testing"; type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; const exactGet: Equal Promise> = true; -const exactAvailable: Equal boolean> = true; +const exactAvailable: Equal boolean> = true; void [exactGet, exactAvailable]; const options: TopSitesOptions = { diff --git a/tests/tooling/codegen.test.mjs b/tests/tooling/codegen.test.mjs index 2a033ee..cc0ce29 100644 --- a/tests/tooling/codegen.test.mjs +++ b/tests/tooling/codegen.test.mjs @@ -307,7 +307,7 @@ test.each([ ])("derives %s availability names without losing camelCase", (namespace, expected) => { const [module] = generateAvailability([{namespace}]); expect(module.exports).toEqual([expected]); - expect(module.source).toContain(`export const ${expected} = (): boolean`); + expect(module.source).toContain(`export const ${expected} = (method?: ApiMethodName): boolean`); }); test.each([null, [], {}, {namespace: "../tabs"}, {namespace: "tabs", template: "action"}])("rejects invalid availability descriptions: %j", spec => { From e2f8c2346d3aa4c74844c78a562f25d25ecb63f7 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:09 +0200 Subject: [PATCH 2/6] fix(alarms)!: normalize removal and WebKit creation contracts Keep boolean results for named deletion and derive legacy WebKit results from an existence check. Read the collection before clearAll in every browser and ignore inconsistent native results. Preserve WebKit creation and null lookup compatibility, with regression tests beside the alarm methods. BREAKING CHANGE: clearAllAlarm now reports whether the collection observed before deletion was nonempty. An empty Chromium collection returns false instead of the previous native true. Reads and deletion are not atomic. --- docs/alarms.md | 16 ++- src/api/alarms/methods.test.ts | 230 ++++++++++++++++++++++++++++++++- src/api/alarms/methods.ts | 53 +++++++- src/internal/webkit.ts | 4 + tests/api/native.ts | 20 +++ 5 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 src/internal/webkit.ts create mode 100644 tests/api/native.ts diff --git a/docs/alarms.md b/docs/alarms.md index d9fb82c..e488042 100644 --- a/docs/alarms.md +++ b/docs/alarms.md @@ -54,7 +54,11 @@ if (isAvailableAlarms()) { clearAlarm(name: string): Promise ``` -Clears the alarm with the specified name, returning `true` if an existing alarm was found and cleared. +Clears the alarm with the specified name and preserves a native boolean when provided. WebKit first reads whether +the alarm exists; older implementations that report no removal result use this earlier observation. That fallback +is not atomic: another context or alarm firing can change the state between the read and deletion. +Chromium and Firefox perform no pre-read. An unexpected missing/non-boolean result outside the legacy WebKit fallback +rejects the Promise rather than silently reporting `false`. Read and deletion errors also reject. @@ -64,7 +68,13 @@ Clears the alarm with the specified name, returning `true` if an existing alarm clearAllAlarm(): Promise ``` -Clears all alarms, returning `true` if any alarms were found and cleared. +Clears all alarms and returns whether the collection read immediately before deletion was nonempty. Every browser +uses `getAll` followed by `clearAll`; native removal results are ignored. This gives one boolean contract despite +Chromium returning `true` for an empty collection and Firefox 159/legacy WebKit reporting no result. +The read and deletion are not atomic: another context or alarm firing may change the collection between them. +Read and deletion errors reject instead of returning the earlier observation. + +This changes the previous native-result behavior: an empty Chromium collection now returns `false`. @@ -75,6 +85,8 @@ createAlarm(name: string, info: chrome.alarms.AlarmCreateInfo): Promise ``` Creates a new alarm or updates an existing one with the given name and scheduling options. +The wrapper uses WebKit's supported two-argument call, handling both its older synchronous return and newer Promise. +Native synchronous errors and Promise rejections reject the public `Promise`. diff --git a/src/api/alarms/methods.test.ts b/src/api/alarms/methods.test.ts index acbfbf1..8c53e48 100644 --- a/src/api/alarms/methods.test.ts +++ b/src/api/alarms/methods.test.ts @@ -1,5 +1,6 @@ -import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import {clearAlarm, clearAllAlarm, createAlarm, createAlarmIfNotExists, getAlarm, getAllAlarm} from "./methods"; @@ -18,6 +19,7 @@ describe.each(["chrome", "firefox"] as const)("alarms methods in %s", profile => harness = createBrowserHarness(); restoreGlobals = installBrowserGlobals(harness, {profile}); harness.configurable.active.alarms.get.setResult(undefined); + harness.configurable.active.alarms.getAll.setResult([]); harness.configurable.active.alarms.create.setResult(undefined); }); @@ -33,10 +35,11 @@ describe.each(["chrome", "firefox"] as const)("alarms methods in %s", profile => expect(method.calls[0].args).toEqual(["sync"]); }); - test.each([true, false])("clearAllAlarm preserves %s and passes no native arguments", async result => { + test.each([{alarms: []}, {alarms: [alarm]}])("clearAllAlarm reports the earlier collection $alarms and passes no native arguments", async ({alarms}) => { const method = harness.configurable.active.alarms.clearAll; - method.setResult(result); - await expect(clearAllAlarm()).resolves.toBe(result); + harness.configurable.active.alarms.getAll.setResult(alarms); + method.setResult(true); + await expect(clearAllAlarm()).resolves.toBe(alarms.length > 0); expect(method.calls).toHaveLength(1); expect(method.calls[0].args).toEqual([]); }); @@ -115,3 +118,222 @@ describe.each(["chrome", "firefox"] as const)("alarms methods in %s", profile => await expect(createAlarmIfNotExists("sync", {periodInMinutes: 5})).rejects.toThrow("Alarm creation failed"); }); }); + +describe("WebKit alarm signatures", () => { + let native: ReturnType["native"]; + let restore: () => void; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + }); + + afterEach(() => restore()); + + test.each(["safari-web-extension", "safari-extension", "webkit-extension"])("creates without a callback in %s", scheme => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + + const create = jest.fn((...args: unknown[]) => { + expect(args).toHaveLength(2); + }); + + native.alarms = {create}; + const info = {delayInMinutes: 10}; + + return expect(createAlarm("sync", info)).resolves.toBeUndefined(); + }); + + test("waits for newer WebKit's native Promise and preserves its rejection", async () => { + let finish!: () => void; + + const pending = new Promise(resolve => { + finish = resolve; + }); + + native.alarms = {create: jest.fn(() => pending)}; + let settled = false; + + const created = createAlarm("sync", {delayInMinutes: 1}).then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + finish(); + await created; + native.alarms.create = () => Promise.reject(new Error("Creation denied")); + await expect(createAlarm("sync", {delayInMinutes: 1})).rejects.toThrow("Creation denied"); + }); + + test("returns synchronous creation errors as rejected Promises", async () => { + native.alarms = {create: () => { + throw new Error("Invalid schedule"); + }}; + + await expect(createAlarm("sync", {delayInMinutes: 1})).rejects.toThrow("Invalid schedule"); + }); + + test("a second createAlarmIfNotExists call preserves the stored schedule", async () => { + const alarms = new Map(); + + const create = jest.fn((name: string, info: chrome.alarms.AlarmCreateInfo) => { + alarms.set(name, {name, scheduledTime: info.when!, persistAcrossSessions: false}); + }); + + native.alarms = { + create, + get: (name: string, callback: (value: chrome.alarms.Alarm | null) => void) => callback(alarms.get(name) ?? null), + getAll: (callback: (values: chrome.alarms.Alarm[]) => void) => callback([...alarms.values()]), + }; + + await expect(getAlarm("sync")).resolves.toBeUndefined(); + await expect(createAlarmIfNotExists("sync", {when: 123456})).resolves.toBe(true); + await expect(createAlarmIfNotExists("sync", {when: 987654})).resolves.toBe(false); + expect((await getAllAlarm())[0].scheduledTime).toBe(123456); + expect(create).toHaveBeenCalledTimes(1); + }); +}); + +describe("absent native alarms results", () => { + let native: ReturnType["native"]; + let restore: () => void; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + }); + + afterEach(() => restore()); + + test.each(["callback", "promise"])("normalizes an absent result from %s", async mode => { + const result = (value: unknown) => (...args: unknown[]) => { + if (mode === "promise") { + return Promise.resolve(value); + } + + (args.at(-1) as (value: unknown) => void)(value); + }; + + native.alarms = {get: result(null)}; + await expect(getAlarm("missing")).resolves.toBeUndefined(); + }); +}); + +describe("alarm removal result compatibility", () => { + let native: ReturnType["native"]; + let restore: () => void; + let stored: Set; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + stored = new Set(["existing"]); + + native.alarms = { + get: jest.fn((name: string, callback: (value: object | null) => void) => callback(stored.has(name) ? {name} : null)), + getAll: jest.fn((callback: (values: object[]) => void) => callback([...stored].map(name => ({name})))), + clear: jest.fn((name: string, callback: (result?: boolean) => void) => { + stored.delete(name); callback(); + }), + clearAll: jest.fn((callback: (result?: boolean) => void) => { + stored.clear(); callback(); + }), + }; + }); + + afterEach(() => restore()); + + test("legacy WebKit derives clear results from the pre-read", async () => { + await expect(clearAlarm("existing")).resolves.toBe(true); + expect(stored.has("existing")).toBe(false); + await expect(clearAlarm("existing")).resolves.toBe(false); + stored.add("again"); + await expect(clearAllAlarm()).resolves.toBe(true); + await expect(clearAllAlarm()).resolves.toBe(false); + }); + + test("a native boolean takes precedence over WebKit's earlier snapshot", async () => { + native.alarms.clear.mockImplementation((_name: string, callback: (result: boolean) => void) => callback(false)); + await expect(clearAlarm("existing")).resolves.toBe(false); + expect(native.alarms.get).toHaveBeenCalledTimes(1); + expect(native.alarms.getAll).not.toHaveBeenCalled(); + }); + + test.each(["chrome-extension", "moz-extension"])("%s clearAlarm never performs a pre-read", async scheme => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + native.alarms.clear.mockImplementation((_name: string, callback: (result: boolean) => void) => callback(true)); + await expect(clearAlarm("existing")).resolves.toBe(true); + expect(native.alarms.get).not.toHaveBeenCalled(); + expect(native.alarms.getAll).not.toHaveBeenCalled(); + }); + + test("unexpected non-WebKit clear results reject", async () => { + native.runtime.getURL = path => `chrome-extension://fixture/${path}`; + + await expect(clearAlarm("existing")) + .rejects.toThrow("The alarms.clear API did not return a boolean removal result"); + + expect(native.alarms.get).not.toHaveBeenCalled(); + }); + + describe.each(["chrome-extension", "moz-extension", "webkit-extension"])("clearAllAlarm in %s", scheme => { + test.each([true, false, undefined])("ignores native result %s and reads before clearing", async result => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + + native.alarms.clearAll.mockImplementation((callback: (result?: boolean) => void) => { + stored.clear(); + callback(result); + }); + + await expect(clearAllAlarm()).resolves.toBe(true); + expect(stored.size).toBe(0); + await expect(clearAllAlarm()).resolves.toBe(false); + expect(native.alarms.getAll).toHaveBeenCalledTimes(2); + expect(native.alarms.getAll.mock.invocationCallOrder[0]).toBeLessThan(native.alarms.clearAll.mock.invocationCallOrder[0]); + expect(native.alarms.get).not.toHaveBeenCalled(); + }); + + test("a read error rejects without clearing", async () => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + + native.alarms.getAll.mockImplementation(() => { + throw new Error("Lookup denied"); + }); + + await expect(clearAllAlarm()).rejects.toThrow("Lookup denied"); + expect(native.alarms.clearAll).not.toHaveBeenCalled(); + }); + + test("a deletion error rejects instead of returning the snapshot", async () => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + + native.alarms.clearAll.mockImplementation(() => { + throw new Error("Removal denied"); + }); + + await expect(clearAllAlarm()).rejects.toThrow("Removal denied"); + expect(native.alarms.getAll).toHaveBeenCalledTimes(1); + }); + }); + + test.each(["get", "getAll"])("WebKit %s failures reject before deletion", async method => { + native.alarms[method].mockImplementation(() => { + throw new Error("Lookup denied"); + }); + + await expect(method === "get" ? clearAlarm("existing") : clearAllAlarm()).rejects.toThrow("Lookup denied"); + expect(native.alarms.clear).not.toHaveBeenCalled(); + expect(native.alarms.clearAll).not.toHaveBeenCalled(); + }); + + test("WebKit removal failures are not replaced by the pre-read result", async () => { + native.alarms.clear.mockImplementation(() => { + throw new Error("Removal denied"); + }); + + await expect(clearAlarm("existing")).rejects.toThrow("Removal denied"); + }); +}); diff --git a/src/api/alarms/methods.ts b/src/api/alarms/methods.ts index b3fb469..ef19e15 100644 --- a/src/api/alarms/methods.ts +++ b/src/api/alarms/methods.ts @@ -1,17 +1,59 @@ +import {isWebKitExtension} from "../../internal/webkit"; import {browser} from "../browser"; -import {callWithPromise} from "../utils"; +import {callBrowserMethod, callWithPromise} from "../utils"; type Alarm = chrome.alarms.Alarm; type AlarmCreateInfo = chrome.alarms.AlarmCreateInfo; const alarms = () => browser().alarms; -export const clearAlarm = (name: string): Promise => callWithPromise(cb => alarms().clear(name, cb)); +const removalResult = (method: string, result: unknown, existed?: boolean): boolean => { + if (typeof result === "boolean") { + return result; + } + + if (result === undefined && existed !== undefined) { + return existed; + } + + throw new Error(`The alarms.${method} API did not return a boolean removal result`); +}; + +/** Legacy WebKit uses a non-atomic existence check when native removal reports no result. */ +export const clearAlarm = async (name: string): Promise => { + const existed = isWebKitExtension(browser()) ? (await getAlarm(name)) !== undefined : undefined; + + const result = await callBrowserMethod({ + callback: (api, done) => api.alarms.clear(name, done), + promise: api => api.alarms.clear(name), + }); -export const clearAllAlarm = (): Promise => callWithPromise(cb => alarms().clearAll(cb)); + return removalResult("clear", result, existed); +}; + +/** Returns whether the collection was nonempty before deletion; the observation is not atomic. */ +export const clearAllAlarm = async (): Promise => { + const existed = (await getAllAlarm()).length > 0; + + await callBrowserMethod({ + callback: (api, done) => api.alarms.clearAll(done), + promise: api => api.alarms.clearAll(), + }); + + return existed; +}; export const createAlarm = (name: string, info: AlarmCreateInfo): Promise => - callWithPromise(cb => alarms().create(name, info, cb)); + callWithPromise(cb => { + const api = browser(); + + // Legacy WebKit rejects a callback and returns void; newer WebKit returns a Promise. + if (isWebKitExtension(api)) { + return Promise.resolve(api.alarms.create(name, info)); + } + + return api.alarms.create(name, info, cb); + }); export const createAlarmIfNotExists = async (name: string, info: AlarmCreateInfo): Promise => { const alarm = await getAlarm(name); @@ -25,6 +67,7 @@ export const createAlarmIfNotExists = async (name: string, info: AlarmCreateInfo return true; }; -export const getAlarm = (name: string): Promise => callWithPromise(cb => alarms().get(name, cb)); +export const getAlarm = async (name: string): Promise => + (await callWithPromise(cb => alarms().get(name, cb))) ?? undefined; export const getAllAlarm = (): Promise => callWithPromise(cb => alarms().getAll(cb)); diff --git a/src/internal/webkit.ts b/src/internal/webkit.ts new file mode 100644 index 0000000..275a3ea --- /dev/null +++ b/src/internal/webkit.ts @@ -0,0 +1,4 @@ +/** Safari and standalone WKWebExtension hosts share the WebKit API signatures. */ +export const isWebKitExtension = (api: typeof chrome): boolean => + typeof api.runtime?.getURL === "function" + && /^(?:safari-web-extension|safari-extension|webkit-extension):/.test(api.runtime.getURL("")); diff --git a/tests/api/native.ts b/tests/api/native.ts new file mode 100644 index 0000000..6ff9ba1 --- /dev/null +++ b/tests/api/native.ts @@ -0,0 +1,20 @@ +import {type BrowserTestApi, installGlobals} from "../../src/testing"; + +/** Partial native shapes for callback, Promise-only and legacy synchronous API regressions. */ +export const installNativeApiFixture = () => { + const native: { + runtime: {id: string; getURL(path: string): string; getManifest(): {manifest_version: 2 | 3}}; + [namespace: string]: any; + } = { + runtime: { + id: "native-fixture", + getURL: path => `webkit-extension://fixture/${path}`, + getManifest: () => ({manifest_version: 3}), + }, + }; + + const api = native as unknown as BrowserTestApi; + const restore = installGlobals({chrome: api, browser: api}); + + return {native, restore}; +}; From 65beedc9318f2c6a19aea756d67d4b4534d92249 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:09 +0200 Subject: [PATCH 3/6] fix(action): adapt enabled-state calls across browsers Use WebKit details objects, numeric Chrome and Firefox arguments, and the Firefox Promise interface while retaining the selected native receiver. --- src/api/action/api.ts | 5 +++- src/api/action/methods.test.ts | 44 ++++++++++++++++++++++++++++++++++ src/api/action/methods.ts | 22 ++++++++++++++--- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/api/action/api.ts b/src/api/action/api.ts index 3e1f067..9d3f3b8 100644 --- a/src/api/action/api.ts +++ b/src/api/action/api.ts @@ -4,5 +4,8 @@ import {getManifestVersion} from "../runtime"; export type Action = typeof chrome.action; type BrowserAction = typeof chrome.browserAction; +export const actionNamespace = (): "action" | "browserAction" => + getManifestVersion() === 3 ? "action" : "browserAction"; + export const action = () => - (getManifestVersion() === 3 ? browser().action : browser().browserAction) as T; + (actionNamespace() === "action" ? browser().action : browser().browserAction) as T; diff --git a/src/api/action/methods.test.ts b/src/api/action/methods.test.ts index d45c337..e49f61a 100644 --- a/src/api/action/methods.test.ts +++ b/src/api/action/methods.test.ts @@ -1,6 +1,8 @@ import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserTestApi, installGlobals} from "../../testing"; +import {isActionEnabled} from "./methods"; import {clearBadgeText, getBadgeText, getDefaultPopup, setBadgeText, setBadgeTextColor} from "./methods"; describe.each([2, 3] as const)("action methods in MV%s", version => { @@ -116,3 +118,45 @@ describe.each([2, 3] as const)("action methods in MV%s", version => { expect(setColor).not.toHaveBeenCalled(); }); }); + +describe("native action signatures", () => { + let native: ReturnType["native"]; + let restore: () => void; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + }); + + afterEach(() => restore()); + + test.each(["webkit-extension", "chrome-extension", "moz-extension"])("isActionEnabled uses the %s native argument shape and retains its receiver", async scheme => { + native.runtime.getURL = path => `${scheme}://fixture/${path}`; + + const action = { + isEnabled: jest.fn(function (this: unknown, details: unknown, callback: (value: boolean) => void) { + expect(this).toBe(action); + expect(details).toEqual(scheme === "webkit-extension" ? {tabId: 7} : 7); + callback(true); + }), + }; + + native.action = action; + await expect(isActionEnabled(7)).resolves.toBe(true); + expect(action.isEnabled).toHaveBeenCalledTimes(1); + }); + + test("Firefox isActionEnabled omits callbacks for its Promise-only native signature", async () => { + Object.assign(native.runtime, {getBrowserInfo: () => undefined}); + native.runtime.getURL = path => `moz-extension://fixture/${path}`; + + native.action = {isEnabled: jest.fn((...args: unknown[]) => { + expect(args).toEqual([7]); + + return Promise.resolve(false); + })}; + + await expect(isActionEnabled(7)).resolves.toBe(false); + }); +}); diff --git a/src/api/action/methods.ts b/src/api/action/methods.ts index 8e7d2c4..54faced 100644 --- a/src/api/action/methods.ts +++ b/src/api/action/methods.ts @@ -1,5 +1,6 @@ +import {isWebKitExtension} from "../../internal/webkit"; import {getManifest, getManifestVersion} from "../runtime"; -import {callWithPromise} from "../utils"; +import {callBrowserMethod, callWithPromise} from "../utils"; import {type Action, action} from "./api"; type ColorArray = chrome.extensionTypes.ColorArray; @@ -30,8 +31,23 @@ export const getActionTitle = (tabId?: number): Promise => export const getActionUserSetting = (): Promise => callWithPromise(cb => action().getUserSettings(cb)); -export const isActionEnabled = (tabId: number): Promise => - callWithPromise(cb => action().isEnabled(tabId, cb)); +export const isActionEnabled = (tabId: number): Promise => { + const invoke = (api: typeof chrome, callback?: (enabled: boolean) => void) => { + const isEnabled = action().isEnabled.bind(action()) as ( + details: number | {tabId: number}, + callback?: (enabled: boolean) => void + ) => void | Promise; + + const details = isWebKitExtension(api) ? {tabId} : tabId; + + return callback ? isEnabled(details, callback) : isEnabled(details); + }; + + return callBrowserMethod({ + callback: invoke, + promise: api => invoke(api) as Promise, + }); +}; export const openActionPopup = (windowId?: number): Promise => callWithPromise(cb => action().openPopup({windowId}, cb)); From b3b712256e3a09fd207b75d592aee7aa4d5f5974 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:10 +0200 Subject: [PATCH 4/6] fix(api)!: normalize absent results and unsupported methods Normalize null current-tab results, preserve cookie details or null, and name missing native methods without losing callback errors. Select the manifest action namespace in diagnostics and reject unsupported getBrowserInfo calls. BREAKING CHANGE: removeCookie returns CookieDetails | null. Handle absent results. Unsupported getBrowserInfo calls reject a Promise instead of throwing synchronously; guarded missing methods use API-named Error objects instead of native TypeError messages. --- docs/cookies.md | 6 +- docs/safari.md | 85 +++++++++++++++++++ src/api/action/methods.test.ts | 13 +++ src/api/action/methods.ts | 11 +-- src/api/cookies/methods.test.ts | 41 +++++++++ src/api/cookies/methods.ts | 8 +- .../declarative-net-request/methods.test.ts | 13 +++ src/api/declarative-net-request/methods.ts | 9 +- src/api/extension/methods.test.ts | 12 +++ src/api/extension/methods.ts | 3 +- src/api/i18n/methods.test.ts | 13 +++ src/api/i18n/methods.ts | 3 +- src/api/permissions/methods.test.ts | 12 +++ src/api/permissions/methods.ts | 5 +- src/api/runtime/methods.test.ts | 23 ++++- src/api/runtime/methods.ts | 16 ++-- src/api/tabs/methods.test.ts | 38 +++++++++ src/api/tabs/methods.ts | 18 ++-- src/api/web-request/methods.test.ts | 12 +++ src/api/web-request/methods.ts | 3 +- src/internal/api-method.ts | 14 +++ .../consumer-types/safari-contracts-types.ts | 17 ++++ 22 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 tests/consumer-types/safari-contracts-types.ts diff --git a/docs/cookies.md b/docs/cookies.md index 73979b0..d1c11ec 100644 --- a/docs/cookies.md +++ b/docs/cookies.md @@ -89,10 +89,12 @@ Retrieves the partition key for the cookie associated with the given frame (Mani ### removeCookie ``` -removeCookie(details: chrome.cookies.CookieDetails): Promise +removeCookie(details: chrome.cookies.CookieDetails): Promise ``` -Removes the cookie matching the specified details, returning the details of the removed cookie. +Removes the cookie matching the specified details. Preserves returned native details and normalizes an absent result +(`null` or `undefined`) to `null`. Chromium can return details even when the cookie was already absent; this result +does not establish that a cookie existed. See the [migration notes](safari.md#breaking-change-migration). diff --git a/docs/safari.md b/docs/safari.md index 6314842..917eed5 100644 --- a/docs/safari.md +++ b/docs/safari.md @@ -26,3 +26,88 @@ import type {ApiMethodName} from "@addon-core/browser"; const method: ApiMethodName = "query"; ``` +## Adapted calls and results + +- `createAlarm(name, info)` calls WebKit without a callback. Legacy WebKit returns `undefined` synchronously; newer + WebKit can return a Promise. Both become `Promise`, including synchronous exceptions and Promise rejections. +- `isActionEnabled(tabId)` retains its numeric public argument and passes `{tabId}` to WebKit. Chromium and Firefox + keep their numeric native argument. Firefox's Promise-only method is called without a callback; other action + methods retain callback handling. +- `getAlarm()` and `getCurrentTab()` normalize native `null` to their declared `undefined` absence result. +- `clearAlarm()` keeps `Promise` and prefers a native boolean. Only WebKit checks existence before removal; + legacy WebKit reports no result and uses that earlier, non-atomic observation. Chromium/Firefox do not pre-read; + unexpected non-boolean results reject instead of being coerced to false. +- `clearAllAlarm()` reads the alarm list before deletion in every browser and returns whether it was nonempty, + ignoring the native result. Chromium reports `true` for an empty collection; Firefox 159 and legacy WebKit report + no result. The shared public contract returns `false` for an empty collection and `true` for a nonempty one. + The read and deletion are separate operations, so concurrent changes can affect what is actually removed. +- `removeCookie()` preserves native details and normalizes `undefined`/`null` to `null`. Some browsers return the + request details for an already absent cookie, so an object is not proof that a cookie previously existed. + +WebKit is detected using the extension URL scheme (`safari-web-extension:`, legacy `safari-extension:` or standalone +`webkit-extension:`), without user-agent sniffing or retrying a mutating native operation. Callback support remains +in use for other methods. A general switch to Promise-only calls would not resolve missing methods or result shapes. + +The APIs identified as missing inside otherwise exposed namespaces now reject with their native API name, for example +`The cookies.getPartitionKey API is not supported in this browser`. This includes the audited action, tabs, cookies, +permissions, runtime, DNR, i18n, extension and webRequest methods; it does not emulate those missing browser features. + +## Breaking-change migration + +BREAKING CHANGE: `removeCookie()` returns `Promise`. Consumers must handle an +absent native cookie removal result: + +```ts +const cookie = await removeCookie({url: "https://example.com/", name: "session"}); +if (cookie !== null) { + console.log(cookie.name); +} +``` + +`clearAllAlarm()` still returns `Promise`, but now returns `false` for an empty collection in every browser. +This changes Chromium's previous native `true` result and requires a breaking-change marker on the alarms commit. + +`getBrowserInfo()` now rejects its Promise with an API-named error when unsupported, instead of throwing a synchronous +`TypeError`. The adapted missing-method errors are ordinary `Error` objects; do not depend on the old native `TypeError` +messages. These changes require a breaking-change release marker and a minor bump while the package remains on `0.x`. + +## Verification + +Build first, then run the production package in disposable extensions: + +```sh +npm run build +npm run test:browser -- --browser chromium --api compatibility --binary /path/to/chrome-for-testing +npm run test:browser -- --browser firefox --api compatibility --binary /path/to/firefox +npm run test:webkit +``` + +The WebKit command requires macOS 15.4+ and the Xcode Command Line Tools. It prints the installed system WebKit and macOS +versions. Its native `WKWebExtensionController` uses nonpersistent extension/web data stores and minimal host window/tab +delegates. Extension APIs are not mocked. It checks alarm creation, lookup, repeat schedules, repeated conditional +creation, deletion, action state, absent results, method capabilities and callback errors. + +This host does not establish Safari application UI behavior, iOS behavior, popup presentation, host-specific optional +features, actual alarm firing after the requested delay, or every public wrapper. Chromium/Firefox run the separate +`compatibility` suite through their real extension runners. Unit tests additionally cover legacy synchronous and newer +Promise-returning WebKit implementations. For upstream alarms changes, see +[WebKit commit 0756528](https://github.com/WebKit/WebKit/commit/07565287df4a952e02a052d06125123d24ffcebd). + +## Verification snapshot — 2026-11-05 + +Runtime: system WebKit **22625.2.8.11.9**, macOS **27.2**, standalone native host; Chromium **153.0.8010.12** and +Firefox **159.0** in disposable extension profiles. This is not a Safari application UI or iOS test. + +| Category | Evidence and scope | +| --- | --- | +| Runtime-tested adaptations | Alarm creation/get/getAll/removal, one-shot and repeating schedules, a second conditional creation preserving `scheduledTime`, action enable/disable/isEnabled, badge text, current-tab and cookie absence normalization, callback errors. | +| Observed missing methods in this WebKit build | `action.getBadgeTextColor/setBadgeTextColor/getUserSettings`; `tabs.discard/highlight/group/ungroup/getZoomSettings/setZoomSettings`; `cookies.getPartitionKey`; `permissions.addHostAccessRequest/removeHostAccessRequest`; `runtime.getBrowserInfo/getContexts/getPackageDirectoryEntry/requestUpdateCheck/restart/restartAfterDelay`; DNR `getAvailableStaticRuleCount/getDisabledRuleIds/testMatchOutcome/updateStaticRules`; `i18n.detectLanguage`; `extension.setUpdateUrlData`; `webRequest.handlerBehaviorChanged`. These observations depend on permissions, manifest and host/build. | +| Context/permission-limited absence | `runtime.connectNative` is absent without a `nativeMessaging` permission request. Legacy `tabs.executeScript/insertCSS/removeCSS` are absent in this MV3 fixture; the wrappers select `scripting` in MV3. These observations do not establish Safari-wide absence. | +| Source-only / unit checks | Complete wrapper argument and event behavior is not established by callable presence. Guards and newer Promise-returning WebKit creation are covered by unit tests; upstream alarms changes were checked in WebKit source. | +| Remaining gaps | Actual delayed alarm firing, every wrapper's successful runtime behavior and errors, all options/result fields, event payloads, popup/user-gesture UI, iOS, and optional APIs controlled by host/build flags. | + +The missing-method observations above came from a separate native audit with the relevant permissions granted, +including `webRequest`. A namespace absent from a differently configured host must not be labelled an unsupported +Safari API without further investigation. Automatically recorded observations and human-reviewed findings are +separate concerns; a callable inventory is not a runtime conformance result for every wrapper. + diff --git a/src/api/action/methods.test.ts b/src/api/action/methods.test.ts index e49f61a..79ad1f2 100644 --- a/src/api/action/methods.test.ts +++ b/src/api/action/methods.test.ts @@ -160,3 +160,16 @@ describe("native action signatures", () => { await expect(isActionEnabled(7)).resolves.toBe(false); }); }); + +test.each([2, 3] as const)("MV%s missing action methods use the selected namespace in errors", async version => { + const {native, restore} = installNativeApiFixture(); + native.runtime.getManifest = () => ({manifest_version: version}); + native.action = {}; + native.browserAction = {}; + + try { + await expect(isActionEnabled(7)).rejects.toThrow(`The ${version === 3 ? "action" : "browserAction"}.isEnabled API is not supported in this browser`); + } finally { + restore(); + } +}); diff --git a/src/api/action/methods.ts b/src/api/action/methods.ts index 54faced..2d84aad 100644 --- a/src/api/action/methods.ts +++ b/src/api/action/methods.ts @@ -1,7 +1,8 @@ +import {requireApiMethod} from "../../internal/api-method"; import {isWebKitExtension} from "../../internal/webkit"; import {getManifest, getManifestVersion} from "../runtime"; import {callBrowserMethod, callWithPromise} from "../utils"; -import {type Action, action} from "./api"; +import {type Action, action, actionNamespace} from "./api"; type ColorArray = chrome.extensionTypes.ColorArray; type TabIconDetails = chrome.action.TabIconDetails & chrome.browserAction.TabIconDetails; @@ -20,7 +21,7 @@ export const getBadgeText = (tabId?: number): Promise => callWithPromise(cb => action().getBadgeText({tabId}, cb)); export const getBadgeTextColor = (tabId?: number): Promise => - callWithPromise(cb => action().getBadgeTextColor({tabId}, cb)); + callWithPromise(cb => requireApiMethod(action(), actionNamespace(), "getBadgeTextColor")({tabId}, cb)); export const getActionPopup = (tabId?: number): Promise => callWithPromise(cb => action().getPopup({tabId}, cb)); @@ -29,11 +30,11 @@ export const getActionTitle = (tabId?: number): Promise => callWithPromise(cb => action().getTitle({tabId}, cb)); export const getActionUserSetting = (): Promise => - callWithPromise(cb => action().getUserSettings(cb)); + callWithPromise(cb => requireApiMethod(action(), actionNamespace(), "getUserSettings")(cb)); export const isActionEnabled = (tabId: number): Promise => { const invoke = (api: typeof chrome, callback?: (enabled: boolean) => void) => { - const isEnabled = action().isEnabled.bind(action()) as ( + const isEnabled = requireApiMethod(action(), actionNamespace(), "isEnabled") as ( details: number | {tabId: number}, callback?: (enabled: boolean) => void ) => void | Promise; @@ -63,7 +64,7 @@ export const setBadgeTextColor = (color: Color, tabId?: number): Promise = return Promise.resolve(); } - return callWithPromise(cb => action().setBadgeTextColor({color, tabId}, cb)); + return callWithPromise(cb => requireApiMethod(action(), actionNamespace(), "setBadgeTextColor")({color, tabId}, cb)); }; export const setActionIcon = (details: TabIconDetails): Promise => diff --git a/src/api/cookies/methods.test.ts b/src/api/cookies/methods.test.ts index 34f40c8..fc22879 100644 --- a/src/api/cookies/methods.test.ts +++ b/src/api/cookies/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import {getAllCookie, getAllCookieStores, getCookie, getCookiePartitionKey, removeCookie, setCookie} from "./methods"; @@ -76,6 +77,7 @@ describe.each(["chrome", "firefox"] as const)("cookies methods in %s", profile = ["get", () => getCookie(details)], ["getAll", () => getAllCookie()], ["getAllCookieStores", () => getAllCookieStores()], + ["getPartitionKey", () => getCookiePartitionKey({tabId: 7})], ["remove", () => removeCookie(details)], ["set", () => setCookie(details)], ] as const)("%s rejects native failures", async (name, invoke) => { @@ -83,3 +85,42 @@ describe.each(["chrome", "firefox"] as const)("cookies methods in %s", profile = await expect(invoke()).rejects.toThrow("Cookie access denied"); }); }); + +describe("absent native cookies results", () => { + let native: ReturnType["native"]; + let restore: () => void; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + }); + + afterEach(() => restore()); + + test.each(["callback", "promise"])("normalizes an absent result from %s", async mode => { + const result = (value: unknown) => (...args: unknown[]) => { + if (mode === "promise") { + return Promise.resolve(value); + } + + (args.at(-1) as (value: unknown) => void)(value); + }; + + native.cookies = {remove: result(undefined)}; + await expect(removeCookie({url: "https://example.test/", name: "missing"})).resolves.toBeNull(); + native.cookies.remove = result(null); + await expect(removeCookie({url: "https://example.test/", name: "missing"})).resolves.toBeNull(); + }); +}); + +test("missing cookies.getPartitionKey rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.cookies = {}; + + try { + await expect(getCookiePartitionKey({tabId: 7})).rejects.toThrow("The cookies.getPartitionKey API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/cookies/methods.ts b/src/api/cookies/methods.ts index 829d91b..e6a8a6a 100644 --- a/src/api/cookies/methods.ts +++ b/src/api/cookies/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; @@ -21,10 +22,11 @@ export const getAllCookie = (details?: GetAllDetails): Promise => export const getAllCookieStores = (): Promise => callWithPromise(cb => cookies().getAllCookieStores(cb)); export const getCookiePartitionKey = (details: FrameDetails): Promise => - callWithPromise(cb => cookies().getPartitionKey(details, result => cb(result.partitionKey))); + callWithPromise(cb => requireApiMethod(cookies(), "cookies", "getPartitionKey")(details, result => cb(result?.partitionKey))); -export const removeCookie = (details: CookieDetails): Promise => - callWithPromise(cb => cookies().remove(details, cb)); +/** Normalizes an absent native removal result to null; preserves returned cookie details. */ +export const removeCookie = async (details: CookieDetails): Promise => + (await callWithPromise(cb => cookies().remove(details, cb))) ?? null; export const setCookie = (details: SetDetails): Promise => callWithPromise(cb => cookies().set(details, cb)); diff --git a/src/api/declarative-net-request/methods.test.ts b/src/api/declarative-net-request/methods.test.ts index 0fb0c92..cd1e564 100644 --- a/src/api/declarative-net-request/methods.test.ts +++ b/src/api/declarative-net-request/methods.test.ts @@ -1,8 +1,10 @@ import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; import {installAvailabilityGlobals} from "../../../tests/api/availability"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import * as api from "./methods"; +import {getDnrAvailableStaticRuleCount} from "./methods"; const rule: chrome.declarativeNetRequest.Rule = {id: 1, action: {type: "block"}, condition: {urlFilter: "example.test"}}; const rules = [rule]; @@ -181,3 +183,14 @@ describe("DNR method boundaries", () => { await expect(api.getDnrDynamicRules()).rejects.toBe(error); }); }); + +test("missing declarativeNetRequest.getAvailableStaticRuleCount rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.declarativeNetRequest = {}; + + try { + await expect(getDnrAvailableStaticRuleCount()).rejects.toThrow("The declarativeNetRequest.getAvailableStaticRuleCount API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/declarative-net-request/methods.ts b/src/api/declarative-net-request/methods.ts index c61a595..32dbfb4 100644 --- a/src/api/declarative-net-request/methods.ts +++ b/src/api/declarative-net-request/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; @@ -37,13 +38,13 @@ export const updateDnrEnabledRulesets = (options: UpdateRulesetOptions): Promise callWithPromise(cb => dnr().updateEnabledRulesets(options, cb)); export const getDnrDisabledRuleIds = (options: GetDisabledRuleIdsOptions): Promise => - callWithPromise(cb => dnr().getDisabledRuleIds(options, cb)); + callWithPromise(cb => requireApiMethod(dnr(), "declarativeNetRequest", "getDisabledRuleIds")(options, cb)); export const updateDnrStaticRules = (options: UpdateStaticRulesOptions): Promise => - callWithPromise(cb => dnr().updateStaticRules(options, cb)); + callWithPromise(cb => requireApiMethod(dnr(), "declarativeNetRequest", "updateStaticRules")(options, cb)); export const getDnrAvailableStaticRuleCount = (): Promise => - callWithPromise(cb => dnr().getAvailableStaticRuleCount(cb)); + callWithPromise(cb => requireApiMethod(dnr(), "declarativeNetRequest", "getAvailableStaticRuleCount")(cb)); export const getDnrMatchedRules = (filter?: MatchedRulesFilter): Promise => callWithPromise(cb => filter === undefined ? dnr().getMatchedRules(cb) : dnr().getMatchedRules(filter, cb)); @@ -56,7 +57,7 @@ export const testDnrMatchOutcome = ( request: TestMatchRequestDetails, options?: DnrTestMatchOptions ): Promise => - callWithPromise(cb => options === undefined ? dnr().testMatchOutcome(request, cb) : dnr().testMatchOutcome(request, options)); + callWithPromise(cb => options === undefined ? requireApiMethod(dnr(), "declarativeNetRequest", "testMatchOutcome")(request, cb) : requireApiMethod(dnr(), "declarativeNetRequest", "testMatchOutcome")(request, options)); export const getDnrRegexSupport = (options: RegexOptions): Promise => callWithPromise(cb => dnr().isRegexSupported(options, cb)); diff --git a/src/api/extension/methods.test.ts b/src/api/extension/methods.test.ts index e9c8643..fd08a2f 100644 --- a/src/api/extension/methods.test.ts +++ b/src/api/extension/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import * as api from "./methods"; @@ -66,3 +67,14 @@ describe.each(["chrome", "firefox"] as const)("extension methods in %s", profile expect(invoke).toThrow(error); }); }); + +test("missing setUpdateUrlData reports the native namespace synchronously", () => { + const {native, restore} = installNativeApiFixture(); + native.extension = {}; + + try { + expect(() => api.setUpdateUrlData("key=value")).toThrow("The extension.setUpdateUrlData API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/extension/methods.ts b/src/api/extension/methods.ts index c64ddcb..133f88b 100644 --- a/src/api/extension/methods.ts +++ b/src/api/extension/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; @@ -16,4 +17,4 @@ export const isAllowedFileSchemeAccess = (): Promise => export const isAllowedIncognitoAccess = (): Promise => callWithPromise(cb => extension().isAllowedIncognitoAccess(cb)); -export const setUpdateUrlData = (data: string): void => extension().setUpdateUrlData(data); +export const setUpdateUrlData = (data: string): void => requireApiMethod(extension(), "extension", "setUpdateUrlData")(data); diff --git a/src/api/i18n/methods.test.ts b/src/api/i18n/methods.test.ts index 2a63fcb..ffcf9d3 100644 --- a/src/api/i18n/methods.test.ts +++ b/src/api/i18n/methods.test.ts @@ -1,7 +1,9 @@ import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import * as api from "./methods"; +import {detectI18Language} from "./methods"; describe.each(["chrome", "firefox"] as const)("i18n methods in %s", profile => { let harness: BrowserHarness; @@ -69,3 +71,14 @@ describe.each(["chrome", "firefox"] as const)("i18n methods in %s", profile => { expect(api.getDefaultLanguage).toThrow(error); }); }); + +test("missing i18n.detectLanguage rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.i18n = {}; + + try { + await expect(detectI18Language("hello")).rejects.toThrow("The i18n.detectLanguage API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/i18n/methods.ts b/src/api/i18n/methods.ts index 81123a7..43b5f72 100644 --- a/src/api/i18n/methods.ts +++ b/src/api/i18n/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {getManifest} from "../runtime"; import {callWithPromise} from "../utils"; @@ -8,7 +9,7 @@ const i18n = () => browser().i18n; // Methods export const detectI18Language = (text: string): Promise => - callWithPromise(cb => i18n().detectLanguage(text, cb)); + callWithPromise(cb => requireApiMethod(i18n(), "i18n", "detectLanguage")(text, cb)); export const getI18nAcceptLanguages = (): Promise => callWithPromise(cb => i18n().getAcceptLanguages(cb)); diff --git a/src/api/permissions/methods.test.ts b/src/api/permissions/methods.test.ts index a7fdcc5..7b220d4 100644 --- a/src/api/permissions/methods.test.ts +++ b/src/api/permissions/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import {addHostAccessRequest, containsPermissions, getAllPermissions, removeHostAccessRequest, removePermissions, requestPermissions} from "./methods"; @@ -65,3 +66,14 @@ describe.each(["chrome", "firefox"] as const)("permissions methods in %s", profi await expect(invoke()).rejects.toThrow("Permission request failed"); }); }); + +test("missing permissions.addHostAccessRequest rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.permissions = {}; + + try { + await expect(addHostAccessRequest({tabId: 7})).rejects.toThrow("The permissions.addHostAccessRequest API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/permissions/methods.ts b/src/api/permissions/methods.ts index 7205d45..18fcb79 100644 --- a/src/api/permissions/methods.ts +++ b/src/api/permissions/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; @@ -9,7 +10,7 @@ const permissions = () => browser().permissions; // Methods export const addHostAccessRequest = (request?: AddHostAccessRequest): Promise => - callWithPromise(cb => permissions().addHostAccessRequest(request || {}, cb)); + callWithPromise(cb => requireApiMethod(permissions(), "permissions", "addHostAccessRequest")(request || {}, cb)); export const containsPermissions = (permission: Permissions): Promise => callWithPromise(cb => permissions().contains(permission, cb)); @@ -20,7 +21,7 @@ export const removePermissions = (permission: Permissions): Promise => callWithPromise(cb => permissions().remove(permission, cb)); export const removeHostAccessRequest = (request?: RemoveHostAccessRequest): Promise => - callWithPromise(cb => permissions().removeHostAccessRequest(request || {}, cb)); + callWithPromise(cb => requireApiMethod(permissions(), "permissions", "removeHostAccessRequest")(request || {}, cb)); export const requestPermissions = (permission: Permissions): Promise => callWithPromise(cb => permissions().request(permission, cb)); diff --git a/src/api/runtime/methods.test.ts b/src/api/runtime/methods.test.ts index f9381a2..8158af1 100644 --- a/src/api/runtime/methods.test.ts +++ b/src/api/runtime/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {describeSafePredicate} from "../../../tests/api/predicates"; import { type BrowserHarness, @@ -105,7 +106,7 @@ describe.each(["chrome", "firefox"] as const)("runtime methods in %s", profile = test("getBrowserInfo retains its Firefox-only Promise behavior", async () => { if (profile === "chrome") { - expect(() => getBrowserInfo()).toThrow(TypeError); + await expect(getBrowserInfo()).rejects.toThrow("The runtime.getBrowserInfo API is not supported in this browser"); expect(harness.runtime.getBrowserInfo.calls).toHaveLength(0); return; @@ -216,3 +217,23 @@ describe.each(["chrome", "firefox"] as const)("runtime methods in %s", profile = expect(invoke).toThrow("Synchronous runtime failure"); }); }); + +test("missing runtime.getContexts rejects with the API name", async () => { + const {restore} = installNativeApiFixture(); + + try { + await expect(getContexts({})).rejects.toThrow("The runtime.getContexts API is not supported in this browser"); + } finally { + restore(); + } +}); + +test("missing runtime.getBrowserInfo rejects with the API name", async () => { + const {restore} = installNativeApiFixture(); + + try { + await expect(getBrowserInfo()).rejects.toThrow("The runtime.getBrowserInfo API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/runtime/methods.ts b/src/api/runtime/methods.ts index b3e9792..5c5ada6 100644 --- a/src/api/runtime/methods.ts +++ b/src/api/runtime/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; @@ -23,18 +24,17 @@ export const connect = (extensionId: string, connectInfo?: object): Port => runt export const connectNative = (application: string): Port => runtime().connectNative(application); export const getContexts = (filter: ContextFilter): Promise => - callWithPromise(cb => runtime().getContexts(filter, cb)); + callWithPromise(cb => requireApiMethod(runtime(), "runtime", "getContexts")(filter, cb)); export const getManifest = (): Manifest => runtime().getManifest(); export const getPackageDirectoryEntry = (): Promise => - callWithPromise(cb => runtime().getPackageDirectoryEntry(cb)); + callWithPromise(cb => requireApiMethod(runtime(), "runtime", "getPackageDirectoryEntry")(cb)); export const getPlatformInfo = (): Promise => callWithPromise(cb => runtime().getPlatformInfo(cb)); -export const getBrowserInfo = (): Promise => { - return (runtime() as unknown as FirefoxRuntime).getBrowserInfo(); -}; +export const getBrowserInfo = async (): Promise => + requireApiMethod(runtime() as unknown as FirefoxRuntime, "runtime", "getBrowserInfo")(); export const getUrl = (path: string): string => runtime().getURL(path); @@ -43,12 +43,12 @@ export const openOptionsPage = (): Promise => callWithPromise(cb => runtim export const reload = (): void => runtime().reload(); export const requestUpdateCheck = (): Promise => - callWithPromise(cb => runtime().requestUpdateCheck((status, details) => cb({status, details}))); + callWithPromise(cb => requireApiMethod(runtime(), "runtime", "requestUpdateCheck")((status, details) => cb({status, details}))); -export const restart = (): void => runtime().restart(); +export const restart = (): void => requireApiMethod(runtime(), "runtime", "restart")(); export const restartAfterDelay = (seconds: number): Promise => - callWithPromise(cb => runtime().restartAfterDelay(seconds, cb)); + callWithPromise(cb => requireApiMethod(runtime(), "runtime", "restartAfterDelay")(seconds, cb)); export const sendMessage = (message: M): Promise => callWithPromise(cb => runtime().sendMessage(message, cb)); diff --git a/src/api/tabs/methods.test.ts b/src/api/tabs/methods.test.ts index f04f8a4..8914c4f 100644 --- a/src/api/tabs/methods.test.ts +++ b/src/api/tabs/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "../../testing"; import * as api from "./methods"; @@ -269,3 +270,40 @@ test.each(["chrome", "firefox"] as const)("ungroupTab supports shared %s namespa restore(); } }); + +describe("absent native tabs results", () => { + let native: ReturnType["native"]; + let restore: () => void; + + beforeEach(() => { + const fixture = installNativeApiFixture(); + native = fixture.native; + restore = fixture.restore; + }); + + afterEach(() => restore()); + + test.each(["callback", "promise"])("normalizes an absent result from %s", async mode => { + const result = (value: unknown) => (...args: unknown[]) => { + if (mode === "promise") { + return Promise.resolve(value); + } + + (args.at(-1) as (value: unknown) => void)(value); + }; + + native.tabs = {getCurrent: result(null)}; + await expect(api.getCurrentTab()).resolves.toBeUndefined(); + }); +}); + +test("missing tabs.discard rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.tabs = {}; + + try { + await expect(api.discardTab(7)).rejects.toThrow("The tabs.discard API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/tabs/methods.ts b/src/api/tabs/methods.ts index 2295a2c..b77c935 100644 --- a/src/api/tabs/methods.ts +++ b/src/api/tabs/methods.ts @@ -1,3 +1,4 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callBrowserMethod, callWithPromise} from "../utils"; @@ -35,29 +36,30 @@ export const createTab = (properties: CreateProperties): Promise => export const detectTabLanguage = (tabId: number): Promise => callWithPromise(cb => tabs().detectLanguage(tabId, cb)); -export const discardTab = (tabId: number): Promise => callWithPromise(cb => tabs().discard(tabId, cb)); +export const discardTab = (tabId: number): Promise => callWithPromise(cb => requireApiMethod(tabs(), "tabs", "discard")(tabId, cb)); export const duplicateTab = (tabId: number): Promise => callWithPromise(cb => tabs().duplicate(tabId, cb)); export const getTab = (tabId: number): Promise => callWithPromise(cb => tabs().get(tabId, cb)); -export const getCurrentTab = (): Promise => callWithPromise(cb => tabs().getCurrent(cb)); +export const getCurrentTab = async (): Promise => + (await callWithPromise(cb => tabs().getCurrent(cb))) ?? undefined; export const getTabZoom = (tabId: number): Promise => callWithPromise(cb => tabs().getZoom(tabId, cb)); export const getTabZoomSettings = (tabId: number): Promise => - callWithPromise(cb => tabs().getZoomSettings(tabId, cb)); + callWithPromise(cb => requireApiMethod(tabs(), "tabs", "getZoomSettings")(tabId, cb)); export const goTabBack = (tabId: number): Promise => callWithPromise(cb => tabs().goBack(tabId, () => cb())); export const goTabForward = (tabId: number): Promise => callWithPromise(cb => tabs().goForward(tabId, () => cb())); -export const groupTabs = (options: GroupOptions): Promise => callWithPromise(cb => tabs().group(options, cb)); +export const groupTabs = (options: GroupOptions): Promise => callWithPromise(cb => requireApiMethod(tabs(), "tabs", "group")(options, cb)); export const highlightTab = (highlightInfo: HighlightInfo): Promise => - callWithPromise(cb => tabs().highlight(highlightInfo, cb)); + callWithPromise(cb => requireApiMethod(tabs(), "tabs", "highlight")(highlightInfo, cb)); export const moveTab = (tabId: number, moveProperties: MoveProperties): Promise => callWithPromise(cb => tabs().move(tabId, moveProperties, cb)); @@ -83,12 +85,12 @@ export const setTabZoom = (tabId: number, zoomFactor: number): Promise => callWithPromise(cb => tabs().setZoom(tabId, zoomFactor, () => cb())); export const setTabZoomSettings = (tabId: number, zoomSettings: ZoomSettings): Promise => - callWithPromise(cb => tabs().setZoomSettings(tabId, zoomSettings, () => cb())); + callWithPromise(cb => requireApiMethod(tabs(), "tabs", "setZoomSettings")(tabId, zoomSettings, () => cb())); export const ungroupTab = (tabIds: number | [number, ...number[]]): Promise => callBrowserMethod({ - callback: (api, done) => api.tabs.ungroup(tabIds, done), - promise: api => api.tabs.ungroup(tabIds), + callback: (api, done) => requireApiMethod(api.tabs, "tabs", "ungroup")(tabIds, done), + promise: api => requireApiMethod(api.tabs, "tabs", "ungroup")(tabIds), }); export const updateTab = (tabId: number, updateProperties: UpdateProperties): Promise => diff --git a/src/api/web-request/methods.test.ts b/src/api/web-request/methods.test.ts index fb19d94..5d0eba2 100644 --- a/src/api/web-request/methods.test.ts +++ b/src/api/web-request/methods.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {installNativeApiFixture} from "../../../tests/api/native"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "../../testing"; import {handlerWebRequestBehaviorChanged} from "./methods"; @@ -77,3 +78,14 @@ describe.each(["chrome", "firefox"] as const)("webRequest methods in %s", profil await expect(handlerWebRequestBehaviorChanged()).rejects.toBe(error); }); }); + +test("missing webRequest.handlerBehaviorChanged rejects with the API name", async () => { + const {native, restore} = installNativeApiFixture(); + native.webRequest = {}; + + try { + await expect(handlerWebRequestBehaviorChanged()).rejects.toThrow("The webRequest.handlerBehaviorChanged API is not supported in this browser"); + } finally { + restore(); + } +}); diff --git a/src/api/web-request/methods.ts b/src/api/web-request/methods.ts index 7d3efad..15d8ba2 100644 --- a/src/api/web-request/methods.ts +++ b/src/api/web-request/methods.ts @@ -1,7 +1,8 @@ +import {requireApiMethod} from "../../internal/api-method"; import {browser} from "../browser"; import {callWithPromise} from "../utils"; const webRequest = () => browser().webRequest; export const handlerWebRequestBehaviorChanged = (): Promise => - callWithPromise(cb => webRequest().handlerBehaviorChanged(() => cb())); + callWithPromise(cb => requireApiMethod(webRequest(), "webRequest", "handlerBehaviorChanged")(() => cb())); diff --git a/src/internal/api-method.ts b/src/internal/api-method.ts index 030e4b9..4189e74 100644 --- a/src/internal/api-method.ts +++ b/src/internal/api-method.ts @@ -1,3 +1,17 @@ export type ApiMethodName = T extends unknown ? { [Name in keyof T]-?: Extract any> extends never ? never : Name; }[keyof T] : never; + +export const requireApiMethod = ( + api: T | undefined, + namespace: string, + name: Name +): T[Name] => { + const method = api?.[name]; + + if (typeof method !== "function") { + throw new Error(`The ${namespace}.${String(name)} API is not supported in this browser`); + } + + return method.bind(api) as T[Name]; +}; diff --git a/tests/consumer-types/safari-contracts-types.ts b/tests/consumer-types/safari-contracts-types.ts new file mode 100644 index 0000000..14a067c --- /dev/null +++ b/tests/consumer-types/safari-contracts-types.ts @@ -0,0 +1,17 @@ +import {clearAlarm, clearAllAlarm, createAlarm, getAlarm, getCurrentTab, isActionEnabled, removeCookie} from "@addon-core/browser"; + +const cleared: Promise = clearAlarm("sync"); +const clearedAll: Promise = clearAllAlarm(); +const removed: Promise = removeCookie({url: "https://example.test/", name: "missing"}); +const created: Promise = createAlarm("sync", {delayInMinutes: 10}); +const alarm: Promise = getAlarm("sync"); +const current: Promise = getCurrentTab(); +const enabled: Promise = isActionEnabled(7); +void [cleared, clearedAll, removed, created, alarm, current, enabled]; + +const alwaysBoolean: Promise = clearAlarm("sync"); +// @ts-expect-error A missing cookie is represented by null. +const alwaysCookie: Promise = removeCookie({url: "https://example.test/", name: "missing"}); +// @ts-expect-error Native WebKit details objects do not change the public numeric argument. +isActionEnabled({tabId: 7}); +void [alwaysBoolean, alwaysCookie]; From 032308bb702425d0ed0586e5e0ab17190e306c69 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:10 +0200 Subject: [PATCH 5/6] test(webkit): add an isolated system WebKit contract host Exercise the production package with native WKWebExtension APIs and disposable data stores. Record actual system versions and verify scheduling, repeated conditional creation, removal, action state, missing results and errors. --- package.json | 1 + tests/webkit/host.m | 67 +++++++++++++++++++++++++++ tests/webkit/probe.ts | 93 ++++++++++++++++++++++++++++++++++++++ tests/webkit/run.mjs | 55 ++++++++++++++++++++++ tests/webkit/tsconfig.json | 11 +++++ 5 files changed, 227 insertions(+) create mode 100644 tests/webkit/host.m create mode 100644 tests/webkit/probe.ts create mode 100644 tests/webkit/run.mjs create mode 100644 tests/webkit/tsconfig.json diff --git a/package.json b/package.json index 0bf6c18..1942cad 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:ci": "npm test -- --ci --passWithNoTests --coverage", "test:browser": "node ./tests/browser/run.mjs", + "test:webkit": "node ./tests/webkit/run.mjs", "typecheck:browser": "tsc -p tests/browser/tsconfig.json", "test:browser-match-patterns": "node ./tests/browser-match-patterns/check.mjs", "test:consumer-types": "npm run build && node ./tests/consumer-types/check.mjs", diff --git a/tests/webkit/host.m b/tests/webkit/host.m new file mode 100644 index 0000000..b4dc267 --- /dev/null +++ b/tests/webkit/host.m @@ -0,0 +1,67 @@ +#import +#import +@interface AuditWindow : NSObject +@property NSArray *tabs; +@end +@interface AuditTab : NSObject +@property AuditWindow *window; +@end +@implementation AuditWindow +- (NSArray *)tabsForWebExtensionContext:(WKWebExtensionContext *)c { return self.tabs; } +- (id)activeTabForWebExtensionContext:(WKWebExtensionContext *)c { return self.tabs.firstObject; } +@end +@implementation AuditTab +- (id)windowForWebExtensionContext:(WKWebExtensionContext *)c { return self.window; } +- (NSUInteger)indexInWindowForWebExtensionContext:(WKWebExtensionContext *)c { return 0; } +- (NSString *)titleForWebExtensionContext:(WKWebExtensionContext *)c { return @"Isolated host tab"; } +- (NSURL *)urlForWebExtensionContext:(WKWebExtensionContext *)c { return [NSURL URLWithString:@"about:blank"]; } +- (BOOL)isSelectedForWebExtensionContext:(WKWebExtensionContext *)c { return YES; } +- (BOOL)isLoadingCompleteForWebExtensionContext:(WKWebExtensionContext *)c { return YES; } +@end +@interface Receiver : NSObject +@property WKWebView *webView; +@property AuditWindow *window; +@property WKWebExtensionController *controller; +@property WKWebExtensionContext *context; +@end +@implementation Receiver +- (NSArray *)webExtensionController:(WKWebExtensionController *)c openWindowsForExtensionContext:(WKWebExtensionContext *)e { return @[self.window]; } +- (id)webExtensionController:(WKWebExtensionController *)c focusedWindowForExtensionContext:(WKWebExtensionContext *)e { return self.window; } +- (void)userContentController:(WKUserContentController *)c didReceiveScriptMessage:(WKScriptMessage *)m { + NSData *data = [NSJSONSerialization dataWithJSONObject:m.body options:NSJSONWritingPrettyPrinted error:nil]; + puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); + exit(0); +} +- (void)webView:(WKWebView *)v didFailProvisionalNavigation:(WKNavigation *)n withError:(NSError *)e { NSLog(@"navigation: %@", e); exit(2); } +@end +int main(int argc, const char *argv[]) { @autoreleasepool { + if (argc != 2) { fputs("Expected an extension directory\n", stderr); return 2; } + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; + Receiver *receiver = [Receiver new]; + receiver.window = [AuditWindow new]; + AuditTab *tab = [AuditTab new]; tab.window = receiver.window; receiver.window.tabs = @[tab]; + NSURL *root = [NSURL fileURLWithPath:[NSString stringWithUTF8String:argv[1]] isDirectory:YES]; + [WKWebExtension extensionWithResourceBaseURL:root completionHandler:^(WKWebExtension *extension, NSError *error) { + if (error) { NSLog(@"extension: %@", error); exit(2); } + WKWebExtensionControllerConfiguration *controllerConfig = WKWebExtensionControllerConfiguration.nonPersistentConfiguration; + WKWebViewConfiguration *webViewConfig = [WKWebViewConfiguration new]; + webViewConfig.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore; + controllerConfig.webViewConfiguration = webViewConfig; + receiver.controller = [[WKWebExtensionController alloc] initWithConfiguration:controllerConfig]; + receiver.controller.delegate = receiver; + receiver.context = [[WKWebExtensionContext alloc] initForExtension:extension]; + receiver.context.baseURL = [NSURL URLWithString:@"webkit-extension://audit/"]; + for (NSString *permission in extension.requestedPermissions) [receiver.context setPermissionStatus:WKWebExtensionContextPermissionStatusGrantedExplicitly forPermission:permission]; + for (WKWebExtensionMatchPattern *pattern in extension.requestedPermissionMatchPatterns) [receiver.context setPermissionStatus:WKWebExtensionContextPermissionStatusGrantedExplicitly forMatchPattern:pattern]; + NSError *loadError = nil; + if (![receiver.controller loadExtensionContext:receiver.context error:&loadError]) { NSLog(@"load: %@", loadError); exit(2); } + WKWebViewConfiguration *config = receiver.context.webViewConfiguration; + [config.userContentController addScriptMessageHandler:receiver name:@"result"]; + receiver.webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:config]; + receiver.webView.navigationDelegate = receiver; + [receiver.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"probe.html" relativeToURL:receiver.context.baseURL]]]; + }]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 45 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ NSLog(@"timeout"); exit(3); }); + [NSApp run]; +} } diff --git a/tests/webkit/probe.ts b/tests/webkit/probe.ts new file mode 100644 index 0000000..3a1d380 --- /dev/null +++ b/tests/webkit/probe.ts @@ -0,0 +1,93 @@ +import { + browser, clearAlarm, clearAllAlarm, createAlarm, createAlarmIfNotExists, disableAction, + enableAction, getAlarm, getAllAlarm, getBadgeText, getCurrentTab, getDnrDynamicRules, + getRegisteredContentScripts, isActionEnabled, isAvailableAction, isAvailableCookies, + isAvailableDnr, isAvailableI18n, isAvailablePermissions, isAvailableRuntime, isAvailableTabs, + isAvailableWebRequest, queryTabs, removeCookie, setBadgeText, +} from "../../dist/index.js"; +import {assert, rejects} from "../browser/extension/assert"; + +const results: {id: string; status: string; error?: string}[] = []; +const native = browser(); + +async function scenario(id: string, run: () => Promise) { + try { + await run(); + results.push({id, status: "passed"}); + } catch (error) { + results.push({id, status: "failed", error: String(error)}); + } +} + +async function main() { + await scenario("alarms", async () => { + const when = Date.now() + 600000; + + try { + assert(await getAlarm("missing") === undefined, "Missing alarm must be undefined"); + await createAlarm("once", {when}); + await createAlarm("repeating", {delayInMinutes: 10, periodInMinutes: 5}); + const alarms = await getAllAlarm(); + // Legacy WebKit derives the fire date after IPC delivery, adding a small dispatch delay. + assert(alarms.some(alarm => alarm.name === "once" && Math.abs(alarm.scheduledTime - when) < 1000), `One-shot schedule was not stored: ${JSON.stringify({when, alarms})}`); + assert(alarms.some(alarm => alarm.name === "repeating" && alarm.periodInMinutes === 5), "Repeating schedule was not stored"); + assert(await createAlarmIfNotExists("conditional", {when}) === true, "First call must create"); + const before = await native.alarms.get("conditional"); + assert(await createAlarmIfNotExists("conditional", {when: when + 600000}) === false, "Second call must preserve"); + assert((await native.alarms.get("conditional"))?.scheduledTime === before?.scheduledTime, "Second call changed the schedule"); + const removed = await clearAlarm("once"); + assert(removed === true, "Native removal result was changed"); + assert(await getAlarm("once") === undefined, "Alarm was not removed"); + const missing = await clearAlarm("once"); + assert(missing === false, "Missing removal result was changed"); + const cleared = await clearAllAlarm(); + assert(cleared === true, "Clearing a nonempty collection must return true"); + assert((await native.alarms.getAll()).length === 0, "Alarms were not cleared"); + assert(await clearAllAlarm() === false, "Clearing an empty collection must return false"); + await rejects(() => createAlarm("invalid", {when: "invalid" as unknown as number}), "Invalid alarm arguments must reject"); + } finally { + await native.alarms.clearAll(); + } + }); + + await scenario("action", async () => { + const [tab] = await queryTabs(); + assert(typeof tab?.id === "number", "Host tab was not exposed"); + await enableAction(tab.id); + assert(await isActionEnabled(tab.id) === true, "Enabled action must return true"); + await disableAction(tab.id); + assert(await isActionEnabled(tab.id) === false, "Disabled action must return false"); + await setBadgeText("test", tab.id); + assert(await getBadgeText(tab.id) === "test", "Badge callback lost its result"); + }); + + await scenario("absent-results", async () => { + assert(await getCurrentTab() === undefined, "Extension page must have no current tab"); + assert(await removeCookie({url: "https://example.test/", name: "missing"}) === null, "Missing cookie must be null"); + }); + + await scenario("capabilities", async () => { + assert(isAvailableTabs(), "Tabs namespace must be present"); + assert(isAvailableTabs("discard") === (typeof native.tabs.discard === "function"), "Tabs capability differs"); + assert(isAvailableAction("getBadgeTextColor") === (typeof native.action.getBadgeTextColor === "function"), "Action capability differs"); + assert(isAvailableCookies("getPartitionKey") === (typeof native.cookies.getPartitionKey === "function"), "Cookies capability differs"); + assert(isAvailablePermissions("addHostAccessRequest") === (typeof native.permissions.addHostAccessRequest === "function"), "Permissions capability differs"); + assert(isAvailableRuntime("getContexts") === (typeof native.runtime.getContexts === "function"), "Runtime capability differs"); + assert(isAvailableDnr("testMatchOutcome") === (typeof native.declarativeNetRequest.testMatchOutcome === "function"), "DNR capability differs"); + assert(isAvailableI18n("detectLanguage") === (typeof native.i18n.detectLanguage === "function"), "i18n capability differs"); + assert(isAvailableWebRequest("handlerBehaviorChanged") === (typeof native.webRequest?.handlerBehaviorChanged === "function"), "webRequest capability differs"); + assert(Array.isArray(await getDnrDynamicRules()), "DNR callbacks must still return rules"); + assert(Array.isArray(await getRegisteredContentScripts()), "Scripting callbacks must still return scripts"); + }); + + await scenario("callback-errors", async () => { + await rejects(() => isActionEnabled(999999), "Invalid tab must reject"); + await rejects(() => getBadgeText(999999), "Callback lastError must reject"); + }); + + const host = globalThis as typeof globalThis & {webkit: {messageHandlers: {result: {postMessage(value: unknown): void}}}}; + + host.webkit.messageHandlers.result.postMessage({results}); +} + +void main(); diff --git a/tests/webkit/run.mjs b/tests/webkit/run.mjs new file mode 100644 index 0000000..9184e8c --- /dev/null +++ b/tests/webkit/run.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import {execFile} from "node:child_process"; +import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {dirname, join, resolve} from "node:path"; +import {fileURLToPath} from "node:url"; +import {promisify} from "node:util"; + +import {build} from "tsup"; + +const run = promisify(execFile); +const directory = dirname(fileURLToPath(import.meta.url)); +const root = resolve(directory, "../.."); +assert.equal(process.platform, "darwin", "System WebKit tests require macOS 15.4+ and the Xcode Command Line Tools"); +const temporary = await mkdtemp(join(tmpdir(), "addon-browser-webkit-")); + +try { + await run(process.execPath, [join(root, "node_modules/typescript/bin/tsc"), "-p", join(directory, "tsconfig.json")]); + const extension = join(temporary, "extension"); + await mkdir(extension); + + await writeFile(join(extension, "manifest.json"), JSON.stringify({ + manifest_version: 3, name: "Isolated WebKit contract tests", version: "0.0.1", action: {}, + permissions: ["alarms", "tabs", "cookies", "contextMenus", "scripting", "declarativeNetRequest", "webRequest"], + host_permissions: ["https://example.test/*"], + })); + + await writeFile(join(extension, "probe.html"), ''); + + await build({ + config: false, entry: {probe: join(directory, "probe.ts")}, outDir: extension, + bundle: true, format: ["iife"], platform: "browser", target: "es2022", + dts: false, sourcemap: false, silent: true, outExtension: () => ({js: ".js"}), + }); + + const host = join(temporary, "host"); + await run("xcrun", ["clang", "-fobjc-arc", "-framework", "Cocoa", "-framework", "WebKit", join(directory, "host.m"), "-o", host]); + + const [{stdout: macOS}, {stdout: webKit}, {stdout}] = await Promise.all([ + run("sw_vers", ["-productVersion"]), + run("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", "/System/Library/Frameworks/WebKit.framework/Resources/Info.plist"]), + run(host, [extension], {timeout: 50000, maxBuffer: 4 * 1024 * 1024}), + ]); + + const {results} = JSON.parse(stdout); + assert.deepEqual(results.map(result => result.id), ["alarms", "action", "absent-results", "capabilities", "callback-errors"]); + console.log(`System WebKit ${webKit.trim()} on macOS ${macOS.trim()}`); + + for (const result of results) { + assert.equal(result.status, "passed", `${result.id}: ${result.error ?? "did not pass"}`); + console.log(`PASS webkit ${result.id}`); + } +} finally { + await rm(temporary, {recursive: true, force: true}); +} diff --git a/tests/webkit/tsconfig.json b/tests/webkit/tsconfig.json new file mode 100644 index 0000000..8bda7dc --- /dev/null +++ b/tests/webkit/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false + }, + "include": [ + "./probe.ts" + ] +} From 6c97c6a2c0a564edb5c7673c87629b6b2cdc84b4 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:10 +0200 Subject: [PATCH 6/6] test(browser): verify native compatibility contracts Run strict true/false alarm removal assertions and action/result regressions in the production Chromium and Firefox extension runners. --- tests/browser/README.md | 7 ++ .../api/compatibility/contracts.scenario.ts | 68 +++++++++++++++++++ tests/browser/api/compatibility/index.ts | 14 ++++ tests/browser/api/index.ts | 3 +- 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/browser/api/compatibility/contracts.scenario.ts create mode 100644 tests/browser/api/compatibility/index.ts diff --git a/tests/browser/README.md b/tests/browser/README.md index a649b23..a27d240 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -191,6 +191,13 @@ npm run test:browser -- --browser chromium --api identity --binary /path/to/chro npm run test:browser -- --browser firefox --api identity --binary /path/to/firefox ``` +## Compatibility coverage + +The `compatibility` suite checks alarm schedules and results, repeated conditional creation, action enabled state, +absent current-tab/cookie results and per-method availability in Chromium and Firefox. It uses only disposable profile +data. Run it with `--api compatibility`. System WebKit has a separate macOS command, `npm run test:webkit`; see the +[Safari verification notes](../../docs/safari.md#verification) for its native host and remaining gaps. + ## Add another API 1. Add `api//index.ts` exporting a `BrowserSuite` and one or more `.scenario.ts` files. diff --git a/tests/browser/api/compatibility/contracts.scenario.ts b/tests/browser/api/compatibility/contracts.scenario.ts new file mode 100644 index 0000000..ec84190 --- /dev/null +++ b/tests/browser/api/compatibility/contracts.scenario.ts @@ -0,0 +1,68 @@ +import { + browser, clearAlarm, clearAllAlarm, createAlarm, createAlarmIfNotExists, createTab, + disableAction, enableAction, getAlarm, getAllAlarm, getBadgeText, getCurrentTab, + isActionEnabled, isAvailableAction, isAvailableCookies, isAvailableTabs, removeCookie, + removeTab, setBadgeText, +} from "../../../../dist/index.js"; +import {assert, rejects, waitFor} from "../../extension/assert"; + +import type {BrowserScenario} from "../../types"; + +export default { + id: "native-contracts", + async run(context) { + const native = browser(); + const prefix = `compatibility-${Date.now()}`; + const once = `${prefix}-once`; + const repeating = `${prefix}-repeating`; + const conditional = `${prefix}-conditional`; + const when = Date.now() + 600000; + + try { + assert(await getAlarm(once) === undefined, "Missing alarms must be undefined"); + await createAlarm(once, {when}); + await createAlarm(repeating, {delayInMinutes: 10, periodInMinutes: 5}); + const alarms = await getAllAlarm(); + assert(alarms.some(alarm => alarm.name === once && alarm.scheduledTime === when), "One-shot alarm was not stored"); + assert(alarms.some(alarm => alarm.name === repeating && alarm.periodInMinutes === 5), "Repeating alarm was not stored"); + assert(await createAlarmIfNotExists(conditional, {when}) === true, "Missing alarm must be created"); + const before = await native.alarms.get(conditional); + assert(await createAlarmIfNotExists(conditional, {when: when + 600000}) === false, "Existing alarm must be retained"); + assert((await native.alarms.get(conditional))?.scheduledTime === before?.scheduledTime, "Second create changed the scheduled time"); + assert(await clearAlarm(once) === true, "Removing an existing alarm must preserve the native boolean"); + assert(await clearAlarm(once) === false, "Removing a missing alarm must preserve false"); + await rejects(() => createAlarm(`${prefix}-invalid`, {when: "invalid" as unknown as number}), "Invalid native alarm arguments must reject"); + await native.alarms.clearAll(); + await createAlarm(repeating, {delayInMinutes: 10}); + const cleared = await clearAllAlarm(); + assert(cleared === true, "Clearing a nonempty collection must return true"); + assert(await clearAllAlarm() === false, "Clearing an empty collection must return false"); + assert((await native.alarms.getAll()).length === 0, "Alarms were not cleared"); + } finally { + await native.alarms.clearAll(); + } + + assert(await getCurrentTab() === undefined, "A background context must have no current tab"); + const missingCookie = await removeCookie({url: `${context.base}/`, name: `${prefix}-missing`}); + assert(missingCookie === null || missingCookie.name === `${prefix}-missing`, "Cookie removal must return details or null"); + const tab = await createTab({url: "about:blank", active: false}); + assert(typeof tab.id === "number", "Fixture tab has no ID"); + + try { + await waitFor(() => native.tabs.get(tab.id!), value => value.status === "complete", "initial tab navigation"); + await enableAction(tab.id); + assert(await isActionEnabled(tab.id) === true, "Enabled action must return true"); + await disableAction(tab.id); + assert(await isActionEnabled(tab.id) === false, "Disabled action must return false"); + await setBadgeText("test", tab.id); + assert(await getBadgeText(tab.id) === "test", "Action callback handling lost the badge value"); + assert(isAvailableAction("isEnabled") === (typeof native.action.isEnabled === "function"), "Action method capability differs from native API"); + assert(isAvailableTabs("discard") === (typeof native.tabs.discard === "function"), "Tabs method capability differs from native API"); + assert(isAvailableCookies("getPartitionKey") === (typeof native.cookies.getPartitionKey === "function"), "Cookie method capability differs from native API"); + } finally { + await removeTab(tab.id); + } + + await rejects(() => isActionEnabled(tab.id!), "Missing tabs must reject rather than return false"); + }, +} satisfies BrowserScenario; diff --git a/tests/browser/api/compatibility/index.ts b/tests/browser/api/compatibility/index.ts new file mode 100644 index 0000000..0a7d5ab --- /dev/null +++ b/tests/browser/api/compatibility/index.ts @@ -0,0 +1,14 @@ +import contracts from "./contracts.scenario"; + +import type {BrowserSuite} from "../../types"; + +export default { + id: "compatibility", + browsers: ["chromium", "firefox"], + profiles: [{ + id: "granted", + permissions: ["alarms", "tabs", "cookies"], + manifest: {chromium: {action: {}}, firefox: {action: {}}}, + scenarios: [contracts], + }], +} satisfies BrowserSuite; diff --git a/tests/browser/api/index.ts b/tests/browser/api/index.ts index 46c8764..e0b410a 100644 --- a/tests/browser/api/index.ts +++ b/tests/browser/api/index.ts @@ -1,4 +1,5 @@ import bookmarks from "./bookmarks"; +import compatibility from "./compatibility"; import dnr from "./declarative-net-request"; import identity from "./identity"; import search from "./search"; @@ -8,5 +9,5 @@ import topSites from "./top-sites"; import type {BrowserSuite} from "../types"; -const suites: BrowserSuite[] = [bookmarks, dnr, identity, search, sidebar, sidePanel, tabGroups, topSites]; +const suites: BrowserSuite[] = [bookmarks, compatibility, dnr, identity, search, sidebar, sidePanel, tabGroups, topSites]; export default suites;