From 32383c3d34212f2a5e13101444875d8ef3f7892d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:56:22 +0300 Subject: [PATCH 01/11] feat(testing): add framework-agnostic browser harness --- package.json | 7 +- scripts/verify-build.mjs | 137 +++ src/testing/browser-state.ts | 134 +++ src/testing/configurable.test.ts | 104 +++ src/testing/configurable.ts | 710 ++++++++++++++++ src/testing/coverage.test.ts | 209 +++++ src/testing/coverage.ts | 922 +++++++++++++++++++++ src/testing/event.test.ts | 134 +++ src/testing/event.ts | 113 +++ src/testing/fixtures.test.ts | 112 +++ src/testing/fixtures.ts | 134 +++ src/testing/globals.integration.test.ts | 276 ++++++ src/testing/globals.ts | 238 ++++++ src/testing/harness.test.ts | 69 ++ src/testing/harness.ts | 350 ++++++++ src/testing/index.ts | 78 ++ src/testing/internal.ts | 88 ++ src/testing/listener-errors.ts | 54 ++ src/testing/method.test.ts | 257 ++++++ src/testing/method.ts | 281 +++++++ src/testing/permissions.ts | 180 ++++ src/testing/production.integration.test.ts | 75 ++ src/testing/runtime.messaging.test.ts | 201 +++++ src/testing/runtime.ts | 522 ++++++++++++ src/testing/scripting.ts | 180 ++++ src/testing/stateful.integration.test.ts | 281 +++++++ src/testing/tabs.ts | 573 +++++++++++++ src/testing/types.ts | 182 ++++ src/testing/windows.ts | 328 ++++++++ tsup.config.ts | 27 +- 30 files changed, 6954 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-build.mjs create mode 100644 src/testing/browser-state.ts create mode 100644 src/testing/configurable.test.ts create mode 100644 src/testing/configurable.ts create mode 100644 src/testing/coverage.test.ts create mode 100644 src/testing/coverage.ts create mode 100644 src/testing/event.test.ts create mode 100644 src/testing/event.ts create mode 100644 src/testing/fixtures.test.ts create mode 100644 src/testing/fixtures.ts create mode 100644 src/testing/globals.integration.test.ts create mode 100644 src/testing/globals.ts create mode 100644 src/testing/harness.test.ts create mode 100644 src/testing/harness.ts create mode 100644 src/testing/index.ts create mode 100644 src/testing/internal.ts create mode 100644 src/testing/listener-errors.ts create mode 100644 src/testing/method.test.ts create mode 100644 src/testing/method.ts create mode 100644 src/testing/permissions.ts create mode 100644 src/testing/production.integration.test.ts create mode 100644 src/testing/runtime.messaging.test.ts create mode 100644 src/testing/runtime.ts create mode 100644 src/testing/scripting.ts create mode 100644 src/testing/stateful.integration.test.ts create mode 100644 src/testing/tabs.ts create mode 100644 src/testing/types.ts create mode 100644 src/testing/windows.ts diff --git a/package.json b/package.json index e8c3b1b..879ab8c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,11 @@ "types": "./dist/utils.d.ts", "import": "./dist/utils.js", "require": "./dist/utils.cjs" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js", + "require": "./dist/testing/index.cjs" } }, "files": [ @@ -53,7 +58,7 @@ }, "scripts": { "prepare": "husky", - "build": "tsup && node ./scripts/copy-api-types.mjs", + "build": "tsup && node ./scripts/copy-api-types.mjs && node ./scripts/verify-build.mjs", "prepublishOnly": "npm run build", "dev": "tsup --watch", "lint": "biome check .", diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs new file mode 100644 index 0000000..c564168 --- /dev/null +++ b/scripts/verify-build.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import {existsSync, readdirSync, readFileSync} from "node:fs"; +import {createRequire} from "node:module"; +import {dirname, resolve} from "node:path"; +import {fileURLToPath} from "node:url"; +import ts from "typescript"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const sourceEntry = resolve(projectRoot, "src/index.ts"); +const declarationEntry = resolve(projectRoot, "dist/index.d.ts"); +const testingSourceDirectory = resolve(projectRoot, "src/testing"); + +const getModuleExports = (file, compilerOptions) => { + const program = ts.createProgram([file], compilerOptions); + const sourceFile = program.getSourceFile(file); + + assert.ok(sourceFile, `Unable to load ${file}`); + + const checker = program.getTypeChecker(); + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + + assert.ok(moduleSymbol, `Unable to resolve module symbol for ${file}`); + + return checker.getExportsOfModule(moduleSymbol).map(symbol => { + const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + + return { + hasValue: (resolved.flags & ts.SymbolFlags.Value) !== 0, + name: symbol.getName(), + }; + }); +}; + +const sortNames = values => values.map(value => value.name).sort(); +const sourceExports = getModuleExports(sourceEntry, { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + types: ["chrome"], +}); +const declarationExports = getModuleExports(declarationEntry, { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + types: ["chrome"], +}); + +assert.equal(sourceExports.length, 331, "The source public-export baseline changed; update the coverage matrix first"); +assert.equal( + sourceExports.filter(value => value.hasValue).length, + 328, + "The source runtime-export baseline changed; update the coverage matrix first" +); +assert.deepEqual(sortNames(declarationExports), sortNames(sourceExports), "Source and declaration exports differ"); + +const expectedTypeOnly = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"]; +assert.deepEqual( + sourceExports + .filter(value => !value.hasValue) + .map(value => value.name) + .sort(), + expectedTypeOnly.slice().sort(), + "The type-only public-export allowlist changed" +); + +const esm = await import(`${new URL("../dist/index.js", import.meta.url).href}?verify=${Date.now()}`); +const require = createRequire(import.meta.url); +const cjs = require(resolve(projectRoot, "dist/index.cjs")); +const sourceValueNames = sourceExports + .filter(value => value.hasValue) + .map(value => value.name) + .sort(); + +assert.deepEqual(Object.keys(esm).sort(), sourceValueNames, "ESM runtime exports differ from source value exports"); +assert.deepEqual(Object.keys(cjs).sort(), sourceValueNames, "CJS runtime exports differ from source value exports"); + +const sourceIndex = readFileSync(sourceEntry, "utf8"); +const esmIndex = readFileSync(resolve(projectRoot, "dist/index.js"), "utf8"); +const cjsIndex = readFileSync(resolve(projectRoot, "dist/index.cjs"), "utf8"); +const esmMap = JSON.parse(readFileSync(resolve(projectRoot, "dist/index.js.map"), "utf8")); +const cjsMap = JSON.parse(readFileSync(resolve(projectRoot, "dist/index.cjs.map"), "utf8")); + +assert.doesNotMatch(sourceIndex, /(?:^|\/)testing(?:\/|")/m, "The production source entrypoint imports testing code"); +assert.doesNotMatch(esmIndex, /createBrowserHarness/, "The production ESM bundle contains testing code"); +assert.doesNotMatch(cjsIndex, /createBrowserHarness/, "The production CJS bundle contains testing code"); +assert.equal( + esmMap.sources.some(source => source.includes("/testing/")), + false, + "The production ESM source map contains testing modules" +); +assert.equal( + cjsMap.sources.some(source => source.includes("/testing/")), + false, + "The production CJS source map contains testing modules" +); + +for (const file of ["dist/testing/index.js", "dist/testing/index.cjs", "dist/testing/index.d.ts"]) { + readFileSync(resolve(projectRoot, file)); +} + +for (const file of ["dist/testing/index.js.map", "dist/testing/index.cjs.map"]) { + assert.equal(existsSync(resolve(projectRoot, file)), false, `${file} must not be published`); +} + +const testingDeclarations = readFileSync(resolve(projectRoot, "dist/testing/index.d.ts"), "utf8"); + +assert.match(testingDeclarations, /^\/\/\/ /); +assert.match(testingDeclarations, /^\/\/\/ /m); +assert.equal( + existsSync(resolve(projectRoot, "dist/testing/index.d.ts.map")), + false, + "Testing declaration maps are not published" +); + +const listRuntimeSources = directory => + readdirSync(directory, {withFileTypes: true}).flatMap(entry => { + const file = resolve(directory, entry.name); + + if (entry.isDirectory()) return listRuntimeSources(file); + if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) return []; + + return [{file, source: readFileSync(file, "utf8")}]; + }); + +const testingRuntimeSources = listRuntimeSources(testingSourceDirectory); + +for (const {file, source} of testingRuntimeSources) { + assert.doesNotMatch( + source, + /(?:from\s+["'](?:@jest\/globals|jest|@rstest\/[^"']*|vitest|sinon)["']|\b(?:jest|rs|vi|expect)\s*\.)/, + `Testing runtime source ${file} depends on a test runner` + ); +} + +console.log("Verified 331 TypeScript exports, 328 ESM/CJS runtime exports, and isolated testing bundles."); diff --git a/src/testing/browser-state.ts b/src/testing/browser-state.ts new file mode 100644 index 0000000..0595bc7 --- /dev/null +++ b/src/testing/browser-state.ts @@ -0,0 +1,134 @@ +import {createWindowFixture} from "./fixtures"; +import {cloneRecord} from "./internal"; + +export interface BrowserMemoryStateOptions { + tabs?: readonly chrome.tabs.Tab[]; + windows?: readonly chrome.windows.Window[]; +} + +export interface BrowserMemoryState { + readonly tabs: Map; + readonly windows: Map; + readonly lastFocusedWindowId: number | undefined; + reset(): void; + nextTabId(): number; + nextWindowId(): number; + currentWindowId(): number | undefined; + setLastFocusedWindow(id: number | undefined): void; + cloneTab(tab: chrome.tabs.Tab): chrome.tabs.Tab; + cloneWindow(window: chrome.windows.Window, populate?: boolean): chrome.windows.Window; + reindexTabs(windowId: number): void; + ensureWindow(windowId?: number): chrome.windows.Window; +} + +export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): BrowserMemoryState => { + const initialWindows = (options.windows ?? []).map(window => cloneRecord(window)); + const nestedTabs = initialWindows.flatMap(window => + typeof window.id === "number" + ? (window.tabs ?? []).map(tab => cloneRecord({...tab, windowId: window.id as number})) + : [] + ); + const initialTabs = [...nestedTabs, ...(options.tabs ?? [])].map(tab => cloneRecord(tab)); + let tabs = new Map(); + let windows = new Map(); + let tabCounter = 0; + let windowCounter = 0; + let lastFocusedWindowId: number | undefined; + + const reset = (): void => { + tabs = new Map(); + windows = new Map(); + + for (const window of initialWindows) { + if (typeof window.id !== "number") continue; + const copy = cloneRecord(window); + delete copy.tabs; + windows.set(window.id, copy); + } + for (const tab of initialTabs) { + if (typeof tab.id !== "number") continue; + tabs.set(tab.id, cloneRecord(tab)); + if (!windows.has(tab.windowId)) { + windows.set(tab.windowId, createWindowFixture({focused: false, id: tab.windowId, tabs: undefined})); + } + } + + tabCounter = Math.max(0, ...tabs.keys()); + windowCounter = Math.max(0, ...windows.keys()); + lastFocusedWindowId = [...windows.values()].find(window => window.focused)?.id ?? [...windows.keys()][0]; + }; + + const state: BrowserMemoryState = { + get tabs() { + return tabs; + }, + get windows() { + return windows; + }, + get lastFocusedWindowId() { + return lastFocusedWindowId; + }, + cloneTab(tab) { + return cloneRecord(tab); + }, + cloneWindow(window, populate = false) { + const copy = cloneRecord(window); + if (populate && typeof copy.id === "number") { + copy.tabs = [...tabs.values()] + .filter(tab => tab.windowId === copy.id) + .sort((left, right) => left.index - right.index) + .map(tab => cloneRecord(tab)); + } else { + delete copy.tabs; + } + return copy; + }, + currentWindowId() { + return ( + [...windows.values()].find(window => window.focused)?.id ?? + lastFocusedWindowId ?? + [...windows.keys()][0] + ); + }, + ensureWindow(windowId) { + const requestedId = windowId ?? state.currentWindowId(); + if (typeof requestedId === "number") { + const existing = windows.get(requestedId); + if (existing) return existing; + } + + const id = typeof windowId === "number" ? windowId : state.nextWindowId(); + const window = createWindowFixture({focused: windows.size === 0, id, tabs: undefined}); + windows.set(id, window); + if (window.focused) lastFocusedWindowId = id; + return window; + }, + nextTabId() { + do { + tabCounter += 1; + } while (tabs.has(tabCounter)); + return tabCounter; + }, + nextWindowId() { + do { + windowCounter += 1; + } while (windows.has(windowCounter)); + return windowCounter; + }, + reindexTabs(windowId) { + [...tabs.values()] + .filter(tab => tab.windowId === windowId) + .sort((left, right) => left.index - right.index) + .forEach((tab, index) => { + tab.index = index; + }); + }, + reset, + setLastFocusedWindow(id) { + lastFocusedWindowId = id; + }, + }; + + reset(); + return state; +}; diff --git a/src/testing/configurable.test.ts b/src/testing/configurable.test.ts new file mode 100644 index 0000000..0077527 --- /dev/null +++ b/src/testing/configurable.test.ts @@ -0,0 +1,104 @@ +import {createConfigurableNamespaces} from "./configurable"; +import {RAW_CAPABILITY_COVERAGE} from "./coverage"; +import {createLastErrorController} from "./internal"; + +describe("configurable browser namespaces", () => { + test("materializes every configurable method and raw event from the capability matrix", () => { + const configurable = createConfigurableNamespaces({facade: "chrome"}); + + for (const entry of RAW_CAPABILITY_COVERAGE) { + if (entry.coverage === "configurable" && entry.kind === "method") { + expect(configurable.method(entry.path)).toBeDefined(); + } + if (entry.kind === "event") { + expect(configurable.event(entry.path)).toBeDefined(); + } + } + }); + + test("exposes typed callback controls and records callback calls", async () => { + const configurable = createConfigurableNamespaces({facade: "chrome"}); + const item = {id: 7} as chrome.downloads.DownloadItem; + + configurable.controls.downloads.search.setResult([item]); + + const result = await new Promise(resolve => { + configurable.api.downloads.search({id: 7}, resolve); + }); + + expect(result).toEqual([item]); + expect(configurable.controls.downloads.search.calls).toMatchObject([ + { + args: [{id: 7}], + callbackCalls: [[[item]]], + invocation: "callback", + }, + ]); + expect(configurable.calls.map(call => call.api)).toEqual(["downloads.search"]); + }); + + test("uses dual Promise behavior for browser facades", async () => { + const configurable = createConfigurableNamespaces({facade: "browser"}); + const alarm: chrome.alarms.Alarm = { + name: "deterministic-alarm", + persistAcrossSessions: false, + scheduledTime: 1, + }; + + configurable.controls.alarms.getAll.setResult([alarm]); + + await expect(configurable.api.alarms.getAll()).resolves.toEqual([alarm]); + expect(configurable.controls.alarms.getAll.calls[0]).toMatchObject({ + args: [], + callback: undefined, + invocation: "promise", + }); + }); + + test("uses runtime.lastError only while a failing callback runs", () => { + const lastError = createLastErrorController(); + const configurable = createConfigurableNamespaces({facade: "chrome", lastError}); + let observed: chrome.runtime.LastError | undefined; + + configurable.controls.alarms.getAll.failNext(new Error("alarms unavailable")); + configurable.api.alarms.getAll(() => { + observed = lastError.current; + }); + + expect(observed?.message).toBe("alarms unavailable"); + expect(lastError.current).toBeUndefined(); + }); + + test("supports filtered event registrations and manual emission", async () => { + const configurable = createConfigurableNamespaces({facade: "chrome"}); + const listener = jest.fn(); + const filter: chrome.webNavigation.WebNavigationEventFilter = {url: [{hostEquals: "example.test"}]}; + + configurable.api.webNavigation.onCommitted.addListener(listener, filter); + const details = {tabId: 3} as chrome.webNavigation.WebNavigationTransitionCallbackDetails; + await configurable.controls.webNavigation.onCommitted.emit(details); + + expect(listener).toHaveBeenCalledWith(details); + expect(configurable.controls.webNavigation.onCommitted.registrations()[0]?.args).toEqual([filter]); + }); + + test("physically removes and restores method capabilities", () => { + const configurable = createConfigurableNamespaces({facade: "chrome"}); + + configurable.setCapability("offscreen.hasDocument", false); + expect(configurable.hasCapability("offscreen.hasDocument")).toBe(false); + expect("hasDocument" in configurable.api.offscreen).toBe(false); + + configurable.reset(); + expect(configurable.hasCapability("offscreen.hasDocument")).toBe(true); + expect(typeof configurable.api.offscreen.hasDocument).toBe("function"); + }); + + test("does not silently answer an unconfigured method", () => { + const configurable = createConfigurableNamespaces({facade: "chrome"}); + + expect(() => configurable.api.extension.getViews()).toThrow( + 'Browser method "extension.getViews" was called without a configured result or implementation.' + ); + }); +}); diff --git a/src/testing/configurable.ts b/src/testing/configurable.ts new file mode 100644 index 0000000..196604e --- /dev/null +++ b/src/testing/configurable.ts @@ -0,0 +1,710 @@ +import {RAW_CAPABILITY_COVERAGE, type RawCapabilityEntry} from "./coverage"; +import {type BrowserEventHarness, createBrowserEvent} from "./event"; +import {type BrowserMethod, type BrowserMethodLastErrorController, createBrowserMethod} from "./method"; +import type {BrowserHarnessCall} from "./types"; + +type AnyFunction = (...args: never[]) => unknown; +type BrowserEventLike = { + addListener: AnyFunction; + removeListener: AnyFunction; + hasListener: AnyFunction; +}; + +type MethodKeys = { + [TKey in keyof TApi]-?: TApi[TKey] extends AnyFunction ? TKey : never; +}[keyof TApi]; + +type EventKeys = { + [TKey in keyof TApi]-?: TApi[TKey] extends BrowserEventLike ? TKey : never; +}[keyof TApi]; + +type Last = TValues extends readonly [...infer _, infer TValue] ? TValue : never; +type CallbackArguments = [Last>] extends [never] + ? never + : Last> extends (...args: infer TArgs) => unknown + ? TArgs + : never; + +type ResultFromCallback = TArgs extends readonly [] + ? undefined + : TArgs extends readonly [infer TResult] + ? TResult + : TArgs extends readonly [(infer TResult)?] + ? TResult | undefined + : TArgs; + +export type BrowserMethodResult = + Awaited> extends void + ? [CallbackArguments] extends [never] + ? undefined + : ResultFromCallback> + : Awaited>; + +type MethodControls = { + readonly [TKey in MethodKeys]: BrowserMethod< + Extract unknown>, + BrowserMethodResult> + >; +}; + +type ListenerArguments = Parameters[0] extends ( + ...args: infer TArgs +) => unknown + ? TArgs + : readonly unknown[]; + +type RegistrationArguments = + Parameters extends readonly [unknown, ...infer TArgs] ? TArgs : readonly unknown[]; + +type EventControls = { + readonly [TKey in EventKeys]: BrowserEventHarness< + ListenerArguments>, + RegistrationArguments> + >; +}; + +export type BrowserNamespaceHarness = { + readonly api: TApi; + reset(): void; +} & MethodControls & + EventControls; + +export type ActionConfigurableApi = Pick< + typeof chrome.action, + | "disable" + | "enable" + | "getBadgeBackgroundColor" + | "getBadgeText" + | "getBadgeTextColor" + | "getPopup" + | "getTitle" + | "getUserSettings" + | "isEnabled" + | "onClicked" + | "onUserSettingsChanged" + | "openPopup" + | "setBadgeBackgroundColor" + | "setBadgeText" + | "setBadgeTextColor" + | "setIcon" + | "setPopup" + | "setTitle" +>; + +export type BrowserActionConfigurableApi = Pick< + typeof chrome.browserAction, + | "disable" + | "enable" + | "getBadgeBackgroundColor" + | "getBadgeText" + | "getPopup" + | "getTitle" + | "onClicked" + | "setBadgeBackgroundColor" + | "setBadgeText" + | "setIcon" + | "setPopup" + | "setTitle" +>; + +export type AlarmsConfigurableApi = Pick< + typeof chrome.alarms, + "clear" | "clearAll" | "create" | "get" | "getAll" | "onAlarm" +>; +export type AudioConfigurableApi = Pick< + typeof chrome.audio, + | "getDevices" + | "getMute" + | "onDeviceListChanged" + | "onLevelChanged" + | "onMuteChanged" + | "setActiveDevices" + | "setMute" + | "setProperties" +>; +export type BrowsingDataConfigurableApi = Pick< + typeof chrome.browsingData, + | "remove" + | "removeAppcache" + | "removeCache" + | "removeCacheStorage" + | "removeCookies" + | "removeDownloads" + | "removeFileSystems" + | "removeFormData" + | "removeHistory" + | "removeIndexedDB" + | "removeLocalStorage" + | "removePasswords" + | "removeServiceWorkers" + | "removeWebSQL" + | "settings" +>; +export type CommandsConfigurableApi = Pick; +export type ContextMenusConfigurableApi = Pick< + typeof chrome.contextMenus, + "create" | "onClicked" | "remove" | "removeAll" | "update" +>; +export type CookiesConfigurableApi = Pick< + typeof chrome.cookies, + "get" | "getAll" | "getAllCookieStores" | "getPartitionKey" | "onChanged" | "remove" | "set" +>; +export type DocumentScanConfigurableApi = Pick< + typeof chrome.documentScan, + | "cancelScan" + | "closeScanner" + | "getOptionGroups" + | "getScannerList" + | "openScanner" + | "readScanData" + | "scan" + | "setOptions" + | "startScan" +>; +export type DownloadsConfigurableApi = Pick< + typeof chrome.downloads, + | "acceptDanger" + | "cancel" + | "download" + | "erase" + | "getFileIcon" + | "onChanged" + | "onCreated" + | "onDeterminingFilename" + | "open" + | "pause" + | "removeFile" + | "resume" + | "search" + | "setUiOptions" + | "show" + | "showDefaultFolder" +>; +export type ExtensionConfigurableApi = Pick< + typeof chrome.extension, + "getBackgroundPage" | "getViews" | "isAllowedFileSchemeAccess" | "isAllowedIncognitoAccess" | "setUpdateUrlData" +>; +export type HistoryConfigurableApi = Pick< + typeof chrome.history, + "addUrl" | "deleteAll" | "deleteRange" | "deleteUrl" | "getVisits" | "onVisited" | "onVisitRemoved" | "search" +>; +export type I18nConfigurableApi = Pick< + typeof chrome.i18n, + "detectLanguage" | "getAcceptLanguages" | "getMessage" | "getUILanguage" +>; +export type IdentityConfigurableApi = Pick< + typeof chrome.identity, + | "clearAllCachedAuthTokens" + | "getAccounts" + | "getAuthToken" + | "getProfileUserInfo" + | "getRedirectURL" + | "launchWebAuthFlow" + | "onSignInChanged" + | "removeCachedAuthToken" +>; +export type IdleConfigurableApi = Pick< + typeof chrome.idle, + "getAutoLockDelay" | "onStateChanged" | "queryState" | "setDetectionInterval" +>; +export type ManagementConfigurableApi = Pick< + typeof chrome.management, + | "createAppShortcut" + | "generateAppForLink" + | "get" + | "getAll" + | "getPermissionWarningsById" + | "getPermissionWarningsByManifest" + | "getSelf" + | "launchApp" + | "onDisabled" + | "onEnabled" + | "onInstalled" + | "onUninstalled" + | "setEnabled" + | "setLaunchType" + | "uninstall" + | "uninstallSelf" +>; +export type NotificationsConfigurableApi = Pick< + typeof chrome.notifications, + | "clear" + | "create" + | "getAll" + | "getPermissionLevel" + | "onButtonClicked" + | "onClicked" + | "onClosed" + | "onPermissionLevelChanged" + | "update" +>; +export type OffscreenConfigurableApi = Pick< + typeof chrome.offscreen, + "closeDocument" | "createDocument" | "hasDocument" +>; +export type PermissionsConfigurableApi = Pick< + typeof chrome.permissions, + "addHostAccessRequest" | "onAdded" | "onRemoved" | "removeHostAccessRequest" +>; +export type RuntimeConfigurableApi = Pick< + typeof chrome.runtime, + | "connect" + | "connectNative" + | "getPackageDirectoryEntry" + | "getPlatformInfo" + | "onConnect" + | "onConnectExternal" + | "onInstalled" + | "onMessage" + | "onMessageExternal" + | "onRestartRequired" + | "onStartup" + | "onSuspend" + | "onSuspendCanceled" + | "onUpdateAvailable" + | "onUserScriptConnect" + | "onUserScriptMessage" + | "openOptionsPage" + | "reload" + | "requestUpdateCheck" + | "restart" + | "restartAfterDelay" + | "setUninstallURL" +>; +export type ScriptingConfigurableApi = Pick; +export type SidePanelConfigurableApi = Pick< + typeof chrome.sidePanel, + "close" | "getOptions" | "getPanelBehavior" | "open" | "setOptions" | "setPanelBehavior" +>; +export type TabCaptureConfigurableApi = Pick< + typeof chrome.tabCapture, + "capture" | "getCapturedTabs" | "getMediaStreamId" | "onStatusChanged" +>; +export type TabsConfigurableApi = Pick< + typeof chrome.tabs, + | "captureVisibleTab" + | "connect" + | "detectLanguage" + | "discard" + | "duplicate" + | "executeScript" + | "getCurrent" + | "getZoom" + | "getZoomSettings" + | "goBack" + | "goForward" + | "group" + | "highlight" + | "insertCSS" + | "move" + | "onActivated" + | "onAttached" + | "onCreated" + | "onDetached" + | "onHighlighted" + | "onMoved" + | "onRemoved" + | "onReplaced" + | "onUpdated" + | "onZoomChange" + | "reload" + | "removeCSS" + | "setZoom" + | "setZoomSettings" + | "ungroup" +>; +export type UserScriptsConfigurableApi = Pick< + typeof chrome.userScripts, + | "configureWorld" + | "execute" + | "getScripts" + | "getWorldConfigurations" + | "register" + | "resetWorldConfiguration" + | "unregister" + | "update" +>; +export type WebNavigationConfigurableApi = Pick< + typeof chrome.webNavigation, + | "getAllFrames" + | "getFrame" + | "onBeforeNavigate" + | "onCommitted" + | "onCompleted" + | "onCreatedNavigationTarget" + | "onDOMContentLoaded" + | "onErrorOccurred" + | "onHistoryStateUpdated" + | "onReferenceFragmentUpdated" + | "onTabReplaced" +>; +export type WebRequestConfigurableApi = Pick< + typeof chrome.webRequest, + | "handlerBehaviorChanged" + | "onAuthRequired" + | "onBeforeRedirect" + | "onBeforeRequest" + | "onBeforeSendHeaders" + | "onCompleted" + | "onErrorOccurred" + | "onHeadersReceived" + | "onResponseStarted" + | "onSendHeaders" +>; +export type WindowsEventsConfigurableApi = Pick< + typeof chrome.windows, + "onBoundsChanged" | "onCreated" | "onFocusChanged" | "onRemoved" +>; +export type FirefoxSidebarActionConfigurableApi = Pick< + typeof browser.sidebarAction, + "close" | "getPanel" | "getTitle" | "isOpen" | "open" | "setIcon" | "setPanel" | "setTitle" | "toggle" +>; +export type OperaSidebarActionConfigurableApi = Pick< + typeof opr.sidebarAction, + | "getBadgeBackgroundColor" + | "getBadgeText" + | "getBadgeTextColor" + | "getPanel" + | "getTitle" + | "setBadgeBackgroundColor" + | "setBadgeText" + | "setBadgeTextColor" + | "setIcon" + | "setPanel" + | "setTitle" +>; + +export interface ConfigurableBrowserApi { + action: ActionConfigurableApi; + alarms: AlarmsConfigurableApi; + audio: AudioConfigurableApi; + browserAction: BrowserActionConfigurableApi; + browsingData: BrowsingDataConfigurableApi; + commands: CommandsConfigurableApi; + contextMenus: ContextMenusConfigurableApi; + cookies: CookiesConfigurableApi; + documentScan: DocumentScanConfigurableApi; + downloads: DownloadsConfigurableApi; + extension: ExtensionConfigurableApi; + history: HistoryConfigurableApi; + i18n: I18nConfigurableApi; + identity: IdentityConfigurableApi; + idle: IdleConfigurableApi; + management: ManagementConfigurableApi; + notifications: NotificationsConfigurableApi; + offscreen: OffscreenConfigurableApi; + permissions: PermissionsConfigurableApi; + runtime: RuntimeConfigurableApi; + scripting: ScriptingConfigurableApi; + sidePanel: SidePanelConfigurableApi; + tabCapture: TabCaptureConfigurableApi; + tabs: TabsConfigurableApi; + userScripts: UserScriptsConfigurableApi; + webNavigation: WebNavigationConfigurableApi; + webRequest: WebRequestConfigurableApi; + windows: WindowsEventsConfigurableApi; +} + +export interface ConfigurableBrowserControls { + readonly action: BrowserNamespaceHarness; + readonly alarms: BrowserNamespaceHarness; + readonly audio: BrowserNamespaceHarness; + readonly browserAction: BrowserNamespaceHarness; + readonly browsingData: BrowserNamespaceHarness; + readonly commands: BrowserNamespaceHarness; + readonly contextMenus: BrowserNamespaceHarness; + readonly cookies: BrowserNamespaceHarness; + readonly documentScan: BrowserNamespaceHarness; + readonly downloads: BrowserNamespaceHarness; + readonly extension: BrowserNamespaceHarness; + readonly history: BrowserNamespaceHarness; + readonly i18n: BrowserNamespaceHarness; + readonly identity: BrowserNamespaceHarness; + readonly idle: BrowserNamespaceHarness; + readonly management: BrowserNamespaceHarness; + readonly notifications: BrowserNamespaceHarness; + readonly offscreen: BrowserNamespaceHarness; + readonly permissions: BrowserNamespaceHarness; + readonly runtime: BrowserNamespaceHarness; + readonly scripting: BrowserNamespaceHarness; + readonly sidePanel: BrowserNamespaceHarness; + readonly tabCapture: BrowserNamespaceHarness; + readonly tabs: BrowserNamespaceHarness; + readonly userScripts: BrowserNamespaceHarness; + readonly webNavigation: BrowserNamespaceHarness; + readonly webRequest: BrowserNamespaceHarness; + readonly windows: BrowserNamespaceHarness; + readonly sidebarAction: BrowserNamespaceHarness; + readonly operaSidebarAction: BrowserNamespaceHarness; +} + +export interface ConfigurableNamespacesOptions { + readonly facade: "chrome" | "browser"; + readonly lastError?: BrowserMethodLastErrorController; + readonly nextSequence?: () => number; +} + +export interface ConfigurableNamespaces { + readonly api: ConfigurableBrowserApi; + readonly controls: ConfigurableBrowserControls; + readonly sidebarActionApi: FirefoxSidebarActionConfigurableApi; + readonly operaSidebarActionApi: OperaSidebarActionConfigurableApi; + readonly calls: readonly BrowserHarnessCall[]; + setCapability(path: string, enabled: boolean): void; + hasCapability(path: string): boolean; + method unknown, TResult = BrowserMethodResult>( + path: string + ): BrowserMethod; + event( + path: string + ): BrowserEventHarness; + reset(): void; +} + +const NO_RESULT_METHODS = new Set([ + "action.disable", + "action.enable", + "action.openPopup", + "action.setBadgeBackgroundColor", + "action.setBadgeText", + "action.setBadgeTextColor", + "action.setIcon", + "action.setPopup", + "action.setTitle", + "alarms.create", + "audio.setActiveDevices", + "audio.setMute", + "audio.setProperties", + "browserAction.disable", + "browserAction.enable", + "browserAction.setBadgeBackgroundColor", + "browserAction.setBadgeText", + "browserAction.setIcon", + "browserAction.setPopup", + "browserAction.setTitle", + "browsingData.remove", + "browsingData.removeAppcache", + "browsingData.removeCache", + "browsingData.removeCacheStorage", + "browsingData.removeCookies", + "browsingData.removeDownloads", + "browsingData.removeFileSystems", + "browsingData.removeFormData", + "browsingData.removeHistory", + "browsingData.removeIndexedDB", + "browsingData.removeLocalStorage", + "browsingData.removePasswords", + "browsingData.removeServiceWorkers", + "browsingData.removeWebSQL", + "contextMenus.create", + "contextMenus.remove", + "contextMenus.removeAll", + "contextMenus.update", + "downloads.acceptDanger", + "downloads.cancel", + "downloads.open", + "downloads.pause", + "downloads.removeFile", + "downloads.resume", + "downloads.setUiOptions", + "extension.setUpdateUrlData", + "history.addUrl", + "history.deleteAll", + "history.deleteRange", + "history.deleteUrl", + "idle.setDetectionInterval", + "identity.clearAllCachedAuthTokens", + "identity.removeCachedAuthToken", + "management.createAppShortcut", + "management.generateAppForLink", + "management.launchApp", + "management.setEnabled", + "management.setLaunchType", + "management.uninstall", + "management.uninstallSelf", + "offscreen.closeDocument", + "offscreen.createDocument", + "permissions.addHostAccessRequest", + "permissions.removeHostAccessRequest", + "runtime.openOptionsPage", + "runtime.reload", + "runtime.restart", + "runtime.restartAfterDelay", + "runtime.setUninstallURL", + "scripting.insertCSS", + "scripting.removeCSS", + "sidePanel.close", + "sidePanel.open", + "sidePanel.setOptions", + "sidePanel.setPanelBehavior", + "tabs.goBack", + "tabs.goForward", + "tabs.insertCSS", + "tabs.reload", + "tabs.removeCSS", + "tabs.setZoom", + "tabs.setZoomSettings", + "tabs.ungroup", + "userScripts.configureWorld", + "userScripts.register", + "userScripts.resetWorldConfiguration", + "userScripts.unregister", + "userScripts.update", + "webRequest.handlerBehaviorChanged", + "browser.sidebarAction.close", + "browser.sidebarAction.open", + "browser.sidebarAction.setIcon", + "browser.sidebarAction.setPanel", + "browser.sidebarAction.setTitle", + "browser.sidebarAction.toggle", + "opr.sidebarAction.setBadgeBackgroundColor", + "opr.sidebarAction.setBadgeText", + "opr.sidebarAction.setBadgeTextColor", + "opr.sidebarAction.setIcon", + "opr.sidebarAction.setPanel", + "opr.sidebarAction.setTitle", +]); + +const MULTI_RESULT_METHODS = new Set(["runtime.requestUpdateCheck"]); + +const isConfigurableMember = (entry: RawCapabilityEntry): boolean => + entry.coverage === "configurable" || entry.kind === "event"; + +const namespaceControl = (controls: Record, api: object): Record => ({ + ...controls, + api, + reset(): void { + for (const control of Object.values(controls)) { + (control as {reset(): void}).reset(); + } + }, +}); + +/** Creates all non-stateful raw namespaces used by the production entrypoint. */ +export const createConfigurableNamespaces = (options: ConfigurableNamespacesOptions): ConfigurableNamespaces => { + const apiNamespaces: Record> = {}; + const namespaceControls: Record> = {}; + const methods = new Map unknown, unknown>>(); + const events = new Map>(); + const enabled = new Set(); + + const entries = RAW_CAPABILITY_COVERAGE.filter(isConfigurableMember); + + for (const entry of entries) { + const isFirefoxSidebar = entry.namespace === "browser.sidebarAction"; + const isOperaSidebar = entry.namespace === "opr.sidebarAction"; + const namespace = isFirefoxSidebar ? "sidebarAction" : isOperaSidebar ? "operaSidebarAction" : entry.namespace; + apiNamespaces[namespace] ??= {}; + namespaceControls[namespace] ??= {}; + const namespaceApi = apiNamespaces[namespace]; + const controls = namespaceControls[namespace]; + + if (entry.kind === "event") { + const event = createBrowserEvent(); + events.set(entry.path, event); + controls[entry.member] = event; + namespaceApi[entry.member] = event.api; + enabled.add(entry.path); + continue; + } + + if (entry.kind !== "method") continue; + + const invocation = options.facade === "chrome" ? entry.chromeInvocation : entry.browserInvocation; + + if (!invocation) { + throw new Error(`Browser method "${entry.path}" has no ${options.facade} invocation contract.`); + } + + const callbackArgs = NO_RESULT_METHODS.has(entry.path) + ? () => [] + : MULTI_RESULT_METHODS.has(entry.path) + ? (result: unknown) => result as readonly unknown[] + : (result: unknown) => [result]; + const method = createBrowserMethod<(...args: never[]) => unknown, unknown>({ + callback: "last", + callbackArgs, + invocation, + lastError: options.lastError, + name: entry.path, + nextSequence: options.nextSequence, + }); + + methods.set(entry.path, method); + controls[entry.member] = method; + namespaceApi[entry.member] = method.api; + enabled.add(entry.path); + } + + const controls = Object.fromEntries( + Object.entries(namespaceControls).map(([namespace, members]) => [ + namespace, + namespaceControl(members, apiNamespaces[namespace]), + ]) + ) as unknown as ConfigurableBrowserControls; + const api = Object.fromEntries( + Object.entries(apiNamespaces).filter( + ([namespace]) => namespace !== "sidebarAction" && namespace !== "operaSidebarAction" + ) + ) as unknown as ConfigurableBrowserApi; + + const setCapability = (path: string, isEnabled: boolean): void => { + const entry = entries.find(candidate => candidate.path === path); + + if (!entry) { + throw new Error(`Unknown configurable browser capability "${path}".`); + } + + const namespace = + entry.namespace === "browser.sidebarAction" + ? "sidebarAction" + : entry.namespace === "opr.sidebarAction" + ? "operaSidebarAction" + : entry.namespace; + const namespaceApi = apiNamespaces[namespace]; + const control = namespaceControls[namespace][entry.member] as {api: unknown}; + + if (isEnabled) { + namespaceApi[entry.member] = control.api; + enabled.add(path); + } else { + Reflect.deleteProperty(namespaceApi, entry.member); + enabled.delete(path); + } + }; + + return { + api, + controls, + get calls(): readonly BrowserHarnessCall[] { + return [...methods.entries()] + .flatMap(([path, method]) => method.calls.map(call => ({...call, api: path}))) + .sort((left, right) => left.sequence - right.sequence); + }, + event( + path: string + ): BrowserEventHarness { + const event = events.get(path); + if (!event) throw new Error(`Unknown configurable browser event "${path}".`); + return event as unknown as BrowserEventHarness; + }, + hasCapability(path): boolean { + return enabled.has(path); + }, + method unknown, TResult = BrowserMethodResult>( + path: string + ): BrowserMethod { + const method = methods.get(path); + if (!method) throw new Error(`Unknown configurable browser method "${path}".`); + return method as BrowserMethod; + }, + operaSidebarActionApi: apiNamespaces.operaSidebarAction as unknown as OperaSidebarActionConfigurableApi, + reset(): void { + for (const method of methods.values()) method.reset(); + for (const event of events.values()) event.reset(); + for (const entry of entries) setCapability(entry.path, true); + }, + setCapability, + sidebarActionApi: apiNamespaces.sidebarAction as unknown as FirefoxSidebarActionConfigurableApi, + }; +}; diff --git a/src/testing/coverage.test.ts b/src/testing/coverage.test.ts new file mode 100644 index 0000000..1dfe531 --- /dev/null +++ b/src/testing/coverage.test.ts @@ -0,0 +1,209 @@ +import ts from "typescript"; +import { + EXPECTED_ROOT_RUNTIME_EXPORT_COUNT, + EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT, + PUBLIC_EXPORT_COVERAGE, + RAW_CAPABILITY_COVERAGE, + TYPE_ONLY_ROOT_EXPORTS, +} from "./coverage"; +import {createBrowserHarness} from "./harness"; +import type {RawCapabilityEntry} from "./coverage"; +import type {BrowserMethod} from "./method"; + +type Harness = ReturnType; +type UnknownRecord = Record; +type AnyBrowserMethod = BrowserMethod<(...args: never[]) => unknown, unknown>; + +const asRecord = (value: unknown): UnknownRecord | undefined => + value !== null && typeof value === "object" ? (value as UnknownRecord) : undefined; + +const memberOf = (value: unknown, member: string): unknown => asRecord(value)?.[member]; + +const directMethodNamespace = (harness: Harness, namespace: string): unknown => { + switch (namespace) { + case "runtime": + return harness.runtime; + case "permissions": + return harness.permissions; + case "tabs": + return harness.tabs; + case "windows": + return harness.windows; + case "scripting": + return harness.scripting; + default: + return undefined; + } +}; + +const directEventNamespace = (harness: Harness, namespace: string): unknown => { + switch (namespace) { + case "runtime": + return harness.runtime.events; + case "permissions": + return harness.permissions; + case "tabs": + return harness.tabs.events; + case "windows": + return harness.windows.events; + default: + return undefined; + } +}; + +const configurableNamespaces = (harness: Harness, namespace: string): readonly unknown[] => { + if (namespace === "browser.sidebarAction") return [harness.sidebar.firefox]; + if (namespace === "opr.sidebarAction") return [harness.sidebar.opera]; + return [memberOf(harness.configurable.chrome, namespace), memberOf(harness.configurable.browser, namespace)]; +}; + +/** Resolves the control that actually owns a raw facade member, independent of its declared coverage. */ +const resolveRawCapability = (harness: Harness, entry: RawCapabilityEntry): readonly unknown[] => { + if (entry.kind === "property") { + return [harness.chrome, harness.browser].map(facade => { + const namespace = memberOf(facade, entry.namespace); + const record = asRecord(namespace); + return record ? Object.getOwnPropertyDescriptor(record, entry.member) : undefined; + }); + } + + const directNamespace = + entry.kind === "method" + ? directMethodNamespace(harness, entry.namespace) + : directEventNamespace(harness, entry.namespace); + const directControl = memberOf(directNamespace, entry.member); + if (directControl !== undefined) return [directControl]; + + return configurableNamespaces(harness, entry.namespace).map(namespace => memberOf(namespace, entry.member)); +}; + +const isBrowserMethodControl = (value: unknown): value is AnyBrowserMethod => { + const record = asRecord(value); + return ( + record !== undefined && + typeof record.api === "function" && + Array.isArray(record.calls) && + typeof record.hasDefaultImplementation === "boolean" && + typeof record.reset === "function" + ); +}; + +const isBrowserEventControl = (value: unknown): boolean => { + const record = asRecord(value); + const api = asRecord(record?.api); + return ( + record !== undefined && + api !== undefined && + typeof api.addListener === "function" && + typeof api.removeListener === "function" && + typeof api.hasListener === "function" && + typeof record.emit === "function" && + typeof record.reset === "function" + ); +}; + +const isPropertyDescriptor = (value: unknown): boolean => { + const record = asRecord(value); + return record !== undefined && ("value" in record || "get" in record); +}; + +const isValidResolution = (entry: RawCapabilityEntry, control: unknown): boolean => { + if (entry.kind === "method") return isBrowserMethodControl(control); + if (entry.kind === "event") return isBrowserEventControl(control); + return isPropertyDescriptor(control); +}; + +const rootExports = () => { + const program = ts.createProgram(["src/index.ts"], { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + target: ts.ScriptTarget.ESNext, + types: ["chrome"], + }); + const checker = program.getTypeChecker(); + const source = program.getSourceFile("src/index.ts"); + + if (!source) throw new Error("Unable to load src/index.ts for the public export coverage test"); + + const moduleSymbol = checker.getSymbolAtLocation(source); + if (!moduleSymbol) throw new Error("Unable to resolve the src/index.ts module symbol"); + + return {checker, exports: checker.getExportsOfModule(moduleSymbol)}; +}; + +describe("testing coverage matrices", () => { + test("classifies every root TypeScript export exactly once", () => { + const {exports} = rootExports(); + const actual = exports.map(symbol => symbol.name).sort(); + const classified = PUBLIC_EXPORT_COVERAGE.map(entry => entry.name).sort(); + + expect(actual).toHaveLength(EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT); + expect(classified).toHaveLength(EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT); + expect(new Set(classified).size).toBe(classified.length); + expect(classified).toEqual(actual); + expect(PUBLIC_EXPORT_COVERAGE.filter(entry => entry.coverage === "unsupported")).toEqual([]); + }); + + test("keeps the three interfaces type-only and the other 328 exports runtime-visible", () => { + const {checker, exports} = rootExports(); + const typeOnly = exports + .filter(symbol => { + const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return !(target.flags & ts.SymbolFlags.Value); + }) + .map(symbol => symbol.name) + .sort(); + + expect(typeOnly).toEqual([...TYPE_ONLY_ROOT_EXPORTS].sort()); + expect(exports.length - typeOnly.length).toBe(EXPECTED_ROOT_RUNTIME_EXPORT_COUNT); + }); + + test("classifies every raw capability path once", () => { + const paths = RAW_CAPABILITY_COVERAGE.map(entry => entry.path); + + expect(paths).toHaveLength(305); + expect(new Set(paths).size).toBe(paths.length); + expect( + RAW_CAPABILITY_COVERAGE.filter( + entry => entry.kind === "method" && (!entry.browserInvocation || !entry.chromeInvocation) + ) + ).toEqual([]); + }); + + test("resolves all raw capabilities to their actual harness controls", () => { + const harness = createBrowserHarness(); + const resolutions = RAW_CAPABILITY_COVERAGE.map(entry => ({ + controls: resolveRawCapability(harness, entry), + entry, + })); + + expect(resolutions).toHaveLength(305); + expect( + resolutions + .filter( + ({controls, entry}) => + controls.length === 0 || controls.some(control => !isValidResolution(entry, control)) + ) + .map(({entry}) => `${entry.kind}:${entry.path}`) + ).toEqual([]); + }); + + test("keeps stateful coverage equivalent to having a default implementation", () => { + const harness = createBrowserHarness(); + const mismatches = RAW_CAPABILITY_COVERAGE.filter(entry => entry.kind === "method") + .flatMap(entry => resolveRawCapability(harness, entry).map(control => ({control, entry}))) + .filter(({control, entry}) => { + if (!isBrowserMethodControl(control)) return true; + return (entry.coverage === "stateful") !== control.hasDefaultImplementation; + }) + .map(({control, entry}) => ({ + coverage: entry.coverage, + hasDefaultImplementation: isBrowserMethodControl(control) + ? control.hasDefaultImplementation + : "unresolved", + path: entry.path, + })); + + expect(mismatches).toEqual([]); + }); +}); diff --git a/src/testing/coverage.ts b/src/testing/coverage.ts new file mode 100644 index 0000000..0d3fe56 --- /dev/null +++ b/src/testing/coverage.ts @@ -0,0 +1,922 @@ +export type PublicExportKind = "method-wrapper" | "event-wrapper" | "class" | "enum" | "interface"; + +export type PublicExportCoverage = "stateful" | "configurable" | "event" | "behavioral" | "declaration" | "unsupported"; + +export interface PublicExportCoverageEntry { + readonly name: string; + readonly module: string; + readonly kind: PublicExportKind; + readonly coverage: PublicExportCoverage; +} + +const entries = ( + module: string, + kind: PublicExportKind, + coverage: PublicExportCoverage, + names: readonly string[] +): PublicExportCoverageEntry[] => names.map(name => ({coverage, kind, module, name})); + +/** + * Classification of every export from the package root entrypoint. + * + * Keep this list explicit. The coverage test compares it with the TypeScript + * compiler's view of `src/index.ts`, so adding an unclassified root export is a + * deliberate test failure instead of an implicit fake implementation. + */ +export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [ + ...entries("action", "method-wrapper", "configurable", [ + "disableAction", + "enableAction", + "getBadgeBgColor", + "getBadgeText", + "getBadgeTextColor", + "getActionPopup", + "getActionTitle", + "getActionUserSetting", + "isActionEnabled", + "openActionPopup", + "setBadgeBgColor", + "setBadgeText", + "setBadgeTextColor", + "setActionIcon", + "setActionPopup", + "setActionTitle", + ]), + ...entries("action", "method-wrapper", "behavioral", ["getDefaultPopup", "clearBadgeText"]), + ...entries("action", "event-wrapper", "event", ["onActionClicked", "onActionUserSettingsChanged"]), + + ...entries("alarms", "method-wrapper", "configurable", [ + "clearAlarm", + "clearAllAlarm", + "createAlarm", + "getAlarm", + "getAllAlarm", + ]), + ...entries("alarms", "event-wrapper", "event", ["onAlarm"]), + + ...entries("audio", "method-wrapper", "configurable", [ + "getAudioDevices", + "getAudioMute", + "setAudioActiveDevices", + "setAudioMute", + "setAudioProperties", + ]), + ...entries("audio", "event-wrapper", "event", [ + "onAudioDeviceListChanged", + "onAudioLevelChanged", + "onAudioMuteChanged", + ]), + + ...entries("browser", "method-wrapper", "behavioral", ["browser"]), + + ...entries("browserDetection", "enum", "declaration", ["BrowserName", "BrowserFamily", "BrowserGuessSource"]), + ...entries("browserDetection", "interface", "declaration", ["BrowserGuess"]), + ...entries("browserDetection", "method-wrapper", "behavioral", ["guessBrowser", "isBrowser", "isBrowserFamily"]), + + ...entries("browsingData", "method-wrapper", "configurable", [ + "removeBrowsingData", + "removeAppcacheData", + "removeCacheData", + "removeCacheStorageData", + "removeCookiesData", + "removeDownloadsData", + "removeFileSystemsData", + "removeFormData", + "removeHistoryData", + "removeIndexedDBData", + "removeLocalStorageData", + "removePasswordsData", + "removeServiceWorkersData", + "removeWebSQLData", + "getBrowsingDataSettings", + ]), + + ...entries("commands", "method-wrapper", "configurable", ["getAllCommands"]), + ...entries("commands", "event-wrapper", "event", ["onCommand", "onSpecificCommand"]), + + ...entries("contextMenus", "method-wrapper", "configurable", [ + "createContextMenus", + "removeContextMenus", + "removeAllContextMenus", + "updateContextMenus", + ]), + ...entries("contextMenus", "method-wrapper", "behavioral", ["createOrUpdateContextMenu"]), + ...entries("contextMenus", "event-wrapper", "event", ["onContextMenusClicked"]), + + ...entries("cookies", "method-wrapper", "configurable", [ + "getCookie", + "getAllCookie", + "getAllCookieStores", + "getCookiePartitionKey", + "removeCookie", + "setCookie", + ]), + ...entries("cookies", "event-wrapper", "event", ["onCookieChanged"]), + + ...entries("documentScan", "method-wrapper", "configurable", [ + "cancelDocScanning", + "closeDocScanner", + "getDocScannerOptionGroups", + "getDocScannerList", + "openDocScanner", + "readDocScanningData", + "docScanning", + "setDocScannerOptions", + "startDocScanning", + ]), + + ...entries("downloads", "class", "behavioral", ["BlockDownloadError"]), + ...entries("downloads", "method-wrapper", "configurable", [ + "acceptDownloadDanger", + "cancelDownload", + "eraseDownload", + "getDownloadFileIcon", + "openDownload", + "pauseDownload", + "removeDownloadFile", + "resumeDownload", + "searchDownloads", + "setDownloadsUiOptions", + "showDownloadFolder", + ]), + ...entries("downloads", "method-wrapper", "behavioral", [ + "download", + "showDownload", + "findDownload", + "isDownloadExists", + "getDownloadState", + ]), + ...entries("downloads", "event-wrapper", "event", [ + "onDownloadsChanged", + "onDownloadsCreated", + "onDownloadsDeterminingFilename", + ]), + + ...entries("env", "method-wrapper", "behavioral", ["isBackground"]), + + ...entries("extension", "method-wrapper", "configurable", [ + "getBackgroundPage", + "getViews", + "isAllowedFileSchemeAccess", + "isAllowedIncognitoAccess", + "setUpdateUrlData", + ]), + + ...entries("history", "method-wrapper", "configurable", [ + "addHistoryUrl", + "deleteAllHistory", + "deleteRangeHistory", + "deleteHistoryUrl", + "getHistoryVisits", + "searchHistory", + ]), + ...entries("history", "event-wrapper", "event", ["onHistoryVisited", "onHistoryVisitRemoved"]), + + ...entries("i18n", "method-wrapper", "configurable", [ + "detectI18Language", + "getI18nAcceptLanguages", + "getI18nUILanguage", + "getI18nMessage", + ]), + ...entries("i18n", "method-wrapper", "behavioral", ["getDefaultLanguage"]), + + ...entries("identity", "interface", "declaration", ["LaunchWebAuthFlowDetails"]), + ...entries("identity", "method-wrapper", "configurable", [ + "getIdentityRedirectUrl", + "launchWebAuthFlow", + "getAuthToken", + "removeCachedAuthToken", + "clearAllCachedAuthTokens", + "getProfileUserInfo", + "getIdentityAccounts", + ]), + ...entries("identity", "event-wrapper", "event", ["onIdentitySignInChanged"]), + + ...entries("idle", "method-wrapper", "configurable", [ + "getIdleAutoLockDelay", + "queryIdleState", + "setIdleDetectionInterval", + ]), + ...entries("idle", "event-wrapper", "event", ["onIdleStateChanged"]), + + ...entries("management", "method-wrapper", "configurable", [ + "createAppShortcut", + "generateAppForLink", + "getExtensionInfo", + "getAllExtensionInfo", + "getPermissionWarningsById", + "getPermissionWarningsByManifest", + "getCurrentExtension", + "launchExtensionApp", + "setExtensionEnabled", + "setExtensionLaunchType", + "uninstallExtension", + "uninstallCurrentExtension", + ]), + ...entries("management", "event-wrapper", "event", [ + "onExtensionDisabled", + "onExtensionEnabled", + "onExtensionInstalled", + "onExtensionUninstalled", + ]), + + ...entries("notifications", "method-wrapper", "configurable", [ + "clearNotification", + "createNotification", + "getAllNotifications", + "getNotificationPermissionLevel", + "updateNotification", + ]), + ...entries("notifications", "method-wrapper", "behavioral", ["isAvailableNotifications", "clearAllNotifications"]), + ...entries("notifications", "event-wrapper", "event", [ + "onNotificationsButtonClicked", + "onNotificationsClicked", + "onNotificationsClosed", + "onNotificationsPermissionLevelChanged", + ]), + + ...entries("offscreen", "method-wrapper", "configurable", ["closeOffscreen", "createOffscreen", "hasOffscreen"]), + ...entries("offscreen", "method-wrapper", "behavioral", [ + "getOffscreenContext", + "getOffscreenUrl", + "getOffscreenPath", + "hasOffscreenUrl", + "hasOffscreenPath", + ]), + + ...entries("permissions", "method-wrapper", "configurable", ["addHostAccessRequest", "removeHostAccessRequest"]), + ...entries("permissions", "method-wrapper", "stateful", [ + "containsPermissions", + "getAllPermissions", + "removePermissions", + "requestPermissions", + ]), + ...entries("permissions", "event-wrapper", "event", ["onPermissionsAdded", "onPermissionsRemoved"]), + + ...entries("runtime", "method-wrapper", "configurable", [ + "connect", + "connectNative", + "getPackageDirectoryEntry", + "getPlatformInfo", + "openOptionsPage", + "reload", + "requestUpdateCheck", + "restart", + "restartAfterDelay", + "setUninstallUrl", + ]), + ...entries("runtime", "method-wrapper", "stateful", [ + "getContexts", + "getManifest", + "getBrowserInfo", + "getUrl", + "sendMessage", + ]), + ...entries("runtime", "method-wrapper", "behavioral", ["getId", "getManifestVersion", "isManifestVersion3"]), + ...entries("runtime", "event-wrapper", "event", [ + "onConnect", + "onConnectExternal", + "onInstalled", + "onMessage", + "onMessageExternal", + "onRestartRequired", + "onStartup", + "onSuspend", + "onSuspendCanceled", + "onUpdateAvailable", + "onUserScriptConnect", + "onUserScriptMessage", + ]), + + ...entries("scripting", "method-wrapper", "configurable", ["executeScript", "insertCss", "removeCss"]), + ...entries("scripting", "method-wrapper", "stateful", [ + "getRegisteredContentScripts", + "registerContentScripts", + "unregisterContentScripts", + "updateContentScripts", + ]), + ...entries("scripting", "method-wrapper", "behavioral", ["isAvailableScripting"]), + + ...entries("sidebar", "class", "behavioral", ["SidebarError"]), + ...entries("sidebar", "method-wrapper", "behavioral", [ + "getSidebarOptions", + "getSidebarBehavior", + "canOpenSidebar", + "canCloseSidebar", + "openSidebar", + "closeSidebar", + "setSidebarOptions", + "setSidebarBehavior", + "isOpenSidebar", + "toggleSidebar", + "setSidebarPath", + "getSidebarPath", + "setSidebarTitle", + "setSidebarBadgeText", + "clearSidebarBadgeText", + "setSidebarIcon", + "setSidebarBadgeTextColor", + "setSidebarBadgeBgColor", + "getSidebarTitle", + "getSidebarBadgeText", + "getSidebarBadgeTextColor", + "getSidebarBadgeBgColor", + ]), + + ...entries("tabCapture", "method-wrapper", "configurable", [ + "createTabCapture", + "getCapturedTabs", + "getCaptureMediaStreamId", + ]), + ...entries("tabCapture", "event-wrapper", "event", ["onCaptureStatusChanged"]), + + ...entries("tabs", "method-wrapper", "stateful", [ + "createTab", + "getCurrentTab", + "getTab", + "queryTabs", + "removeTab", + "updateTab", + ]), + ...entries("tabs", "method-wrapper", "configurable", [ + "captureVisibleTab", + "connectTab", + "detectTabLanguage", + "discardTab", + "duplicateTab", + "getTabZoom", + "getTabZoomSettings", + "goTabBack", + "goTabForward", + "groupTabs", + "highlightTab", + "moveTab", + "moveTabs", + "reloadTab", + "sendTabMessage", + "setTabZoom", + "setTabZoomSettings", + "ungroupTab", + "executeScriptTab", + "insertCssTab", + "removeCssTab", + ]), + ...entries("tabs", "method-wrapper", "behavioral", [ + "getTabUrl", + "getActiveTab", + "queryTabIds", + "findTab", + "findTabById", + "findTabByUrl", + "updateTabAsSelected", + "updateTabAsActive", + "openOrCreateTab", + "openOrCreateTabByUrl", + ]), + ...entries("tabs", "event-wrapper", "event", [ + "onTabActivated", + "onTabAttached", + "onTabCreated", + "onTabDetached", + "onTabHighlighted", + "onTabMoved", + "onTabRemoved", + "onTabReplaced", + "onTabUpdated", + "onTabZoomChange", + ]), + + ...entries("userScripts", "method-wrapper", "configurable", [ + "configureUserScriptsWorld", + "getUserScripts", + "getUserScriptsWorldConfigs", + "executeUserScript", + "registerUserScripts", + "resetUserScriptsWorldConfigs", + "unregisterUserScripts", + "updateUserScripts", + ]), + ...entries("userScripts", "method-wrapper", "behavioral", ["isAvailableUserScripts"]), + + ...entries("webNavigation", "method-wrapper", "configurable", ["getAllFrames", "getFrame"]), + ...entries("webNavigation", "event-wrapper", "event", [ + "onWebNavigationBeforeNavigate", + "onWebNavigationCommitted", + "onWebNavigationCompleted", + "onWebNavigationCreatedNavigationTarget", + "onWebNavigationDOMContentLoaded", + "onWebNavigationErrorOccurred", + "onWebNavigationHistoryStateUpdated", + "onWebNavigationReferenceFragmentUpdated", + "onWebNavigationTabReplaced", + ]), + + ...entries("webRequest", "method-wrapper", "configurable", ["handlerWebRequestBehaviorChanged"]), + ...entries("webRequest", "event-wrapper", "event", [ + "onWebRequestAuthRequired", + "onWebRequestBeforeRedirect", + "onWebRequestBeforeRequest", + "onWebRequestBeforeSendHeaders", + "onWebRequestCompleted", + "onWebRequestErrorOccurred", + "onWebRequestHeadersReceived", + "onWebRequestResponseStarted", + "onWebRequestSendHeaders", + ]), + + ...entries("windows", "interface", "declaration", ["WindowEventFilter"]), + ...entries("windows", "method-wrapper", "stateful", [ + "createWindow", + "getWindow", + "getAllWindows", + "getCurrentWindow", + "getLastFocusedWindow", + "removeWindow", + "updateWindow", + ]), + ...entries("windows", "event-wrapper", "event", [ + "onWindowBoundsChanged", + "onWindowCreated", + "onWindowFocusChanged", + "onWindowRemoved", + ]), +] as const; + +export const TYPE_ONLY_ROOT_EXPORTS = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"] as const; + +export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 331; +export const EXPECTED_ROOT_RUNTIME_EXPORT_COUNT = 328; + +export const getPublicExportCoverage = (name: string): PublicExportCoverageEntry | undefined => + PUBLIC_EXPORT_COVERAGE.find(entry => entry.name === name); + +export type RawCapabilityKind = "method" | "event" | "property"; +export type RawCapabilityCoverage = "stateful" | "configurable" | "event"; +export type RawMethodInvocation = "sync" | "callback" | "promise" | "dual" | "promise-tolerant" | "hybrid"; +export type RawFailureChannel = + | "none" + | "sync-throw" + | "callback-last-error" + | "promise-rejection" + | "invocation-dependent"; + +export interface RawCapabilityEntry { + readonly path: string; + readonly namespace: string; + readonly member: string; + readonly kind: RawCapabilityKind; + readonly coverage: RawCapabilityCoverage; + readonly chromeInvocation?: RawMethodInvocation; + readonly browserInvocation?: RawMethodInvocation; + readonly failureChannel: RawFailureChannel; + readonly supportedOptions?: readonly string[]; +} + +type InvocationPair = { + readonly chrome: RawMethodInvocation; + readonly browser: RawMethodInvocation; +}; + +const callbackInvocation: InvocationPair = {browser: "dual", chrome: "callback"}; +const syncInvocation: InvocationPair = {browser: "sync", chrome: "sync"}; +const promiseInvocation: InvocationPair = {browser: "promise", chrome: "promise"}; +const hybridInvocation: InvocationPair = {browser: "hybrid", chrome: "hybrid"}; + +const methodCapabilities = ( + namespace: string, + coverage: Exclude, + invocation: InvocationPair, + names: readonly string[], + supportedOptions?: Readonly> +): RawCapabilityEntry[] => + names.map(member => ({ + browserInvocation: invocation.browser, + chromeInvocation: invocation.chrome, + coverage, + failureChannel: + invocation.browser === "sync" && invocation.chrome === "sync" ? "sync-throw" : "invocation-dependent", + kind: "method", + member, + namespace, + path: `${namespace}.${member}`, + supportedOptions: supportedOptions?.[member], + })); + +const eventCapabilities = (namespace: string, names: readonly string[]): RawCapabilityEntry[] => + names.map(member => ({ + coverage: "event", + failureChannel: "none", + kind: "event", + member, + namespace, + path: `${namespace}.${member}`, + })); + +const propertyCapabilities = ( + namespace: string, + coverage: Exclude, + names: readonly string[] +): RawCapabilityEntry[] => + names.map(member => ({ + coverage, + failureChannel: "none", + kind: "property", + member, + namespace, + path: `${namespace}.${member}`, + })); + +/** + * Raw WebExtension members used by the production wrappers. The harness may + * model a member statefully or expose a configurable test double, but it must + * never synthesize an unlisted browser capability. + */ +export const RAW_CAPABILITY_COVERAGE: readonly RawCapabilityEntry[] = [ + ...methodCapabilities("action", "configurable", callbackInvocation, [ + "disable", + "enable", + "getBadgeBackgroundColor", + "getBadgeText", + "getBadgeTextColor", + "getPopup", + "getTitle", + "getUserSettings", + "isEnabled", + "openPopup", + "setBadgeBackgroundColor", + "setBadgeText", + "setBadgeTextColor", + "setIcon", + "setPopup", + "setTitle", + ]), + ...eventCapabilities("action", ["onClicked", "onUserSettingsChanged"]), + ...methodCapabilities("browserAction", "configurable", callbackInvocation, [ + "disable", + "enable", + "getBadgeBackgroundColor", + "getBadgeText", + "getPopup", + "getTitle", + "setBadgeBackgroundColor", + "setBadgeText", + "setIcon", + "setPopup", + "setTitle", + ]), + ...eventCapabilities("browserAction", ["onClicked"]), + + ...methodCapabilities("alarms", "configurable", callbackInvocation, [ + "clear", + "clearAll", + "create", + "get", + "getAll", + ]), + ...eventCapabilities("alarms", ["onAlarm"]), + + ...methodCapabilities("audio", "configurable", callbackInvocation, [ + "getDevices", + "getMute", + "setActiveDevices", + "setMute", + "setProperties", + ]), + ...eventCapabilities("audio", ["onDeviceListChanged", "onLevelChanged", "onMuteChanged"]), + + ...methodCapabilities("browsingData", "configurable", callbackInvocation, [ + "remove", + "removeAppcache", + "removeCache", + "removeCacheStorage", + "removeCookies", + "removeDownloads", + "removeFileSystems", + "removeFormData", + "removeHistory", + "removeIndexedDB", + "removeLocalStorage", + "removePasswords", + "removeServiceWorkers", + "removeWebSQL", + "settings", + ]), + + ...methodCapabilities("commands", "configurable", callbackInvocation, ["getAll"]), + ...eventCapabilities("commands", ["onCommand"]), + + ...methodCapabilities("contextMenus", "configurable", callbackInvocation, [ + "create", + "remove", + "removeAll", + "update", + ]), + ...eventCapabilities("contextMenus", ["onClicked"]), + + ...methodCapabilities("cookies", "configurable", callbackInvocation, [ + "get", + "getAll", + "getAllCookieStores", + "getPartitionKey", + "remove", + "set", + ]), + ...eventCapabilities("cookies", ["onChanged"]), + + ...methodCapabilities("documentScan", "configurable", callbackInvocation, [ + "cancelScan", + "closeScanner", + "getOptionGroups", + "getScannerList", + "openScanner", + "readScanData", + "scan", + "setOptions", + "startScan", + ]), + + ...methodCapabilities("downloads", "configurable", callbackInvocation, [ + "acceptDanger", + "cancel", + "download", + "erase", + "getFileIcon", + "open", + "pause", + "removeFile", + "resume", + "search", + "setUiOptions", + ]), + ...methodCapabilities("downloads", "configurable", syncInvocation, ["show", "showDefaultFolder"]), + ...eventCapabilities("downloads", ["onChanged", "onCreated", "onDeterminingFilename"]), + + ...methodCapabilities("extension", "configurable", syncInvocation, [ + "getBackgroundPage", + "getViews", + "setUpdateUrlData", + ]), + ...methodCapabilities("extension", "configurable", callbackInvocation, [ + "isAllowedFileSchemeAccess", + "isAllowedIncognitoAccess", + ]), + + ...methodCapabilities("history", "configurable", callbackInvocation, [ + "addUrl", + "deleteAll", + "deleteRange", + "deleteUrl", + "getVisits", + "search", + ]), + ...eventCapabilities("history", ["onVisited", "onVisitRemoved"]), + + ...methodCapabilities("i18n", "configurable", callbackInvocation, ["detectLanguage", "getAcceptLanguages"]), + ...methodCapabilities("i18n", "configurable", syncInvocation, ["getMessage", "getUILanguage"]), + + ...methodCapabilities("identity", "configurable", syncInvocation, ["getRedirectURL"]), + ...methodCapabilities("identity", "configurable", callbackInvocation, [ + "clearAllCachedAuthTokens", + "getAccounts", + "getProfileUserInfo", + "launchWebAuthFlow", + "removeCachedAuthToken", + ]), + ...methodCapabilities("identity", "configurable", hybridInvocation, ["getAuthToken"]), + ...eventCapabilities("identity", ["onSignInChanged"]), + + ...methodCapabilities("idle", "configurable", callbackInvocation, ["getAutoLockDelay", "queryState"]), + ...methodCapabilities("idle", "configurable", syncInvocation, ["setDetectionInterval"]), + ...eventCapabilities("idle", ["onStateChanged"]), + + ...methodCapabilities("management", "configurable", callbackInvocation, [ + "createAppShortcut", + "generateAppForLink", + "get", + "getAll", + "getPermissionWarningsById", + "getPermissionWarningsByManifest", + "getSelf", + "launchApp", + "setEnabled", + "setLaunchType", + "uninstall", + "uninstallSelf", + ]), + ...eventCapabilities("management", ["onDisabled", "onEnabled", "onInstalled", "onUninstalled"]), + + ...methodCapabilities("notifications", "configurable", callbackInvocation, [ + "clear", + "create", + "getAll", + "getPermissionLevel", + "update", + ]), + ...eventCapabilities("notifications", ["onButtonClicked", "onClicked", "onClosed", "onPermissionLevelChanged"]), + + ...methodCapabilities("offscreen", "configurable", callbackInvocation, [ + "closeDocument", + "createDocument", + "hasDocument", + ]), + + ...methodCapabilities("permissions", "configurable", callbackInvocation, [ + "addHostAccessRequest", + "removeHostAccessRequest", + ]), + ...methodCapabilities("permissions", "stateful", callbackInvocation, ["contains", "getAll", "remove", "request"]), + ...eventCapabilities("permissions", ["onAdded", "onRemoved"]), + + ...propertyCapabilities("runtime", "stateful", ["id", "lastError"]), + ...methodCapabilities("runtime", "configurable", syncInvocation, ["connect", "connectNative", "reload", "restart"]), + ...methodCapabilities("runtime", "configurable", callbackInvocation, [ + "getPackageDirectoryEntry", + "getPlatformInfo", + "openOptionsPage", + "requestUpdateCheck", + "restartAfterDelay", + "setUninstallURL", + ]), + ...methodCapabilities("runtime", "stateful", syncInvocation, ["getManifest", "getURL"]), + ...methodCapabilities("runtime", "stateful", callbackInvocation, ["getContexts", "sendMessage"]), + ...methodCapabilities("runtime", "stateful", promiseInvocation, ["getBrowserInfo"]), + ...eventCapabilities("runtime", [ + "onConnect", + "onConnectExternal", + "onInstalled", + "onMessage", + "onMessageExternal", + "onRestartRequired", + "onStartup", + "onSuspend", + "onSuspendCanceled", + "onUpdateAvailable", + "onUserScriptConnect", + "onUserScriptMessage", + ]), + + ...methodCapabilities("scripting", "configurable", callbackInvocation, ["executeScript", "insertCSS", "removeCSS"]), + ...methodCapabilities("scripting", "stateful", callbackInvocation, [ + "getRegisteredContentScripts", + "registerContentScripts", + "unregisterContentScripts", + "updateContentScripts", + ]), + + ...methodCapabilities("sidePanel", "configurable", callbackInvocation, [ + "close", + "getOptions", + "getPanelBehavior", + "open", + "setOptions", + "setPanelBehavior", + ]), + + ...methodCapabilities("tabCapture", "configurable", callbackInvocation, [ + "capture", + "getCapturedTabs", + "getMediaStreamId", + ]), + ...eventCapabilities("tabCapture", ["onStatusChanged"]), + + ...methodCapabilities( + "tabs", + "stateful", + callbackInvocation, + ["create", "get", "getCurrent", "query", "remove", "update"], + { + query: [ + "active", + "audible", + "autoDiscardable", + "currentWindow", + "discarded", + "frozen", + "groupId", + "highlighted", + "index", + "lastFocusedWindow", + "muted", + "pinned", + "splitViewId", + "status", + "title (literal only)", + "url (literal only)", + "windowId", + "windowType", + ], + } + ), + ...methodCapabilities("tabs", "configurable", syncInvocation, ["connect"]), + ...methodCapabilities("tabs", "configurable", callbackInvocation, [ + "captureVisibleTab", + "detectLanguage", + "discard", + "duplicate", + "executeScript", + "getZoom", + "getZoomSettings", + "goBack", + "goForward", + "group", + "highlight", + "insertCSS", + "move", + "reload", + "removeCSS", + "sendMessage", + "setZoom", + "setZoomSettings", + "ungroup", + ]), + ...eventCapabilities("tabs", [ + "onActivated", + "onAttached", + "onCreated", + "onDetached", + "onHighlighted", + "onMoved", + "onRemoved", + "onReplaced", + "onUpdated", + "onZoomChange", + ]), + + ...methodCapabilities("userScripts", "configurable", callbackInvocation, ["getScripts"]), + ...methodCapabilities("userScripts", "configurable", promiseInvocation, [ + "configureWorld", + "execute", + "getWorldConfigurations", + "register", + "resetWorldConfiguration", + "unregister", + "update", + ]), + + ...methodCapabilities("webNavigation", "configurable", callbackInvocation, ["getAllFrames", "getFrame"]), + ...eventCapabilities("webNavigation", [ + "onBeforeNavigate", + "onCommitted", + "onCompleted", + "onCreatedNavigationTarget", + "onDOMContentLoaded", + "onErrorOccurred", + "onHistoryStateUpdated", + "onReferenceFragmentUpdated", + "onTabReplaced", + ]), + + ...methodCapabilities("webRequest", "configurable", callbackInvocation, ["handlerBehaviorChanged"]), + ...eventCapabilities("webRequest", [ + "onAuthRequired", + "onBeforeRedirect", + "onBeforeRequest", + "onBeforeSendHeaders", + "onCompleted", + "onErrorOccurred", + "onHeadersReceived", + "onResponseStarted", + "onSendHeaders", + ]), + + ...methodCapabilities("windows", "stateful", callbackInvocation, [ + "create", + "get", + "getAll", + "getCurrent", + "getLastFocused", + "remove", + "update", + ]), + ...eventCapabilities("windows", ["onBoundsChanged", "onCreated", "onFocusChanged", "onRemoved"]), + + ...methodCapabilities("browser.sidebarAction", "configurable", promiseInvocation, [ + "close", + "getPanel", + "getTitle", + "isOpen", + "open", + "setIcon", + "setPanel", + "setTitle", + "toggle", + ]), + ...methodCapabilities("opr.sidebarAction", "configurable", callbackInvocation, [ + "getBadgeBackgroundColor", + "getBadgeText", + "getBadgeTextColor", + "getPanel", + "getTitle", + ]), + ...methodCapabilities("opr.sidebarAction", "configurable", syncInvocation, [ + "setBadgeBackgroundColor", + "setBadgeText", + "setBadgeTextColor", + "setIcon", + "setPanel", + "setTitle", + ]), +] as const; + +export const getRawCapability = (path: string): RawCapabilityEntry | undefined => + RAW_CAPABILITY_COVERAGE.find(entry => entry.path === path); diff --git a/src/testing/event.test.ts b/src/testing/event.test.ts new file mode 100644 index 0000000..3ce249b --- /dev/null +++ b/src/testing/event.test.ts @@ -0,0 +1,134 @@ +import {createBrowserEvent} from "./event"; + +describe("createBrowserEvent", () => { + test("preserves listener identity and supports idempotent unsubscribe", async () => { + const event = createBrowserEvent<[value: string]>(); + const listener = jest.fn(); + + event.api.addListener(listener); + event.api.addListener(listener); + + expect(event.listenerCount()).toBe(1); + expect(event.api.hasListener(listener)).toBe(true); + + const unsubscribe = event.on(listener); + unsubscribe(); + unsubscribe(); + + expect(event.api.hasListener(listener)).toBe(false); + await event.emit("ignored"); + expect(listener).not.toHaveBeenCalled(); + }); + + test("starts a snapshot of every listener synchronously and awaits async results", async () => { + const event = createBrowserEvent<[value: number]>(); + const calls: string[] = []; + let release: (() => void) | undefined; + + event.api.addListener(value => { + calls.push(`first:${value}`); + return new Promise(resolve => { + release = resolve; + }); + }); + event.api.addListener(value => { + calls.push(`second:${value}`); + }); + + const emitted = event.emit(2); + + expect(calls).toEqual(["first:2", "second:2"]); + + release?.(); + await emitted; + }); + + test("uses an emission snapshot when listeners change during emission", async () => { + const event = createBrowserEvent<[]>(); + const second = jest.fn(); + const first = jest.fn(() => event.api.removeListener(second)); + + event.api.addListener(first); + event.api.addListener(second); + + await event.emit(); + await event.emit(); + + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenCalledTimes(1); + }); + + test("assimilates arbitrary thenables", async () => { + const event = createBrowserEvent<[]>(); + const failure = new Error("thenable failed"); + + event.api.addListener(() => ({ + // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable. + then(_resolve: (value: unknown) => void, reject: (reason: unknown) => void) { + reject(failure); + }, + })); + + await expect(event.emit()).rejects.toBe(failure); + }); + + test("runs every listener before rethrowing one failure", async () => { + const event = createBrowserEvent<[]>(); + const failure = new Error("listener failed"); + const remaining = jest.fn(); + + event.api.addListener(() => { + throw failure; + }); + event.api.addListener(remaining); + + await expect(event.emit()).rejects.toBe(failure); + expect(remaining).toHaveBeenCalledTimes(1); + }); + + test("aggregates multiple failures after all listeners settle", async () => { + const event = createBrowserEvent<[]>(); + const first = new Error("first"); + const second = new Error("second"); + + event.api.addListener(() => { + throw first; + }); + event.api.addListener(() => Promise.reject(second)); + + try { + await event.emit(); + throw new Error("Expected emit to reject"); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([first, second]); + } + }); + + test("isolates instances and resets listeners", () => { + const first = createBrowserEvent<[]>(); + const second = createBrowserEvent<[]>(); + const listener = jest.fn(); + + first.api.addListener(listener); + second.api.addListener(listener); + first.reset(); + + expect(first.listenerCount()).toBe(0); + expect(second.listenerCount()).toBe(1); + }); + + test("records optional registration arguments", () => { + const event = createBrowserEvent<[value: string], [filter: {url: string}]>(); + const listener = jest.fn(); + const filter = {url: "https://example.com"}; + + event.api.addListener(listener, filter); + + expect(event.registrations()).toEqual([{listener, args: [filter]}]); + expect(Object.isFrozen(event.registrations())).toBe(true); + + event.api.removeListener(listener); + expect(event.registrations()).toEqual([]); + }); +}); diff --git a/src/testing/event.ts b/src/testing/event.ts new file mode 100644 index 0000000..433baa5 --- /dev/null +++ b/src/testing/event.ts @@ -0,0 +1,113 @@ +export type BrowserEventListener = (...args: TArgs) => unknown; + +export interface BrowserEventApi< + TArgs extends readonly unknown[], + TRegistrationArgs extends readonly unknown[] = readonly unknown[], +> { + addListener(listener: BrowserEventListener, ...registrationArgs: TRegistrationArgs): void; + removeListener(listener: BrowserEventListener): void; + hasListener(listener: BrowserEventListener): boolean; +} + +export interface BrowserEventRegistration< + TArgs extends readonly unknown[], + TRegistrationArgs extends readonly unknown[] = readonly unknown[], +> { + readonly listener: BrowserEventListener; + readonly args: TRegistrationArgs; +} + +export interface BrowserEventHarness< + TArgs extends readonly unknown[], + TRegistrationArgs extends readonly unknown[] = readonly unknown[], +> { + readonly api: BrowserEventApi; + on(listener: BrowserEventListener, ...registrationArgs: TRegistrationArgs): () => void; + emit(...args: TArgs): Promise; + listenerCount(): number; + registrations(): readonly BrowserEventRegistration[]; + reset(): void; +} + +/** + * Creates an isolated WebExtension-style event with explicit emission controls. + */ +export function createBrowserEvent< + TArgs extends readonly unknown[], + TRegistrationArgs extends readonly unknown[] = readonly unknown[], +>(): BrowserEventHarness { + const listeners = new Set>(); + const registrationArgs = new Map, TRegistrationArgs>(); + + const api: BrowserEventApi = { + addListener(listener, ...args) { + listeners.add(listener); + registrationArgs.set(listener, args); + }, + removeListener(listener) { + listeners.delete(listener); + registrationArgs.delete(listener); + }, + hasListener(listener) { + return listeners.has(listener); + }, + }; + + return { + api, + on(listener, ...args) { + api.addListener(listener, ...args); + + let subscribed = true; + + return () => { + if (!subscribed) { + return; + } + + subscribed = false; + api.removeListener(listener); + }; + }, + async emit(...args) { + const pending = [...listeners].map(listener => { + try { + return Promise.resolve(listener(...args)); + } catch (error) { + return Promise.reject(error); + } + }); + const outcomes = await Promise.allSettled(pending); + const errors = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === "rejected") + .map(outcome => outcome.reason); + + if (errors.length === 1) { + throw errors[0]; + } + + if (errors.length > 1) { + throw new AggregateError(errors, "Multiple browser event listeners failed"); + } + }, + listenerCount() { + return listeners.size; + }, + registrations() { + return Object.freeze( + [...listeners].map(listener => + Object.freeze({ + listener, + args: Object.freeze([ + ...(registrationArgs.get(listener) ?? []), + ]) as unknown as TRegistrationArgs, + }) + ) + ); + }, + reset() { + listeners.clear(); + registrationArgs.clear(); + }, + }; +} diff --git a/src/testing/fixtures.test.ts b/src/testing/fixtures.test.ts new file mode 100644 index 0000000..9caf571 --- /dev/null +++ b/src/testing/fixtures.test.ts @@ -0,0 +1,112 @@ +import { + createExtensionContextFixture, + createInjectionResultFixture, + createInstalledDetailsFixture, + createManifestFixture, + createMessageSenderFixture, + createPermissionsFixture, + createTabFixture, + createWindowFixture, +} from "./fixtures"; + +describe("testing fixtures", () => { + test("creates deterministic manifests and clones nested overrides", () => { + const permissions: chrome.runtime.ManifestPermission[] = ["tabs"]; + const first = createManifestFixture({name: "Overridden", permissions}); + const second = createManifestFixture({name: "Overridden", permissions}); + + expect(first).toMatchObject({manifest_version: 3, name: "Overridden", version: "1.0.0"}); + expect(first).not.toBe(second); + expect(first.permissions).not.toBe(permissions); + expect(first.permissions).not.toBe(second.permissions); + }); + + test("creates a valid fresh tab with cloned nested state", () => { + const mutedInfo: chrome.tabs.MutedInfo = {muted: true, reason: "user"}; + const first = createTabFixture({id: 7, active: false, mutedInfo}); + const second = createTabFixture({id: 7, active: false, mutedInfo}); + + expect(first).toMatchObject({ + id: 7, + index: 0, + windowId: 1, + active: false, + highlighted: true, + pinned: false, + frozen: false, + discarded: false, + groupId: -1, + }); + expect(first).not.toBe(second); + expect(first.mutedInfo).not.toBe(mutedInfo); + expect(first.mutedInfo).not.toBe(second.mutedInfo); + }); + + test("creates a fresh window and clones tabs", () => { + const tab = createTabFixture(); + const first = createWindowFixture({id: 3, tabs: [tab]}); + const second = createWindowFixture({id: 3, tabs: [tab]}); + + expect(first).toMatchObject({id: 3, focused: true, alwaysOnTop: false, incognito: false}); + expect(first.tabs).not.toBe(second.tabs); + expect(first.tabs?.[0]).not.toBe(tab); + expect(first.tabs?.[0]).not.toBe(second.tabs?.[0]); + }); + + test("creates fresh permission arrays", () => { + const permissions = ["tabs"] as chrome.runtime.ManifestPermission[]; + const origins = ["https://example.com/*"]; + const first = createPermissionsFixture({permissions, origins}); + const second = createPermissionsFixture({permissions, origins}); + + expect(first).toEqual({permissions, origins}); + expect(first.permissions).not.toBe(permissions); + expect(first.permissions).not.toBe(second.permissions); + expect(first.origins).not.toBe(origins); + expect(first.origins).not.toBe(second.origins); + }); + + test("creates installed details with an install reason", () => { + expect(createInstalledDetailsFixture()).toEqual({reason: "install"}); + expect(createInstalledDetailsFixture({reason: "update", previousVersion: "0.9.0"})).toEqual({ + reason: "update", + previousVersion: "0.9.0", + }); + }); + + test("creates message senders and clones overridden tabs", () => { + const tab = createTabFixture(); + const sender = createMessageSenderFixture({tab}); + + expect(sender).toMatchObject({ + id: "test-extension-id", + origin: "chrome-extension://test-extension-id", + url: "chrome-extension://test-extension-id/background.html", + }); + expect(sender.tab).not.toBe(tab); + }); + + test("creates deterministic extension contexts", () => { + const context = createExtensionContextFixture({contextType: "OFFSCREEN_DOCUMENT", documentId: "document-2"}); + + expect(context).toEqual({ + contextId: "test-context-id", + contextType: "OFFSCREEN_DOCUMENT", + documentId: "document-2", + frameId: -1, + incognito: false, + tabId: -1, + windowId: -1, + }); + }); + + test("creates typed generic injection results and clones result objects", () => { + const result = {value: 15, items: ["first"]}; + const injection = createInjectionResultFixture({frameId: 2, result}); + const typed: chrome.scripting.InjectionResult<{value: number; items: string[]}> = injection; + + expect(typed).toEqual({documentId: "test-document-id", frameId: 2, result}); + expect(typed.result).not.toBe(result); + expect(typed.result?.items).not.toBe(result.items); + }); +}); diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts new file mode 100644 index 0000000..23abca1 --- /dev/null +++ b/src/testing/fixtures.ts @@ -0,0 +1,134 @@ +const TEST_EXTENSION_ID = "test-extension-id"; +const TEST_EXTENSION_ORIGIN = `chrome-extension://${TEST_EXTENSION_ID}`; + +type FixtureOverrides = Readonly>; + +function cloneFixtureValue(value: T): T { + if (Array.isArray(value)) { + return value.map(item => cloneFixtureValue(item)) as T; + } + + if (value !== null && typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + + if (prototype === Object.prototype || prototype === null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneFixtureValue(item)])) as T; + } + } + + return value; +} + +function createFixture(defaults: T, overrides: FixtureOverrides): T { + return cloneFixtureValue({...defaults, ...overrides}); +} + +export function createManifestFixture( + overrides: FixtureOverrides = {} +): chrome.runtime.Manifest { + return createFixture( + { + manifest_version: 3, + name: "Test Extension", + version: "1.0.0", + }, + overrides + ); +} + +export function createTabFixture(overrides: FixtureOverrides = {}): chrome.tabs.Tab { + return createFixture( + { + id: 1, + index: 0, + windowId: 1, + active: true, + selected: true, + highlighted: true, + pinned: false, + frozen: false, + incognito: false, + discarded: false, + autoDiscardable: true, + groupId: -1, + status: "complete", + title: "Test Tab", + url: `${TEST_EXTENSION_ORIGIN}/index.html`, + }, + overrides + ); +} + +export function createWindowFixture(overrides: FixtureOverrides = {}): chrome.windows.Window { + return createFixture( + { + id: 1, + focused: true, + alwaysOnTop: false, + incognito: false, + state: "normal", + type: "normal", + tabs: [], + }, + overrides + ); +} + +export function createPermissionsFixture( + overrides: FixtureOverrides = {} +): chrome.permissions.Permissions { + return createFixture( + { + permissions: [], + origins: [], + }, + overrides + ); +} + +export function createInstalledDetailsFixture( + overrides: FixtureOverrides = {} +): chrome.runtime.InstalledDetails { + return createFixture({reason: "install"}, overrides); +} + +export function createMessageSenderFixture( + overrides: FixtureOverrides = {} +): chrome.runtime.MessageSender { + return createFixture( + { + id: TEST_EXTENSION_ID, + origin: TEST_EXTENSION_ORIGIN, + url: `${TEST_EXTENSION_ORIGIN}/background.html`, + }, + overrides + ); +} + +export function createExtensionContextFixture( + overrides: FixtureOverrides = {} +): chrome.runtime.ExtensionContext { + return createFixture( + { + contextId: "test-context-id", + contextType: "BACKGROUND", + frameId: -1, + incognito: false, + tabId: -1, + windowId: -1, + }, + overrides + ); +} + +export function createInjectionResultFixture( + overrides: FixtureOverrides> = {} +): chrome.scripting.InjectionResult { + return createFixture>( + { + documentId: "test-document-id", + frameId: 0, + }, + overrides + ); +} diff --git a/src/testing/globals.integration.test.ts b/src/testing/globals.integration.test.ts new file mode 100644 index 0000000..ed4eb87 --- /dev/null +++ b/src/testing/globals.integration.test.ts @@ -0,0 +1,276 @@ +import {BrowserGuessSource, BrowserName, guessBrowser} from "../browserDetection"; +import {getI18nUILanguage} from "../i18n"; +import {getId} from "../runtime"; +import {canOpenSidebar, getSidebarTitle, openSidebar, SidebarError, setSidebarTitle} from "../sidebar"; +import {onTabCreated} from "../tabs"; +import {createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "./index"; + +const restorers: Array<() => void> = []; + +afterEach(() => { + while (restorers.length > 0) restorers.pop()?.(); +}); + +describe("transactional browser globals", () => { + test("leaves omitted globals untouched while explicit undefined removes one temporarily", () => { + const harness = createBrowserHarness(); + const restoreOuter = installGlobals({browser: harness.browser, chrome: harness.chrome}); + restorers.push(restoreOuter); + const chromeDescriptor = Reflect.getOwnPropertyDescriptor(globalThis, "chrome"); + const browserDescriptor = Reflect.getOwnPropertyDescriptor(globalThis, "browser"); + const restoreInner = installGlobals({browser: undefined}); + restorers.push(restoreInner); + + expect("browser" in globalThis).toBe(false); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor); + + restoreInner(); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(browserDescriptor); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor); + }); + + test("restores exact descriptors, absent globals, and tolerates repeated restore", () => { + const keys = ["chrome", "browser", "opr", "safari", "navigator", "window", "location"] as const; + const before = Object.fromEntries(keys.map(key => [key, Reflect.getOwnPropertyDescriptor(globalThis, key)])); + const harness = createBrowserHarness(); + const restore = installBrowserGlobals(harness, {context: "serviceWorker", profile: "firefox"}); + restorers.push(restore); + + expect(globalThis.browser).toBe(harness.browser); + expect(globalThis.chrome).toBe(harness.chrome); + expect("window" in globalThis).toBe(false); + expect("location" in globalThis).toBe(false); + + restore(); + restore(); + + for (const key of keys) { + expect(Reflect.getOwnPropertyDescriptor(globalThis, key)).toEqual(before[key]); + } + }); + + test("distinguishes omitted globals from explicit undefined and restores profile markers", () => { + const outerHarness = createBrowserHarness(); + const outerRestore = installGlobals({ + browser: outerHarness.browser, + chrome: outerHarness.chrome, + opr: {}, + safari: {marker: "original"}, + }); + restorers.push(outerRestore); + const outerDescriptors = { + browser: Reflect.getOwnPropertyDescriptor(globalThis, "browser"), + chrome: Reflect.getOwnPropertyDescriptor(globalThis, "chrome"), + opr: Reflect.getOwnPropertyDescriptor(globalThis, "opr"), + safari: Reflect.getOwnPropertyDescriptor(globalThis, "safari"), + }; + const profileHarness = createBrowserHarness(); + const restoreProfile = installBrowserGlobals(profileHarness, {profile: "chrome"}); + restorers.push(restoreProfile); + + expect(globalThis.chrome).toBe(profileHarness.chrome); + expect("browser" in globalThis).toBe(false); + expect("opr" in globalThis).toBe(false); + expect("safari" in globalThis).toBe(false); + + restoreProfile(); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(outerDescriptors.browser); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(outerDescriptors.chrome); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "opr")).toEqual(outerDescriptors.opr); + expect(Reflect.getOwnPropertyDescriptor(globalThis, "safari")).toEqual(outerDescriptors.safari); + }); +}); + +describe("browser profiles and routing", () => { + test("models a content script as a deterministic host page instead of an extension page", () => { + const harness = createBrowserHarness(); + const restoreExtensionPage = installBrowserGlobals(harness, {context: "extensionPage", profile: "chrome"}); + restorers.push(restoreExtensionPage); + + expect(globalThis.location.pathname).toBe("/index.html"); + expect(globalThis.location.href).toBeUndefined(); + + restoreExtensionPage(); + const restoreContentScript = installBrowserGlobals(harness, {context: "contentScript", profile: "chrome"}); + restorers.push(restoreContentScript); + + expect(globalThis.location).toMatchObject({ + href: "https://example.test/content/page.html", + origin: "https://example.test", + pathname: "/content/page.html", + protocol: "https:", + }); + expect(globalThis.window.location).toBe(globalThis.location); + }); + + test("routes to browser when runtime.id exists and falls back to chrome when it does not", () => { + const harness = createBrowserHarness(); + harness.configurable.chrome.i18n.getUILanguage.setResult("chrome-language"); + harness.configurable.browser.i18n.getUILanguage.setResult("browser-language"); + const restoreFirefox = installBrowserGlobals(harness, {profile: "firefox"}); + restorers.push(restoreFirefox); + + expect(getI18nUILanguage()).toBe("browser-language"); + + restoreFirefox(); + const browserWithoutRuntimeId = harness.createProfileFacade("browser", true); + Reflect.deleteProperty(browserWithoutRuntimeId.runtime, "id"); + const restoreFallback = installBrowserGlobals(harness, { + globals: {browser: browserWithoutRuntimeId, chrome: harness.chrome}, + profile: "custom", + }); + restorers.push(restoreFallback); + + expect(getI18nUILanguage()).toBe("chrome-language"); + }); + + test("throws a clear production error when neither namespace is available", () => { + const harness = createBrowserHarness(); + restorers.push( + installBrowserGlobals(harness, { + globals: {browser: undefined, chrome: undefined}, + profile: "custom", + }) + ); + + expect(() => getId()).toThrow("WebExtension API not available in this context"); + }); + + test("removes a disabled method from the already-installed facade and changes detection fallback", async () => { + const harness = createBrowserHarness(); + restorers.push(installBrowserGlobals(harness, {profile: "firefox"})); + + expect(typeof globalThis.browser?.runtime.getBrowserInfo).toBe("function"); + harness.capabilities.set("runtime.getBrowserInfo", false); + expect("getBrowserInfo" in (globalThis.browser?.runtime ?? {})).toBe(false); + + await expect(guessBrowser()).resolves.toMatchObject({ + name: BrowserName.Firefox, + source: BrowserGuessSource.UserAgent, + }); + + harness.reset(); + expect(typeof globalThis.browser?.runtime.getBrowserInfo).toBe("function"); + }); + + test.each([ + ["chrome", true, false, false], + ["firefox", false, true, false], + ["opera", false, false, true], + ["safari", false, false, false], + ] as const)("%s installs only its coherent sidebar flavor", (profile, hasSidePanel, hasFirefoxSidebar, hasOperaSidebar) => { + const harness = createBrowserHarness(); + const restore = installBrowserGlobals(harness, {profile}); + + try { + expect(Boolean(globalThis.chrome?.sidePanel)).toBe(hasSidePanel); + expect(Boolean(globalThis.browser?.sidebarAction)).toBe(hasFirefoxSidebar); + expect(Boolean(globalThis.opr?.sidebarAction)).toBe(hasOperaSidebar); + expect(canOpenSidebar()).toBe(hasSidePanel || hasFirefoxSidebar); + } finally { + restore(); + } + }); + + test("executes Chrome, Firefox, and Opera sidebar methods with their real invocation styles", async () => { + const chromeHarness = createBrowserHarness(); + chromeHarness.sidebar.sidePanel.open.setResult(undefined); + const restoreChrome = installBrowserGlobals(chromeHarness, {profile: "chrome"}); + restorers.push(restoreChrome); + await expect(openSidebar({windowId: 1})).resolves.toBeUndefined(); + expect(chromeHarness.sidebar.sidePanel.open.calls[0]).toMatchObject({invocation: "callback"}); + restoreChrome(); + + const firefoxHarness = createBrowserHarness(); + firefoxHarness.sidebar.firefox.open.setResult(undefined); + const restoreFirefox = installBrowserGlobals(firefoxHarness, {profile: "firefox"}); + restorers.push(restoreFirefox); + await expect(openSidebar({windowId: 1})).resolves.toBeUndefined(); + expect(firefoxHarness.sidebar.firefox.open.calls[0]).toMatchObject({invocation: "promise"}); + restoreFirefox(); + + const operaHarness = createBrowserHarness(); + operaHarness.sidebar.opera.setTitle.setResult(undefined); + operaHarness.sidebar.opera.getTitle.setResult("Opera title"); + restorers.push(installBrowserGlobals(operaHarness, {profile: "opera"})); + + await expect(setSidebarTitle("Configured title", 4)).resolves.toBeUndefined(); + await expect(getSidebarTitle(4)).resolves.toBe("Opera title"); + expect(operaHarness.sidebar.opera.setTitle.calls[0]).toMatchObject({ + args: [{tabId: 4, title: "Configured title"}], + callback: undefined, + invocation: "sync", + }); + expect(operaHarness.sidebar.opera.getTitle.calls[0]).toMatchObject({invocation: "callback"}); + }); + + test("none flavor exposes the real SidebarError path", async () => { + const harness = createBrowserHarness(); + harness.sidebar.flavor = "none"; + restorers.push( + installBrowserGlobals(harness, { + globals: {browser: undefined, chrome: harness.chrome, opr: undefined, safari: undefined}, + profile: "custom", + }) + ); + + await expect(openSidebar({windowId: 1})).rejects.toBeInstanceOf(SidebarError); + await expect(openSidebar({windowId: 1})).rejects.toThrow( + "The sidebarAction.open API is not supported in this browser" + ); + }); +}); + +describe("raw events and production listener error handling", () => { + test("keeps raw errors observable while capturing safeListener behavior", async () => { + const forwarded: unknown[][] = []; + const restoreConsole = installGlobals({consoleError: (...args) => forwarded.push(args)}); + restorers.push(restoreConsole); + const harness = createBrowserHarness(); + restorers.push(installBrowserGlobals(harness, {captureListenerErrors: true, profile: "chrome"})); + const tab = createTabFixture(); + + const rawFailure = new Error("raw listener failed"); + harness.tabs.events.onCreated.api.addListener(() => { + throw rawFailure; + }); + await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(rawFailure); + + harness.tabs.events.onCreated.reset(); + const syncFailure = new Error("sync listener failed"); + const unsubscribeSync = onTabCreated(() => { + throw syncFailure; + }); + await expect(harness.tabs.events.onCreated.emit(tab)).resolves.toBeUndefined(); + unsubscribeSync(); + expect(harness.listenerErrors.entries).toEqual([{args: [], error: syncFailure, kind: "sync"}]); + + harness.tabs.events.onCreated.reset(); + harness.listenerErrors.reset(); + const promiseFailure = new Error("promise listener failed"); + const unsubscribePromise = onTabCreated(async () => { + throw promiseFailure; + }); + await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(promiseFailure); + unsubscribePromise(); + expect(harness.listenerErrors.entries).toEqual([{args: [], error: promiseFailure, kind: "promise"}]); + + harness.tabs.events.onCreated.reset(); + harness.listenerErrors.reset(); + const thenableFailure = new Error("custom thenable failed"); + const thenable = { + // biome-ignore lint/suspicious/noThenProperty: This intentionally models a non-Promise thenable. + then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void { + reject(thenableFailure); + }, + }; + const unsubscribeThenable = onTabCreated((() => thenable) as unknown as Parameters[0]); + await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(thenableFailure); + unsubscribeThenable(); + expect(harness.listenerErrors.entries).toEqual([]); + + console.error("unrecognized listener output", 7); + expect(harness.listenerErrors.raw).toEqual([["unrecognized listener output", 7]]); + expect(forwarded).toEqual([["unrecognized listener output", 7]]); + }); +}); diff --git a/src/testing/globals.ts b/src/testing/globals.ts new file mode 100644 index 0000000..3573db5 --- /dev/null +++ b/src/testing/globals.ts @@ -0,0 +1,238 @@ +import {type BrowserHarness, sidebarDefaultForProfile} from "./harness"; +import type {BrowserProfile, BrowserTestApi, ExtensionContextKind} from "./types"; + +export interface NavigatorTestValue extends Partial { + brave?: { + isBrave?: () => boolean | Promise; + }; + userAgentData?: { + brands?: Array<{brand: string; version: string}>; + getHighEntropyValues?: (hints: string[]) => Promise<{ + brands?: Array<{brand: string; version: string}>; + fullVersionList?: Array<{brand: string; version: string}>; + }>; + }; +} + +export type WindowTestValue = Partial; +export type LocationTestValue = Partial; + +export interface TestGlobalValues { + chrome?: BrowserTestApi | undefined; + browser?: BrowserTestApi | undefined; + opr?: {sidebarAction?: Partial} | undefined; + safari?: object | undefined; + navigator?: NavigatorTestValue | undefined; + window?: WindowTestValue | undefined; + location?: LocationTestValue | undefined; + consoleError?: ((...args: unknown[]) => void) | undefined; +} + +interface DescriptorChange { + key: PropertyKey; + descriptor: PropertyDescriptor | undefined; + target: object; +} + +const applyDescriptor = (target: object, key: PropertyKey, value: unknown): void => { + if (typeof value === "undefined") { + if (!Reflect.deleteProperty(target, key)) { + throw new Error(`Unable to remove global ${String(key)}`); + } + return; + } + + if ( + !Reflect.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) + ) { + throw new Error(`Unable to install global ${String(key)}`); + } +}; + +const restoreChanges = (changes: readonly DescriptorChange[]): void => { + for (const {descriptor, key, target} of [...changes].reverse()) { + if (descriptor) { + Reflect.defineProperty(target, key, descriptor); + } else { + Reflect.deleteProperty(target, key); + } + } +}; + +/** Installs only own properties present in `values` and restores their exact descriptors. */ +export const installGlobals = (values: TestGlobalValues): (() => void) => { + const changes: DescriptorChange[] = []; + + try { + for (const key of ["chrome", "browser", "opr", "safari", "navigator", "window", "location"] as const) { + if (!Object.hasOwn(values, key)) continue; + + changes.push({descriptor: Reflect.getOwnPropertyDescriptor(globalThis, key), key, target: globalThis}); + applyDescriptor(globalThis, key, values[key]); + } + + if (Object.hasOwn(values, "consoleError")) { + changes.push({ + descriptor: Reflect.getOwnPropertyDescriptor(console, "error"), + key: "error", + target: console, + }); + applyDescriptor(console, "error", values.consoleError); + } + } catch (error) { + restoreChanges(changes); + throw error; + } + + let restored = false; + + return (): void => { + if (restored) return; + restored = true; + restoreChanges(changes); + }; +}; + +export interface ContextGlobals { + location: LocationTestValue | undefined; + window: WindowTestValue | undefined; +} + +export const createContextGlobals = (kind: ExtensionContextKind): ContextGlobals => { + if (kind === "serviceWorker" || kind === "none") { + return {location: undefined, window: undefined}; + } + + const location = + kind === "contentScript" + ? ({ + hash: "", + host: "example.test", + hostname: "example.test", + href: "https://example.test/content/page.html", + origin: "https://example.test", + pathname: "/content/page.html", + port: "", + protocol: "https:", + search: "", + } satisfies LocationTestValue) + : ({ + pathname: kind === "backgroundPage" ? "/_generated_background_page.html" : "/index.html", + } satisfies LocationTestValue); + + return { + location, + window: {location: location as Location} as WindowTestValue, + }; +}; + +export interface InstallBrowserGlobalsOptions { + profile?: BrowserProfile; + context?: ExtensionContextKind; + captureListenerErrors?: boolean; + /** Required to express non-standard namespace combinations with the custom profile. */ + globals?: TestGlobalValues; +} + +const chromeNavigator = (): NavigatorTestValue => ({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + userAgentData: { + brands: [ + {brand: "Chromium", version: "126"}, + {brand: "Google Chrome", version: "126"}, + ], + }, +}); + +const firefoxNavigator = (): NavigatorTestValue => ({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", +}); + +const operaNavigator = (): NavigatorTestValue => ({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36 OPR/112.0.0.0", + userAgentData: { + brands: [ + {brand: "Chromium", version: "126"}, + {brand: "Opera", version: "112"}, + ], + }, +}); + +const safariNavigator = (): NavigatorTestValue => ({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", +}); + +const profileGlobals = (harness: BrowserHarness, profile: Exclude): TestGlobalValues => { + switch (profile) { + case "chrome": + return { + browser: undefined, + chrome: harness.chrome, + navigator: chromeNavigator(), + opr: undefined, + safari: undefined, + }; + case "firefox": + return { + browser: harness.browser, + chrome: harness.chrome, + navigator: firefoxNavigator(), + opr: undefined, + safari: undefined, + }; + case "opera": + return { + browser: undefined, + chrome: harness.chrome, + navigator: operaNavigator(), + opr: Object.defineProperty({}, "sidebarAction", { + configurable: true, + enumerable: true, + get: () => harness.getOperaSidebarAction(), + }), + safari: undefined, + }; + case "safari": + return { + browser: harness.browser, + chrome: harness.chrome, + navigator: safariNavigator(), + opr: undefined, + safari: {}, + }; + } +}; + +/** Installs a coherent browser profile. Importing this module never mutates globals by itself. */ +export const installBrowserGlobals = ( + harness: BrowserHarness, + options: InstallBrowserGlobalsOptions = {} +): (() => void) => { + const profile = options.profile ?? "chrome"; + const context = options.context ?? "extensionPage"; + + harness.setActiveProfile(profile); + harness.setProfileSidebarFlavor(sidebarDefaultForProfile(profile)); + if (profile !== "custom") { + harness.setProfileCapability("runtime.getBrowserInfo", profile === "firefox"); + } + + const values: TestGlobalValues = + profile === "custom" + ? {...createContextGlobals(context), ...(options.globals ?? {})} + : {...profileGlobals(harness, profile), ...createContextGlobals(context), ...(options.globals ?? {})}; + + if (options.captureListenerErrors) { + values.consoleError = harness.getListenerErrorHandler(console.error); + } + + return installGlobals(values); +}; diff --git a/src/testing/harness.test.ts b/src/testing/harness.test.ts new file mode 100644 index 0000000..90ce9d4 --- /dev/null +++ b/src/testing/harness.test.ts @@ -0,0 +1,69 @@ +import {installBrowserGlobals, installGlobals} from "./globals"; +import {createBrowserHarness} from "./harness"; + +describe("browser profiles and globals", () => { + test("keeps Firefox facades live after installation and restores exact descriptors", () => { + const browserDescriptor = Object.getOwnPropertyDescriptor(globalThis, "browser"); + const chromeDescriptor = Object.getOwnPropertyDescriptor(globalThis, "chrome"); + const harness = createBrowserHarness({extensionId: "initial-id"}); + const restore = installBrowserGlobals(harness, {context: "serviceWorker", profile: "firefox"}); + + expect(globalThis.browser).toBe(harness.browser); + expect(globalThis.chrome).toBe(harness.chrome); + expect(globalThis.window).toBeUndefined(); + expect(globalThis.location).toBeUndefined(); + expect(globalThis.browser.sidebarAction).toBeDefined(); + + harness.runtime.setExtensionId("changed-id"); + expect(globalThis.browser.runtime.id).toBe("changed-id"); + + harness.capabilities.set("runtime.getBrowserInfo", false); + expect("getBrowserInfo" in globalThis.browser.runtime).toBe(false); + harness.capabilities.set("runtime.getBrowserInfo", true); + expect(typeof globalThis.browser.runtime.getBrowserInfo).toBe("function"); + + harness.sidebar.flavor = "none"; + expect(globalThis.browser.sidebarAction).toBeUndefined(); + + restore(); + restore(); + expect(Object.getOwnPropertyDescriptor(globalThis, "browser")).toEqual(browserDescriptor); + expect(Object.getOwnPropertyDescriptor(globalThis, "chrome")).toEqual(chromeDescriptor); + }); + + test.each([ + ["chrome", false, false, false], + ["opera", false, true, false], + ["safari", true, false, true], + ] as const)("installs a coherent %s profile", (profile, hasBrowser, hasOpera, hasSafari) => { + const harness = createBrowserHarness(); + const restore = installBrowserGlobals(harness, {profile}); + + expect(typeof globalThis.browser !== "undefined").toBe(hasBrowser); + expect(typeof globalThis.opr !== "undefined").toBe(hasOpera); + expect(typeof globalThis.safari !== "undefined").toBe(hasSafari); + expect(globalThis.window?.location).toBe(globalThis.location); + + restore(); + }); + + test("captures only known listener errors and forwards unknown console errors", () => { + const forwarded: unknown[][] = []; + const harness = createBrowserHarness(); + const restoreConsole = installGlobals({consoleError: (...args) => forwarded.push(args)}); + const restore = installBrowserGlobals(harness, {captureListenerErrors: true}); + const restoreNested = installBrowserGlobals(harness, {captureListenerErrors: true}); + const syncError = new Error("sync failure"); + + console.error("Listener error:", syncError); + console.error("unrelated", 42); + + expect(harness.listenerErrors.entries).toEqual([{args: [], error: syncError, kind: "sync"}]); + expect(harness.listenerErrors.raw).toEqual([["unrelated", 42]]); + expect(forwarded).toEqual([["unrelated", 42]]); + + restoreNested(); + restore(); + restoreConsole(); + }); +}); diff --git a/src/testing/harness.ts b/src/testing/harness.ts new file mode 100644 index 0000000..320e0ce --- /dev/null +++ b/src/testing/harness.ts @@ -0,0 +1,350 @@ +import {createBrowserMemoryState} from "./browser-state"; +import { + type ConfigurableBrowserControls, + type ConfigurableNamespaces, + createConfigurableNamespaces, +} from "./configurable"; +import {createLastErrorController} from "./internal"; +import {createListenerErrorCapture, type ListenerErrorBuffer} from "./listener-errors"; +import {createPermissionsHarness, type PermissionsHarness} from "./permissions"; +import {createRuntimeHarness, type RuntimeHarness} from "./runtime"; +import {createScriptingHarness, type ScriptingHarness} from "./scripting"; +import {createTabsHarness, type TabsHarness} from "./tabs"; +import {createWindowsHarness, type WindowsHarness} from "./windows"; +import type {BrowserMethodCall} from "./method"; +import type { + BrowserHarnessCall, + BrowserProfile, + BrowserTestApi, + OperaSidebarActionTestApi, + SidebarFlavor, +} from "./types"; + +export interface BrowserHarnessOptions { + extensionId?: string; + manifest?: chrome.runtime.Manifest; + permissions?: chrome.permissions.Permissions; + contexts?: readonly chrome.runtime.ExtensionContext[]; + messageSender?: chrome.runtime.MessageSender; + tabs?: readonly chrome.tabs.Tab[]; + windows?: readonly chrome.windows.Window[]; + registeredContentScripts?: readonly chrome.scripting.RegisteredContentScript[]; +} + +export interface BrowserCapabilitiesHarness { + set(path: string, enabled: boolean): void; + has(path: string): boolean; +} + +export interface SidebarHarness { + flavor: SidebarFlavor; + readonly sidePanel: ConfigurableBrowserControls["sidePanel"]; + readonly firefox: ConfigurableBrowserControls["sidebarAction"]; + readonly opera: ConfigurableBrowserControls["operaSidebarAction"]; +} + +export interface ConfigurableHarness { + readonly chrome: ConfigurableBrowserControls; + readonly browser: ConfigurableBrowserControls; + readonly active: ConfigurableBrowserControls; + readonly chromeNamespaces: ConfigurableNamespaces; + readonly browserNamespaces: ConfigurableNamespaces; +} + +export interface BrowserHarness { + readonly chrome: BrowserTestApi; + readonly browser: BrowserTestApi; + readonly runtime: RuntimeHarness; + readonly permissions: PermissionsHarness; + readonly tabs: TabsHarness; + readonly windows: WindowsHarness; + readonly scripting: ScriptingHarness; + readonly configurable: ConfigurableHarness; + readonly capabilities: BrowserCapabilitiesHarness; + readonly sidebar: SidebarHarness; + readonly listenerErrors: ListenerErrorBuffer; + readonly calls: readonly BrowserHarnessCall[]; + reset(): void; + /** @internal Used by the profile installer. */ + setActiveProfile(profile: BrowserProfile): void; + /** @internal Used by the profile installer without overriding an explicit flavor. */ + setProfileSidebarFlavor(flavor: SidebarFlavor): void; + /** @internal Applies a profile default unless the consumer explicitly changed the capability. */ + setProfileCapability(path: string, enabled: boolean): void; + /** @internal Browser-profile view without changing the harness facades. */ + createProfileFacade(facade: "chrome" | "browser", includeBrowserInfo: boolean): BrowserTestApi; + /** @internal Opera global for the currently selected flavor. */ + getOperaSidebarAction(): OperaSidebarActionTestApi | undefined; + /** @internal Handler installed only when listener capture is requested. */ + getListenerErrorHandler(forward?: (...args: unknown[]) => void): (...args: unknown[]) => void; +} + +interface NamedMethodCalls { + namespace: string; + source: Record; +} + +const methodCalls = ({namespace, source}: NamedMethodCalls): BrowserHarnessCall[] => + Object.entries(source).flatMap(([member, control]) => { + if (!control || typeof control !== "object" || !("calls" in control)) return []; + return (control as {calls: readonly BrowserMethodCall[]}).calls.map(call => ({ + ...call, + api: `${namespace}.${member}`, + })); + }); + +const cloneFacade = (api: BrowserTestApi): BrowserTestApi => { + const copy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(api)) as BrowserTestApi; + for (const [namespace, value] of Object.entries(api)) { + if (value && typeof value === "object") { + const namespaceCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(value)); + Reflect.set(copy as object, namespace, namespaceCopy); + } + } + return copy; +}; + +const sidebarDefaultForProfile = (profile: BrowserProfile): SidebarFlavor => { + if (profile === "firefox") return "firefoxSidebarAction"; + if (profile === "opera") return "operaSidebarAction"; + if (profile === "chrome") return "sidePanel"; + return "none"; +}; + +export const createBrowserHarness = (options: BrowserHarnessOptions = {}): BrowserHarness => { + let sequence = 0; + const nextSequence = (): number => ++sequence; + const lastError = createLastErrorController(); + const state = createBrowserMemoryState({tabs: options.tabs, windows: options.windows}); + const configChrome = createConfigurableNamespaces({facade: "chrome", lastError, nextSequence}); + const configBrowser = createConfigurableNamespaces({facade: "browser", lastError, nextSequence}); + const runtime = createRuntimeHarness(options, lastError, nextSequence); + const permissions = createPermissionsHarness(options.permissions, lastError, nextSequence); + const tabs = createTabsHarness(state, lastError, nextSequence); + const windows = createWindowsHarness(state, tabs, lastError, nextSequence); + const scripting = createScriptingHarness(options.registeredContentScripts, lastError, nextSequence); + const listenerCapture = createListenerErrorCapture(); + + const mergeDescriptors = (target: object, source: object): void => { + Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); + }; + + const chrome = configChrome.api as unknown as BrowserTestApi; + const browser = configBrowser.api as unknown as BrowserTestApi; + const sidePanelChromeApi = configChrome.api.sidePanel; + const sidePanelBrowserApi = configBrowser.api.sidePanel; + let activeProfile: BrowserProfile = "chrome"; + let sidebarFlavor: SidebarFlavor = "sidePanel"; + let sidebarExplicit = false; + const explicitCapabilities = new Set(); + + const ownedChrome: Record = {}; + const ownedBrowser: Record = {}; + + const mergeStateful = (): void => { + mergeDescriptors(chrome.runtime, runtime.chromeApi); + mergeDescriptors(browser.runtime, runtime.browserApi); + mergeDescriptors(chrome.permissions, permissions.api); + mergeDescriptors(browser.permissions, permissions.api); + mergeDescriptors(chrome.tabs, tabs.api); + mergeDescriptors(browser.tabs, tabs.api); + mergeDescriptors(chrome.windows, windows.api); + mergeDescriptors(browser.windows, windows.api); + mergeDescriptors(chrome.scripting, scripting.api); + mergeDescriptors(browser.scripting, scripting.api); + }; + + mergeStateful(); + + for (const [namespace, chromeNamespace, browserNamespace] of [ + ["runtime", runtime.chromeApi, runtime.browserApi], + ["permissions", permissions.api, permissions.api], + ["tabs", tabs.api, tabs.api], + ["windows", windows.api, windows.api], + ["scripting", scripting.api, scripting.api], + ] as const) { + for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(chromeNamespace))) { + ownedChrome[`${namespace}.${member}`] = descriptor; + } + for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(browserNamespace))) { + ownedBrowser[`${namespace}.${member}`] = descriptor; + } + } + ownedBrowser["runtime.getBrowserInfo"] = Object.getOwnPropertyDescriptor( + runtime.browserApi, + "getBrowserInfo" + ) as PropertyDescriptor; + + const applySidebarFlavor = (): void => { + Reflect.deleteProperty(chrome, "sidePanel"); + Reflect.deleteProperty(browser, "sidePanel"); + Reflect.deleteProperty(chrome, "sidebarAction"); + Reflect.deleteProperty(browser, "sidebarAction"); + + if (sidebarFlavor === "sidePanel") { + chrome.sidePanel = sidePanelChromeApi; + browser.sidePanel = sidePanelBrowserApi; + } else if (sidebarFlavor === "firefoxSidebarAction") { + browser.sidebarAction = configBrowser.sidebarActionApi; + } + }; + + applySidebarFlavor(); + + const setOwnedCapability = (path: string, enabled: boolean): boolean => { + if (!(path in ownedChrome) && !(path in ownedBrowser)) return false; + const [namespace, member] = path.split("."); + const chromeNamespace = (chrome as unknown as Record>)[namespace]; + const browserNamespace = (browser as unknown as Record>)[namespace]; + if (enabled) { + if (path in ownedChrome) Object.defineProperty(chromeNamespace, member, ownedChrome[path]); + if (path in ownedBrowser) Object.defineProperty(browserNamespace, member, ownedBrowser[path]); + } else { + Reflect.deleteProperty(chromeNamespace, member); + Reflect.deleteProperty(browserNamespace, member); + } + return true; + }; + + const applyCapability = (path: string, enabled: boolean): void => { + if (setOwnedCapability(path, enabled)) return; + let recognized = false; + for (const config of [configChrome, configBrowser]) { + try { + config.setCapability(path, enabled); + recognized = true; + } catch { + // The other facade or a stateful namespace may own this path. + } + } + if (!recognized) throw new Error(`Unknown browser capability "${path}"`); + }; + + const capabilities: BrowserCapabilitiesHarness = { + has(path): boolean { + const [namespace, member] = path.split("."); + const chromeNamespace = (chrome as unknown as Record | undefined>)[ + namespace + ]; + const browserNamespace = (browser as unknown as Record | undefined>)[ + namespace + ]; + return ( + Boolean(chromeNamespace && member in chromeNamespace) || + Boolean(browserNamespace && member in browserNamespace) + ); + }, + set(path, enabled): void { + applyCapability(path, enabled); + explicitCapabilities.add(path); + }, + }; + + const sidebar: SidebarHarness = { + get flavor() { + return sidebarFlavor; + }, + set flavor(value: SidebarFlavor) { + sidebarFlavor = value; + sidebarExplicit = true; + applySidebarFlavor(); + }, + sidePanel: configChrome.controls.sidePanel, + firefox: configBrowser.controls.sidebarAction, + opera: configChrome.controls.operaSidebarAction, + }; + + const configurable: ConfigurableHarness = { + chrome: configChrome.controls, + browser: configBrowser.controls, + get active() { + return activeProfile === "firefox" || activeProfile === "safari" + ? configBrowser.controls + : configChrome.controls; + }, + chromeNamespaces: configChrome, + browserNamespaces: configBrowser, + }; + + const callSources: NamedMethodCalls[] = [ + {namespace: "runtime", source: runtime as unknown as Record}, + {namespace: "permissions", source: permissions as unknown as Record}, + {namespace: "tabs", source: tabs as unknown as Record}, + {namespace: "windows", source: windows as unknown as Record}, + {namespace: "scripting", source: scripting as unknown as Record}, + ]; + + return { + chrome, + browser, + runtime, + permissions, + tabs, + windows, + scripting, + configurable, + capabilities, + sidebar, + listenerErrors: listenerCapture, + get calls() { + return [...callSources.flatMap(methodCalls), ...configChrome.calls, ...configBrowser.calls].sort( + (left, right) => left.sequence - right.sequence + ); + }, + createProfileFacade(facade, includeBrowserInfo) { + const result = cloneFacade(facade === "chrome" ? chrome : browser); + if (!includeBrowserInfo) Reflect.deleteProperty(result.runtime, "getBrowserInfo"); + return result; + }, + getListenerErrorHandler(forward) { + if (forward) listenerCapture.setForward(forward); + return listenerCapture.handler; + }, + getOperaSidebarAction() { + return sidebarFlavor === "operaSidebarAction" + ? (configChrome.operaSidebarActionApi as unknown as OperaSidebarActionTestApi) + : undefined; + }, + reset(): void { + sequence = 0; + state.reset(); + runtime.reset(); + if (activeProfile === "firefox") runtime.setUrlScheme("moz-extension"); + else if (activeProfile === "safari") runtime.setUrlScheme("safari-web-extension"); + permissions.reset(); + tabs.reset(); + windows.reset(); + scripting.reset(); + configChrome.reset(); + configBrowser.reset(); + lastError.reset(); + listenerCapture.reset(); + explicitCapabilities.clear(); + mergeStateful(); + for (const path of new Set([...Object.keys(ownedChrome), ...Object.keys(ownedBrowser)])) { + setOwnedCapability(path, true); + } + applyCapability("runtime.getBrowserInfo", activeProfile === "firefox"); + sidebarExplicit = false; + sidebarFlavor = sidebarDefaultForProfile(activeProfile); + applySidebarFlavor(); + }, + setActiveProfile(profile) { + activeProfile = profile; + if (profile === "firefox") runtime.setUrlScheme("moz-extension"); + else if (profile === "safari") runtime.setUrlScheme("safari-web-extension"); + else if (profile !== "custom") runtime.setUrlScheme("chrome-extension"); + }, + setProfileCapability(path, enabled) { + if (!explicitCapabilities.has(path)) applyCapability(path, enabled); + }, + setProfileSidebarFlavor(flavor) { + if (!sidebarExplicit) { + sidebarFlavor = flavor; + applySidebarFlavor(); + } + }, + }; +}; + +export {sidebarDefaultForProfile}; diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..ba5740a --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,78 @@ +export { + EXPECTED_ROOT_RUNTIME_EXPORT_COUNT, + EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT, + getPublicExportCoverage, + getRawCapability, + PUBLIC_EXPORT_COVERAGE, + RAW_CAPABILITY_COVERAGE, + TYPE_ONLY_ROOT_EXPORTS, +} from "./coverage"; +export {createBrowserEvent} from "./event"; +export { + createExtensionContextFixture, + createInjectionResultFixture, + createInstalledDetailsFixture, + createManifestFixture, + createMessageSenderFixture, + createPermissionsFixture, + createTabFixture, + createWindowFixture, +} from "./fixtures"; +export {installBrowserGlobals, installGlobals} from "./globals"; +export {createBrowserHarness} from "./harness"; +export {createBrowserMethod} from "./method"; +export type { + PublicExportCoverage, + PublicExportCoverageEntry, + PublicExportKind, + RawCapabilityCoverage, + RawCapabilityEntry, + RawCapabilityKind, + RawFailureChannel, + RawMethodInvocation, +} from "./coverage"; +export type { + BrowserEventApi, + BrowserEventHarness, + BrowserEventListener, + BrowserEventRegistration, +} from "./event"; +export type { + InstallBrowserGlobalsOptions, + LocationTestValue, + NavigatorTestValue, + TestGlobalValues, + WindowTestValue, +} from "./globals"; +export type { + BrowserCapabilitiesHarness, + BrowserHarness, + BrowserHarnessOptions, + ConfigurableHarness, + SidebarHarness, +} from "./harness"; +export type {ListenerErrorBuffer, ListenerErrorKind, ListenerErrorRecord} from "./listener-errors"; +export type { + BrowserMethod, + BrowserMethodCall, + BrowserMethodCallback, + BrowserMethodInvocation, + BrowserMethodInvocationStyle, + BrowserMethodObservedInvocation, + BrowserMethodOptions, +} from "./method"; +export type { + BrowserHarnessCall, + BrowserProfile, + BrowserTestApi, + ExtensionContextKind, + FirefoxSidebarActionTestApi, + OperaSidebarActionTestApi, + PermissionsTestApi, + RuntimeTestApi, + ScriptingTestApi, + SidebarFlavor, + SidePanelTestApi, + TabsTestApi, + WindowsTestApi, +} from "./types"; diff --git a/src/testing/internal.ts b/src/testing/internal.ts new file mode 100644 index 0000000..254e6c7 --- /dev/null +++ b/src/testing/internal.ts @@ -0,0 +1,88 @@ +import type {BrowserHarnessCall, RuntimeLastErrorController} from "./types"; + +export const cloneArray = (values: readonly T[] | undefined): T[] | undefined => + values ? values.map(value => cloneRecord(value)) : undefined; + +export const cloneRecord = (value: T): T => { + if (Array.isArray(value)) { + return value.map(item => cloneRecord(item)) as T; + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [key, cloneRecord(item)]) + ) as T; + } + + return value; +}; + +const errorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + + return "Unknown browser API error"; +}; + +export const createLastErrorController = (): RuntimeLastErrorController & {reset(): void} => { + let current: chrome.runtime.LastError | undefined; + + return { + get current(): chrome.runtime.LastError | undefined { + return current; + }, + reset(): void { + current = undefined; + }, + runWithLastError(error: unknown, callback: () => T): T { + const previous = current; + current = {message: errorMessage(error)}; + + try { + return callback(); + } finally { + current = previous; + } + }, + }; +}; + +export const createCallCollector = () => { + const sources = new Map readonly Omit[]>(); + + return { + add(api: string, calls: () => readonly Omit[]): void { + sources.set(api, calls); + }, + all(): BrowserHarnessCall[] { + return [...sources.entries()] + .flatMap(([api, getCalls]) => getCalls().map(call => ({...call, api}))) + .sort((left, right) => left.sequence - right.sequence); + }, + }; +}; + +export const matchesContextFilter = ( + context: chrome.runtime.ExtensionContext, + filter: chrome.runtime.ContextFilter +): boolean => { + const checks: Array<[readonly unknown[] | undefined, unknown]> = [ + [filter.contextIds, context.contextId], + [filter.contextTypes, context.contextType], + [filter.documentIds, context.documentId], + [filter.documentOrigins, context.documentOrigin], + [filter.documentUrls, context.documentUrl], + [filter.frameIds, context.frameId], + [filter.tabIds, context.tabId], + [filter.windowIds, context.windowId], + ]; + + if (typeof filter.incognito === "boolean" && context.incognito !== filter.incognito) return false; + + return checks.every(([expected, actual]) => !expected || expected.includes(actual)); +}; + +export const unsupportedApiError = (name: string): Error => new Error(`Browser test API "${name}" is not configured`); + +export const missingEntityError = (kind: "tab" | "window", id: number): Error => + new Error(`No ${kind} with id: ${id}.`); diff --git a/src/testing/listener-errors.ts b/src/testing/listener-errors.ts new file mode 100644 index 0000000..466ba77 --- /dev/null +++ b/src/testing/listener-errors.ts @@ -0,0 +1,54 @@ +export type ListenerErrorKind = "sync" | "promise"; + +export interface ListenerErrorRecord { + kind: ListenerErrorKind; + error: unknown; + args: readonly unknown[]; +} + +export interface ListenerErrorBuffer { + readonly entries: ListenerErrorRecord[]; + readonly raw: Array; + reset(): void; +} + +export interface ListenerErrorCapture extends ListenerErrorBuffer { + handler: (...args: unknown[]) => void; + setForward(forward: (...args: unknown[]) => void): void; +} + +const prefixes: Record = { + "Listener error:": "sync", + "Listener in promise error:": "promise", +}; + +export const createListenerErrorCapture = (forward?: (...args: unknown[]) => void): ListenerErrorCapture => { + let original = forward ?? console.error.bind(console); + const entries: ListenerErrorRecord[] = []; + const raw: Array = []; + const handler = (...args: unknown[]): void => { + const [prefix, error, ...details] = args; + const kind = typeof prefix === "string" ? prefixes[prefix] : undefined; + + if (kind && args.length >= 2) { + entries.push({args: details, error, kind}); + return; + } + + raw.push([...args]); + original(...args); + }; + + return { + entries, + raw, + handler, + reset(): void { + entries.length = 0; + raw.length = 0; + }, + setForward(value): void { + if (value !== handler) original = (...args) => Reflect.apply(value, console, args); + }, + }; +}; diff --git a/src/testing/method.test.ts b/src/testing/method.test.ts new file mode 100644 index 0000000..338312b --- /dev/null +++ b/src/testing/method.test.ts @@ -0,0 +1,257 @@ +import {createBrowserMethod} from "./method"; + +type SyncApi = (value: string) => number; +type CallbackApi = (value: string, callback: (result: number) => void) => void; +type PromiseApi = (value: string) => Promise; +type DualApi = { + (value: string): Promise; + (value: string, callback: (result: number) => void): void; +}; +type PromiseTolerantApi = (value: string, callback?: (result: number) => void) => Promise; +type HybridApi = (value: string, callback: (result: number) => void) => Promise | undefined; + +describe("createBrowserMethod", () => { + test("records sync calls and returns a persistent result", () => { + const method = createBrowserMethod({name: "runtime.sync", invocation: "sync"}); + + method.setResult(3); + + expect(method.api("input")).toBe(3); + expect(method.calls).toEqual([ + { + sequence: 1, + args: ["input"], + callback: undefined, + invocation: "sync", + callbackCalls: [], + }, + ]); + expect(Object.isFrozen(method.calls)).toBe(true); + expect(Object.isFrozen(method.calls[0].args)).toBe(true); + }); + + test("names an unconfigured method in sync and Promise errors", async () => { + const syncMethod = createBrowserMethod({name: "runtime.sync", invocation: "sync"}); + const promiseMethod = createBrowserMethod({name: "tabs.query", invocation: "promise"}); + + expect(() => syncMethod.api("input")).toThrow( + 'Browser method "runtime.sync" was called without a configured result or implementation.' + ); + await expect(promiseMethod.api("input")).rejects.toThrow( + 'Browser method "tabs.query" was called without a configured result or implementation.' + ); + }); + + test("uses failNext before queued and persistent results", async () => { + const method = createBrowserMethod({name: "tabs.get", invocation: "promise"}); + const failure = new Error("temporary failure"); + + method.setResult(4); + method.queueResult(2, 3); + method.failNext(failure); + + await expect(method.api("first")).rejects.toBe(failure); + await expect(method.api("second")).resolves.toBe(2); + await expect(method.api("third")).resolves.toBe(3); + await expect(method.api("fourth")).resolves.toBe(4); + }); + + test("turns synchronous implementation errors into Promise rejections", async () => { + const failure = new Error("implementation failed"); + const method = createBrowserMethod({ + name: "tabs.get", + invocation: "promise", + implementation: (() => { + throw failure; + }) as PromiseApi, + }); + + await expect(method.api("input")).rejects.toBe(failure); + }); + + test("invokes callback methods and tracks callback calls", () => { + const method = createBrowserMethod({ + name: "tabs.callback", + invocation: "callback", + }); + const callback = jest.fn(); + + method.setResult(5); + + expect(method.api("input", callback)).toBeUndefined(); + expect(callback).toHaveBeenCalledWith(5); + expect(method.calls[0]).toMatchObject({ + args: ["input"], + callback, + invocation: "callback", + callbackCalls: [[5]], + }); + }); + + test("supports callback argument mapping for void and multiple result callbacks", () => { + type MultiCallbackApi = (callback: (left: string, right: number) => void) => void; + const method = createBrowserMethod({ + name: "runtime.multi", + invocation: "callback", + callbackArgs: value => value, + }); + const callback = jest.fn(); + + method.setResult(["value", 7]); + method.api(callback); + + expect(callback).toHaveBeenCalledWith("value", 7); + }); + + test("selects callback or Promise behavior for dual methods at call time", async () => { + const method = createBrowserMethod({name: "tabs.dual", invocation: "dual"}); + const callback = jest.fn(); + + method.setResult(8); + + expect(method.api("callback", callback)).toBeUndefined(); + await expect(method.api("promise")).resolves.toBe(8); + expect(callback).toHaveBeenCalledWith(8); + expect(method.calls.map(call => call.invocation)).toEqual(["callback", "promise"]); + }); + + test("promise-tolerant methods ignore a trailing callback and always return a Promise", async () => { + const method = createBrowserMethod({ + name: "runtime.promiseTolerant", + invocation: "promise-tolerant", + }); + const callback = jest.fn(); + + method.setResult(9); + + await expect(method.api("input", callback)).resolves.toBe(9); + expect(callback).not.toHaveBeenCalled(); + expect(method.calls[0]).toMatchObject({callback, invocation: "promise-tolerant", callbackCalls: []}); + }); + + test("promise-only methods reject callback invocations without consuming configured results", async () => { + const method = createBrowserMethod({name: "tabs.promise", invocation: "promise"}); + const unsafeApi = method.api as unknown as (...args: unknown[]) => Promise; + + method.queueResult(10); + + await expect(unsafeApi("invalid", jest.fn())).rejects.toThrow( + 'Browser method "tabs.promise" is promise-only and does not accept a callback argument.' + ); + await expect(method.api("valid")).resolves.toBe(10); + }); + + test("callback-only methods reject missing callbacks without consuming configured results", () => { + const method = createBrowserMethod({name: "tabs.callback", invocation: "callback"}); + const unsafeApi = method.api as unknown as (...args: unknown[]) => void; + const callback = jest.fn(); + + method.queueResult(11); + + expect(() => unsafeApi("invalid")).toThrow( + 'Browser method "tabs.callback" requires a callback as its final argument.' + ); + method.api("valid", callback); + expect(callback).toHaveBeenCalledWith(11); + }); + + test("lets hybrid implementations call a callback and return a thenable", async () => { + const method = createBrowserMethod({name: "identity.getAuthToken", invocation: "hybrid"}); + const callback = jest.fn(); + const returned = Promise.resolve(13); + + method.setImplementation((_value, implementationCallback) => { + implementationCallback(12); + return returned; + }); + + expect(method.api("input", callback)).toBe(returned); + await expect(returned).resolves.toBe(13); + expect(callback).toHaveBeenCalledWith(12); + expect(method.calls[0].callbackCalls).toEqual([[12]]); + }); + + test("exposes lastError only while a failed callback runs", () => { + let lastError: unknown; + const controller = { + runWithLastError(error: unknown, callback: () => T): T { + lastError = error; + try { + return callback(); + } finally { + lastError = undefined; + } + }, + }; + const method = createBrowserMethod({ + name: "tabs.get", + invocation: "callback", + lastError: controller, + }); + const failure = new Error("missing tab"); + const observed: unknown[] = []; + + method.failNext(failure); + method.api("input", () => observed.push(lastError)); + + expect(observed).toEqual([failure]); + expect(lastError).toBeUndefined(); + }); + + test("does not silently lose callback failures without a lastError controller", () => { + const method = createBrowserMethod({name: "tabs.get", invocation: "callback"}); + + method.failNext(new Error("missing tab")); + + expect(() => method.api("input", jest.fn())).toThrow( + 'Browser method "tabs.get" cannot expose a callback error without a lastError controller.' + ); + }); + + test("keeps a default implementation across reset while clearing user configuration", () => { + const defaultImplementation: SyncApi = value => value.length; + const method = createBrowserMethod({ + name: "runtime.default", + invocation: "sync", + implementation: defaultImplementation, + }); + + expect(method.hasDefaultImplementation).toBe(true); + expect(method.api("abc")).toBe(3); + + method.setResult(20); + expect(method.api("x")).toBe(20); + + method.reset(); + + expect(method.calls).toEqual([]); + expect(method.api("abcd")).toBe(4); + expect(method.calls[0].sequence).toBe(1); + }); + + test("reports whether the current reset baseline has a default implementation", () => { + const method = createBrowserMethod({name: "runtime.default", invocation: "sync"}); + + expect(method.hasDefaultImplementation).toBe(false); + method.setImplementation(value => value.length); + expect(method.hasDefaultImplementation).toBe(false); + method.setDefaultImplementation(value => value.length + 1); + expect(method.hasDefaultImplementation).toBe(true); + method.setDefaultImplementation(undefined); + expect(method.hasDefaultImplementation).toBe(false); + }); + + test("supports a shared sequence source", () => { + let sequence = 40; + const method = createBrowserMethod({ + name: "runtime.sharedSequence", + invocation: "sync", + nextSequence: () => ++sequence, + }); + + method.setResult(1); + method.api("input"); + + expect(method.calls[0].sequence).toBe(41); + }); +}); diff --git a/src/testing/method.ts b/src/testing/method.ts new file mode 100644 index 0000000..ac69d38 --- /dev/null +++ b/src/testing/method.ts @@ -0,0 +1,281 @@ +export type BrowserMethodInvocationStyle = "sync" | "callback" | "promise" | "dual" | "promise-tolerant" | "hybrid"; + +export type BrowserMethodObservedInvocation = "sync" | "callback" | "promise" | "promise-tolerant" | "hybrid"; + +export type BrowserMethodInvocation = BrowserMethodObservedInvocation; + +export type BrowserMethodCallback = (...args: unknown[]) => unknown; + +export interface BrowserMethodLastErrorController { + runWithLastError(error: unknown, callback: () => T): T; +} + +export interface BrowserMethodCall { + readonly sequence: number; + readonly args: readonly unknown[]; + readonly callback: BrowserMethodCallback | undefined; + readonly invocation: BrowserMethodObservedInvocation; + readonly callbackCalls: readonly (readonly unknown[])[]; +} + +type BrowserMethodFunction = (...args: never[]) => unknown; + +export interface BrowserMethodOptions { + readonly name: string; + readonly invocation: BrowserMethodInvocationStyle; + readonly callback?: "last"; + readonly callbackArgs?: (result: TResult) => readonly unknown[]; + readonly implementation?: TApi; + readonly lastError?: BrowserMethodLastErrorController; + readonly nextSequence?: () => number; +} + +export interface BrowserMethod { + readonly api: TApi; + readonly calls: readonly BrowserMethodCall[]; + /** Whether the constructor/default baseline supplies an implementation. */ + readonly hasDefaultImplementation: boolean; + setResult(value: TResult): void; + queueResult(...values: readonly TResult[]): void; + setImplementation(implementation: TApi): void; + setDefaultImplementation(implementation: TApi | undefined): void; + failNext(error: unknown): void; + reset(): void; +} + +interface MutableBrowserMethodCall { + sequence: number; + args: unknown[]; + callback: BrowserMethodCallback | undefined; + invocation: BrowserMethodObservedInvocation; + callbackCalls: unknown[][]; +} + +interface ConfiguredResult { + configured: true; + value: TResult; +} + +const UNCONFIGURED_RESULT = {configured: false} as const; + +function methodConfigurationError(name: string, message: string): Error { + return new Error(`Browser method "${name}" ${message}`); +} + +function observedInvocation( + style: BrowserMethodInvocationStyle, + hasCallback: boolean +): BrowserMethodObservedInvocation { + switch (style) { + case "dual": + return hasCallback ? "callback" : "promise"; + case "promise-tolerant": + return "promise-tolerant"; + default: + return style; + } +} + +function immutableCall(call: MutableBrowserMethodCall): BrowserMethodCall { + return Object.freeze({ + sequence: call.sequence, + args: Object.freeze([...call.args]), + callback: call.callback, + invocation: call.invocation, + callbackCalls: Object.freeze(call.callbackCalls.map(args => Object.freeze([...args]))), + }); +} + +/** + * Creates a small configurable browser method without depending on a test runner. + */ +export function createBrowserMethod( + options: BrowserMethodOptions +): BrowserMethod { + let sequence = 0; + let calls: MutableBrowserMethodCall[] = []; + let queuedResults: TResult[] = []; + let queuedErrors: unknown[] = []; + let result: ConfiguredResult | typeof UNCONFIGURED_RESULT = UNCONFIGURED_RESULT; + let implementation: TApi | undefined; + let defaultImplementation = options.implementation; + + const recognizesCallback = + options.callback === "last" || + options.invocation === "callback" || + options.invocation === "promise" || + options.invocation === "dual" || + options.invocation === "promise-tolerant" || + options.invocation === "hybrid"; + + const invoke = (...rawArgs: unknown[]): unknown => { + const possibleCallback = recognizesCallback ? rawArgs.at(-1) : undefined; + const callback = + typeof possibleCallback === "function" ? (possibleCallback as BrowserMethodCallback) : undefined; + const args = callback ? rawArgs.slice(0, -1) : [...rawArgs]; + const invocation = observedInvocation(options.invocation, callback !== undefined); + const call: MutableBrowserMethodCall = { + sequence: options.nextSequence?.() ?? ++sequence, + args, + callback, + invocation, + callbackCalls: [], + }; + calls.push(call); + + const trackedCallback: BrowserMethodCallback | undefined = callback + ? (...callbackArgs) => { + call.callbackCalls.push([...callbackArgs]); + return callback(...callbackArgs); + } + : undefined; + + if (options.invocation === "callback" && !callback) { + throw methodConfigurationError(options.name, "requires a callback as its final argument."); + } + + if (options.invocation === "promise" && callback) { + return Promise.reject( + methodConfigurationError(options.name, "is promise-only and does not accept a callback argument.") + ); + } + + const isCallbackInvocation = + options.invocation === "callback" || + (options.invocation === "dual" && callback !== undefined) || + (options.invocation === "hybrid" && callback !== undefined); + const isPromiseInvocation = + options.invocation === "promise" || + options.invocation === "promise-tolerant" || + (options.invocation === "dual" && callback === undefined) || + (options.invocation === "hybrid" && callback === undefined); + + if (queuedErrors.length > 0) { + const error = queuedErrors.shift(); + + if (isCallbackInvocation && trackedCallback) { + if (!options.lastError) { + throw methodConfigurationError( + options.name, + "cannot expose a callback error without a lastError controller." + ); + } + + const callWithError = () => trackedCallback(); + options.lastError.runWithLastError(error, callWithError); + + return undefined; + } + + if (isPromiseInvocation) { + return Promise.reject(error); + } + + throw error; + } + + const hasQueuedResult = queuedResults.length > 0; + const configuredResult = hasQueuedResult + ? ({configured: true, value: queuedResults.shift() as TResult} satisfies ConfiguredResult) + : result; + const activeImplementation = implementation ?? defaultImplementation; + + if (configuredResult.configured && (hasQueuedResult || !implementation)) { + if (isCallbackInvocation && trackedCallback) { + const callbackArgs = + options.callbackArgs?.(configuredResult.value) ?? + (typeof configuredResult.value === "undefined" ? [] : [configuredResult.value]); + trackedCallback(...callbackArgs); + return undefined; + } + + if (isPromiseInvocation) { + return Promise.resolve(configuredResult.value); + } + + return configuredResult.value; + } + + if (activeImplementation) { + const implementationArgs = [...args]; + + if (trackedCallback && options.invocation !== "promise-tolerant" && options.invocation !== "promise") { + implementationArgs.push(trackedCallback); + } + + let implementationResult: unknown; + + try { + implementationResult = Reflect.apply(activeImplementation, undefined, implementationArgs); + } catch (error) { + if (isPromiseInvocation) { + return Promise.reject(error); + } + + throw error; + } + + if (options.invocation === "hybrid") { + return implementationResult; + } + + if (isCallbackInvocation) { + return undefined; + } + + if (isPromiseInvocation) { + return Promise.resolve(implementationResult); + } + + return implementationResult; + } + + const error = methodConfigurationError( + options.name, + "was called without a configured result or implementation." + ); + + if (isPromiseInvocation) { + return Promise.reject(error); + } + + throw error; + }; + + return { + api: invoke as unknown as TApi, + get calls() { + return Object.freeze(calls.map(immutableCall)); + }, + get hasDefaultImplementation() { + return defaultImplementation !== undefined; + }, + setResult(value) { + implementation = undefined; + result = {configured: true, value}; + }, + queueResult(...values) { + queuedResults.push(...values); + }, + setImplementation(value) { + result = UNCONFIGURED_RESULT; + implementation = value; + }, + setDefaultImplementation(value) { + defaultImplementation = value; + }, + failNext(error) { + queuedErrors.push(error); + }, + reset() { + // Keep the default implementation: reset restores the current default/stateful + // baseline while clearing call history and one-off consumer configuration. + sequence = 0; + calls = []; + queuedResults = []; + queuedErrors = []; + result = UNCONFIGURED_RESULT; + implementation = undefined; + }, + }; +} diff --git a/src/testing/permissions.ts b/src/testing/permissions.ts new file mode 100644 index 0000000..aa51253 --- /dev/null +++ b/src/testing/permissions.ts @@ -0,0 +1,180 @@ +import {type BrowserEventHarness, createBrowserEvent} from "./event"; +import {createPermissionsFixture} from "./fixtures"; +import {type BrowserMethod, createBrowserMethod} from "./method"; +import type {PermissionsTestApi, RuntimeLastErrorController} from "./types"; + +type PermissionEventArgs = Parameters[0]>; + +export interface PermissionsHarness { + readonly api: PermissionsTestApi; + readonly addHostAccessRequest: BrowserMethod; + readonly contains: BrowserMethod; + readonly getAll: BrowserMethod; + readonly remove: BrowserMethod; + readonly removeHostAccessRequest: BrowserMethod; + readonly request: BrowserMethod; + readonly onAdded: BrowserEventHarness; + readonly onRemoved: BrowserEventHarness; + readonly value: chrome.permissions.Permissions; + grant(value: chrome.permissions.Permissions): Promise; + revoke(value: chrome.permissions.Permissions): Promise; + set(value: chrome.permissions.Permissions): void; + reset(): void; +} + +const includesAll = (current: Set, expected: readonly T[] | undefined): boolean => + (expected ?? []).every(value => current.has(value)); + +export const createPermissionsHarness = ( + initialValue: chrome.permissions.Permissions | undefined, + lastError: RuntimeLastErrorController, + nextSequence?: () => number +): PermissionsHarness => { + const initial = createPermissionsFixture(initialValue); + let permissions = new Set(initial.permissions ?? []); + let origins = new Set(initial.origins ?? []); + + const onAdded = createBrowserEvent(); + const onRemoved = createBrowserEvent(); + + const addHostAccessRequest = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "permissions.addHostAccessRequest", + nextSequence, + }); + const removeHostAccessRequest = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "permissions.removeHostAccessRequest", + nextSequence, + }); + const contains = createBrowserMethod({ + callback: "last", + implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { + const result = includesAll(permissions, value.permissions) && includesAll(origins, value.origins); + callback?.(result); + return result; + }) as unknown as typeof chrome.permissions.contains, + invocation: "dual", + lastError, + name: "permissions.contains", + nextSequence, + }); + const getAll = createBrowserMethod({ + callback: "last", + implementation: ((callback?: (result: chrome.permissions.Permissions) => void) => { + const result = {origins: [...origins], permissions: [...permissions]}; + callback?.(result); + return result; + }) as unknown as typeof chrome.permissions.getAll, + invocation: "dual", + lastError, + name: "permissions.getAll", + nextSequence, + }); + + const apply = async ( + value: chrome.permissions.Permissions, + action: "grant" | "revoke" + ): Promise => { + const changedPermissions: chrome.runtime.ManifestPermission[] = []; + const changedOrigins: string[] = []; + const mutate = (set: Set, item: T): boolean => { + if (action === "revoke") return set.delete(item); + if (set.has(item)) return false; + set.add(item); + return true; + }; + + for (const permission of value.permissions ?? []) { + if (mutate(permissions, permission)) changedPermissions.push(permission); + } + for (const origin of value.origins ?? []) { + if (mutate(origins, origin)) changedOrigins.push(origin); + } + + const changed = {origins: changedOrigins, permissions: changedPermissions}; + if (changedPermissions.length || changedOrigins.length) { + await (action === "grant" ? onAdded : onRemoved).emit(changed); + } + return changed; + }; + + const request = createBrowserMethod({ + callback: "last", + implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { + await apply(value, "grant"); + callback?.(true); + return true; + }) as unknown as typeof chrome.permissions.request, + invocation: "dual", + lastError, + name: "permissions.request", + nextSequence, + }); + const remove = createBrowserMethod({ + callback: "last", + implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { + const changed = await apply(value, "revoke"); + const result = Boolean(changed.permissions?.length || changed.origins?.length); + callback?.(result); + return result; + }) as unknown as typeof chrome.permissions.remove, + invocation: "dual", + lastError, + name: "permissions.remove", + nextSequence, + }); + + const api = { + addHostAccessRequest: addHostAccessRequest.api, + contains: contains.api, + getAll: getAll.api, + onAdded: onAdded.api, + onRemoved: onRemoved.api, + remove: remove.api, + removeHostAccessRequest: removeHostAccessRequest.api, + request: request.api, + } as unknown as PermissionsTestApi; + + const methods = [addHostAccessRequest, contains, getAll, remove, removeHostAccessRequest, request]; + + return { + api, + addHostAccessRequest, + contains, + getAll, + remove, + removeHostAccessRequest, + request, + onAdded, + onRemoved, + get value() { + return {origins: [...origins], permissions: [...permissions]}; + }, + async grant(value): Promise { + await apply(value, "grant"); + }, + async revoke(value): Promise { + await apply(value, "revoke"); + }, + reset(): void { + permissions = new Set(initial.permissions ?? []); + origins = new Set(initial.origins ?? []); + methods.forEach(method => { + method.reset(); + }); + onAdded.reset(); + onRemoved.reset(); + }, + set(value): void { + permissions = new Set(value.permissions ?? []); + origins = new Set(value.origins ?? []); + }, + }; +}; diff --git a/src/testing/production.integration.test.ts b/src/testing/production.integration.test.ts new file mode 100644 index 0000000..0b8b774 --- /dev/null +++ b/src/testing/production.integration.test.ts @@ -0,0 +1,75 @@ +import {BlockDownloadError, download} from "../downloads"; +import {findTabById, getTabUrl} from "../tabs"; +import {getUserScripts} from "../userScripts"; +import {createBrowserHarness, installGlobals} from "./index"; + +const restorers: Array<() => void> = []; + +afterEach(() => { + while (restorers.length > 0) restorers.pop()?.(); +}); + +describe("current production behavior through the browser harness", () => { + test("uses the callback-schema getScripts method without weakening promise-only methods", async () => { + const harness = createBrowserHarness(); + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + const scripts: chrome.userScripts.RegisteredUserScript[] = [ + {id: "configured-script", js: [{file: "content.js"}], matches: ["https://example.test/*"]}, + ]; + harness.configurable.chrome.userScripts.getScripts.setResult(scripts); + + await expect(getUserScripts(["configured-script"])).resolves.toEqual(scripts); + expect(harness.configurable.chrome.userScripts.getScripts.calls[0]).toMatchObject({ + args: [{ids: ["configured-script"]}], + callback: expect.any(Function), + invocation: "callback", + }); + }); + + test("locks in the current missing-tab rejection cascade", async () => { + const harness = createBrowserHarness(); + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + + // Locks in current behavior; see https://github.com/addon-stack/browser/issues/23. + await expect(findTabById(999)).rejects.toThrow("No tab with id: 999."); + await expect(getTabUrl(999)).rejects.toThrow("No tab with id: 999."); + }); + + test("download succeeds after the production 100 ms delay", async () => { + const harness = createBrowserHarness(); + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + harness.configurable.chrome.downloads.download.setResult(41); + harness.configurable.chrome.downloads.search.setResult([ + {error: undefined, exists: true, id: 41, state: "in_progress"} as chrome.downloads.DownloadItem, + ]); + const startedAt = performance.now(); + + await expect(download({url: "https://download.example/file.zip"})).resolves.toBe(41); + + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90); + expect(harness.configurable.chrome.downloads.download.calls[0]?.args).toEqual([ + {conflictAction: "uniquify", url: "https://download.example/file.zip"}, + ]); + }); + + test("download preserves the exact BlockDownloadError class after the production delay", async () => { + const harness = createBrowserHarness(); + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + harness.configurable.chrome.downloads.download.setResult(42); + harness.configurable.chrome.downloads.search.setResult([ + {error: "USER_CANCELED", exists: true, id: 42, state: "interrupted"} as chrome.downloads.DownloadItem, + ]); + const startedAt = performance.now(); + + let failure: unknown; + try { + await download({url: "https://download.example/requires-permission.zip"}); + } catch (error) { + failure = error; + } + + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90); + expect(failure).toBeInstanceOf(BlockDownloadError); + expect(failure).toMatchObject({message: "Requires user permission to upload"}); + }); +}); diff --git a/src/testing/runtime.messaging.test.ts b/src/testing/runtime.messaging.test.ts new file mode 100644 index 0000000..c8fe91b --- /dev/null +++ b/src/testing/runtime.messaging.test.ts @@ -0,0 +1,201 @@ +import {onMessage, sendMessage} from "../runtime"; +import {createBrowserHarness, createMessageSenderFixture, createTabFixture, installGlobals} from "./index"; + +const restorers: Array<() => void> = []; +const CHANNEL_CLOSED_MESSAGE = + 'Browser method "runtime.sendMessage" message channel closed before a response was received.'; + +const installChromeHarness = (harness: ReturnType): void => { + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); +}; + +afterEach(() => { + while (restorers.length > 0) restorers.pop()?.(); +}); + +describe("stateful runtime messaging", () => { + test("routes the real callback wrapper through onMessage with the configured sender", async () => { + const harness = createBrowserHarness(); + const sender = createMessageSenderFixture({id: "sender-id", tab: createTabFixture({id: 42})}); + const received: Array<{message: unknown; sender: chrome.runtime.MessageSender}> = []; + + harness.runtime.setMessageSender(sender); + installChromeHarness(harness); + const unsubscribe = onMessage((message, actualSender, sendResponse) => { + received.push({message, sender: actualSender}); + sendResponse({kind: "pong"}); + }); + + await expect(sendMessage({kind: "ping"})).resolves.toEqual({kind: "pong"}); + expect(received).toEqual([{message: {kind: "ping"}, sender}]); + expect(harness.runtime.sendMessage.calls[0]).toMatchObject({ + args: [{kind: "ping"}], + invocation: "callback", + callbackCalls: [[{kind: "pong"}]], + }); + + unsubscribe(); + }); + + test("uses the first actual response while still starting every listener synchronously", async () => { + const harness = createBrowserHarness(); + const calls: string[] = []; + + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + calls.push("first"); + sendResponse("first response"); + }); + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + calls.push("second"); + sendResponse("second response"); + }); + + const response = harness.browser.runtime.sendMessage("ping"); + + expect(calls).toEqual(["first", "second"]); + await expect(response).resolves.toBe("first response"); + + const asyncHarness = createBrowserHarness(); + let resolveSlow: (value: string) => void = () => undefined; + asyncHarness.runtime.events.onMessage.on(() => { + calls.push("slow started"); + return new Promise(resolve => { + resolveSlow = resolve; + }); + }); + asyncHarness.runtime.events.onMessage.on(() => { + calls.push("fast started"); + return Promise.resolve("fast response"); + }); + + const asyncResponse = asyncHarness.browser.runtime.sendMessage("ping"); + + expect(calls).toEqual(["first", "second", "slow started", "fast started"]); + await expect(asyncResponse).resolves.toBe("fast response"); + resolveSlow("slow response"); + await Promise.resolve(); + }); + + test("holds the response channel only when a listener returns true", async () => { + const heldHarness = createBrowserHarness(); + heldHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + queueMicrotask(() => sendResponse("async response")); + return true; + }); + + await expect(heldHarness.browser.runtime.sendMessage("ping")).resolves.toBe("async response"); + + const closedHarness = createBrowserHarness(); + closedHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + queueMicrotask(() => sendResponse("too late")); + }); + + await expect(closedHarness.browser.runtime.sendMessage("ping")).resolves.toBeUndefined(); + }); + + test("accepts Promise and arbitrary thenable responses", async () => { + const promiseHarness = createBrowserHarness(); + promiseHarness.runtime.events.onMessage.on(() => Promise.resolve({source: "promise"})); + + await expect(promiseHarness.runtime.emitMessage("ping")).resolves.toEqual({source: "promise"}); + + const thenableHarness = createBrowserHarness(); + thenableHarness.runtime.events.onMessage.on(() => ({ + // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable. + then(resolve: (value: unknown) => void) { + resolve({source: "thenable"}); + }, + })); + + await expect(thenableHarness.browser.runtime.sendMessage("ping")).resolves.toEqual({source: "thenable"}); + }); + + test("resolves undefined when no listener responds", async () => { + const emptyHarness = createBrowserHarness(); + await expect(emptyHarness.browser.runtime.sendMessage("ping")).resolves.toBeUndefined(); + + const silentHarness = createBrowserHarness(); + silentHarness.runtime.events.onMessage.on(() => "not a WebExtension response channel"); + await expect(silentHarness.runtime.emitMessage("ping")).resolves.toBeUndefined(); + }); + + test("exposes dispatch failures through callback-scoped runtime.lastError", async () => { + const harness = createBrowserHarness(); + const failure = new Error("listener failed"); + const observed: Array<{message: string | undefined; response: unknown}> = []; + harness.runtime.events.onMessage.on(() => Promise.reject(failure)); + + await new Promise(resolve => { + harness.chrome.runtime.sendMessage("ping", response => { + observed.push({message: harness.chrome.runtime.lastError?.message, response}); + resolve(); + }); + }); + + expect(observed).toEqual([{message: "listener failed", response: undefined}]); + expect(harness.chrome.runtime.lastError).toBeUndefined(); + }); + + test("explicitly closes every pending message channel and ignores late responses", async () => { + const harness = createBrowserHarness(); + const lateResponses: Array<(response?: unknown) => void> = []; + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + lateResponses.push(sendResponse); + return true; + }); + + const first = harness.browser.runtime.sendMessage("first"); + const second = harness.runtime.emitMessage("second"); + harness.runtime.closeMessageChannels(); + + await expect(first).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); + await expect(second).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); + + lateResponses.forEach(sendResponse => { + sendResponse("too late"); + }); + harness.runtime.closeMessageChannels(); + await expect(first).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); + await expect(second).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); + }); + + test("reports an explicitly closed callback channel through scoped runtime.lastError", async () => { + const harness = createBrowserHarness(); + const observed: Array<{message: string | undefined; response: unknown}> = []; + harness.runtime.events.onMessage.on(() => true); + + const callbackFinished = new Promise(resolve => { + harness.chrome.runtime.sendMessage("ping", response => { + observed.push({message: harness.chrome.runtime.lastError?.message, response}); + resolve(); + }); + }); + + harness.runtime.closeMessageChannels(); + await callbackFinished; + + expect(observed).toEqual([{message: CHANNEL_CLOSED_MESSAGE, response: undefined}]); + expect(harness.chrome.runtime.lastError).toBeUndefined(); + }); + + test("reset rejects pending channels but leaves already settled dispatches unchanged", async () => { + const harness = createBrowserHarness(); + let holdOpen = false; + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { + if (holdOpen) return true; + sendResponse("settled response"); + }); + + const settled = harness.browser.runtime.sendMessage("settled"); + await expect(settled).resolves.toBe("settled response"); + + holdOpen = true; + const pending = harness.browser.runtime.sendMessage("pending"); + harness.reset(); + + await expect(pending).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); + await expect(settled).resolves.toBe("settled response"); + expect(harness.runtime.events.onMessage.listenerCount()).toBe(0); + expect(() => harness.runtime.closeMessageChannels()).not.toThrow(); + }); +}); diff --git a/src/testing/runtime.ts b/src/testing/runtime.ts new file mode 100644 index 0000000..9d9ed41 --- /dev/null +++ b/src/testing/runtime.ts @@ -0,0 +1,522 @@ +import {type BrowserEventHarness, createBrowserEvent} from "./event"; +import {createManifestFixture, createMessageSenderFixture} from "./fixtures"; +import {cloneRecord, matchesContextFilter} from "./internal"; +import {type BrowserMethod, createBrowserMethod} from "./method"; +import type {RuntimeLastErrorController, RuntimeTestApi} from "./types"; + +type ListenerArgs unknown, ...args: never[]): unknown}> = + Parameters[0]>; + +type InstalledArgs = ListenerArgs; +type StartupArgs = ListenerArgs; +type MessageArgs = ListenerArgs; +type ConnectArgs = ListenerArgs; +type ConnectExternalArgs = ListenerArgs; +type MessageExternalArgs = ListenerArgs; +type RestartRequiredArgs = ListenerArgs; +type SuspendArgs = ListenerArgs; +type SuspendCanceledArgs = ListenerArgs; +type UpdateAvailableArgs = ListenerArgs; +type UserScriptConnectArgs = ListenerArgs; +type UserScriptMessageArgs = ListenerArgs; + +interface RequestUpdateCheckResult { + status: `${chrome.runtime.RequestUpdateCheckStatus}`; + details?: chrome.runtime.UpdateCheckDetails; +} + +export interface RuntimeHarnessOptions { + extensionId?: string; + manifest?: chrome.runtime.Manifest; + contexts?: readonly chrome.runtime.ExtensionContext[]; + messageSender?: chrome.runtime.MessageSender; +} + +export interface RuntimeEventsHarness { + onConnect: BrowserEventHarness; + onConnectExternal: BrowserEventHarness; + onInstalled: BrowserEventHarness; + onMessage: BrowserEventHarness; + onMessageExternal: BrowserEventHarness; + onRestartRequired: BrowserEventHarness; + onStartup: BrowserEventHarness; + onSuspend: BrowserEventHarness; + onSuspendCanceled: BrowserEventHarness; + onUpdateAvailable: BrowserEventHarness; + onUserScriptConnect: BrowserEventHarness; + onUserScriptMessage: BrowserEventHarness; +} + +export interface RuntimeHarness { + readonly chromeApi: RuntimeTestApi; + readonly browserApi: RuntimeTestApi; + readonly connect: BrowserMethod; + readonly connectNative: BrowserMethod; + readonly getContexts: BrowserMethod; + readonly getManifest: BrowserMethod; + readonly getPackageDirectoryEntry: BrowserMethod< + typeof chrome.runtime.getPackageDirectoryEntry, + FileSystemDirectoryEntry + >; + readonly getPlatformInfo: BrowserMethod; + readonly getBrowserInfo: BrowserMethod; + readonly getURL: BrowserMethod; + readonly openOptionsPage: BrowserMethod; + readonly reload: BrowserMethod; + readonly requestUpdateCheck: BrowserMethod; + readonly restart: BrowserMethod; + readonly restartAfterDelay: BrowserMethod; + readonly sendMessage: BrowserMethod; + readonly setUninstallURL: BrowserMethod; + readonly events: RuntimeEventsHarness; + readonly messageSender: chrome.runtime.MessageSender; + readonly contexts: readonly chrome.runtime.ExtensionContext[]; + readonly manifest: chrome.runtime.Manifest; + readonly id: string; + readonly lastError: chrome.runtime.LastError | undefined; + setExtensionId(id: string): void; + setUrlScheme(scheme: "chrome-extension" | "moz-extension" | "safari-web-extension"): void; + setManifest(manifest: chrome.runtime.Manifest): void; + setContexts(contexts: readonly chrome.runtime.ExtensionContext[]): void; + addContext(context: chrome.runtime.ExtensionContext): void; + removeContext(contextId: string): void; + setMessageSender(sender: chrome.runtime.MessageSender): void; + emitMessage(message: unknown): Promise; + closeMessageChannels(): void; + reset(): void; +} + +const eventApi = ( + event: BrowserEventHarness +): chrome.events.Event<(...args: TArgs) => void> => + event.api as unknown as chrome.events.Event<(...args: TArgs) => void>; + +export const createRuntimeHarness = ( + options: RuntimeHarnessOptions, + lastError: RuntimeLastErrorController, + nextSequence?: () => number +): RuntimeHarness => { + const initialId = options.extensionId ?? "test-extension-id"; + const initialManifest = createManifestFixture(options.manifest); + const initialContexts = (options.contexts ?? []).map(context => cloneRecord(context)); + const initialSender = createMessageSenderFixture(options.messageSender); + + let extensionId = initialId; + let urlScheme: "chrome-extension" | "moz-extension" | "safari-web-extension" = "chrome-extension"; + let manifest = initialManifest; + let contexts = initialContexts; + let messageSender = initialSender; + + const events: RuntimeEventsHarness = { + onConnect: createBrowserEvent(), + onConnectExternal: createBrowserEvent(), + onInstalled: createBrowserEvent(), + onMessage: createBrowserEvent(), + onMessageExternal: createBrowserEvent(), + onRestartRequired: createBrowserEvent(), + onStartup: createBrowserEvent(), + onSuspend: createBrowserEvent(), + onSuspendCanceled: createBrowserEvent(), + onUpdateAvailable: createBrowserEvent(), + onUserScriptConnect: createBrowserEvent(), + onUserScriptMessage: createBrowserEvent(), + }; + + interface MessageChannel { + close(): void; + } + + const messageChannels = new Set(); + const messageChannelClosedError = (): Error => + new Error('Browser method "runtime.sendMessage" message channel closed before a response was received.'); + + const closeMessageChannels = (): void => { + for (const channel of [...messageChannels]) channel.close(); + }; + + const dispatchMessage = (message: unknown): Promise => + new Promise((resolve, reject) => { + let dispatchFinished = false; + let heldOpen = false; + let pendingResponses = 0; + let settled = false; + const errors: unknown[] = []; + + const channel: MessageChannel = { + close(): void { + rejectFirst(messageChannelClosedError()); + }, + }; + + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + messageChannels.delete(channel); + callback(); + }; + + const resolveFirst = (response: unknown): void => { + settle(() => resolve(response)); + }; + + const rejectFirst = (error: unknown): void => { + settle(() => reject(error)); + }; + + const finishWithoutResponse = (): void => { + if (settled || !dispatchFinished || pendingResponses > 0 || heldOpen) return; + + if (errors.length === 1) { + rejectFirst(errors[0]); + } else if (errors.length > 1) { + rejectFirst(new AggregateError(errors, "Multiple runtime.onMessage listeners failed")); + } else { + resolveFirst(undefined); + } + }; + + const sendResponse = (response?: unknown): void => { + resolveFirst(response); + }; + const sender = cloneRecord(messageSender); + const registrations = events.onMessage.registrations(); + messageChannels.add(channel); + + for (const {listener} of registrations) { + let listenerResult: unknown; + + try { + listenerResult = listener(message, sender, sendResponse); + } catch (error) { + errors.push(error); + continue; + } + + if (listenerResult === true) { + heldOpen = true; + continue; + } + + let then: unknown; + + try { + then = + listenerResult !== null && + (typeof listenerResult === "object" || typeof listenerResult === "function") + ? Reflect.get(listenerResult, "then") + : undefined; + } catch (error) { + errors.push(error); + continue; + } + + if (typeof then === "function") { + pendingResponses += 1; + Promise.resolve(listenerResult).then( + response => { + resolveFirst(response); + pendingResponses -= 1; + finishWithoutResponse(); + }, + error => { + errors.push(error); + pendingResponses -= 1; + finishWithoutResponse(); + } + ); + } + + // WebExtension message listeners answer through sendResponse or a + // Promise/thenable. Synchronous return values other than literal + // true do not become message responses. + } + + dispatchFinished = true; + finishWithoutResponse(); + }); + + const isMessageOptions = (value: unknown): boolean => { + if (value === undefined) return true; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + + return Object.keys(value).every(key => key === "includeTlsChannelId"); + }; + + const messageFromSendArguments = (args: readonly unknown[]): unknown => { + if (args.length < 2) return args[0]; + if (args.length === 2 && isMessageOptions(args[1])) return args[0]; + + return args[1]; + }; + + const sendRuntimeMessage = ((...rawArgs: unknown[]): Promise | undefined => { + const possibleCallback = rawArgs.at(-1); + const callback = typeof possibleCallback === "function" ? possibleCallback : undefined; + const args = callback ? rawArgs.slice(0, -1) : rawArgs; + const response = dispatchMessage(messageFromSendArguments(args)); + + if (!callback) return response; + + void response.then( + value => { + callback(value); + }, + error => { + lastError.runWithLastError(error, () => callback()); + } + ); + + return undefined; + }) as unknown as typeof chrome.runtime.sendMessage; + + const connect = createBrowserMethod({ + invocation: "sync", + name: "runtime.connect", + nextSequence, + }); + const connectNative = createBrowserMethod({ + invocation: "sync", + name: "runtime.connectNative", + nextSequence, + }); + const getContexts = createBrowserMethod({ + callback: "last", + implementation: (( + filter: chrome.runtime.ContextFilter, + callback?: (value: chrome.runtime.ExtensionContext[]) => void + ) => { + const result = contexts + .filter(context => matchesContextFilter(context, filter)) + .map(context => cloneRecord(context)); + callback?.(result); + return result; + }) as unknown as typeof chrome.runtime.getContexts, + invocation: "dual", + lastError, + name: "runtime.getContexts", + nextSequence, + }); + const getManifest = createBrowserMethod({ + implementation: (() => cloneRecord(manifest)) as typeof chrome.runtime.getManifest, + invocation: "sync", + name: "runtime.getManifest", + nextSequence, + }); + const getPackageDirectoryEntry = createBrowserMethod< + typeof chrome.runtime.getPackageDirectoryEntry, + FileSystemDirectoryEntry + >({ + callback: "last", + invocation: "dual", + lastError, + name: "runtime.getPackageDirectoryEntry", + nextSequence, + }); + const getPlatformInfo = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "runtime.getPlatformInfo", + nextSequence, + }); + const getBrowserInfo = createBrowserMethod({ + implementation: (() => + Promise.resolve({ + buildID: "test-build-id", + name: "Firefox", + vendor: "Mozilla", + version: "126.0", + })) as typeof browser.runtime.getBrowserInfo, + invocation: "promise", + name: "runtime.getBrowserInfo", + nextSequence, + }); + const getURL = createBrowserMethod({ + implementation: ((path: string) => { + const normalized = path.replace(/^\/+/, ""); + return `${urlScheme}://${extensionId}/${normalized}`; + }) as typeof chrome.runtime.getURL, + invocation: "sync", + name: "runtime.getURL", + nextSequence, + }); + const openOptionsPage = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "runtime.openOptionsPage", + nextSequence, + }); + const reload = createBrowserMethod({ + invocation: "sync", + name: "runtime.reload", + nextSequence, + }); + const requestUpdateCheck = createBrowserMethod({ + callback: "last", + callbackArgs: result => [result.status, result.details], + invocation: "dual", + lastError, + name: "runtime.requestUpdateCheck", + nextSequence, + }); + const restart = createBrowserMethod({ + invocation: "sync", + name: "runtime.restart", + nextSequence, + }); + const restartAfterDelay = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "runtime.restartAfterDelay", + nextSequence, + }); + const sendMessage = createBrowserMethod({ + callback: "last", + implementation: sendRuntimeMessage, + invocation: "dual", + lastError, + name: "runtime.sendMessage", + nextSequence, + }); + const setUninstallURL = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "runtime.setUninstallURL", + nextSequence, + }); + + const commonApi = { + connect: connect.api, + connectNative: connectNative.api, + getContexts: getContexts.api, + getManifest: getManifest.api, + getPackageDirectoryEntry: getPackageDirectoryEntry.api, + getPlatformInfo: getPlatformInfo.api, + getURL: getURL.api, + onConnect: eventApi(events.onConnect), + onConnectExternal: eventApi(events.onConnectExternal), + onInstalled: eventApi(events.onInstalled), + onMessage: eventApi(events.onMessage), + onMessageExternal: eventApi(events.onMessageExternal), + onRestartRequired: eventApi(events.onRestartRequired), + onStartup: eventApi(events.onStartup), + onSuspend: eventApi(events.onSuspend), + onSuspendCanceled: eventApi(events.onSuspendCanceled), + onUpdateAvailable: eventApi(events.onUpdateAvailable), + onUserScriptConnect: eventApi(events.onUserScriptConnect), + onUserScriptMessage: eventApi(events.onUserScriptMessage), + openOptionsPage: openOptionsPage.api, + reload: reload.api, + requestUpdateCheck: requestUpdateCheck.api, + restart: restart.api, + restartAfterDelay: restartAfterDelay.api, + sendMessage: sendMessage.api, + setUninstallURL: setUninstallURL.api, + }; + + const chromeApi = commonApi as unknown as RuntimeTestApi; + const browserApi = {...commonApi, getBrowserInfo: getBrowserInfo.api} as unknown as RuntimeTestApi; + + for (const api of [chromeApi, browserApi]) { + Reflect.defineProperty(api, "id", {configurable: true, enumerable: true, get: () => extensionId}); + Reflect.defineProperty(api, "lastError", {configurable: true, enumerable: true, get: () => lastError.current}); + } + + const methods = [ + connect, + connectNative, + getContexts, + getManifest, + getPackageDirectoryEntry, + getPlatformInfo, + getBrowserInfo, + getURL, + openOptionsPage, + reload, + requestUpdateCheck, + restart, + restartAfterDelay, + sendMessage, + setUninstallURL, + ]; + + return { + chromeApi, + browserApi, + connect, + connectNative, + getContexts, + getManifest, + getPackageDirectoryEntry, + getPlatformInfo, + getBrowserInfo, + getURL, + openOptionsPage, + reload, + requestUpdateCheck, + restart, + restartAfterDelay, + sendMessage, + setUninstallURL, + events, + closeMessageChannels, + get contexts() { + return contexts.map(context => cloneRecord(context)); + }, + get id() { + return extensionId; + }, + get manifest() { + return cloneRecord(manifest); + }, + get lastError() { + return lastError.current; + }, + get messageSender() { + return cloneRecord(messageSender); + }, + addContext(context): void { + contexts.push(cloneRecord(context)); + }, + emitMessage(message): Promise { + return dispatchMessage(message); + }, + removeContext(contextId): void { + contexts = contexts.filter(context => context.contextId !== contextId); + }, + reset(): void { + closeMessageChannels(); + extensionId = initialId; + urlScheme = "chrome-extension"; + manifest = cloneRecord(initialManifest); + contexts = initialContexts.map(context => cloneRecord(context)); + messageSender = cloneRecord(initialSender); + methods.forEach(method => { + method.reset(); + }); + Object.values(events).forEach(event => { + event.reset(); + }); + }, + setContexts(value): void { + contexts = value.map(context => cloneRecord(context)); + }, + setExtensionId(value): void { + extensionId = value; + }, + setManifest(value): void { + manifest = cloneRecord(value); + }, + setMessageSender(value): void { + messageSender = cloneRecord(value); + }, + setUrlScheme(value): void { + urlScheme = value; + }, + }; +}; diff --git a/src/testing/scripting.ts b/src/testing/scripting.ts new file mode 100644 index 0000000..77a2d17 --- /dev/null +++ b/src/testing/scripting.ts @@ -0,0 +1,180 @@ +import {cloneRecord} from "./internal"; +import {type BrowserMethod, createBrowserMethod} from "./method"; +import type {RuntimeLastErrorController, ScriptingTestApi} from "./types"; + +export interface ScriptingHarness { + readonly api: ScriptingTestApi; + readonly executeScript: BrowserMethod< + typeof chrome.scripting.executeScript, + chrome.scripting.InjectionResult[] + >; + readonly getRegisteredContentScripts: BrowserMethod< + typeof chrome.scripting.getRegisteredContentScripts, + chrome.scripting.RegisteredContentScript[] + >; + readonly insertCSS: BrowserMethod; + readonly registerContentScripts: BrowserMethod; + readonly removeCSS: BrowserMethod; + readonly unregisterContentScripts: BrowserMethod; + readonly updateContentScripts: BrowserMethod; + readonly registeredContentScripts: readonly chrome.scripting.RegisteredContentScript[]; + setRegisteredContentScripts(scripts: readonly chrome.scripting.RegisteredContentScript[]): void; + reset(): void; +} + +export const createScriptingHarness = ( + initialScripts: readonly chrome.scripting.RegisteredContentScript[] | undefined, + lastError: RuntimeLastErrorController, + nextSequence?: () => number +): ScriptingHarness => { + const initial = (initialScripts ?? []).map(script => cloneRecord(script)); + let scripts = new Map(initial.map(script => [script.id, script])); + + const executeScript = createBrowserMethod< + typeof chrome.scripting.executeScript, + chrome.scripting.InjectionResult[] + >({callback: "last", invocation: "dual", lastError, name: "scripting.executeScript", nextSequence}); + const insertCSS = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "scripting.insertCSS", + nextSequence, + }); + const removeCSS = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "scripting.removeCSS", + nextSequence, + }); + const getRegisteredContentScripts = createBrowserMethod< + typeof chrome.scripting.getRegisteredContentScripts, + chrome.scripting.RegisteredContentScript[] + >({ + callback: "last", + implementation: (( + filterOrCallback?: + | chrome.scripting.ContentScriptFilter + | ((value: chrome.scripting.RegisteredContentScript[]) => void), + possibleCallback?: (value: chrome.scripting.RegisteredContentScript[]) => void + ) => { + const filter = typeof filterOrCallback === "function" ? {} : (filterOrCallback ?? {}); + const callback = typeof filterOrCallback === "function" ? filterOrCallback : possibleCallback; + const result = [...scripts.values()] + .filter(script => !filter.ids || filter.ids.includes(script.id)) + .map(script => cloneRecord(script)); + callback?.(result); + return result; + }) as unknown as typeof chrome.scripting.getRegisteredContentScripts, + invocation: "dual", + lastError, + name: "scripting.getRegisteredContentScripts", + nextSequence, + }); + const registerContentScripts = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + implementation: ((values: chrome.scripting.RegisteredContentScript[], callback?: () => void) => { + const duplicate = values.find(script => scripts.has(script.id)); + if (duplicate) { + const error = new Error(`Content script "${duplicate.id}" is already registered`); + if (callback) return lastError.runWithLastError(error, callback); + throw error; + } + values.forEach(script => { + scripts.set(script.id, cloneRecord(script)); + }); + callback?.(); + }) as unknown as typeof chrome.scripting.registerContentScripts, + invocation: "dual", + lastError, + name: "scripting.registerContentScripts", + nextSequence, + }); + const updateContentScripts = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + implementation: ((values: chrome.scripting.RegisteredContentScript[], callback?: () => void) => { + const missing = values.find(script => !scripts.has(script.id)); + if (missing) { + const error = new Error(`Content script "${missing.id}" is not registered`); + if (callback) return lastError.runWithLastError(error, callback); + throw error; + } + values.forEach(script => { + scripts.set(script.id, {...scripts.get(script.id), ...cloneRecord(script)}); + }); + callback?.(); + }) as unknown as typeof chrome.scripting.updateContentScripts, + invocation: "dual", + lastError, + name: "scripting.updateContentScripts", + nextSequence, + }); + const unregisterContentScripts = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + implementation: (( + filterOrCallback?: chrome.scripting.ContentScriptFilter | (() => void), + possibleCallback?: () => void + ) => { + const filter = typeof filterOrCallback === "function" ? {} : filterOrCallback; + const callback = typeof filterOrCallback === "function" ? filterOrCallback : possibleCallback; + if (filter?.ids) { + filter.ids.forEach(id => { + scripts.delete(id); + }); + } else scripts.clear(); + callback?.(); + }) as unknown as typeof chrome.scripting.unregisterContentScripts, + invocation: "dual", + lastError, + name: "scripting.unregisterContentScripts", + nextSequence, + }); + + const api = { + executeScript: executeScript.api, + getRegisteredContentScripts: getRegisteredContentScripts.api, + insertCSS: insertCSS.api, + registerContentScripts: registerContentScripts.api, + removeCSS: removeCSS.api, + unregisterContentScripts: unregisterContentScripts.api, + updateContentScripts: updateContentScripts.api, + } as ScriptingTestApi; + const methods = [ + executeScript, + getRegisteredContentScripts, + insertCSS, + registerContentScripts, + removeCSS, + unregisterContentScripts, + updateContentScripts, + ]; + + return { + api, + executeScript, + getRegisteredContentScripts, + insertCSS, + registerContentScripts, + removeCSS, + unregisterContentScripts, + updateContentScripts, + get registeredContentScripts() { + return [...scripts.values()].map(script => cloneRecord(script)); + }, + reset(): void { + scripts = new Map(initial.map(script => [script.id, cloneRecord(script)])); + methods.forEach(method => { + method.reset(); + }); + }, + setRegisteredContentScripts(values): void { + scripts = new Map(values.map(script => [script.id, cloneRecord(script)])); + }, + }; +}; diff --git a/src/testing/stateful.integration.test.ts b/src/testing/stateful.integration.test.ts new file mode 100644 index 0000000..9907ce2 --- /dev/null +++ b/src/testing/stateful.integration.test.ts @@ -0,0 +1,281 @@ +import { + containsPermissions, + getAllPermissions, + onPermissionsAdded, + onPermissionsRemoved, + removePermissions, + requestPermissions, +} from "../permissions"; +import { + getId, + getManifest, + getUrl, + onInstalled, + onMessage, + onStartup, + sendMessage as sendRuntimeMessage, +} from "../runtime"; +import { + executeScript, + getRegisteredContentScripts, + registerContentScripts, + unregisterContentScripts, + updateContentScripts, +} from "../scripting"; +import {createTab, queryTabs, updateTab} from "../tabs"; +import {createWindow, getAllWindows, removeWindow} from "../windows"; +import { + createBrowserHarness, + createInjectionResultFixture, + createInstalledDetailsFixture, + createManifestFixture, + createMessageSenderFixture, + createPermissionsFixture, + createTabFixture, + createWindowFixture, + installGlobals, +} from "./index"; + +const restorers: Array<() => void> = []; + +const installChromeHarness = (harness: ReturnType): void => { + restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); +}; + +afterEach(() => { + while (restorers.length > 0) restorers.pop()?.(); +}); + +describe("stateful browser test harness", () => { + test("emits runtime lifecycle/messages and configures sendMessage results and errors", async () => { + const harness = createBrowserHarness(); + installChromeHarness(harness); + const installed: chrome.runtime.InstalledDetails[] = []; + let startupCount = 0; + const messages: Array<{message: unknown; sender: chrome.runtime.MessageSender}> = []; + const unsubscribeInstalled = onInstalled(details => installed.push(details)); + const unsubscribeStartup = onStartup(() => { + startupCount += 1; + }); + const unsubscribeMessage = onMessage((message, sender) => { + messages.push({message, sender}); + }); + const sender = createMessageSenderFixture({id: "sender-extension-id", tab: createTabFixture({id: 31})}); + harness.runtime.setMessageSender(sender); + + await harness.runtime.events.onInstalled.emit(createInstalledDetailsFixture({reason: "update"})); + await harness.runtime.events.onStartup.emit(); + await harness.runtime.emitMessage({kind: "ping"}); + + expect(installed).toEqual([expect.objectContaining({reason: "update"})]); + expect(startupCount).toBe(1); + expect(messages).toEqual([{message: {kind: "ping"}, sender}]); + + harness.runtime.sendMessage.setResult({kind: "pong"}); + await expect(sendRuntimeMessage({kind: "ping"})).resolves.toEqual({kind: "pong"}); + const failure = new Error("message delivery failed"); + harness.runtime.sendMessage.failNext(failure); + await expect(sendRuntimeMessage({kind: "retry"})).rejects.toThrow("message delivery failed"); + expect(harness.chrome.runtime.lastError).toBeUndefined(); + + unsubscribeInstalled(); + unsubscribeStartup(); + unsubscribeMessage(); + expect(harness.runtime.events.onInstalled.listenerCount()).toBe(0); + expect(harness.runtime.events.onStartup.listenerCount()).toBe(0); + expect(harness.runtime.events.onMessage.listenerCount()).toBe(0); + }); + + test("runs the real runtime and permissions wrappers against isolated mutable state", async () => { + const harness = createBrowserHarness({ + extensionId: "runtime-test-id", + manifest: createManifestFixture({name: "Runtime Test"}), + permissions: createPermissionsFixture({permissions: ["storage"]}), + }); + installChromeHarness(harness); + + expect(getId()).toBe("runtime-test-id"); + expect(getManifest()).toMatchObject({manifest_version: 3, name: "Runtime Test"}); + expect(getUrl("/options.html")).toBe("chrome-extension://runtime-test-id/options.html"); + + const added: chrome.permissions.Permissions[] = []; + const removed: chrome.permissions.Permissions[] = []; + const unsubscribeAdded = onPermissionsAdded(value => added.push(value)); + const unsubscribeRemoved = onPermissionsRemoved(value => removed.push(value)); + + await expect(requestPermissions({origins: ["https://example.test/*"], permissions: ["tabs"]})).resolves.toBe( + true + ); + await expect( + containsPermissions({origins: ["https://example.test/*"], permissions: ["storage", "tabs"]}) + ).resolves.toBe(true); + await expect(removePermissions({permissions: ["tabs"]})).resolves.toBe(true); + + expect(await getAllPermissions()).toEqual({ + origins: ["https://example.test/*"], + permissions: ["storage"], + }); + expect(added).toEqual([{origins: ["https://example.test/*"], permissions: ["tabs"]}]); + expect(removed).toEqual([{origins: [], permissions: ["tabs"]}]); + + unsubscribeAdded(); + unsubscribeRemoved(); + expect(harness.permissions.onAdded.listenerCount()).toBe(0); + expect(harness.permissions.onRemoved.listenerCount()).toBe(0); + }); + + test("keeps tabs and windows in one state and rejects unsupported query filters", async () => { + const harness = createBrowserHarness({ + tabs: [ + createTabFixture({ + active: true, + id: 11, + title: "Initial", + url: "https://initial.example/page", + windowId: 7, + }), + ], + windows: [createWindowFixture({focused: true, id: 7})], + }); + installChromeHarness(harness); + + const createdWindow = await createWindow({ + focused: true, + url: ["https://one.example/page", "https://two.example/page"], + }); + expect(createdWindow?.tabs).toHaveLength(2); + + const windows = await getAllWindows({populate: true}); + const populated = windows.find(window => window.id === createdWindow?.id); + expect(populated?.tabs?.map(tab => tab.url)).toEqual(["https://one.example/page", "https://two.example/page"]); + + const createdTab = await createTab({ + active: true, + url: "https://literal.example/path", + windowId: createdWindow?.id, + }); + await expect(queryTabs({active: true, currentWindow: true})).resolves.toEqual([ + expect.objectContaining({id: createdTab.id, url: "https://literal.example/path"}), + ]); + await expect(queryTabs({url: "https://literal.example/path"})).resolves.toHaveLength(1); + await expect(queryTabs({url: "https://*.example/*"})).rejects.toThrow( + "tabs.query url match patterns are not supported" + ); + await expect(queryTabs({id: createdTab.id} as chrome.tabs.QueryInfo)).rejects.toThrow( + 'tabs.query filter "id" is not supported' + ); + + await expect(updateTab(createdTab.id as number, {pinned: true})).resolves.toMatchObject({pinned: true}); + expect(harness.tabs.values.find(tab => tab.id === createdTab.id)).toMatchObject({pinned: true}); + + await removeWindow(createdWindow?.id as number); + expect(harness.windows.values.some(window => window.id === createdWindow?.id)).toBe(false); + expect(harness.tabs.values.some(tab => tab.windowId === createdWindow?.id)).toBe(false); + }); + + test("combines configurable scripting results with a stateful content-script registry", async () => { + const harness = createBrowserHarness({ + registeredContentScripts: [{id: "initial", js: ["initial.js"], matches: ["https://initial.example/*"]}], + }); + installChromeHarness(harness); + + harness.scripting.executeScript.setResult([createInjectionResultFixture({frameId: 3, result: "executed"})]); + await expect(executeScript({func: () => "production function", target: {tabId: 1}})).resolves.toEqual([ + expect.objectContaining({frameId: 3, result: "executed"}), + ]); + + await registerContentScripts([{id: "added", js: ["added.js"], matches: ["https://added.example/*"]}]); + await updateContentScripts([{id: "added", js: ["updated.js"]}]); + await expect(getRegisteredContentScripts({ids: ["added"]})).resolves.toEqual([ + expect.objectContaining({id: "added", js: ["updated.js"], matches: ["https://added.example/*"]}), + ]); + + await unregisterContentScripts({ids: ["added"]}); + await expect(getRegisteredContentScripts()).resolves.toEqual([ + expect.objectContaining({id: "initial", js: ["initial.js"]}), + ]); + + expect(harness.calls.map(call => call.api)).toEqual([ + "scripting.executeScript", + "scripting.registerContentScripts", + "scripting.updateContentScripts", + "scripting.getRegisteredContentScripts", + "scripting.unregisterContentScripts", + "scripting.getRegisteredContentScripts", + ]); + }); + + test("exposes runtime.lastError only inside callbacks and rejects Promise failures", async () => { + const harness = createBrowserHarness({ + tabs: [createTabFixture({id: 1, url: "https://callback.example/", windowId: 1})], + windows: [createWindowFixture({id: 1})], + }); + let callbackLastError: chrome.runtime.LastError | undefined; + + const callbackResult = await new Promise(resolve => { + harness.chrome.tabs.query({active: true}, resolve); + }); + expect(callbackResult).toEqual([expect.objectContaining({id: 1})]); + await expect(harness.browser.tabs.query({active: true})).resolves.toEqual([expect.objectContaining({id: 1})]); + + await new Promise(resolve => { + harness.chrome.tabs.get(404, () => { + callbackLastError = harness.chrome.runtime.lastError; + resolve(); + }); + }); + + expect(callbackLastError?.message).toBe("No tab with id: 404."); + expect(harness.chrome.runtime.lastError).toBeUndefined(); + await expect(harness.browser.tabs.get(404)).rejects.toThrow("No tab with id: 404."); + expect(harness.browser.runtime.lastError).toBeUndefined(); + }); + + test("reset restores initial state and two harnesses never share state", async () => { + const first = createBrowserHarness({ + extensionId: "first-id", + permissions: {permissions: ["storage"]}, + tabs: [createTabFixture({id: 1, windowId: 1})], + windows: [createWindowFixture({id: 1})], + }); + const second = createBrowserHarness({extensionId: "second-id"}); + + await first.permissions.grant({permissions: ["tabs"]}); + await first.tabs.create.api({url: "https://first.example/"}); + + expect(first.runtime.id).toBe("first-id"); + expect(second.runtime.id).toBe("second-id"); + expect(first.permissions.value.permissions).toEqual(["storage", "tabs"]); + expect(second.permissions.value.permissions).toEqual([]); + expect(first.tabs.values).toHaveLength(2); + expect(second.tabs.values).toHaveLength(0); + + first.reset(); + + expect(first.permissions.value.permissions).toEqual(["storage"]); + expect(first.tabs.values).toHaveLength(1); + expect(first.calls).toEqual([]); + expect(second.permissions.value.permissions).toEqual([]); + }); + + test("keeps explicit window fixtures and their nested tabs synchronized", () => { + const harness = createBrowserHarness({ + windows: [ + createWindowFixture({ + id: 5, + tabs: [createTabFixture({id: 51, windowId: 999})], + }), + ], + }); + + expect(harness.tabs.values).toEqual([expect.objectContaining({id: 51, windowId: 5})]); + expect(harness.windows.values[0]?.tabs).toEqual([expect.objectContaining({id: 51, windowId: 5})]); + + harness.windows.set([createWindowFixture({id: 8, tabs: [createTabFixture({id: 81, windowId: 5})]})]); + + expect(harness.tabs.values).toEqual([expect.objectContaining({id: 81, windowId: 8})]); + expect(harness.windows.values).toEqual([ + expect.objectContaining({id: 8, tabs: [expect.objectContaining({id: 81, windowId: 8})]}), + ]); + }); +}); diff --git a/src/testing/tabs.ts b/src/testing/tabs.ts new file mode 100644 index 0000000..3391a51 --- /dev/null +++ b/src/testing/tabs.ts @@ -0,0 +1,573 @@ +import {type BrowserEventHarness, createBrowserEvent} from "./event"; +import {createTabFixture} from "./fixtures"; +import {missingEntityError} from "./internal"; +import {type BrowserMethod, createBrowserMethod} from "./method"; +import type {BrowserMemoryState} from "./browser-state"; +import type {RuntimeLastErrorController, TabsTestApi} from "./types"; + +type ListenerArgs unknown, ...args: never[]): unknown}> = + Parameters[0]>; + +export interface TabsEventsHarness { + onActivated: BrowserEventHarness>; + onAttached: BrowserEventHarness>; + onCreated: BrowserEventHarness>; + onDetached: BrowserEventHarness>; + onHighlighted: BrowserEventHarness>; + onMoved: BrowserEventHarness>; + onRemoved: BrowserEventHarness>; + onReplaced: BrowserEventHarness>; + onUpdated: BrowserEventHarness>; + onZoomChange: BrowserEventHarness>; +} + +export interface TabsHarness { + readonly api: TabsTestApi; + readonly captureVisibleTab: BrowserMethod; + readonly connect: BrowserMethod; + readonly create: BrowserMethod; + readonly detectLanguage: BrowserMethod; + readonly discard: BrowserMethod; + readonly duplicate: BrowserMethod; + readonly executeScript: BrowserMethod; + readonly get: BrowserMethod; + readonly getCurrent: BrowserMethod; + readonly getZoom: BrowserMethod; + readonly getZoomSettings: BrowserMethod; + readonly goBack: BrowserMethod; + readonly goForward: BrowserMethod; + readonly group: BrowserMethod; + readonly highlight: BrowserMethod; + readonly insertCSS: BrowserMethod; + readonly move: BrowserMethod; + readonly query: BrowserMethod; + readonly reload: BrowserMethod; + readonly remove: BrowserMethod; + readonly removeCSS: BrowserMethod; + readonly sendMessage: BrowserMethod; + readonly setZoom: BrowserMethod; + readonly setZoomSettings: BrowserMethod; + readonly ungroup: BrowserMethod; + readonly update: BrowserMethod; + readonly events: TabsEventsHarness; + readonly values: readonly chrome.tabs.Tab[]; + set(tabs: readonly chrome.tabs.Tab[]): void; + reset(): void; +} + +const supportedQueryFields = new Set([ + "active", + "audible", + "autoDiscardable", + "currentWindow", + "discarded", + "frozen", + "groupId", + "highlighted", + "index", + "lastFocusedWindow", + "muted", + "pinned", + "splitViewId", + "status", + "title", + "url", + "windowId", + "windowType", +]); + +const assertExactPattern = (field: "title" | "url", value: string): void => { + if (value === "" || value.includes("*")) { + throw new Error(`tabs.query ${field} match patterns are not supported; use an exact value`); + } +}; + +const ignoreAutoEventError = (promise: Promise): void => { + promise.catch(() => undefined); +}; + +export const createTabsHarness = ( + state: BrowserMemoryState, + lastError: RuntimeLastErrorController, + nextSequence?: () => number +): TabsHarness => { + const events: TabsEventsHarness = { + onActivated: createBrowserEvent(), + onAttached: createBrowserEvent(), + onCreated: createBrowserEvent(), + onDetached: createBrowserEvent(), + onHighlighted: createBrowserEvent(), + onMoved: createBrowserEvent(), + onRemoved: createBrowserEvent(), + onReplaced: createBrowserEvent(), + onUpdated: createBrowserEvent(), + onZoomChange: createBrowserEvent(), + }; + + const captureVisibleTab = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.captureVisibleTab", + nextSequence, + }); + const connect = createBrowserMethod({ + invocation: "sync", + name: "tabs.connect", + nextSequence, + }); + const create = createBrowserMethod({ + callback: "last", + implementation: ((properties: chrome.tabs.CreateProperties, callback?: (tab: chrome.tabs.Tab) => void) => { + const window = state.ensureWindow(properties.windowId); + const windowId = window.id as number; + const id = state.nextTabId(); + const existing = [...state.tabs.values()].filter(tab => tab.windowId === windowId); + const index = properties.index ?? existing.length; + + for (const tab of existing) { + if (tab.index >= index) tab.index += 1; + if (properties.active !== false) tab.active = false; + } + + const tab = createTabFixture({ + active: properties.active ?? true, + highlighted: properties.active ?? true, + id, + index, + openerTabId: properties.openerTabId, + pinned: properties.pinned ?? false, + selected: properties.active ?? true, + url: properties.url, + windowId, + }); + state.tabs.set(id, tab); + state.reindexTabs(windowId); + const result = state.cloneTab(tab); + callback?.(result); + ignoreAutoEventError(events.onCreated.emit(state.cloneTab(tab))); + if (tab.active) { + ignoreAutoEventError(events.onActivated.emit({tabId: id, windowId})); + } + return result; + }) as unknown as typeof chrome.tabs.create, + invocation: "dual", + lastError, + name: "tabs.create", + nextSequence, + }); + const detectLanguage = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.detectLanguage", + nextSequence, + }); + const discard = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.discard", + nextSequence, + }); + const duplicate = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.duplicate", + nextSequence, + }); + const executeScript = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.executeScript", + nextSequence, + }); + const get = createBrowserMethod({ + callback: "last", + implementation: ((tabId: number, callback?: (tab: chrome.tabs.Tab) => void) => { + const tab = state.tabs.get(tabId); + if (!tab) { + const error = missingEntityError("tab", tabId); + if (callback) { + lastError.runWithLastError(error, () => callback(undefined as unknown as chrome.tabs.Tab)); + return undefined; + } + throw error; + } + const result = state.cloneTab(tab); + callback?.(result); + return result; + }) as unknown as typeof chrome.tabs.get, + invocation: "dual", + lastError, + name: "tabs.get", + nextSequence, + }); + const getCurrent = createBrowserMethod({ + callback: "last", + implementation: ((callback?: (tab?: chrome.tabs.Tab) => void) => { + const windowId = state.currentWindowId(); + const tab = [...state.tabs.values()].find(item => item.windowId === windowId && item.active); + const result = tab ? state.cloneTab(tab) : undefined; + callback?.(result); + return result; + }) as unknown as typeof chrome.tabs.getCurrent, + invocation: "dual", + lastError, + name: "tabs.getCurrent", + nextSequence, + }); + const getZoom = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.getZoom", + nextSequence, + }); + const getZoomSettings = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.getZoomSettings", + nextSequence, + }); + const goBack = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.goBack", + nextSequence, + }); + const goForward = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.goForward", + nextSequence, + }); + const group = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.group", + nextSequence, + }); + const highlight = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.highlight", + nextSequence, + }); + const insertCSS = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.insertCSS", + nextSequence, + }); + const move = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.move", + nextSequence, + }); + const query = createBrowserMethod({ + callback: "last", + implementation: ((queryInfo: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) => { + for (const key of Object.keys(queryInfo)) { + if (!supportedQueryFields.has(key)) throw new Error(`tabs.query filter "${key}" is not supported`); + } + + const exactUrls = typeof queryInfo.url === "string" ? [queryInfo.url] : queryInfo.url; + exactUrls?.forEach(url => { + assertExactPattern("url", url); + }); + if (queryInfo.title) assertExactPattern("title", queryInfo.title); + + const currentWindowId = state.currentWindowId(); + const requestedWindowId = queryInfo.windowId === -2 ? currentWindowId : queryInfo.windowId; + const result = [...state.tabs.values()] + .filter(tab => { + const window = state.windows.get(tab.windowId); + if (queryInfo.status !== undefined && tab.status !== queryInfo.status) return false; + if ( + queryInfo.lastFocusedWindow !== undefined && + (tab.windowId === state.lastFocusedWindowId) !== queryInfo.lastFocusedWindow + ) + return false; + if (requestedWindowId !== undefined && tab.windowId !== requestedWindowId) return false; + if (queryInfo.windowType !== undefined && window?.type !== queryInfo.windowType) return false; + if (queryInfo.active !== undefined && tab.active !== queryInfo.active) return false; + if (queryInfo.index !== undefined && tab.index !== queryInfo.index) return false; + if ( + queryInfo.currentWindow !== undefined && + (tab.windowId === currentWindowId) !== queryInfo.currentWindow + ) + return false; + if (queryInfo.highlighted !== undefined && tab.highlighted !== queryInfo.highlighted) return false; + if (queryInfo.discarded !== undefined && tab.discarded !== queryInfo.discarded) return false; + if (queryInfo.frozen !== undefined && tab.frozen !== queryInfo.frozen) return false; + if (queryInfo.autoDiscardable !== undefined && tab.autoDiscardable !== queryInfo.autoDiscardable) + return false; + if (queryInfo.pinned !== undefined && tab.pinned !== queryInfo.pinned) return false; + if (queryInfo.splitViewId !== undefined && tab.splitViewId !== queryInfo.splitViewId) return false; + if (queryInfo.audible !== undefined && Boolean(tab.audible) !== queryInfo.audible) return false; + if (queryInfo.muted !== undefined && Boolean(tab.mutedInfo?.muted) !== queryInfo.muted) + return false; + if (queryInfo.groupId !== undefined && tab.groupId !== queryInfo.groupId) return false; + if (queryInfo.title !== undefined && tab.title !== queryInfo.title) return false; + if (exactUrls && (!tab.url || !exactUrls.includes(tab.url))) return false; + return true; + }) + .sort((left, right) => left.windowId - right.windowId || left.index - right.index) + .map(tab => state.cloneTab(tab)); + + callback?.(result); + return result; + }) as unknown as typeof chrome.tabs.query, + invocation: "dual", + lastError, + name: "tabs.query", + nextSequence, + }); + const reload = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.reload", + nextSequence, + }); + const remove = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + implementation: ((ids: number | number[], callback?: () => void) => { + const tabIds = Array.isArray(ids) ? ids : [ids]; + const missingId = tabIds.find(id => !state.tabs.has(id)); + if (typeof missingId === "number") { + const error = missingEntityError("tab", missingId); + if (callback) { + lastError.runWithLastError(error, callback); + return; + } + throw error; + } + + for (const id of tabIds) { + const tab = state.tabs.get(id); + if (!tab) continue; + state.tabs.delete(id); + state.reindexTabs(tab.windowId); + ignoreAutoEventError(events.onRemoved.emit(id, {isWindowClosing: false, windowId: tab.windowId})); + } + callback?.(); + }) as typeof chrome.tabs.remove, + invocation: "dual", + lastError, + name: "tabs.remove", + nextSequence, + }); + const removeCSS = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.removeCSS", + nextSequence, + }); + const sendMessage = createBrowserMethod({ + callback: "last", + invocation: "dual", + lastError, + name: "tabs.sendMessage", + nextSequence, + }); + const setZoom = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.setZoom", + nextSequence, + }); + const setZoomSettings = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.setZoomSettings", + nextSequence, + }); + const ungroup = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + invocation: "dual", + lastError, + name: "tabs.ungroup", + nextSequence, + }); + const update = createBrowserMethod({ + callback: "last", + implementation: (( + tabId: number, + properties: chrome.tabs.UpdateProperties, + callback?: (tab?: chrome.tabs.Tab) => void + ) => { + const tab = state.tabs.get(tabId); + if (!tab) { + const error = missingEntityError("tab", tabId); + if (callback) { + lastError.runWithLastError(error, () => callback(undefined)); + return undefined; + } + throw error; + } + if (properties.active) { + for (const other of state.tabs.values()) { + if (other.windowId === tab.windowId) other.active = other.id === tab.id; + } + } + Object.assign(tab, properties); + if (typeof properties.highlighted === "boolean") tab.selected = properties.highlighted; + const result = state.cloneTab(tab); + callback?.(result); + ignoreAutoEventError(events.onUpdated.emit(tabId, {...properties}, state.cloneTab(tab))); + if (properties.active) { + ignoreAutoEventError(events.onActivated.emit({tabId, windowId: tab.windowId})); + } + return result; + }) as unknown as typeof chrome.tabs.update, + invocation: "dual", + lastError, + name: "tabs.update", + nextSequence, + }); + + const api = { + captureVisibleTab: captureVisibleTab.api, + connect: connect.api, + create: create.api, + detectLanguage: detectLanguage.api, + discard: discard.api, + duplicate: duplicate.api, + executeScript: executeScript.api, + get: get.api, + getCurrent: getCurrent.api, + getZoom: getZoom.api, + getZoomSettings: getZoomSettings.api, + goBack: goBack.api, + goForward: goForward.api, + group: group.api, + highlight: highlight.api, + insertCSS: insertCSS.api, + move: move.api, + onActivated: events.onActivated.api, + onAttached: events.onAttached.api, + onCreated: events.onCreated.api, + onDetached: events.onDetached.api, + onHighlighted: events.onHighlighted.api, + onMoved: events.onMoved.api, + onRemoved: events.onRemoved.api, + onReplaced: events.onReplaced.api, + onUpdated: events.onUpdated.api, + onZoomChange: events.onZoomChange.api, + query: query.api, + reload: reload.api, + remove: remove.api, + removeCSS: removeCSS.api, + sendMessage: sendMessage.api, + setZoom: setZoom.api, + setZoomSettings: setZoomSettings.api, + ungroup: ungroup.api, + update: update.api, + } as unknown as TabsTestApi; + + const methods = [ + captureVisibleTab, + connect, + create, + detectLanguage, + discard, + duplicate, + executeScript, + get, + getCurrent, + getZoom, + getZoomSettings, + goBack, + goForward, + group, + highlight, + insertCSS, + move, + query, + reload, + remove, + removeCSS, + sendMessage, + setZoom, + setZoomSettings, + ungroup, + update, + ]; + + return { + api, + captureVisibleTab, + connect, + create, + detectLanguage, + discard, + duplicate, + executeScript, + get, + getCurrent, + getZoom, + getZoomSettings, + goBack, + goForward, + group, + highlight, + insertCSS, + move, + query, + reload, + remove, + removeCSS, + sendMessage, + setZoom, + setZoomSettings, + ungroup, + update, + events, + get values() { + return [...state.tabs.values()] + .sort((left, right) => left.windowId - right.windowId || left.index - right.index) + .map(tab => state.cloneTab(tab)); + }, + reset(): void { + methods.forEach(method => { + method.reset(); + }); + Object.values(events).forEach(event => { + event.reset(); + }); + }, + set(tabs): void { + state.tabs.clear(); + for (const tab of tabs) { + if (typeof tab.id !== "number") throw new Error("A test tab must have a numeric id"); + state.ensureWindow(tab.windowId); + state.tabs.set(tab.id, state.cloneTab(tab)); + } + for (const windowId of new Set(tabs.map(tab => tab.windowId))) state.reindexTabs(windowId); + }, + }; +}; diff --git a/src/testing/types.ts b/src/testing/types.ts new file mode 100644 index 0000000..d79b89b --- /dev/null +++ b/src/testing/types.ts @@ -0,0 +1,182 @@ +import type {ConfigurableBrowserApi} from "./configurable"; +import type {BrowserEventHarness} from "./event"; +import type {BrowserMethod, BrowserMethodCall, BrowserMethodCallback, BrowserMethodObservedInvocation} from "./method"; + +export type RuntimeTestApi = Pick< + typeof chrome.runtime, + | "connect" + | "connectNative" + | "getContexts" + | "getManifest" + | "getPackageDirectoryEntry" + | "getPlatformInfo" + | "getURL" + | "id" + | "lastError" + | "onConnect" + | "onConnectExternal" + | "onInstalled" + | "onMessage" + | "onMessageExternal" + | "onRestartRequired" + | "onStartup" + | "onSuspend" + | "onSuspendCanceled" + | "onUpdateAvailable" + | "onUserScriptConnect" + | "onUserScriptMessage" + | "openOptionsPage" + | "reload" + | "requestUpdateCheck" + | "restart" + | "restartAfterDelay" + | "sendMessage" + | "setUninstallURL" +> & { + getBrowserInfo?: typeof browser.runtime.getBrowserInfo; +}; + +export type PermissionsTestApi = Pick< + typeof chrome.permissions, + | "addHostAccessRequest" + | "contains" + | "getAll" + | "onAdded" + | "onRemoved" + | "remove" + | "removeHostAccessRequest" + | "request" +>; + +export type TabsTestApi = Pick< + typeof chrome.tabs, + | "captureVisibleTab" + | "connect" + | "create" + | "detectLanguage" + | "discard" + | "duplicate" + | "executeScript" + | "get" + | "getCurrent" + | "getZoom" + | "getZoomSettings" + | "goBack" + | "goForward" + | "group" + | "highlight" + | "insertCSS" + | "move" + | "onActivated" + | "onAttached" + | "onCreated" + | "onDetached" + | "onHighlighted" + | "onMoved" + | "onRemoved" + | "onReplaced" + | "onUpdated" + | "onZoomChange" + | "query" + | "reload" + | "remove" + | "removeCSS" + | "sendMessage" + | "setZoom" + | "setZoomSettings" + | "ungroup" + | "update" +>; + +export type WindowsTestApi = Pick< + typeof chrome.windows, + | "WINDOW_ID_CURRENT" + | "WINDOW_ID_NONE" + | "create" + | "get" + | "getAll" + | "getCurrent" + | "getLastFocused" + | "onBoundsChanged" + | "onCreated" + | "onFocusChanged" + | "onRemoved" + | "remove" + | "update" +>; + +export type ScriptingTestApi = Pick< + typeof chrome.scripting, + | "executeScript" + | "getRegisteredContentScripts" + | "insertCSS" + | "registerContentScripts" + | "removeCSS" + | "unregisterContentScripts" + | "updateContentScripts" +>; + +export type SidePanelTestApi = Pick< + typeof chrome.sidePanel, + "close" | "getOptions" | "getPanelBehavior" | "open" | "setOptions" | "setPanelBehavior" +>; + +export type FirefoxSidebarActionTestApi = Pick< + typeof browser.sidebarAction, + "close" | "getPanel" | "getTitle" | "isOpen" | "open" | "setIcon" | "setPanel" | "setTitle" | "toggle" +>; + +export type OperaSidebarActionTestApi = Pick< + typeof opr.sidebarAction, + | "getBadgeBackgroundColor" + | "getBadgeText" + | "getBadgeTextColor" + | "getPanel" + | "getTitle" + | "onBlur" + | "onFocus" + | "setBadgeBackgroundColor" + | "setBadgeText" + | "setBadgeTextColor" + | "setIcon" + | "setPanel" + | "setTitle" +>; + +/** Explicitly supported WebExtension surface. It intentionally does not track all of `typeof chrome`. */ +export type BrowserTestApi = Omit< + ConfigurableBrowserApi, + "permissions" | "runtime" | "scripting" | "sidePanel" | "tabs" | "windows" +> & { + runtime: RuntimeTestApi & ConfigurableBrowserApi["runtime"]; + permissions: PermissionsTestApi & ConfigurableBrowserApi["permissions"]; + tabs: TabsTestApi & ConfigurableBrowserApi["tabs"]; + windows: WindowsTestApi & ConfigurableBrowserApi["windows"]; + scripting: ScriptingTestApi & ConfigurableBrowserApi["scripting"]; + sidePanel?: SidePanelTestApi; + sidebarAction?: FirefoxSidebarActionTestApi; +}; + +export type BrowserProfile = "chrome" | "firefox" | "opera" | "safari" | "custom"; + +export type ExtensionContextKind = "extensionPage" | "serviceWorker" | "backgroundPage" | "contentScript" | "none"; + +export type SidebarFlavor = "sidePanel" | "firefoxSidebarAction" | "operaSidebarAction" | "none"; + +export interface BrowserHarnessCall { + api: string; + args: readonly unknown[]; + callback?: BrowserMethodCallback; + invocation: BrowserMethodObservedInvocation; + sequence: number; +} + +export interface RuntimeLastErrorController { + readonly current: chrome.runtime.LastError | undefined; + runWithLastError(error: unknown, callback: () => T): T; +} + +export type AnyBrowserMethod = BrowserMethod<(...args: never[]) => unknown, unknown>; +export type AnyBrowserEvent = BrowserEventHarness; + +export type {BrowserEventHarness, BrowserMethod, BrowserMethodCall, BrowserMethodObservedInvocation}; diff --git a/src/testing/windows.ts b/src/testing/windows.ts new file mode 100644 index 0000000..ca57f7f --- /dev/null +++ b/src/testing/windows.ts @@ -0,0 +1,328 @@ +import {type BrowserEventHarness, createBrowserEvent} from "./event"; +import {createWindowFixture} from "./fixtures"; +import {missingEntityError} from "./internal"; +import {type BrowserMethod, createBrowserMethod} from "./method"; +import type {BrowserMemoryState} from "./browser-state"; +import type {TabsHarness} from "./tabs"; +import type {RuntimeLastErrorController, WindowsTestApi} from "./types"; + +type ListenerArgs unknown, ...args: never[]): unknown}> = + Parameters[0]>; +type WindowEventRegistrationArgs = [filter?: {windowTypes: `${chrome.windows.WindowType}`[]}]; + +export interface WindowsEventsHarness { + onBoundsChanged: BrowserEventHarness>; + onCreated: BrowserEventHarness, WindowEventRegistrationArgs>; + onFocusChanged: BrowserEventHarness< + ListenerArgs, + WindowEventRegistrationArgs + >; + onRemoved: BrowserEventHarness, WindowEventRegistrationArgs>; +} + +export interface WindowsHarness { + readonly api: WindowsTestApi; + readonly create: BrowserMethod; + readonly get: BrowserMethod; + readonly getAll: BrowserMethod; + readonly getCurrent: BrowserMethod; + readonly getLastFocused: BrowserMethod; + readonly remove: BrowserMethod; + readonly update: BrowserMethod; + readonly events: WindowsEventsHarness; + readonly values: readonly chrome.windows.Window[]; + set(windows: readonly chrome.windows.Window[]): void; + reset(): void; +} + +const ignoreAutoEventError = (promise: Promise): void => { + promise.catch(() => undefined); +}; + +export const createWindowsHarness = ( + state: BrowserMemoryState, + tabs: TabsHarness, + lastError: RuntimeLastErrorController, + nextSequence?: () => number +): WindowsHarness => { + const events: WindowsEventsHarness = { + onBoundsChanged: createBrowserEvent(), + onCreated: createBrowserEvent(), + onFocusChanged: createBrowserEvent(), + onRemoved: createBrowserEvent(), + }; + + const resolveWindow = ( + windowId: number, + populate: boolean, + callback?: (window: chrome.windows.Window) => void + ): chrome.windows.Window | undefined => { + const actualId = windowId === -2 ? state.currentWindowId() : windowId; + const window = typeof actualId === "number" ? state.windows.get(actualId) : undefined; + if (!window) { + const error = missingEntityError("window", windowId); + if (callback) { + lastError.runWithLastError(error, () => callback(undefined as unknown as chrome.windows.Window)); + return undefined; + } + throw error; + } + const result = state.cloneWindow(window, populate); + callback?.(result); + return result; + }; + + const create = createBrowserMethod({ + callback: "last", + implementation: (async ( + createData: chrome.windows.CreateData = {}, + callback?: (window?: chrome.windows.Window) => void + ) => { + const id = state.nextWindowId(); + const focused = createData.focused ?? true; + if (focused) { + for (const existing of state.windows.values()) existing.focused = false; + } + const window = createWindowFixture({ + focused, + height: createData.height, + id, + incognito: createData.incognito ?? false, + left: createData.left, + state: createData.state ?? "normal", + tabs: undefined, + top: createData.top, + type: (createData.type ?? "normal") as `${chrome.windows.WindowType}`, + width: createData.width, + }); + state.windows.set(id, window); + if (focused) state.setLastFocusedWindow(id); + + if (typeof createData.tabId === "number") { + const tab = state.tabs.get(createData.tabId); + if (tab) { + const oldWindowId = tab.windowId; + tab.windowId = id; + tab.index = 0; + state.reindexTabs(oldWindowId); + } + } + + const urls = typeof createData.url === "string" ? [createData.url] : createData.url; + for (const [index, url] of (urls ?? []).entries()) { + await tabs.create.api({active: index === 0, index, url, windowId: id}); + } + + const result = state.cloneWindow(window, true); + callback?.(result); + ignoreAutoEventError(events.onCreated.emit(state.cloneWindow(window, true))); + if (focused) ignoreAutoEventError(events.onFocusChanged.emit(id)); + return result; + }) as unknown as typeof chrome.windows.create, + invocation: "dual", + lastError, + name: "windows.create", + nextSequence, + }); + const get = createBrowserMethod({ + callback: "last", + implementation: (( + windowId: number, + queryOrCallback?: chrome.windows.QueryOptions | ((window: chrome.windows.Window) => void), + possibleCallback?: (window: chrome.windows.Window) => void + ) => { + const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); + const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(windowId, query.populate ?? false, callback); + }) as unknown as typeof chrome.windows.get, + invocation: "dual", + lastError, + name: "windows.get", + nextSequence, + }); + const getAll = createBrowserMethod({ + callback: "last", + implementation: (( + queryOrCallback?: chrome.windows.QueryOptions | ((windows: chrome.windows.Window[]) => void), + possibleCallback?: (windows: chrome.windows.Window[]) => void + ) => { + const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); + const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + const result = [...state.windows.values()] + .filter(window => !query.windowTypes || (window.type && query.windowTypes.includes(window.type))) + .map(window => state.cloneWindow(window, query.populate ?? false)); + callback?.(result); + return result; + }) as unknown as typeof chrome.windows.getAll, + invocation: "dual", + lastError, + name: "windows.getAll", + nextSequence, + }); + const getCurrent = createBrowserMethod({ + callback: "last", + implementation: (( + queryOrCallback?: chrome.windows.QueryOptions | ((window: chrome.windows.Window) => void), + possibleCallback?: (window: chrome.windows.Window) => void + ) => { + const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); + const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(state.currentWindowId() ?? -1, query.populate ?? false, callback); + }) as unknown as typeof chrome.windows.getCurrent, + invocation: "dual", + lastError, + name: "windows.getCurrent", + nextSequence, + }); + const getLastFocused = createBrowserMethod({ + callback: "last", + implementation: (( + queryOrCallback?: chrome.windows.QueryOptions | ((window: chrome.windows.Window) => void), + possibleCallback?: (window: chrome.windows.Window) => void + ) => { + const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); + const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(state.lastFocusedWindowId ?? -1, query.populate ?? false, callback); + }) as unknown as typeof chrome.windows.getLastFocused, + invocation: "dual", + lastError, + name: "windows.getLastFocused", + nextSequence, + }); + const remove = createBrowserMethod({ + callback: "last", + callbackArgs: () => [], + implementation: ((windowId: number, callback?: () => void) => { + const window = state.windows.get(windowId); + if (!window) { + const error = missingEntityError("window", windowId); + if (callback) { + lastError.runWithLastError(error, callback); + return; + } + throw error; + } + const tabIds = [...state.tabs.values()].filter(tab => tab.windowId === windowId).map(tab => tab.id); + state.windows.delete(windowId); + for (const tabId of tabIds) { + if (typeof tabId !== "number") continue; + state.tabs.delete(tabId); + ignoreAutoEventError(tabs.events.onRemoved.emit(tabId, {isWindowClosing: true, windowId})); + } + if (state.lastFocusedWindowId === windowId) { + const nextWindow = [...state.windows.values()][0]; + if (nextWindow) nextWindow.focused = true; + state.setLastFocusedWindow(nextWindow?.id); + } + callback?.(); + ignoreAutoEventError(events.onRemoved.emit(windowId)); + }) as typeof chrome.windows.remove, + invocation: "dual", + lastError, + name: "windows.remove", + nextSequence, + }); + const update = createBrowserMethod({ + callback: "last", + implementation: (( + windowId: number, + updateInfo: chrome.windows.UpdateInfo, + callback?: (window: chrome.windows.Window) => void + ) => { + const window = state.windows.get(windowId); + if (!window) return resolveWindow(windowId, false, callback); + if (updateInfo.focused) { + for (const existing of state.windows.values()) existing.focused = existing.id === windowId; + state.setLastFocusedWindow(windowId); + } else if (updateInfo.focused === false) { + window.focused = false; + } + Object.assign(window, updateInfo); + const result = state.cloneWindow(window, false); + callback?.(result); + ignoreAutoEventError(events.onBoundsChanged.emit(state.cloneWindow(window))); + if (typeof updateInfo.focused === "boolean") { + ignoreAutoEventError(events.onFocusChanged.emit(updateInfo.focused ? windowId : -1)); + } + return result; + }) as unknown as typeof chrome.windows.update, + invocation: "dual", + lastError, + name: "windows.update", + nextSequence, + }); + + const api = { + WINDOW_ID_CURRENT: -2, + WINDOW_ID_NONE: -1, + create: create.api, + get: get.api, + getAll: getAll.api, + getCurrent: getCurrent.api, + getLastFocused: getLastFocused.api, + onBoundsChanged: events.onBoundsChanged.api, + onCreated: events.onCreated.api, + onFocusChanged: events.onFocusChanged.api, + onRemoved: events.onRemoved.api, + remove: remove.api, + update: update.api, + } as unknown as WindowsTestApi; + + const methods = [create, get, getAll, getCurrent, getLastFocused, remove, update]; + + return { + api, + create, + get, + getAll, + getCurrent, + getLastFocused, + remove, + update, + events, + get values() { + return [...state.windows.values()].map(window => state.cloneWindow(window, true)); + }, + reset(): void { + methods.forEach(method => { + method.reset(); + }); + Object.values(events).forEach(event => { + event.reset(); + }); + }, + set(windows): void { + const replacementWindowIds = new Set( + windows.flatMap(window => (typeof window.id === "number" ? [window.id] : [])) + ); + const windowsWithExplicitTabs = new Set( + windows.flatMap(window => + typeof window.id === "number" && Array.isArray(window.tabs) ? [window.id] : [] + ) + ); + + for (const [tabId, tab] of state.tabs) { + if (!replacementWindowIds.has(tab.windowId) || windowsWithExplicitTabs.has(tab.windowId)) { + state.tabs.delete(tabId); + } + } + + state.windows.clear(); + for (const window of windows) { + if (typeof window.id !== "number") throw new Error("A test window must have a numeric id"); + const copy = state.cloneWindow(window); + delete copy.tabs; + state.windows.set(window.id, copy); + + for (const tab of window.tabs ?? []) { + if (typeof tab.id !== "number") throw new Error("A test tab must have a numeric id"); + state.tabs.set(tab.id, state.cloneTab({...tab, windowId: window.id})); + } + state.reindexTabs(window.id); + } + state.setLastFocusedWindow( + [...state.windows.values()].find(window => window.focused)?.id ?? [...state.windows.keys()][0] + ); + }, + }; +}; diff --git a/tsup.config.ts b/tsup.config.ts index 0af8e15..9d050c8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,6 @@ import {defineConfig, type Options} from "tsup"; const common: Options = { - entry: ["src/index.ts", "src/utils.ts"], bundle: true, outDir: "dist", sourcemap: true, @@ -10,6 +9,7 @@ const common: Options = { export default defineConfig([ { ...common, + entry: ["src/index.ts", "src/utils.ts"], format: ["esm"], dts: { banner: '/// \n/// ', @@ -21,7 +21,32 @@ export default defineConfig([ }, { ...common, + entry: {"testing/index": "src/testing/index.ts"}, + format: ["esm"], + sourcemap: false, + dts: { + banner: '/// \n/// ', + }, + outExtension() { + return {js: ".js"}; + }, + clean: false, + }, + { + ...common, + entry: ["src/index.ts", "src/utils.ts"], + format: ["cjs"], + dts: false, + outExtension() { + return {js: ".cjs"}; + }, + clean: false, + }, + { + ...common, + entry: {"testing/index": "src/testing/index.ts"}, format: ["cjs"], + sourcemap: false, dts: false, outExtension() { return {js: ".cjs"}; From fdacb4e2230ab3874fcf389f7083b8f357a09cc8 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:56:46 +0300 Subject: [PATCH 02/11] test(testing): migrate suites and verify package consumers --- src/browserDetection.test.ts | 172 +++++++++------- src/env.test.ts | 158 +++++++-------- src/identity.test.ts | 353 ++++++++++++++++----------------- src/offscreen.test.ts | 168 +++++----------- src/utils.test.ts | 155 ++++++++------- tests/consumer-types/check.mjs | 21 +- tests/consumer-types/cjs.cjs | 25 +++ tests/consumer-types/esm.mjs | 30 +++ tests/consumer-types/index.ts | 26 ++- 9 files changed, 586 insertions(+), 522 deletions(-) create mode 100644 tests/consumer-types/cjs.cjs create mode 100644 tests/consumer-types/esm.mjs diff --git a/src/browserDetection.test.ts b/src/browserDetection.test.ts index 0e5b7d5..e645a6e 100644 --- a/src/browserDetection.test.ts +++ b/src/browserDetection.test.ts @@ -7,65 +7,30 @@ import { isBrowser, isBrowserFamily, } from "./browserDetection"; +import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "./testing"; describe("browser detection", () => { - let originalBrowser: any; - let originalChrome: any; - let originalNavigatorDescriptor: PropertyDescriptor | undefined; - let originalOprDescriptor: PropertyDescriptor | undefined; - let originalSafariDescriptor: PropertyDescriptor | undefined; + let harness: BrowserHarness; + let restoreGlobals: () => void = () => undefined; beforeEach(() => { - originalBrowser = globalThis.browser; - originalChrome = globalThis.chrome; - originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); - originalOprDescriptor = Object.getOwnPropertyDescriptor(globalThis, "opr"); - originalSafariDescriptor = Object.getOwnPropertyDescriptor(globalThis, "safari"); - - delete (globalThis as any).browser; - delete (globalThis as any).chrome; - delete (globalThis as any).navigator; - delete (globalThis as any).opr; - delete (globalThis as any).safari; + harness = createBrowserHarness(); }); afterEach(() => { - (globalThis as any).browser = originalBrowser; - globalThis.chrome = originalChrome; - restoreGlobalProperty("navigator", originalNavigatorDescriptor); - restoreGlobalProperty("opr", originalOprDescriptor); - restoreGlobalProperty("safari", originalSafariDescriptor); - jest.resetAllMocks(); + restoreGlobals(); + restoreGlobals = () => undefined; + jest.restoreAllMocks(); }); - const setGlobalProperty = (name: string, value: any): void => { - Object.defineProperty(globalThis, name, { - configurable: true, - value, - writable: true, + test("guesses Firefox using runtime.getBrowserInfo from the Firefox facade", async () => { + harness.runtime.getBrowserInfo.setResult({ + buildID: "20260708000000", + name: "Firefox", + vendor: "Mozilla", + version: "126.0", }); - }; - - const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - - return; - } - - delete (globalThis as any)[name]; - }; - - test("guesses Firefox using runtime.getBrowserInfo", async () => { - const getBrowserInfo = jest.fn(() => - Promise.resolve({buildID: "20260708000000", name: "Firefox", vendor: "Mozilla", version: "126.0"}) - ); - (globalThis as any).browser = { - runtime: { - getBrowserInfo, - id: "firefox-extension-id", - }, - }; + restoreGlobals = installBrowserGlobals(harness, {context: "none", profile: "firefox"}); await expect(guessBrowser()).resolves.toEqual({ family: BrowserFamily.Firefox, @@ -75,7 +40,9 @@ describe("browser detection", () => { vendor: "Mozilla", version: "126.0", }); - expect(getBrowserInfo).toHaveBeenCalledTimes(1); + expect(harness.runtime.getBrowserInfo.calls).toMatchObject([ + {args: [], callback: undefined, invocation: "promise"}, + ]); }); test("guesses Edge using userAgentData fullVersionList", async () => { @@ -87,14 +54,20 @@ describe("browser detection", () => { ], }) ); - setGlobalProperty("navigator", { - userAgentData: { - brands: [ - {brand: "Chromium", version: "126"}, - {brand: "Microsoft Edge", version: "126"}, - ], - getHighEntropyValues, + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: { + navigator: { + userAgentData: { + brands: [ + {brand: "Chromium", version: "126"}, + {brand: "Microsoft Edge", version: "126"}, + ], + getHighEntropyValues, + }, + }, }, + profile: "chrome", }); await expect(guessBrowser()).resolves.toEqual({ @@ -109,11 +82,15 @@ describe("browser detection", () => { test("guesses Brave before generic Chromium brands", async () => { const isBrave = jest.fn(() => Promise.resolve(true)); - setGlobalProperty("navigator", { - brave: {isBrave}, - userAgentData: { - brands: [{brand: "Chromium", version: "126"}], + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: { + navigator: { + brave: {isBrave}, + userAgentData: {brands: [{brand: "Chromium", version: "126"}]}, + }, }, + profile: "chrome", }); await expect(guessBrowser()).resolves.toEqual({ @@ -125,9 +102,15 @@ describe("browser detection", () => { }); test("guesses Edge using navigator.userAgent fallback", async () => { - setGlobalProperty("navigator", { - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87", + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: { + navigator: { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87", + }, + }, + profile: "chrome", }); await expect(guessBrowser()).resolves.toMatchObject({ @@ -138,24 +121,67 @@ describe("browser detection", () => { }); }); + test("uses the Opera vendor marker when navigator hints are unavailable", async () => { + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: {navigator: {}}, + profile: "opera", + }); + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Chromium, + name: BrowserName.Opera, + source: BrowserGuessSource.BrowserGlobal, + }); + expect(globalThis.opr).toBeDefined(); + expect(globalThis.safari).toBeUndefined(); + }); + + test("uses the Safari vendor marker and removes the Opera marker", async () => { + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: {navigator: {}}, + profile: "safari", + }); + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Safari, + name: BrowserName.Safari, + source: BrowserGuessSource.BrowserGlobal, + }); + expect(globalThis.safari).toBeDefined(); + expect(globalThis.opr).toBeUndefined(); + }); + test("falls back to Chromium for chrome-extension urls", async () => { - const getURL = jest.fn((_path: string) => "chrome-extension://extension-id/"); - globalThis.chrome = { - runtime: { - getURL, - id: "extension-id", - }, - } as any; + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: {navigator: {}}, + profile: "chrome", + }); await expect(guessBrowser()).resolves.toEqual({ family: BrowserFamily.Chromium, name: BrowserName.Chromium, source: BrowserGuessSource.ExtensionUrl, }); - expect(getURL).toHaveBeenCalledWith(""); + expect(harness.runtime.getURL.calls[0]?.args).toEqual([""]); + expect("getBrowserInfo" in globalThis.chrome.runtime).toBe(false); }); - test("returns unknown when no browser signals are available", async () => { + test("returns unknown when a custom profile removes all browser signals", async () => { + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: { + browser: undefined, + chrome: undefined, + navigator: undefined, + opr: undefined, + safari: undefined, + }, + profile: "custom", + }); + await expect(guessBrowser()).resolves.toEqual({ family: BrowserFamily.Unknown, name: BrowserName.Unknown, diff --git a/src/env.test.ts b/src/env.test.ts index 0c42d37..1b7373f 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -1,135 +1,131 @@ -import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; import {isBackground} from "./env"; +import { + type BrowserHarness, + type BrowserTestApi, + createBrowserHarness, + createManifestFixture, + installBrowserGlobals, + installGlobals, +} from "./testing"; describe("isBackground", () => { - let originalBrowserDescriptor: PropertyDescriptor | undefined; - let originalChromeDescriptor: PropertyDescriptor | undefined; - let originalWindowDescriptor: PropertyDescriptor | undefined; - let originalLocationDescriptor: PropertyDescriptor | undefined; + let harness: BrowserHarness; + let restoreGlobals: () => void = () => undefined; beforeEach(() => { - originalBrowserDescriptor = Object.getOwnPropertyDescriptor(globalThis, "browser"); - originalChromeDescriptor = Object.getOwnPropertyDescriptor(globalThis, "chrome"); - originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, "location"); - - delete (globalThis as any).browser; - delete (globalThis as any).chrome; - delete (globalThis as any).window; - delete (globalThis as any).location; + harness = createBrowserHarness(); }); afterEach(() => { - restoreGlobalProperty("browser", originalBrowserDescriptor); - restoreGlobalProperty("chrome", originalChromeDescriptor); - restoreGlobalProperty("window", originalWindowDescriptor); - restoreGlobalProperty("location", originalLocationDescriptor); - jest.resetAllMocks(); + restoreGlobals(); + restoreGlobals = () => undefined; }); - const setGlobalProperty = (name: string, value: unknown): void => { - Object.defineProperty(globalThis, name, { - configurable: true, - value, - writable: true, - }); - }; - - const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - - return; - } - - delete (globalThis as any)[name]; - }; - - const setRuntime = (runtime: object | undefined): void => { - setGlobalProperty("chrome", runtime ? {runtime} : {}); - }; - - const setWindow = (pathname: string): void => { - setGlobalProperty("window", {}); - setGlobalProperty("location", {pathname}); + const installProfile = (context: "backgroundPage" | "extensionPage" | "none" | "serviceWorker"): void => { + restoreGlobals = installBrowserGlobals(harness, {context, profile: "chrome"}); }; test("returns false when the browser API is unavailable", () => { + restoreGlobals = installBrowserGlobals(harness, { + context: "none", + globals: {browser: undefined, chrome: undefined}, + profile: "custom", + }); + expect(isBackground()).toBe(false); }); test("returns false when runtime is unavailable", () => { - setRuntime(undefined); + restoreGlobals = installGlobals({ + browser: undefined, + chrome: {} as BrowserTestApi, + location: undefined, + window: undefined, + }); expect(isBackground()).toBe(false); }); test("returns false when runtime.id is unavailable", () => { - setRuntime({getManifest: jest.fn()}); + restoreGlobals = installGlobals({ + browser: undefined, + chrome: {runtime: {getManifest: harness.runtime.getManifest.api}} as unknown as BrowserTestApi, + location: undefined, + window: undefined, + }); expect(isBackground()).toBe(false); }); - test("returns false without throwing when runtime.getManifest is unavailable", () => { - setRuntime({id: "extension-id", getManifest: undefined}); + test("returns false when runtime.getManifest capability is unavailable", () => { + harness.capabilities.set("runtime.getManifest", false); + installProfile("none"); expect(() => isBackground()).not.toThrow(); expect(isBackground()).toBe(false); }); - test("returns false when runtime.getManifest is not a function", () => { - setRuntime({id: "extension-id", getManifest: "manifest"}); + test("returns false when runtime.getManifest is malformed", () => { + const chromeApi = { + ...harness.chrome, + runtime: {...harness.chrome.runtime, getManifest: "manifest"}, + } as unknown as BrowserTestApi; + restoreGlobals = installGlobals({ + browser: undefined, + chrome: chromeApi, + location: undefined, + window: undefined, + }); expect(isBackground()).toBe(false); }); test("identifies an MV3 service worker as background", () => { - setRuntime({ - getManifest: jest.fn(() => ({ - background: {service_worker: "service-worker.js"}, - manifest_version: 3, - })), - id: "extension-id", - }); + harness.runtime.setManifest( + createManifestFixture({background: {service_worker: "service-worker.js"}, manifest_version: 3}) + ); + installProfile("serviceWorker"); expect(isBackground()).toBe(true); }); test("does not identify an MV3 extension document as background", () => { - setRuntime({ - getManifest: jest.fn(() => ({ - background: {service_worker: "service-worker.js"}, - manifest_version: 3, - })), - id: "extension-id", - }); - setWindow("/popup.html"); + harness.runtime.setManifest( + createManifestFixture({background: {service_worker: "service-worker.js"}, manifest_version: 3}) + ); + installProfile("extensionPage"); expect(isBackground()).toBe(false); }); test("identifies an MV2 generated background page as background", () => { - setRuntime({ - getManifest: jest.fn(() => ({ - background: {scripts: ["background.js"]}, - manifest_version: 2, - })), - id: "extension-id", - }); - setWindow("/_generated_background_page.html"); + harness.runtime.setManifest( + createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) + ); + installProfile("backgroundPage"); expect(isBackground()).toBe(true); }); - test("does not identify a regular extension page as background", () => { - setRuntime({ - getManifest: jest.fn(() => ({ - background: {scripts: ["background.js"]}, - manifest_version: 2, - })), - id: "extension-id", + test("does not identify a regular MV2 extension page as background", () => { + harness.runtime.setManifest( + createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) + ); + installProfile("extensionPage"); + + expect(isBackground()).toBe(false); + }); + + test("treats a malformed location value as a non-background extension page", () => { + harness.runtime.setManifest( + createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) + ); + restoreGlobals = installBrowserGlobals(harness, { + context: "extensionPage", + globals: {location: {}}, + profile: "chrome", }); - setWindow("/popup.html"); expect(isBackground()).toBe(false); }); diff --git a/src/identity.test.ts b/src/identity.test.ts index e44481e..d297ae3 100644 --- a/src/identity.test.ts +++ b/src/identity.test.ts @@ -9,261 +9,260 @@ import { onIdentitySignInChanged, removeCachedAuthToken, } from "./identity"; +import { + type BrowserHarness, + type BrowserTestApi, + createBrowserHarness, + createBrowserMethod, + createManifestFixture, + installGlobals, + type NavigatorTestValue, +} from "./testing"; + +const redirectUrl = "https://chrome-extension-id.chromiumapp.org/oauth?code=123"; describe("identity", () => { - let originalBrowser: any; - let originalChrome: any; - let originalNavigatorDescriptor: PropertyDescriptor | undefined; + let harness: BrowserHarness; + let restoreGlobals: () => void; beforeEach(() => { - originalBrowser = globalThis.browser; - originalChrome = globalThis.chrome; - originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); - - delete (globalThis as any).browser; - delete (globalThis as any).chrome; - delete (globalThis as any).navigator; + harness = createBrowserHarness({ + extensionId: "chrome-extension-id", + manifest: createManifestFixture({manifest_version: 3}), + }); + restoreGlobals = installGlobals({ + browser: undefined, + chrome: harness.chrome, + navigator: undefined, + opr: undefined, + safari: undefined, + }); }); afterEach(() => { - (globalThis as any).browser = originalBrowser; - globalThis.chrome = originalChrome; - restoreGlobalProperty("navigator", originalNavigatorDescriptor); - jest.resetAllMocks(); + restoreGlobals(); + jest.restoreAllMocks(); }); - const setChromeIdentity = ( - identity: Partial, - lastError?: chrome.runtime.LastError, - manifestVersion: 2 | 3 = 3 - ): void => { - globalThis.chrome = { - identity, - runtime: { - getManifest: jest.fn(() => ({manifest_version: manifestVersion})), - id: "chrome-extension-id", - lastError, - }, - } as any; - }; - - const createEvent = () => { - const addListener = jest.fn(); - const removeListener = jest.fn(); - - return {addListener, removeListener}; - }; - - const setNavigator = (navigator: Partial): void => { - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: navigator, - writable: true, + const installChromeWithNavigator = (navigator: NavigatorTestValue): void => { + restoreGlobals(); + restoreGlobals = installGlobals({ + browser: undefined, + chrome: harness.chrome, + navigator, + opr: undefined, + safari: undefined, }); }; - const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - - return; - } - - delete (globalThis as any)[name]; + const installFirefox = (): void => { + restoreGlobals(); + restoreGlobals = installGlobals({ + browser: harness.browser, + chrome: harness.chrome, + navigator: undefined, + opr: undefined, + safari: undefined, + }); }; - test("should generate a redirect url", () => { - const getRedirectURL = jest.fn((path?: string) => `https://chrome-extension-id.chromiumapp.org/${path}`); - setChromeIdentity({getRedirectURL}); + test("should generate a redirect url through a configurable sync method", () => { + harness.configurable.chrome.identity.getRedirectURL.setResult( + "https://chrome-extension-id.chromiumapp.org/oauth" + ); expect(getIdentityRedirectUrl("oauth")).toBe("https://chrome-extension-id.chromiumapp.org/oauth"); - expect(getRedirectURL).toHaveBeenCalledWith("oauth"); + expect(harness.configurable.chrome.identity.getRedirectURL.calls).toMatchObject([ + {args: ["oauth"], callback: undefined, invocation: "sync"}, + ]); }); - test("should launch a Chrome MV2 callback web auth flow", async () => { - const launchWebAuthFlowMock = jest.fn( - (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => - cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") - ); - setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 2); + test.each([2, 3] as const)("should launch a Chrome MV%s callback web auth flow", async manifestVersion => { + harness.runtime.setManifest(createManifestFixture({manifest_version: manifestVersion})); + harness.configurable.chrome.identity.launchWebAuthFlow.setResult(redirectUrl); + const details = {interactive: true, url: "https://accounts.example/oauth"}; - await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( - "https://chrome-extension-id.chromiumapp.org/oauth?code=123" - ); - expect(launchWebAuthFlowMock).toHaveBeenCalledWith( - {url: "https://accounts.example/oauth", interactive: true}, - expect.any(Function) - ); - }); - - test("should launch a Chrome MV3 callback web auth flow", async () => { - const launchWebAuthFlowMock = jest.fn( - (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => - cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") - ); - setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3); - - await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( - "https://chrome-extension-id.chromiumapp.org/oauth?code=123" - ); - expect(launchWebAuthFlowMock).toHaveBeenCalledWith( - {url: "https://accounts.example/oauth", interactive: true}, - expect.any(Function) - ); + await expect(launchWebAuthFlow(details)).resolves.toBe(redirectUrl); + expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls).toMatchObject([ + { + args: [details], + callback: expect.any(Function), + callbackCalls: [[redirectUrl]], + invocation: "callback", + }, + ]); }); - test("should not use Firefox promise-only flow from a user agent fallback", async () => { - const launchWebAuthFlowMock = jest.fn( - (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => - cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") - ); - setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3); - setNavigator({ + test("should not use the Firefox Promise branch from a user-agent fallback alone", async () => { + installChromeWithNavigator({ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", }); + harness.configurable.chrome.identity.launchWebAuthFlow.setResult(redirectUrl); - await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( - "https://chrome-extension-id.chromiumapp.org/oauth?code=123" - ); - expect(launchWebAuthFlowMock).toHaveBeenCalledWith( - {url: "https://accounts.example/oauth", interactive: true}, - expect.any(Function) - ); + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).resolves.toBe(redirectUrl); + expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls[0]).toMatchObject({ + callback: expect.any(Function), + invocation: "callback", + }); }); - test("should launch a Firefox promise-only web auth flow without callback", async () => { - const getBrowserInfo = jest.fn(() => - Promise.resolve({buildID: "1", name: "Firefox", vendor: "Mozilla", version: "86"}) - ); - const launchWebAuthFlowMock = jest.fn((_details: any) => - Promise.resolve("https://extension-id.extensions.allizom.org/oauth?code=123") - ); - (globalThis as any).browser = { - identity: { - launchWebAuthFlow: launchWebAuthFlowMock, - }, - runtime: { - getBrowserInfo, - getManifest: jest.fn(() => ({manifest_version: 2})), - id: "firefox-extension-id", - lastError: undefined, - }, - }; - - await expect( - launchWebAuthFlow({ - redirect_uri: "https://extension-id.extensions.allizom.org/oauth", - url: "https://accounts.example/oauth", - }) - ).resolves.toBe("https://extension-id.extensions.allizom.org/oauth?code=123"); - expect(launchWebAuthFlowMock).toHaveBeenCalledWith({ + test("should use the dual Promise path for a Firefox runtime", async () => { + installFirefox(); + const firefoxRedirect = "https://extension-id.extensions.allizom.org/oauth?code=123"; + harness.configurable.browser.identity.launchWebAuthFlow.setResult(firefoxRedirect); + const details = { redirect_uri: "https://extension-id.extensions.allizom.org/oauth", url: "https://accounts.example/oauth", + }; + + await expect(launchWebAuthFlow(details)).resolves.toBe(firefoxRedirect); + expect(harness.configurable.browser.identity.launchWebAuthFlow.calls).toMatchObject([ + {args: [details], callback: undefined, callbackCalls: [], invocation: "promise"}, + ]); + expect(harness.runtime.getBrowserInfo.calls).toHaveLength(1); + expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls).toHaveLength(0); + }); + + test("should support a promise-tolerant implementation that ignores a supplied callback", async () => { + const method = createBrowserMethod({ + callback: "last", + invocation: "promise-tolerant", + name: "identity.launchWebAuthFlow", }); - expect(getBrowserInfo).toHaveBeenCalledTimes(1); + method.setResult(redirectUrl); + const chromeApi = { + ...harness.chrome, + identity: {...harness.chrome.identity, launchWebAuthFlow: method.api}, + } as BrowserTestApi; + restoreGlobals(); + restoreGlobals = installGlobals({browser: undefined, chrome: chromeApi}); + + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).resolves.toBe(redirectUrl); + expect(method.calls).toMatchObject([ + { + callback: expect.any(Function), + callbackCalls: [], + invocation: "promise-tolerant", + }, + ]); }); - test("should reject launchWebAuthFlow when the browser promise rejects", async () => { - const errorMessage = "Authorization flow failed"; - setChromeIdentity({ - launchWebAuthFlow: jest.fn((_details: chrome.identity.WebAuthFlowDetails) => - Promise.reject(new Error(errorMessage)) - ), - } as any); + test("should reject launchWebAuthFlow through callback-scoped runtime.lastError", async () => { + harness.configurable.chrome.identity.launchWebAuthFlow.failNext(new Error("Authorization flow failed")); - await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).rejects.toThrow(errorMessage); + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).rejects.toThrow( + "Authorization flow failed" + ); + expect(harness.runtime.lastError).toBeUndefined(); }); test("should normalize getAuthToken callback token and granted scopes", async () => { - const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => - cb("access-token", ["email", "profile"]) - ); - setChromeIdentity({getAuthToken: getAuthTokenMock as any}); + harness.configurable.chrome.identity.getAuthToken.setImplementation((( + _details: chrome.identity.TokenDetails, + callback: (token: string, scopes: string[]) => void + ) => { + callback("access-token", ["email", "profile"]); + }) as unknown as typeof chrome.identity.getAuthToken); await expect(getAuthToken({interactive: true})).resolves.toEqual({ grantedScopes: ["email", "profile"], token: "access-token", }); - expect(getAuthTokenMock).toHaveBeenCalledWith({interactive: true}, expect.any(Function)); + expect(harness.configurable.chrome.identity.getAuthToken.calls).toMatchObject([ + { + args: [{interactive: true}], + callbackCalls: [["access-token", ["email", "profile"]]], + invocation: "hybrid", + }, + ]); }); test("should keep object-style getAuthToken results", async () => { const result = {grantedScopes: ["email"], token: "access-token"}; - const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(result)); - setChromeIdentity({getAuthToken: getAuthTokenMock as any}); + harness.configurable.chrome.identity.getAuthToken.setResult(result); await expect(getAuthToken()).resolves.toBe(result); - expect(getAuthTokenMock).toHaveBeenCalledWith({}, expect.any(Function)); + expect(harness.configurable.chrome.identity.getAuthToken.calls[0]?.args).toEqual([{}]); }); test("should treat a null getAuthToken callback value as a token result", async () => { - const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(null)); - setChromeIdentity({getAuthToken: getAuthTokenMock as any}); - - await expect(getAuthToken()).resolves.toEqual({ - grantedScopes: undefined, - token: null, - }); + harness.configurable.chrome.identity.getAuthToken.setImplementation((( + _details: chrome.identity.TokenDetails, + callback: (token: null) => void + ) => { + callback(null); + }) as unknown as typeof chrome.identity.getAuthToken); + + await expect(getAuthToken()).resolves.toEqual({grantedScopes: undefined, token: null}); }); - test("should reject getAuthToken when runtime lastError is set", async () => { - const errorMessage = "OAuth token unavailable"; - setChromeIdentity( - { - getAuthToken: jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(undefined)), - } as any, - {message: errorMessage} - ); + test("should reject getAuthToken when runtime.lastError is set for the callback", async () => { + harness.configurable.chrome.identity.getAuthToken.failNext(new Error("OAuth token unavailable")); - await expect(getAuthToken()).rejects.toThrow(errorMessage); + await expect(getAuthToken()).rejects.toThrow("OAuth token unavailable"); + expect(harness.runtime.lastError).toBeUndefined(); + }); + + test("should model the hybrid callback and thenable race", async () => { + const callbackResult = {grantedScopes: ["email"], token: "callback-token"}; + const promiseResult = {grantedScopes: ["profile"], token: "promise-token"}; + harness.configurable.chrome.identity.getAuthToken.setImplementation((( + _details: chrome.identity.TokenDetails, + callback: (result: chrome.identity.GetAuthTokenResult) => void + ) => { + callback(callbackResult); + return Promise.resolve(promiseResult); + }) as unknown as typeof chrome.identity.getAuthToken); + + await expect(getAuthToken()).resolves.toBe(callbackResult); + expect(harness.configurable.chrome.identity.getAuthToken.calls).toMatchObject([ + {callbackCalls: [[callbackResult]], invocation: "hybrid"}, + ]); }); test("should remove a cached auth token", async () => { - const removeCachedAuthTokenMock = jest.fn((_details: chrome.identity.InvalidTokenDetails, cb: () => void) => - cb() - ); - setChromeIdentity({removeCachedAuthToken: removeCachedAuthTokenMock as any}); + harness.configurable.chrome.identity.removeCachedAuthToken.setResult(undefined); + const details = {token: "access-token"}; - await expect(removeCachedAuthToken({token: "access-token"})).resolves.toBeUndefined(); - expect(removeCachedAuthTokenMock).toHaveBeenCalledWith({token: "access-token"}, expect.any(Function)); + await expect(removeCachedAuthToken(details)).resolves.toBeUndefined(); + expect(harness.configurable.chrome.identity.removeCachedAuthToken.calls[0]?.args).toEqual([details]); }); test("should clear all cached auth tokens", async () => { - const clearAllCachedAuthTokensMock = jest.fn((cb: () => void) => cb()); - setChromeIdentity({clearAllCachedAuthTokens: clearAllCachedAuthTokensMock as any}); + harness.configurable.chrome.identity.clearAllCachedAuthTokens.setResult(undefined); await expect(clearAllCachedAuthTokens()).resolves.toBeUndefined(); - expect(clearAllCachedAuthTokensMock).toHaveBeenCalledWith(expect.any(Function)); + expect(harness.configurable.chrome.identity.clearAllCachedAuthTokens.calls).toHaveLength(1); }); test("should get profile user info", async () => { const profile = {email: "user@example.com", id: "gaia-id"}; - const getProfileUserInfoMock = jest.fn((_details: chrome.identity.ProfileDetails, cb: any) => cb(profile)); - setChromeIdentity({getProfileUserInfo: getProfileUserInfoMock as any}); + harness.configurable.chrome.identity.getProfileUserInfo.setResult(profile); await expect(getProfileUserInfo({accountStatus: "ANY"})).resolves.toBe(profile); - expect(getProfileUserInfoMock).toHaveBeenCalledWith({accountStatus: "ANY"}, expect.any(Function)); + expect(harness.configurable.chrome.identity.getProfileUserInfo.calls[0]?.args).toEqual([ + {accountStatus: "ANY"}, + ]); }); test("should get identity accounts", async () => { const accounts = [{id: "account-id"}]; - const getAccountsMock = jest.fn((cb: any) => cb(accounts)); - setChromeIdentity({getAccounts: getAccountsMock as any}); + harness.configurable.chrome.identity.getAccounts.setResult(accounts); await expect(getIdentityAccounts()).resolves.toBe(accounts); - expect(getAccountsMock).toHaveBeenCalledWith(expect.any(Function)); + expect(harness.configurable.chrome.identity.getAccounts.calls).toHaveLength(1); }); - test("should subscribe to sign-in changes and unsubscribe", () => { - const onSignInChangedEvent = createEvent(); - setChromeIdentity({onSignInChanged: onSignInChangedEvent as any}); - const callback = jest.fn(); + test("should subscribe, emit and unsubscribe sign-in changes", async () => { + const callback = jest.fn<(account: chrome.identity.AccountInfo, signedIn: boolean) => void>(); + const account = {id: "account-id"}; const unsubscribe = onIdentitySignInChanged(callback); + await harness.configurable.chrome.identity.onSignInChanged.emit(account, true); - expect(onSignInChangedEvent.addListener).toHaveBeenCalledWith(expect.any(Function)); - const listener = onSignInChangedEvent.addListener.mock.calls[0][0]; + expect(callback).toHaveBeenCalledWith(account, true); + expect(harness.configurable.chrome.identity.onSignInChanged.listenerCount()).toBe(1); unsubscribe(); - expect(onSignInChangedEvent.removeListener).toHaveBeenCalledWith(listener); + expect(harness.configurable.chrome.identity.onSignInChanged.listenerCount()).toBe(0); }); }); diff --git a/src/offscreen.test.ts b/src/offscreen.test.ts index c39f4d5..855ef0b 100644 --- a/src/offscreen.test.ts +++ b/src/offscreen.test.ts @@ -1,4 +1,4 @@ -import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {afterEach, beforeEach, describe, expect, test} from "@jest/globals"; import { closeOffscreen, createOffscreen, @@ -9,51 +9,39 @@ import { hasOffscreenPath, hasOffscreenUrl, } from "./offscreen"; +import {type BrowserHarness, createBrowserHarness, createExtensionContextFixture, installGlobals} from "./testing"; describe("offscreen", () => { - let originalChrome: any; - let contexts: chrome.runtime.ExtensionContext[]; + let harness: BrowserHarness; + let restoreGlobals: () => void; beforeEach(() => { - originalChrome = globalThis.chrome; - contexts = []; - - globalThis.chrome = { - runtime: { - id: "extension-id", - lastError: undefined, - getContexts: jest.fn((filter: chrome.runtime.ContextFilter, cb: (result: any) => void) => { - const result = contexts.filter(context => { - if (filter.contextTypes && !filter.contextTypes.includes(context.contextType)) { - return false; - } - - return !( - filter.documentUrls && - (!context.documentUrl || !filter.documentUrls.includes(context.documentUrl)) - ); - }); - - cb(result); - }), - getURL: jest.fn((path: string) => `chrome-extension://extension-id/${path.replace(/^\/+/, "")}`), - }, - offscreen: { - closeDocument: jest.fn((cb: () => void) => cb()), - createDocument: jest.fn((_parameters: chrome.offscreen.CreateParameters, cb: () => void) => cb()), - hasDocument: jest.fn((cb: (result: boolean) => void) => cb(contexts.length !== 0)), - }, - } as any; + harness = createBrowserHarness({extensionId: "extension-id"}); + harness.configurable.chrome.offscreen.closeDocument.setResult(undefined); + harness.configurable.chrome.offscreen.createDocument.setResult(undefined); + harness.configurable.chrome.offscreen.hasDocument.setResult(false); + restoreGlobals = installGlobals({browser: undefined, chrome: harness.chrome}); }); afterEach(() => { - globalThis.chrome = originalChrome; - jest.resetAllMocks(); + restoreGlobals(); }); + const setOffscreenContext = (documentUrl = "chrome-extension://extension-id/offscreen.html"): void => { + harness.runtime.setContexts([ + createExtensionContextFixture({ + contextId: "context-id", + contextType: "OFFSCREEN_DOCUMENT", + documentUrl, + }), + ]); + }; + test("should close the current offscreen document", async () => { await expect(closeOffscreen()).resolves.toBeUndefined(); - expect(globalThis.chrome.offscreen.closeDocument).toHaveBeenCalledWith(expect.any(Function)); + expect(harness.configurable.chrome.offscreen.closeDocument.calls).toMatchObject([ + {args: [], callbackCalls: [[]], invocation: "callback"}, + ]); }); test("should create an offscreen document", async () => { @@ -64,132 +52,68 @@ describe("offscreen", () => { }; await expect(createOffscreen(parameters)).resolves.toBeUndefined(); - expect(globalThis.chrome.offscreen.createDocument).toHaveBeenCalledWith(parameters, expect.any(Function)); + expect(harness.configurable.chrome.offscreen.createDocument.calls).toMatchObject([ + {args: [parameters], callbackCalls: [[]], invocation: "callback"}, + ]); }); test("should check whether an offscreen document exists", async () => { await expect(hasOffscreen()).resolves.toBe(false); - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "chrome-extension://extension-id/offscreen.html", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + harness.configurable.chrome.offscreen.hasDocument.setResult(true); await expect(hasOffscreen()).resolves.toBe(true); - expect(globalThis.chrome.offscreen.hasDocument).toHaveBeenCalledWith(expect.any(Function)); + expect(harness.configurable.chrome.offscreen.hasDocument.calls).toHaveLength(2); }); - test("should return the current offscreen context", async () => { - const offscreenContext = { + test("should return the current offscreen context from stateful runtime contexts", async () => { + const offscreenContext = createExtensionContextFixture({ contextId: "context-id", contextType: "OFFSCREEN_DOCUMENT", documentUrl: "chrome-extension://extension-id/offscreen.html", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - } as chrome.runtime.ExtensionContext; + }); + harness.runtime.setContexts([ + createExtensionContextFixture({contextId: "popup-id", contextType: "POPUP"}), + offscreenContext, + ]); - contexts = [ + await expect(getOffscreenContext()).resolves.toEqual(offscreenContext); + expect(harness.runtime.getContexts.calls).toMatchObject([ { - ...offscreenContext, - contextId: "popup-id", - contextType: "POPUP", + args: [{contextTypes: ["OFFSCREEN_DOCUMENT"]}], + callbackCalls: [[[offscreenContext]]], + invocation: "callback", }, - offscreenContext, - ]; - - await expect(getOffscreenContext()).resolves.toBe(offscreenContext); - expect(globalThis.chrome.runtime.getContexts).toHaveBeenCalledWith( - {contextTypes: ["OFFSCREEN_DOCUMENT"]}, - expect.any(Function) - ); + ]); }); test("should return the current offscreen url", async () => { - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "chrome-extension://extension-id/offscreen.html", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + setOffscreenContext(); await expect(getOffscreenUrl()).resolves.toBe("chrome-extension://extension-id/offscreen.html"); }); test("should return the current offscreen pathname", async () => { - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "chrome-extension://extension-id/offscreen.html?mode=audio#ready", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + setOffscreenContext("chrome-extension://extension-id/offscreen.html?mode=audio#ready"); await expect(getOffscreenPath()).resolves.toBe("/offscreen.html"); }); test("should return undefined path for non-extension urls", async () => { - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "https://example.com/offscreen.html", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + setOffscreenContext("https://example.com/offscreen.html"); await expect(getOffscreenPath()).resolves.toBeUndefined(); }); test("should check the current offscreen url", async () => { - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "chrome-extension://extension-id/offscreen.html", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + setOffscreenContext(); await expect(hasOffscreenUrl("chrome-extension://extension-id/offscreen.html")).resolves.toBe(true); await expect(hasOffscreenUrl("chrome-extension://extension-id/other.html")).resolves.toBe(false); }); test("should check the current offscreen path by pathname", async () => { - contexts = [ - { - contextId: "context-id", - contextType: "OFFSCREEN_DOCUMENT", - documentUrl: "chrome-extension://extension-id/offscreen.html?mode=audio#ready", - frameId: 0, - incognito: false, - tabId: -1, - windowId: -1, - }, - ]; + setOffscreenContext("chrome-extension://extension-id/offscreen.html?mode=audio#ready"); await expect(hasOffscreenPath("/offscreen.html")).resolves.toBe(true); await expect(hasOffscreenPath("/offscreen.html?mode=video#other")).resolves.toBe(true); diff --git a/src/utils.test.ts b/src/utils.test.ts index 905cc64..a7cc8de 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,146 +1,167 @@ -import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {afterEach, describe, expect, jest, test} from "@jest/globals"; +import {type BrowserTestApi, createBrowserEvent, installGlobals} from "./testing"; +import {createListenerErrorCapture} from "./testing/listener-errors"; import {callWithPromise, checkLastError, handleListener, safeListener} from "./utils"; +const runtimeApi = (lastError?: chrome.runtime.LastError): BrowserTestApi => + ({runtime: {lastError}}) as unknown as BrowserTestApi; + describe("utils", () => { - let originalChrome: any; - let originalBrowser: any; - let originalConsoleError: any; - - beforeEach(() => { - originalChrome = globalThis.chrome; - originalBrowser = globalThis.browser; - originalConsoleError = console.error; - console.error = jest.fn(); - - delete (globalThis as any).chrome; - delete (globalThis as any).browser; - }); + let restoreGlobals: () => void = () => undefined; afterEach(() => { - globalThis.chrome = originalChrome; - (globalThis as any).browser = originalBrowser; - console.error = originalConsoleError; - jest.resetAllMocks(); + restoreGlobals(); + restoreGlobals = () => undefined; + jest.restoreAllMocks(); }); + const setGlobals = (values: Parameters[0]): void => { + restoreGlobals(); + restoreGlobals = installGlobals(values); + }; + describe("checkLastError", () => { test("should not throw if lastError is undefined", () => { - globalThis.chrome = {runtime: {lastError: undefined}} as any; + setGlobals({browser: undefined, chrome: runtimeApi()}); + expect(() => checkLastError()).not.toThrow(); }); test("should throw Error if lastError exists", () => { const errorMessage = "Some error"; - globalThis.chrome = {runtime: {lastError: {message: errorMessage}}} as any; + setGlobals({browser: undefined, chrome: runtimeApi({message: errorMessage})}); + expect(() => checkLastError()).toThrow(errorMessage); }); test("should throw Error if WebExtension API is not available", () => { + setGlobals({browser: undefined, chrome: undefined}); + expect(() => checkLastError()).toThrow("WebExtension API not available in this context"); }); }); describe("callWithPromise", () => { test("should resolve with result when successful", async () => { - globalThis.chrome = {runtime: {lastError: undefined}} as any; + setGlobals({browser: undefined, chrome: runtimeApi()}); const expectedResult = {foo: "bar"}; - const executor = (cb: any) => cb(expectedResult); + const executor = (callback: (result: typeof expectedResult) => void): void => callback(expectedResult); - const result = await callWithPromise(executor); - expect(result).toBe(expectedResult); + await expect(callWithPromise(executor)).resolves.toBe(expectedResult); }); test("should resolve with undefined when result is undefined", async () => { - globalThis.chrome = {runtime: {lastError: undefined}} as any; - const executor = (cb: any) => cb(undefined); + setGlobals({browser: undefined, chrome: runtimeApi()}); + const executor = (callback: (result: undefined) => void): void => callback(undefined); - const result = await callWithPromise(executor); - expect(result).toBeUndefined(); + await expect(callWithPromise(executor)).resolves.toBeUndefined(); }); test("should reject when lastError exists", async () => { const errorMessage = "Async error"; - globalThis.chrome = {runtime: {lastError: {message: errorMessage}}} as any; - const executor = (cb: any) => cb(null); + setGlobals({browser: undefined, chrome: runtimeApi({message: errorMessage})}); + const executor = (callback: (result: null) => void): void => callback(null); await expect(callWithPromise(executor)).rejects.toThrow(errorMessage); }); test("should reject when lastError exists even if result is provided", async () => { const errorMessage = "Async error"; - globalThis.chrome = {runtime: {lastError: {message: errorMessage}}} as any; - const executor = (cb: any) => cb({data: "some data"}); + setGlobals({browser: undefined, chrome: runtimeApi({message: errorMessage})}); + const executor = (callback: (result: {data: string}) => void): void => callback({data: "some data"}); await expect(callWithPromise(executor)).rejects.toThrow(errorMessage); }); test("should resolve with result from returned Promise", async () => { const expectedResult = {foo: "bar"}; - const executor = () => Promise.resolve(expectedResult); + const executor = (): Promise => Promise.resolve(expectedResult); - const result = await callWithPromise(executor); - expect(result).toBe(expectedResult); + await expect(callWithPromise(executor)).resolves.toBe(expectedResult); }); test("should reject when returned Promise rejects", async () => { - const errorMessage = "Promise fail"; - const executor = () => Promise.reject(new Error(errorMessage)); + const error = new Error("Promise fail"); + const executor = (): Promise => Promise.reject(error); - await expect(callWithPromise(executor)).rejects.toThrow(errorMessage); + await expect(callWithPromise(executor)).rejects.toBe(error); }); }); describe("safeListener", () => { test("should execute listener and return result", () => { - const expectedResult = "success"; - const listener = jest.fn().mockReturnValue(expectedResult); + const listener = jest.fn<(argument: string) => string>(() => "success"); const wrapped = safeListener(listener); - const result = wrapped("arg1"); + expect(wrapped("arg1")).toBe("success"); expect(listener).toHaveBeenCalledWith("arg1"); - expect(result).toBe(expectedResult); }); - test("should catch sync error and log it", () => { + test("should suppress and capture a synchronous listener error", async () => { + const capture = createListenerErrorCapture(); + setGlobals({consoleError: capture.handler}); const error = new Error("Sync fail"); - const listener = () => { - throw error; + const event = createBrowserEvent<[]>(); + event.api.addListener( + safeListener(() => { + throw error; + }) + ); + + await expect(event.emit()).resolves.toBeUndefined(); + expect(capture.entries).toEqual([{args: [], error, kind: "sync"}]); + }); + + test("should log a native Promise rejection while preserving the rejection", async () => { + const capture = createListenerErrorCapture(); + setGlobals({consoleError: capture.handler}); + const error = new Error("Async fail"); + const event = createBrowserEvent<[]>(); + event.api.addListener(safeListener(() => Promise.reject(error))); + + await expect(event.emit()).rejects.toBe(error); + expect(capture.entries).toEqual([{args: [], error, kind: "promise"}]); + }); + + test("should not log a custom thenable rejection, while the event still observes it", async () => { + const capture = createListenerErrorCapture(); + setGlobals({consoleError: capture.handler}); + const error = new Error("Thenable fail"); + const thenable = { + // biome-ignore lint/suspicious/noThenProperty: This test intentionally models a non-Promise thenable. + then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void { + reject(error); + }, }; - const wrapped = safeListener(listener); + const event = createBrowserEvent<[]>(); + event.api.addListener(safeListener(() => thenable)); - const result = wrapped(); - expect(result).toBeUndefined(); - expect(console.error).toHaveBeenCalledWith("Listener error:", error); + await expect(event.emit()).rejects.toBe(error); + expect(capture.entries).toEqual([]); }); - test("should catch promise rejection and log it", async () => { - const error = new Error("Async fail"); - const listener = () => Promise.reject(error); - const wrapped = safeListener(listener); + test("should preserve and forward unknown console errors", () => { + const forward = jest.fn<(...args: unknown[]) => void>(); + const capture = createListenerErrorCapture(forward); + const error = new Error("Unrecognized"); - const result = wrapped(); - expect(result).toBeInstanceOf(Promise); + capture.handler("Unexpected prefix", error, {source: "test"}); - // Wait for promise rejection to be handled - await new Promise(resolve => setTimeout(resolve, 0)); - expect(console.error).toHaveBeenCalledWith("Listener in promise error:", error); + expect(capture.raw).toEqual([["Unexpected prefix", error, {source: "test"}]]); + expect(forward).toHaveBeenCalledWith("Unexpected prefix", error, {source: "test"}); }); }); describe("handleListener", () => { test("should add listener and return unsubscribe function", () => { - const addListener = jest.fn(); - const removeListener = jest.fn(); - const target = {addListener, removeListener} as any; - const callback = () => {}; - - const unsubscribe = handleListener(target, callback); + const event = createBrowserEvent<[string]>(); + const callback = jest.fn<(value: string) => void>(); - expect(addListener).toHaveBeenCalled(); - expect(typeof unsubscribe).toBe("function"); + const unsubscribe = handleListener(event.api as chrome.events.Event<(value: string) => void>, callback); + expect(event.listenerCount()).toBe(1); unsubscribe(); - expect(removeListener).toHaveBeenCalled(); + expect(event.listenerCount()).toBe(0); }); }); }); diff --git a/tests/consumer-types/check.mjs b/tests/consumer-types/check.mjs index e0235a9..21ba1cd 100644 --- a/tests/consumer-types/check.mjs +++ b/tests/consumer-types/check.mjs @@ -9,7 +9,10 @@ const fixtureDirectory = dirname(fileURLToPath(import.meta.url)); const packageDirectory = join(fixtureDirectory, "../.."); const temporaryDirectory = mkdtempSync(join(tmpdir(), "addon-core-browser-consumer-")); const npm = process.platform === "win32" ? "npm.cmd" : "npm"; -const npmOptions = {shell: process.platform === "win32"}; +const npmOptions = { + env: {...process.env, npm_config_cache: join(temporaryDirectory, "npm-cache")}, + shell: process.platform === "win32", +}; try { const [{filename}] = JSON.parse( @@ -36,10 +39,24 @@ try { assert.equal(installedPackage.dependencies?.["@types/chrome"], "^0.2.2"); assert.equal(installedPackage.peerDependencies?.["@types/chrome"], undefined); assert.equal(installedPackage.types, "dist/index.d.ts"); + assert.deepEqual(installedPackage.exports?.["./testing"], { + types: "./dist/testing/index.d.ts", + import: "./dist/testing/index.js", + require: "./dist/testing/index.cjs", + }); const declarations = readFileSync(join(installedPackageDirectory, installedPackage.types), "utf8"); + const testingDeclarations = readFileSync(join(installedPackageDirectory, "dist/testing/index.d.ts"), "utf8"); assert.match(declarations, /^\/\/\/ /); + assert.match(testingDeclarations, /^\/\/\/ /); + assert.match(testingDeclarations, /^\/\/\/ /m); + for (const file of ["dist/testing/index.js", "dist/testing/index.cjs"]) { + assert.equal(existsSync(join(installedPackageDirectory, file)), true, `${file} is missing from the tarball`); + } + for (const file of ["dist/testing/index.js.map", "dist/testing/index.cjs.map"]) { + assert.equal(existsSync(join(installedPackageDirectory, file)), false, `${file} must not be in the tarball`); + } assert.equal(existsSync(join(consumerDirectory, "node_modules/@types/chrome")), true); execFileSync( @@ -49,6 +66,8 @@ try { stdio: "inherit", } ); + execFileSync(process.execPath, [join(consumerDirectory, "esm.mjs")], {cwd: consumerDirectory, stdio: "inherit"}); + execFileSync(process.execPath, [join(consumerDirectory, "cjs.cjs")], {cwd: consumerDirectory, stdio: "inherit"}); } finally { rmSync(temporaryDirectory, {force: true, recursive: true}); } diff --git a/tests/consumer-types/cjs.cjs b/tests/consumer-types/cjs.cjs new file mode 100644 index 0000000..3f2dbdc --- /dev/null +++ b/tests/consumer-types/cjs.cjs @@ -0,0 +1,25 @@ +const assert = require("node:assert/strict"); + +const beforeChrome = Object.getOwnPropertyDescriptor(globalThis, "chrome"); +const beforeBrowser = Object.getOwnPropertyDescriptor(globalThis, "browser"); +const production = require("@addon-core/browser"); +const testing = require("@addon-core/browser/testing"); + +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); + +const harness = testing.createBrowserHarness({ + manifest: testing.createManifestFixture({name: "CJS consumer"}), +}); +const restore = testing.installBrowserGlobals(harness, {profile: "chrome"}); + +try { + assert.equal(production.getManifest().name, "CJS consumer"); + assert.equal(typeof harness.runtime.closeMessageChannels, "function"); +} finally { + restore(); + restore(); +} + +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); diff --git a/tests/consumer-types/esm.mjs b/tests/consumer-types/esm.mjs new file mode 100644 index 0000000..d8b43bf --- /dev/null +++ b/tests/consumer-types/esm.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; + +const beforeChrome = Object.getOwnPropertyDescriptor(globalThis, "chrome"); +const beforeBrowser = Object.getOwnPropertyDescriptor(globalThis, "browser"); +const production = await import("@addon-core/browser"); +const testing = await import("@addon-core/browser/testing"); + +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); +const harness = testing.createBrowserHarness({ + manifest: testing.createManifestFixture({name: "ESM consumer"}), +}); +const restore = testing.installBrowserGlobals(harness, {profile: "chrome"}); +const unsubscribe = harness.runtime.events.onMessage.on(() => true); + +try { + assert.equal(production.getManifest().name, "ESM consumer"); + const pendingResponse = production.sendMessage({kind: "unanswered"}); + harness.runtime.closeMessageChannels(); + await assert.rejects(pendingResponse, { + message: 'Browser method "runtime.sendMessage" message channel closed before a response was received.', + }); +} finally { + unsubscribe(); + restore(); + restore(); +} + +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); +assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); diff --git a/tests/consumer-types/index.ts b/tests/consumer-types/index.ts index 3f30071..8d6f638 100644 --- a/tests/consumer-types/index.ts +++ b/tests/consumer-types/index.ts @@ -1,4 +1,28 @@ -import {onTabUpdated} from "@addon-core/browser"; +import {getManifest, onTabUpdated, queryTabs} from "@addon-core/browser"; +import { + createBrowserHarness, + createManifestFixture, + createTabFixture, + installBrowserGlobals, +} from "@addon-core/browser/testing"; + +const harness = createBrowserHarness({ + manifest: createManifestFixture({name: "Typed consumer"}), + tabs: [createTabFixture({id: 7})], +}); +const restore = installBrowserGlobals(harness, {profile: "firefox"}); +const manifestName: string = getManifest().name; +const queryResult: Promise = queryTabs({active: true}); +const browserQuery: typeof chrome.tabs.query = harness.browser.tabs.query; + +harness.tabs.query.setResult([]); +harness.configurable.browser.downloads.search.setResult([]); +harness.runtime.closeMessageChannels(); + +void browserQuery; +void manifestName; +void queryResult; +restore(); onTabUpdated((tabId, changeInfo, tab) => { const id: number = tabId; From 5f44f80bab7f2367f9572e73e0733a54419e2359 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:57:05 +0300 Subject: [PATCH 03/11] docs(testing): document harness usage and limitations --- README.md | 7 ++ docs/downloads.md | 4 +- docs/testing.md | 61 ++++++++++++++ docs/testing/fixtures.md | 29 +++++++ docs/testing/harness.md | 158 ++++++++++++++++++++++++++++++++++++ docs/testing/jest.md | 44 ++++++++++ docs/testing/limitations.md | 52 ++++++++++++ docs/testing/primitives.md | 63 ++++++++++++++ docs/utils.md | 8 +- 9 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 docs/testing.md create mode 100644 docs/testing/fixtures.md create mode 100644 docs/testing/harness.md create mode 100644 docs/testing/jest.md create mode 100644 docs/testing/limitations.md create mode 100644 docs/testing/primitives.md diff --git a/README.md b/README.md index 12ccf1a..5bcc2cc 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,13 @@ In addition to Chrome API wrappers, this package provides a set of low-level uti For a complete list of utility functions and examples, see the [Utilities Documentation](docs/utils.md). +## Testing + +The framework-agnostic [`@addon-core/browser/testing`](docs/testing.md) subpath provides deterministic fixtures, +configurable browser methods and events, stateful runtime/permissions/tabs/windows/scripting fakes, and reversible +browser-global installation. Importing it never changes `globalThis`, and it has no dependency on Jest or another test +runner. + ## Not yet covered These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet (Chrome OS–only APIs are intentionally omitted). If you’d like to contribute, please see [CONTRIBUTING.md](CONTRIBUTING.md) and open an issue/PR. diff --git a/docs/downloads.md b/docs/downloads.md index 54f859c..b97a6a0 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -59,7 +59,9 @@ Cancels the specified download. download(options: chrome.downloads.DownloadOptions): Promise ``` -Initiates a download with the given options, resolving to the download ID. The wrapper sets `conflictAction: "uniquify"` by default and validates early errors. May throw `BlockDownloadError` if the download is interrupted (e.g., `USER_CANCELED`). +Initiates a download with the given options, resolving to the download ID. The wrapper sets `conflictAction: "uniquify"` by default and waits 100 ms before validating the newly created download item. It may throw `BlockDownloadError` when the item cannot be found or the download is interrupted with `USER_CANCELED`. + +The fixed validation delay is part of the current runtime behavior. Tests that call this wrapper use real wall-clock time until [issue #24](https://github.com/addon-stack/browser/issues/24) introduces a dedicated scheduling seam. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..213f144 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,61 @@ +# Testing without a browser + +`@addon-core/browser/testing` is a framework-agnostic test kit for code that uses `@addon-core/browser`. It provides +deterministic fixtures, configurable methods and events, and a stateful in-memory browser harness. Importing the +testing subpath does not install globals or otherwise change the process. + +## Quick start with plain Node + +```ts +import assert from "node:assert/strict"; +import {getManifest, queryTabs} from "@addon-core/browser"; +import { + createBrowserHarness, + createManifestFixture, + createTabFixture, + installBrowserGlobals, +} from "@addon-core/browser/testing"; + +const harness = createBrowserHarness({ + manifest: createManifestFixture({name: "Consumer extension"}), + tabs: [createTabFixture({active: true, id: 7})], +}); +const restore = installBrowserGlobals(harness, {profile: "chrome"}); + +try { + assert.equal(getManifest().name, "Consumer extension"); + assert.deepEqual( + (await queryTabs({active: true})).map(tab => tab.id), + [7] + ); +} finally { + restore(); +} +``` + +The production functions are imported from `@addon-core/browser` and execute unchanged. Only the browser globals +they read are supplied by the test. + +## What the kit provides + +- [Fixtures](testing/fixtures.md) create fresh, deterministic Chrome-typed data. +- [Primitives](testing/primitives.md) provide runner-independent methods and browser events. +- [Harness and globals](testing/harness.md) provide browser profiles, state, capabilities, reset, and exact global + restoration. +- [Jest usage](testing/jest.md) shows how to combine the kit with Jest without making the kit depend on Jest. +- [Limitations](testing/limitations.md) describes intentional differences from real browsers. + +## Choosing the right kind of test double + +A fixture is only data. A configurable stub records calls and returns or throws exactly what the test configured. A +stateful fake models a small documented part of browser behavior, such as tabs and permissions. A real-browser +integration test runs the extension in Chrome, Firefox, Safari, or another browser. + +Use this kit for deterministic unit and integration tests around your application code. Keep real-browser tests for +browser compatibility, manifest behavior, security boundaries, lifecycle timing, and vendor-specific behavior. + +## Reset and isolation + +Every harness owns independent state. Call `harness.reset()` between tests when reusing one harness, or create a fresh +harness per test. Always call the restore function returned by `installBrowserGlobals()` in `finally`; it is safe to +call more than once and restores the original property descriptors. diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md new file mode 100644 index 0000000..9308246 --- /dev/null +++ b/docs/testing/fixtures.md @@ -0,0 +1,29 @@ +# Testing fixtures + +Fixture factories return current `@types/chrome` types with deterministic minimal defaults. Every call returns fresh +objects and arrays, so changing one fixture cannot mutate another. + +```ts +import { + createExtensionContextFixture, + createInjectionResultFixture, + createInstalledDetailsFixture, + createManifestFixture, + createMessageSenderFixture, + createPermissionsFixture, + createTabFixture, + createWindowFixture, +} from "@addon-core/browser/testing"; + +const manifest = createManifestFixture({name: "Test extension"}); +const tab = createTabFixture({active: true, id: 4, url: "https://example.test/page"}); +const window = createWindowFixture({focused: true, id: 2}); +const permissions = createPermissionsFixture({permissions: ["tabs"]}); +``` + +`createTabFixture` and `createWindowFixture` deliberately include the `Fixture` suffix because the production package +already exports functions named `createTab` and `createWindow`. + +The remaining factories create `chrome.runtime.InstalledDetails`, `chrome.runtime.MessageSender`, +`chrome.runtime.ExtensionContext`, and `chrome.scripting.InjectionResult` values. Override only fields relevant to the +test; IDs and URLs are stable rather than random. diff --git a/docs/testing/harness.md b/docs/testing/harness.md new file mode 100644 index 0000000..78ff1de --- /dev/null +++ b/docs/testing/harness.md @@ -0,0 +1,158 @@ +# Browser test harness and globals + +`createBrowserHarness()` owns the fake state and call history. `installBrowserGlobals()` chooses how that state is +exposed to production code. + +```ts +import {getAllPermissions, requestPermissions} from "@addon-core/browser"; +import { + createBrowserHarness, + createPermissionsFixture, + installBrowserGlobals, +} from "@addon-core/browser/testing"; + +const harness = createBrowserHarness({ + permissions: createPermissionsFixture({permissions: ["tabs"]}), +}); +const restore = installBrowserGlobals(harness, { + context: "serviceWorker", + profile: "chrome", +}); + +try { + await requestPermissions({permissions: ["downloads"]}); + const current = await getAllPermissions(); + // current.permissions contains tabs and downloads +} finally { + restore(); +} +``` + +Profiles are `chrome`, `firefox`, `opera`, `safari`, and `custom`. Contexts are `extensionPage`, `serviceWorker`, +`backgroundPage`, `contentScript`, and `none`. A profile installs a coherent set of `chrome`, `browser`, `opr`, +`safari`, `navigator`, `window`, and `location` markers and temporarily removes conflicting markers. +The `contentScript` context uses the deterministic host-page URL `https://example.test/content/page.html`, while +extension pages use an extension-style `/index.html` path. + +The Chrome and browser facades are different objects backed by the same harness state. In a Firefox profile, +production wrappers normally route to `harness.browser` because `browser.runtime.id` exists. `harness.chrome` remains +available for explicit compatibility tests, but ordinary wrapper calls do not reach it. Sidebar helpers and browser +detection also inspect globals directly. + +## Stateful and configurable controls + +`harness.runtime`, `harness.permissions`, `harness.tabs`, `harness.windows`, and `harness.scripting` expose the stateful +controls and their methods/events. A namespace can still contain configurable members: for example, +`tabs.sendMessage` and `tabs.connect` record calls but do not invent tab-context message or port routing. Complex +namespaces are explicit configurable stubs: + +```ts +harness.configurable.chrome.downloads.search.setResult([]); +harness.configurable.chrome.downloads.download.failNext(new Error("Downloads disabled")); + +// Chronological calls across stateful and configurable methods: +harness.calls; +``` + +Use `.browser` instead of `.chrome` when configuring a Firefox or Safari profile. `harness.configurable.active` follows +the last profile selected by `installBrowserGlobals()`. + +## Installing exact globals + +Use `installGlobals()` for low-level scenarios: + +```ts +import {installGlobals} from "@addon-core/browser/testing"; + +const restore = installGlobals({ + browser: undefined, // temporarily remove it + chrome: customChromeApi, + navigator: {userAgent: "Test Browser/1"}, +}); + +try { + // run application code +} finally { + restore(); +} +``` + +An omitted field is untouched; an explicitly supplied `undefined` temporarily removes that global. Restoration is +idempotent and restores exact original property descriptors, including globals that were originally absent. + +## Capabilities + +Capabilities are method-level. Disabling one physically removes the member so feature detection remains meaningful: + +```ts +harness.capabilities.set("runtime.getBrowserInfo", false); +``` + +An unknown capability name fails explicitly. `harness.reset()` restores state, methods, events, call history, and the +default capability set. + +## Runtime errors and listener errors + +`runtime.sendMessage` is connected to the harness `runtime.onMessage` event. Listeners receive the configured sender, +may call `sendResponse`, return a Promise or thenable response, or return `true` to keep the response channel open for +a later `sendResponse`. The first response wins. `harness.runtime.emitMessage()` uses the same dispatch path and +returns its response. + +```ts +import {onMessage, sendMessage} from "@addon-core/browser"; + +const unsubscribe = onMessage((_message, _sender, sendResponse) => { + queueMicrotask(() => sendResponse({ok: true})); + return true; +}); + +const response = await sendMessage({kind: "ping"}); +unsubscribe(); +// response is {ok: true} +``` + +A listener that returns `true` without eventually calling `sendResponse()` leaves the test Promise pending, just as it +leaves the modeled response channel open. Close such channels explicitly when testing teardown or failure behavior: + +```ts +import assert from "node:assert/strict"; + +const unsubscribe = harness.runtime.events.onMessage.on(() => true); +const pendingResponse = harness.browser.runtime.sendMessage({kind: "ping"}); + +harness.runtime.closeMessageChannels(); +await assert.rejects(pendingResponse, { + message: 'Browser method "runtime.sendMessage" message channel closed before a response was received.', +}); +unsubscribe(); +``` + +`harness.reset()` closes pending channels as part of reset. Synchronous listener returns other than literal `true` are +ignored; use `sendResponse()` or return a Promise/thenable for a response. With no listeners, the harness resolves +`undefined` instead of reproducing Chrome's `Receiving end does not exist` error. + +For a callback-style method configured to fail, `chrome.runtime.lastError` exists only during the callback. The same +failure through a Promise rejects without setting `lastError`. + +```ts +let observed: chrome.runtime.LastError | undefined; + +harness.tabs.query.failNext(new Error("Tabs unavailable")); +harness.chrome.tabs.query({}, () => { + observed = harness.chrome.runtime.lastError; +}); + +// observed?.message === "Tabs unavailable" +// harness.chrome.runtime.lastError === undefined after the callback +``` + +`captureListenerErrors: true` is opt-in sugar over a temporary `console.error` wrapper. Known `safeListener` prefixes +are recorded as structured listener errors. Unknown calls are retained as raw entries and forwarded to the original +`console.error`; the default does not intercept or silence the console. + +When a harness is reused, reset all state and controls explicitly: + +```ts +harness.reset(); +// call history and listeners are empty; initial fixtures and capabilities are restored +``` diff --git a/docs/testing/jest.md b/docs/testing/jest.md new file mode 100644 index 0000000..e10c734 --- /dev/null +++ b/docs/testing/jest.md @@ -0,0 +1,44 @@ +# Using the test kit with Jest + +The kit itself does not import Jest. A Jest suite can install a fresh harness in normal lifecycle hooks: + +```ts +import {afterEach, beforeEach, expect, test} from "@jest/globals"; +import {getManifest} from "@addon-core/browser"; +import { + createBrowserHarness, + createManifestFixture, + installBrowserGlobals, +} from "@addon-core/browser/testing"; + +let restore: () => void; + +beforeEach(() => { + const harness = createBrowserHarness({ + manifest: createManifestFixture({name: "Jest fixture"}), + }); + restore = installBrowserGlobals(harness, {profile: "chrome"}); +}); + +afterEach(() => restore()); + +test("reads the real wrapper through fake globals", () => { + expect(getManifest().name).toBe("Jest fixture"); +}); +``` + +## Supplying fixtures to a module mock + +Fixtures are plain data and can also be returned from an application-level module mock: + +```ts +import {jest} from "@jest/globals"; +import {createTabFixture} from "@addon-core/browser/testing"; + +jest.mock("./current-tab", () => ({ + loadCurrentTab: jest.fn(async () => createTabFixture({active: true, id: 9})), +})); +``` + +This is separate from the main harness workflow. Prefer fake globals when the goal is to exercise the real +`@addon-core/browser` wrapper; use a module mock when the wrapper itself is intentionally outside the test boundary. diff --git a/docs/testing/limitations.md b/docs/testing/limitations.md new file mode 100644 index 0000000..8b5f84e --- /dev/null +++ b/docs/testing/limitations.md @@ -0,0 +1,52 @@ +# Testing limitations + +The test kit models a documented subset of WebExtension behavior. Passing tests do not prove equivalent behavior in +Chrome, Firefox, Safari, Opera, or any other real browser. + +## Intentional differences + +- `tabs.query()` uses AND equality matching for `status`, `lastFocusedWindow`, `windowId`, `windowType`, `active`, + `index`, `currentWindow`, `highlighted`, `discarded`, `frozen`, `autoDiscardable`, `pinned`, `splitViewId`, `audible`, + `muted`, `groupId`, `title`, and `url`. `title` and `url` accept literal values only in v1; browser match patterns + and wildcards fail explicitly instead of returning a potentially incorrect result. Use `tabs.get()` for an ID. +- Complex APIs outside runtime, permissions, tabs, windows, and the scripting content-script registry are configurable + stubs. They do not simulate the browser unless the test supplies an implementation or result. +- `tabs.sendMessage()` and `tabs.connect()` are configurable stubs. The kit does not create content-script contexts, + route messages to a particular tab/frame, or simulate long-lived ports. +- `runtime.sendMessage()` resolves `undefined` when there are no message listeners. Chrome can instead report + `Could not establish connection. Receiving end does not exist.` through callback-scoped `runtime.lastError` (or a + rejected Promise). +- A synchronous `runtime.onMessage` listener return is not a response: every value except literal `true` is ignored. + Return a Promise/thenable or call `sendResponse()` to answer; literal `true` only keeps the response channel open. +- A held-open message channel has no automatic browser-lifecycle timeout. It remains pending until `sendResponse()` or + `harness.runtime.closeMessageChannels()`; explicit closure rejects with the exact message + `Browser method "runtime.sendMessage" message channel closed before a response was received.`; `harness.reset()` also + closes pending message channels. +- Browser profiles model routing and common compatibility shapes, not complete vendor parity. In the Firefox profile, + production wrappers normally use `harness.browser`; configuring the separate `harness.chrome` facade does not change + that routing. +- Browser-event dispatch uses a listener snapshot. A listener removed by another listener during the same `emit()` is + still called for that dispatch; Chrome and DOM events skip a listener removed before its turn. +- The production `download()` helper currently includes a real 100 ms delay. The kit does not install fake timers or + claim full timing determinism. A scheduler seam is tracked in + [GitHub issue #24](https://github.com/addon-stack/browser/issues/24). +- `findTabById()` currently propagates the missing-tab rejection, and `getTabUrl()` consequently preserves the lower + level message. That existing behavior is tracked in + [GitHub issue #23](https://github.com/addon-stack/browser/issues/23) and is not changed by the kit. + +## Listener behavior + +Raw `createBrowserEvent().emit()` waits for Promises and arbitrary thenables and surfaces listener failures. Production +`onXxx()` helpers wrap callbacks with `safeListener`, so their observable behavior differs: + +- a synchronous throw is logged as `Listener error:`, becomes `undefined`, and does not reject raw emit; +- a native Promise rejection is logged as `Listener in promise error:`, while the original returned Promise remains + rejected; +- a custom or cross-realm thenable is not logged because production checks `instanceof Promise`, but the event + primitive still assimilates it and rejects. + +`captureListenerErrors` only structures the existing `console.error` calls. It does not hook listeners directly and is +never enabled by default. + +Use real-browser integration tests for permissions prompts, URL-pattern semantics, service-worker suspension, +cross-context messaging, content-script injection, browser UI, security boundaries, and browser-specific timing. diff --git a/docs/testing/primitives.md b/docs/testing/primitives.md new file mode 100644 index 0000000..3789cc7 --- /dev/null +++ b/docs/testing/primitives.md @@ -0,0 +1,63 @@ +# Testing primitives + +The primitives have no dependency on a test runner or mocking framework. + +## Browser events + +```ts +import {createBrowserEvent} from "@addon-core/browser/testing"; + +const event = createBrowserEvent<[chrome.runtime.InstalledDetails]>(); +const listener = async (details: chrome.runtime.InstalledDetails) => { + // assertions or application work +}; + +event.api.addListener(listener); +event.api.hasListener(listener); // true +const unsubscribe = event.on(listener); + +await event.emit({reason: "install"}); +unsubscribe(); +event.api.removeListener(listener); +event.reset(); +``` + +Listeners are identified by reference. `emit()` takes a listener snapshot, starts every listener synchronously, and +then waits for Promises and other thenables. One listener failure is rethrown directly; multiple failures produce an +`AggregateError`. + +## Configurable methods + +```ts +import {createBrowserMethod} from "@addon-core/browser/testing"; + +const method = createBrowserMethod({ + name: "tabs.query", + invocation: "dual", +}); + +method.setResult([]); +method.queueResult([{id: 2} as chrome.tabs.Tab]); +method.failNext(new Error("Temporary failure")); + +await method.api({active: true}); +method.calls; // immutable snapshots with arguments, callback, style and sequence +method.reset(); +``` + +An unconfigured call fails and names the API. The supported invocation styles are: + +- `sync`: returns or throws synchronously; +- `callback`: requires a trailing callback and returns `undefined`; +- `promise`: rejects a call that supplies a callback and otherwise returns a Promise; +- `dual`: a supplied callback receives the result, otherwise the method returns a Promise; +- `promise-tolerant`: accepts but ignores a trailing callback and always returns a Promise; +- `hybrid`: a custom implementation may invoke a callback and return a thenable. + +`setResult()` configures a persistent result, `queueResult()` appends FIFO results, `setImplementation()` replaces the +implementation, and each `failNext()` appends one FIFO failure. These names intentionally do not imitate Jest's mock +API. `hasDefaultImplementation` reports whether the method has a harness-owned reset baseline. `reset()` preserves +that baseline while clearing calls, queued outcomes, and consumer-provided configuration. + +For callback failures, `runtime.lastError` is present only while the callback runs. Promise failures reject and do not +create `runtime.lastError`. diff --git a/docs/utils.md b/docs/utils.md index f13279d..2888a06 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -62,7 +62,13 @@ export function getPlatformInfo(): Promise { safeListener any>(listener: T): T ``` -Wraps any listener function so that synchronous errors are caught and logged to the console. It also catches and logs rejected promises from async listeners. This ensures that one failing listener doesn't break the extension's execution flow. +Wraps a listener and reports failures to `console.error`. The exact behavior depends on what the listener returns: + +- A synchronous throw is logged with `Listener error:` and suppressed; the wrapper returns `undefined`. +- A rejected native `Promise` is logged with `Listener in promise error:`, but the original rejected promise is still returned to the caller. +- A custom or cross-realm thenable is returned unchanged and is not logged because the implementation checks `result instanceof Promise`. + +The async cases are therefore observable by callers that await the returned value. `safeListener()` is not a general error-isolation boundary. ```ts import { safeListener } from "@addon-core/browser/utils"; From 4d22e916efd0114dfe735577c614c4ed07254f75 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:21:53 +0300 Subject: [PATCH 04/11] fix(tabs): restore findTabById undefined contract Await the lookup and cover catch-all behavior through both browser facades. Preserve getTab errors and document getTabUrl error paths. Closes #23 --- docs/tabs.md | 8 +++- docs/testing/limitations.md | 3 -- src/tabs.ts | 2 +- src/testing/production.integration.test.ts | 52 ++++++++++++++++++---- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/docs/tabs.md b/docs/tabs.md index edc4a6f..2afcd49 100644 --- a/docs/tabs.md +++ b/docs/tabs.md @@ -368,7 +368,9 @@ Removes previously inserted CSS using the MV2 `chrome.tabs.removeCSS`. Not avail getTabUrl(tabId: number): Promise ``` -Returns the current URL of the specified tab or throws if it cannot be determined. +Returns the current URL of the specified tab. Rejects with an `Error` whose message is +`Tab id "${tabId}" not exist` if the tab lookup fails, or `URL not exist by tab id ${tabId}` if the tab exists but has no +URL. The supplied tab ID replaces `${tabId}` in each message. @@ -408,7 +410,9 @@ Returns the first tab matching the query, if any. findTabById(tabId: number): Promise ``` -Resolves with the tab for the given ID, or `undefined` if not available. +Resolves with the tab for the given ID, or `undefined` when `getTab(tabId)` rejects. The helper preserves its catch-all +lookup policy: any lookup rejection is normalized to `undefined`, not only a missing-tab error. Use `getTab()` when the +original browser error is needed. diff --git a/docs/testing/limitations.md b/docs/testing/limitations.md index 8b5f84e..6e092b1 100644 --- a/docs/testing/limitations.md +++ b/docs/testing/limitations.md @@ -30,9 +30,6 @@ Chrome, Firefox, Safari, Opera, or any other real browser. - The production `download()` helper currently includes a real 100 ms delay. The kit does not install fake timers or claim full timing determinism. A scheduler seam is tracked in [GitHub issue #24](https://github.com/addon-stack/browser/issues/24). -- `findTabById()` currently propagates the missing-tab rejection, and `getTabUrl()` consequently preserves the lower - level message. That existing behavior is tracked in - [GitHub issue #23](https://github.com/addon-stack/browser/issues/23) and is not changed by the kit. ## Listener behavior diff --git a/src/tabs.ts b/src/tabs.ts index c5d8c6b..1c05b8e 100644 --- a/src/tabs.ts +++ b/src/tabs.ts @@ -145,7 +145,7 @@ export const findTab = async (queryInfo?: QueryInfo): Promise = export const findTabById = async (tabId: number): Promise => { try { - return getTab(tabId); + return await getTab(tabId); } catch { return undefined; } diff --git a/src/testing/production.integration.test.ts b/src/testing/production.integration.test.ts index 0b8b774..c65788b 100644 --- a/src/testing/production.integration.test.ts +++ b/src/testing/production.integration.test.ts @@ -1,7 +1,7 @@ import {BlockDownloadError, download} from "../downloads"; -import {findTabById, getTabUrl} from "../tabs"; +import {findTabById, getTab, getTabUrl} from "../tabs"; import {getUserScripts} from "../userScripts"; -import {createBrowserHarness, installGlobals} from "./index"; +import {createBrowserHarness, createTabFixture, installGlobals} from "./index"; const restorers: Array<() => void> = []; @@ -26,13 +26,49 @@ describe("current production behavior through the browser harness", () => { }); }); - test("locks in the current missing-tab rejection cascade", async () => { - const harness = createBrowserHarness(); - restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + describe.each(["chrome", "browser"] as const)("tab lookup through the %s facade", facade => { + let harness: ReturnType; + + beforeEach(() => { + harness = createBrowserHarness(); + restorers.push( + installGlobals({ + browser: facade === "browser" ? harness.browser : undefined, + chrome: facade === "chrome" ? harness.chrome : undefined, + }) + ); + }); + + test("returns an existing tab and its URL", async () => { + const tab = createTabFixture({id: 7, url: "https://example.test/existing"}); + harness.tabs.set([tab]); + + await expect(findTabById(7)).resolves.toEqual(tab); + await expect(getTabUrl(7)).resolves.toBe(tab.url); + }); + + test("returns undefined for a missing tab without changing the direct getTab rejection", async () => { + await expect(findTabById(999)).resolves.toBeUndefined(); + await expect(getTab(999)).rejects.toThrow("No tab with id: 999."); + }); + + test("reports the dedicated missing-tab error from getTabUrl", async () => { + await expect(getTabUrl(999)).rejects.toThrow('Tab id "999" not exist'); + }); + + test("preserves the error for an existing tab without a URL", async () => { + harness.tabs.set([createTabFixture({id: 7, url: undefined})]); - // Locks in current behavior; see https://github.com/addon-stack/browser/issues/23. - await expect(findTabById(999)).rejects.toThrow("No tab with id: 999."); - await expect(getTabUrl(999)).rejects.toThrow("No tab with id: 999."); + await expect(getTabUrl(7)).rejects.toThrow("URL not exist by tab id 7"); + }); + + test("normalizes every lookup rejection to undefined, not just missing-tab errors", async () => { + harness.tabs.get.failNext(new Error("Tabs unavailable")); + await expect(findTabById(7)).resolves.toBeUndefined(); + + harness.tabs.get.failNext(new Error("Tabs unavailable")); + await expect(getTabUrl(7)).rejects.toThrow('Tab id "7" not exist'); + }); }); test("download succeeds after the production 100 ms delay", async () => { From e2498093ec5dd5ee2ef73c79f501f9cc96c37662 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:33:07 +0300 Subject: [PATCH 05/11] refactor(downloads): make validation delay controllable Keep the native 100 ms default and the public download signature. Add a per-harness delay control with reset and clean consumer tests. Closes #24 --- docs/downloads.md | 5 +- docs/testing/harness.md | 31 ++++ docs/testing/limitations.md | 7 +- src/downloads.ts | 3 +- src/internal/download-validation.ts | 38 +++++ src/testing/delays.test.ts | 174 +++++++++++++++++++++ src/testing/delays.ts | 44 ++++++ src/testing/harness.ts | 6 + src/testing/index.ts | 1 + src/testing/production.integration.test.ts | 152 +++++++++++++----- tests/consumer-types/cjs.cjs | 51 ++++-- tests/consumer-types/esm.mjs | 21 +++ tests/consumer-types/index.ts | 11 +- 13 files changed, 490 insertions(+), 54 deletions(-) create mode 100644 src/internal/download-validation.ts create mode 100644 src/testing/delays.test.ts create mode 100644 src/testing/delays.ts diff --git a/docs/downloads.md b/docs/downloads.md index b97a6a0..fa093b6 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -61,7 +61,10 @@ download(options: chrome.downloads.DownloadOptions): Promise Initiates a download with the given options, resolving to the download ID. The wrapper sets `conflictAction: "uniquify"` by default and waits 100 ms before validating the newly created download item. It may throw `BlockDownloadError` when the item cannot be found or the download is interrupted with `USER_CANCELED`. -The fixed validation delay is part of the current runtime behavior. Tests that call this wrapper use real wall-clock time until [issue #24](https://github.com/addon-stack/browser/issues/24) introduces a dedicated scheduling seam. +The default validation delay remains 100 ms. Tests using `@addon-core/browser/testing` can skip or defer this wait with +`harness.delays.downloadValidation`, without changing the `download(options)` signature or patching global timers. +See [Download validation delay](testing/harness.md#download-validation-delay) for the test-only control. Controlling the +wait does not simulate the browser's download lifecycle or prove browser compatibility. diff --git a/docs/testing/harness.md b/docs/testing/harness.md index 78ff1de..27a8a65 100644 --- a/docs/testing/harness.md +++ b/docs/testing/harness.md @@ -57,6 +57,37 @@ harness.calls; Use `.browser` instead of `.chrome` when configuring a Firefox or Safari profile. `harness.configurable.active` follows the last profile selected by `installBrowserGlobals()`. +## Download validation delay + +The production `download(options)` helper waits 100 ms before checking the created download. A fresh harness preserves +that default. To skip only this validation wait deterministically, opt in through the test-only delay control: + +```ts +harness.delays.downloadValidation.setResult(undefined); +``` + +The real `download(options)` function still runs, and its raw `downloads.download` and `downloads.search` results must +be configured as usual. After it reaches validation, `harness.delays.downloadValidation.calls` records the requested +delay with `args: [100]`. + +Use `setImplementation()` to hold validation until the test releases it: + +```ts +let releaseValidation = () => {}; +const validationGate = new Promise(resolve => { + releaseValidation = resolve; +}); + +harness.delays.downloadValidation.setImplementation(() => validationGate); +// Start download(options) with configured raw results. +// After it reaches the delay hook, release validation when ready: +releaseValidation(); +``` + +The hook belongs to this harness's fake facades. It does not replace global timers, affect unrelated harnesses, or add +a scheduler argument to the production API. `harness.reset()` clears its calls and configuration and restores the real +100 ms default. This control does not model browser download completion, permissions, or lifecycle timing. + ## Installing exact globals Use `installGlobals()` for low-level scenarios: diff --git a/docs/testing/limitations.md b/docs/testing/limitations.md index 6e092b1..be54e3b 100644 --- a/docs/testing/limitations.md +++ b/docs/testing/limitations.md @@ -27,9 +27,10 @@ Chrome, Firefox, Safari, Opera, or any other real browser. that routing. - Browser-event dispatch uses a listener snapshot. A listener removed by another listener during the same `emit()` is still called for that dispatch; Chrome and DOM events skip a listener removed before its turn. -- The production `download()` helper currently includes a real 100 ms delay. The kit does not install fake timers or - claim full timing determinism. A scheduler seam is tracked in - [GitHub issue #24](https://github.com/addon-stack/browser/issues/24). +- The production `download()` helper retains its real 100 ms validation delay by default, including after + `harness.reset()`. Tests can skip or defer only that wait through + [`harness.delays.downloadValidation`](harness.md#download-validation-delay). The kit does not patch global timers or + simulate browser download lifecycle timing, so this control does not establish real-browser parity. ## Listener behavior diff --git a/src/downloads.ts b/src/downloads.ts index 5bcca5e..50d03e6 100644 --- a/src/downloads.ts +++ b/src/downloads.ts @@ -1,4 +1,5 @@ import {browser} from "./browser"; +import {waitForDownloadValidation} from "./internal/download-validation"; import {callWithPromise, handleListener} from "./utils"; type DownloadItem = chrome.downloads.DownloadItem; @@ -27,7 +28,7 @@ export const download = async (options: DownloadOptions): Promise => { throw new Error("Download id not created"); } - await new Promise(resolve => setTimeout(resolve, 100)); + await waitForDownloadValidation(downloads(), 100); const item = await findDownload(downloadId); diff --git a/src/internal/download-validation.ts b/src/internal/download-validation.ts new file mode 100644 index 0000000..ae19d43 --- /dev/null +++ b/src/internal/download-validation.ts @@ -0,0 +1,38 @@ +/** Resolve lazily so importing either package entrypoint does not touch the symbol registry. */ +export const getDownloadValidationDelayKey = (): symbol => + Symbol.for("@addon-core/browser/download-validation-delay/v1"); + +export const nativeDownloadValidationDelay = (milliseconds: number): Promise => + new Promise(resolve => setTimeout(resolve, milliseconds)); + +const isPromiseLike = (value: unknown): value is PromiseLike => + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof Reflect.get(value, "then") === "function"; + +/** Internal per-facade seam; real browser namespaces retain the native delay. */ +export const waitForDownloadValidation = (downloadsApi: object, milliseconds: number): Promise => { + const key = getDownloadValidationDelayKey(); + + if (!Reflect.has(downloadsApi, key)) { + return nativeDownloadValidationDelay(milliseconds); + } + + const hook: unknown = Reflect.get(downloadsApi, key); + + if (typeof hook !== "function") { + throw new Error( + 'Browser method "downloads.download" has an invalid download validation delay hook: expected a function.' + ); + } + + const result: unknown = Reflect.apply(hook, undefined, [milliseconds]); + + if (!isPromiseLike(result)) { + throw new Error( + 'Browser method "downloads.download" has an invalid download validation delay hook: expected a Promise or thenable result.' + ); + } + + return Promise.resolve(result); +}; diff --git a/src/testing/delays.test.ts b/src/testing/delays.test.ts new file mode 100644 index 0000000..0197d44 --- /dev/null +++ b/src/testing/delays.test.ts @@ -0,0 +1,174 @@ +import { + getDownloadValidationDelayKey, + nativeDownloadValidationDelay, + waitForDownloadValidation, +} from "../internal/download-validation"; +import {type BrowserTestApi, createBrowserHarness, installBrowserGlobals} from "./index"; + +const expectNativeTask = async (pending: Promise): Promise => { + let settled = false; + const observed = pending.then(() => { + settled = true; + }); + + // Native timers run in a later task, not in the Promise microtask queue. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + await observed; + expect(settled).toBe(true); +}; + +describe("download validation delay control", () => { + test("keeps the native task-based default when no hook is installed", async () => { + await expectNativeTask(waitForDownloadValidation({}, 0)); + await expectNativeTask(nativeDownloadValidationDelay(0)); + }); + + test("uses an internal non-enumerable hook shared by both facades of one harness", async () => { + const harness = createBrowserHarness(); + const key = getDownloadValidationDelayKey(); + harness.delays.downloadValidation.setResult(undefined); + + for (const api of [harness.chrome.downloads, harness.browser.downloads]) { + expect(Object.getOwnPropertyDescriptor(api, key)).toMatchObject({ + enumerable: false, + value: harness.delays.downloadValidation.api, + }); + await waitForDownloadValidation(api, 100); + } + + expect(harness.delays.downloadValidation.calls.map(call => call.args)).toEqual([[100], [100]]); + expect(harness.calls.map(call => call.api)).toEqual(["delays.downloadValidation", "delays.downloadValidation"]); + }); + + test("does not share delay configuration or history between harness instances", async () => { + const first = createBrowserHarness(); + const second = createBrowserHarness(); + const failure = new Error("Second scheduler failed"); + first.delays.downloadValidation.setResult(undefined); + second.delays.downloadValidation.failNext(failure); + + expect(first.delays.downloadValidation.api).not.toBe(second.delays.downloadValidation.api); + await waitForDownloadValidation(first.chrome.downloads, 100); + expect(second.delays.downloadValidation.calls).toHaveLength(0); + first.reset(); + + await expect(waitForDownloadValidation(second.browser.downloads, 100)).rejects.toBe(failure); + expect(first.delays.downloadValidation.calls).toHaveLength(0); + expect(second.delays.downloadValidation.calls).toHaveLength(1); + }); + + test.each(["chrome", "browser"] as const)("preserves the hook in a cloned %s facade across reset", async name => { + const harness = createBrowserHarness(); + const facade = harness.createProfileFacade(name, false); + harness.delays.downloadValidation.setResult(undefined); + + expect(facade.downloads).not.toBe(harness[name].downloads); + expect(Object.getOwnPropertyDescriptor(facade.downloads, getDownloadValidationDelayKey())).toMatchObject({ + enumerable: false, + value: harness.delays.downloadValidation.api, + }); + await waitForDownloadValidation(facade.downloads, 100); + expect(harness.delays.downloadValidation.calls[0]?.args).toEqual([100]); + + harness.reset(); + + await expectNativeTask(waitForDownloadValidation(facade.downloads, 0)); + expect(harness.delays.downloadValidation.calls[0]).toMatchObject({sequence: 1, args: [0]}); + }); + + test("reset clears custom results, implementations, queues and errors and restores native delay", async () => { + const harness = createBrowserHarness(); + const delay = harness.delays.downloadValidation; + delay.setResult(undefined); + await delay.api(100); + delay.setImplementation(async () => { + throw new Error("Custom delay must be cleared"); + }); + delay.queueResult(undefined); + delay.failNext(new Error("Queued error must be cleared")); + + harness.reset(); + + expect(delay.calls).toHaveLength(0); + expect(harness.calls).toHaveLength(0); + expect(delay.hasDefaultImplementation).toBe(true); + await expectNativeTask(waitForDownloadValidation(harness.chrome.downloads, 0)); + expect(delay.calls[0]).toMatchObject({sequence: 1, args: [0], invocation: "promise"}); + }); + + test.each([ + "chrome", + "firefox", + "safari", + "opera", + ] as const)("profile installation preserves the hook without replacing timers: %s", async profile => { + const timerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "setTimeout"); + const harness = createBrowserHarness(); + harness.delays.downloadValidation.setResult(undefined); + const restore = installBrowserGlobals(harness, {profile}); + + try { + const facade = ( + profile === "firefox" || profile === "safari" ? globalThis.browser : globalThis.chrome + ) as BrowserTestApi; + await waitForDownloadValidation(facade.downloads, 100); + expect(harness.delays.downloadValidation.calls[0]?.args).toEqual([100]); + expect(Object.getOwnPropertyDescriptor(globalThis, "setTimeout")).toEqual(timerDescriptor); + + harness.reset(); + harness.delays.downloadValidation.setResult(undefined); + await waitForDownloadValidation(facade.downloads, 100); + expect(harness.delays.downloadValidation.calls).toHaveLength(1); + } finally { + restore(); + } + + expect(Object.getOwnPropertyDescriptor(globalThis, "setTimeout")).toEqual(timerDescriptor); + }); + + test("assimilates custom thenables from a valid hook", async () => { + const calls: number[] = []; + const namespace = { + [getDownloadValidationDelayKey()]: (milliseconds: number) => ({ + // biome-ignore lint/suspicious/noThenProperty: Exercises assimilation of a custom thenable. + then(resolve: () => void) { + calls.push(milliseconds); + resolve(); + }, + }), + }; + + await waitForDownloadValidation(namespace, 100); + expect(calls).toEqual([100]); + }); + + test("fails explicitly for a present non-function hook", async () => { + const namespace = {[getDownloadValidationDelayKey()]: null}; + + await expect(async () => waitForDownloadValidation(namespace, 100)).rejects.toThrow( + 'Browser method "downloads.download" has an invalid download validation delay hook: expected a function.' + ); + }); + + test.each([undefined, 0, {}])("fails explicitly for a hook with a non-thenable result: %j", async result => { + const namespace = {[getDownloadValidationDelayKey()]: () => result}; + + await expect(async () => waitForDownloadValidation(namespace, 100)).rejects.toThrow( + 'Browser method "downloads.download" has an invalid download validation delay hook: expected a Promise or thenable result.' + ); + }); + + test("preserves a hook throw without scheduling a fallback wait", async () => { + const error = new Error("Scheduler threw"); + const namespace = { + [getDownloadValidationDelayKey()]: () => { + throw error; + }, + }; + + await expect(async () => waitForDownloadValidation(namespace, 100)).rejects.toBe(error); + }); +}); diff --git a/src/testing/delays.ts b/src/testing/delays.ts new file mode 100644 index 0000000..e87abbc --- /dev/null +++ b/src/testing/delays.ts @@ -0,0 +1,44 @@ +import {getDownloadValidationDelayKey, nativeDownloadValidationDelay} from "../internal/download-validation"; +import {type BrowserMethod, createBrowserMethod} from "./method"; + +export interface BrowserDelaysHarness { + readonly downloadValidation: BrowserMethod<(milliseconds: number) => Promise, void>; + reset(): void; +} + +/** Delay controls are per harness and never replace the environment's timers. */ +export const createBrowserDelaysHarness = ( + downloadsApis: readonly object[], + nextSequence?: () => number +): BrowserDelaysHarness => { + const downloadValidation = createBrowserMethod<(milliseconds: number) => Promise, void>({ + implementation: nativeDownloadValidationDelay, + invocation: "promise", + name: "delays.downloadValidation", + nextSequence, + }); + + const attach = (): void => { + const key = getDownloadValidationDelayKey(); + + for (const api of downloadsApis) { + Object.defineProperty(api, key, { + configurable: true, + enumerable: false, + value: downloadValidation.api, + writable: true, + }); + } + }; + + attach(); + + return { + downloadValidation, + reset(): void { + downloadValidation.setDefaultImplementation(nativeDownloadValidationDelay); + downloadValidation.reset(); + attach(); + }, + }; +}; diff --git a/src/testing/harness.ts b/src/testing/harness.ts index 320e0ce..5e39ea3 100644 --- a/src/testing/harness.ts +++ b/src/testing/harness.ts @@ -4,6 +4,7 @@ import { type ConfigurableNamespaces, createConfigurableNamespaces, } from "./configurable"; +import {type BrowserDelaysHarness, createBrowserDelaysHarness} from "./delays"; import {createLastErrorController} from "./internal"; import {createListenerErrorCapture, type ListenerErrorBuffer} from "./listener-errors"; import {createPermissionsHarness, type PermissionsHarness} from "./permissions"; @@ -59,6 +60,7 @@ export interface BrowserHarness { readonly tabs: TabsHarness; readonly windows: WindowsHarness; readonly scripting: ScriptingHarness; + readonly delays: BrowserDelaysHarness; readonly configurable: ConfigurableHarness; readonly capabilities: BrowserCapabilitiesHarness; readonly sidebar: SidebarHarness; @@ -131,6 +133,7 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows const chrome = configChrome.api as unknown as BrowserTestApi; const browser = configBrowser.api as unknown as BrowserTestApi; + const delays = createBrowserDelaysHarness([chrome.downloads, browser.downloads], nextSequence); const sidePanelChromeApi = configChrome.api.sidePanel; const sidePanelBrowserApi = configBrowser.api.sidePanel; let activeProfile: BrowserProfile = "chrome"; @@ -272,6 +275,7 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows {namespace: "tabs", source: tabs as unknown as Record}, {namespace: "windows", source: windows as unknown as Record}, {namespace: "scripting", source: scripting as unknown as Record}, + {namespace: "delays", source: delays as unknown as Record}, ]; return { @@ -282,6 +286,7 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows tabs, windows, scripting, + delays, configurable, capabilities, sidebar, @@ -317,6 +322,7 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows scripting.reset(); configChrome.reset(); configBrowser.reset(); + delays.reset(); lastError.reset(); listenerCapture.reset(); explicitCapabilities.clear(); diff --git a/src/testing/index.ts b/src/testing/index.ts index ba5740a..ef1bb1b 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -31,6 +31,7 @@ export type { RawFailureChannel, RawMethodInvocation, } from "./coverage"; +export type {BrowserDelaysHarness} from "./delays"; export type { BrowserEventApi, BrowserEventHarness, diff --git a/src/testing/production.integration.test.ts b/src/testing/production.integration.test.ts index c65788b..c412874 100644 --- a/src/testing/production.integration.test.ts +++ b/src/testing/production.integration.test.ts @@ -1,7 +1,7 @@ import {BlockDownloadError, download} from "../downloads"; import {findTabById, getTab, getTabUrl} from "../tabs"; import {getUserScripts} from "../userScripts"; -import {createBrowserHarness, createTabFixture, installGlobals} from "./index"; +import {createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "./index"; const restorers: Array<() => void> = []; @@ -71,41 +71,121 @@ describe("current production behavior through the browser harness", () => { }); }); - test("download succeeds after the production 100 ms delay", async () => { - const harness = createBrowserHarness(); - restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); - harness.configurable.chrome.downloads.download.setResult(41); - harness.configurable.chrome.downloads.search.setResult([ - {error: undefined, exists: true, id: 41, state: "in_progress"} as chrome.downloads.DownloadItem, - ]); - const startedAt = performance.now(); - - await expect(download({url: "https://download.example/file.zip"})).resolves.toBe(41); - - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90); - expect(harness.configurable.chrome.downloads.download.calls[0]?.args).toEqual([ - {conflictAction: "uniquify", url: "https://download.example/file.zip"}, - ]); - }); + describe.each(["chrome", "firefox"] as const)("download with a controlled delay in %s", profile => { + let harness: ReturnType; + const url = "https://download.example/file.zip"; + const createDownloadItemFixture = ( + overrides: Partial = {} + ): chrome.downloads.DownloadItem => ({ + id: 41, + url, + finalUrl: url, + referrer: "", + filename: "/downloads/file.zip", + mime: "application/zip", + startTime: "2026-01-01T00:00:00.000Z", + state: "in_progress", + paused: false, + canResume: false, + danger: "safe", + incognito: false, + exists: true, + bytesReceived: 0, + totalBytes: 100, + fileSize: 100, + ...overrides, + }); - test("download preserves the exact BlockDownloadError class after the production delay", async () => { - const harness = createBrowserHarness(); - restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); - harness.configurable.chrome.downloads.download.setResult(42); - harness.configurable.chrome.downloads.search.setResult([ - {error: "USER_CANCELED", exists: true, id: 42, state: "interrupted"} as chrome.downloads.DownloadItem, - ]); - const startedAt = performance.now(); - - let failure: unknown; - try { - await download({url: "https://download.example/requires-permission.zip"}); - } catch (error) { - failure = error; - } - - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(90); - expect(failure).toBeInstanceOf(BlockDownloadError); - expect(failure).toMatchObject({message: "Requires user permission to upload"}); + beforeEach(() => { + harness = createBrowserHarness(); + restorers.push(installBrowserGlobals(harness, {profile})); + harness.configurable.active.downloads.download.setResult(41); + harness.configurable.active.downloads.search.setResult([createDownloadItemFixture()]); + harness.delays.downloadValidation.setResult(undefined); + }); + + test("succeeds with an immediate wait while requesting the unchanged 100 ms delay", async () => { + await expect(download({url})).resolves.toBe(41); + + expect(harness.delays.downloadValidation.calls).toMatchObject([ + {args: [100], callback: undefined, invocation: "promise"}, + ]); + expect(harness.configurable.active.downloads.download.calls[0]?.args).toEqual([ + {conflictAction: "uniquify", url}, + ]); + expect(harness.calls.map(call => call.api)).toEqual([ + "downloads.download", + "delays.downloadValidation", + "downloads.search", + ]); + }); + + test("does not search until the test releases the delay", async () => { + let release: () => void = () => undefined; + let signalStarted: () => void = () => undefined; + const gate = new Promise(resolve => { + release = resolve; + }); + const started = new Promise(resolve => { + signalStarted = resolve; + }); + harness.delays.downloadValidation.setImplementation(() => { + signalStarted(); + return gate; + }); + + const pending = download({url}); + try { + await started; + expect(harness.configurable.active.downloads.search.calls).toHaveLength(0); + } finally { + release(); + } + + await expect(pending).resolves.toBe(41); + expect(harness.configurable.active.downloads.search.calls[0]?.args).toEqual([{id: 41}]); + }); + + test("preserves a delay failure and does not query the item", async () => { + const error = new Error("Validation wait failed"); + harness.delays.downloadValidation.failNext(error); + + await expect(download({url})).rejects.toBe(error); + expect(harness.configurable.active.downloads.search.calls).toHaveLength(0); + expect(harness.runtime.lastError).toBeUndefined(); + }); + + test("does not schedule validation if download creation fails", async () => { + harness.configurable.active.downloads.download.failNext(new Error("Download unavailable")); + + await expect(download({url})).rejects.toThrow("Download unavailable"); + expect(harness.delays.downloadValidation.calls).toHaveLength(0); + expect(harness.configurable.active.downloads.search.calls).toHaveLength(0); + }); + + test.each([ + [[], "Download item not found after created"], + [ + [createDownloadItemFixture({error: "USER_CANCELED", state: "interrupted"})], + "Requires user permission to upload", + ], + ] as const)("preserves the exact BlockDownloadError for %j", async (items, message) => { + harness.configurable.active.downloads.search.setResult([...items]); + const pending = download({url}); + + await expect(pending).rejects.toBeInstanceOf(BlockDownloadError); + await expect(pending).rejects.toHaveProperty("message", message); + expect(harness.delays.downloadValidation.calls[0]?.args).toEqual([100]); + }); + + test("preserves the ordinary error for other interruptions", async () => { + harness.configurable.active.downloads.search.setResult([ + createDownloadItemFixture({error: "NETWORK_FAILED", state: "interrupted"}), + ]); + const pending = download({url}); + + await expect(pending).rejects.toHaveProperty("message", "Download error: NETWORK_FAILED"); + await expect(pending).rejects.not.toBeInstanceOf(BlockDownloadError); + }); }); }); diff --git a/tests/consumer-types/cjs.cjs b/tests/consumer-types/cjs.cjs index 3f2dbdc..d693bbd 100644 --- a/tests/consumer-types/cjs.cjs +++ b/tests/consumer-types/cjs.cjs @@ -8,18 +8,45 @@ const testing = require("@addon-core/browser/testing"); assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); -const harness = testing.createBrowserHarness({ - manifest: testing.createManifestFixture({name: "CJS consumer"}), -}); -const restore = testing.installBrowserGlobals(harness, {profile: "chrome"}); +async function checkConsumer() { + const harness = testing.createBrowserHarness({ + manifest: testing.createManifestFixture({name: "CJS consumer"}), + }); + harness.delays.downloadValidation.setResult(undefined); + harness.configurable.chrome.downloads.download.setResult(42); + harness.configurable.chrome.downloads.search.setResult([{exists: true, id: 42, state: "in_progress"}]); + const restore = testing.installBrowserGlobals(harness, {profile: "chrome"}); + + try { + assert.equal(production.getManifest().name, "CJS consumer"); + assert.equal(typeof harness.runtime.closeMessageChannels, "function"); + assert.equal(await production.download({url: "https://example.test/cjs.zip"}), 42); + assert.deepEqual( + harness.delays.downloadValidation.calls.map(call => call.args), + [[100]] + ); + harness.configurable.chrome.downloads.search.setResult([ + {error: "USER_CANCELED", exists: true, id: 42, state: "interrupted"}, + ]); + await assert.rejects(production.download({url: "https://example.test/blocked-cjs.zip"}), error => { + assert.ok(error instanceof production.BlockDownloadError); + assert.equal(error.message, "Requires user permission to upload"); + return true; + }); + assert.deepEqual( + harness.delays.downloadValidation.calls.map(call => call.args), + [[100], [100]] + ); + } finally { + restore(); + restore(); + } -try { - assert.equal(production.getManifest().name, "CJS consumer"); - assert.equal(typeof harness.runtime.closeMessageChannels, "function"); -} finally { - restore(); - restore(); + assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); + assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); } -assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); -assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); +checkConsumer().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/consumer-types/esm.mjs b/tests/consumer-types/esm.mjs index d8b43bf..8c4c61d 100644 --- a/tests/consumer-types/esm.mjs +++ b/tests/consumer-types/esm.mjs @@ -10,11 +10,32 @@ assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeB const harness = testing.createBrowserHarness({ manifest: testing.createManifestFixture({name: "ESM consumer"}), }); +harness.delays.downloadValidation.setResult(undefined); +harness.configurable.chrome.downloads.download.setResult(41); +harness.configurable.chrome.downloads.search.setResult([{exists: true, id: 41, state: "in_progress"}]); const restore = testing.installBrowserGlobals(harness, {profile: "chrome"}); const unsubscribe = harness.runtime.events.onMessage.on(() => true); try { assert.equal(production.getManifest().name, "ESM consumer"); + assert.equal(await production.download({url: "https://example.test/esm.zip"}), 41); + assert.deepEqual( + harness.delays.downloadValidation.calls.map(call => call.args), + [[100]] + ); + harness.configurable.chrome.downloads.search.setResult([ + {error: "USER_CANCELED", exists: true, id: 41, state: "interrupted"}, + ]); + await assert.rejects(production.download({url: "https://example.test/blocked-esm.zip"}), error => { + assert.ok(error instanceof production.BlockDownloadError); + assert.equal(error.message, "Requires user permission to upload"); + return true; + }); + assert.deepEqual( + harness.delays.downloadValidation.calls.map(call => call.args), + [[100], [100]] + ); + const pendingResponse = production.sendMessage({kind: "unanswered"}); harness.runtime.closeMessageChannels(); await assert.rejects(pendingResponse, { diff --git a/tests/consumer-types/index.ts b/tests/consumer-types/index.ts index 8d6f638..bc4bc91 100644 --- a/tests/consumer-types/index.ts +++ b/tests/consumer-types/index.ts @@ -1,12 +1,14 @@ import {getManifest, onTabUpdated, queryTabs} from "@addon-core/browser"; import { + type BrowserHarness, + type BrowserMethod, createBrowserHarness, createManifestFixture, createTabFixture, installBrowserGlobals, } from "@addon-core/browser/testing"; -const harness = createBrowserHarness({ +const harness: BrowserHarness = createBrowserHarness({ manifest: createManifestFixture({name: "Typed consumer"}), tabs: [createTabFixture({id: 7})], }); @@ -14,10 +16,17 @@ const restore = installBrowserGlobals(harness, {profile: "firefox"}); const manifestName: string = getManifest().name; const queryResult: Promise = queryTabs({active: true}); const browserQuery: typeof chrome.tabs.query = harness.browser.tabs.query; +const downloadValidationDelay: BrowserMethod<(milliseconds: number) => Promise, void> = + harness.delays.downloadValidation; harness.tabs.query.setResult([]); harness.configurable.browser.downloads.search.setResult([]); harness.runtime.closeMessageChannels(); +downloadValidationDelay.setImplementation(async milliseconds => { + const duration: number = milliseconds; + void duration; +}); +downloadValidationDelay.setResult(undefined); void browserQuery; void manifestName; From 8540768d2c8bdb7e04356c92ff92ec8986eed57b Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:44:19 +0300 Subject: [PATCH 06/11] chore: remove .mailmap file --- .mailmap | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .mailmap diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 2f327b1..0000000 --- a/.mailmap +++ /dev/null @@ -1,2 +0,0 @@ -Addon Stack <191148085+addon-stack@users.noreply.github.com> -Addon Stack \ No newline at end of file From e72c66d49cd72672a029640f5a877dbd9109beab Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:51:42 +0300 Subject: [PATCH 07/11] fix(ci): align local and CI Jest ESM execution Import Jest helpers explicitly in the three affected test suites. Share the ESM launcher across tests, CI and pre-commit checks. --- .github/workflows/ci.yml | 2 -- CONTRIBUTING.md | 4 +++- package.json | 6 +++--- src/testing/configurable.test.ts | 1 + src/testing/event.test.ts | 1 + src/testing/method.test.ts | 1 + 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d86ac..d9d7ab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,6 @@ jobs: name: Build, Lint, Test ${{ needs.compute-matrix.outputs.name_suffix }} needs: compute-matrix runs-on: ${{ matrix.os }} - env: - NODE_OPTIONS: --experimental-vm-modules permissions: contents: read strategy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89718a1..94c2b2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,9 @@ Framework: **Jest** (`npm test`). Recommendations: - For events, verify that the returned function actually removes the listener. - Structure: co-locate tests with the module or use a `__tests__` folder. -In CI use `npm run test:ci`. +In CI use `npm run test:ci`. All test scripts (`npm test`, `npm run test:ci`, and `npm run test:related`) share the same Jest ESM launcher, including Node's `--experimental-vm-modules` flag. No manual `NODE_OPTIONS` setup is needed locally or in CI. + +Import Jest helpers explicitly in test files, for example `import {describe, expect, jest, test} from "@jest/globals"`. In ESM, the `jest` object is not a global. These imports belong only in test suites; the published `@addon-core/browser/testing` runtime remains runner-independent. --- diff --git a/package.json b/package.json index 879ab8c..ea25f01 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,10 @@ "dev": "tsup --watch", "lint": "biome check .", "fix": "biome check --write --unsafe .", - "test": "jest", - "test:ci": "jest --ci --passWithNoTests --coverage", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:ci": "npm test -- --ci --passWithNoTests --coverage", "test:consumer-types": "npm run build && node ./tests/consumer-types/check.mjs", - "test:related": "jest --bail --passWithNoTests", + "test:related": "npm test -- --bail --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit", "release": "release-it", "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci" diff --git a/src/testing/configurable.test.ts b/src/testing/configurable.test.ts index 0077527..f78e77c 100644 --- a/src/testing/configurable.test.ts +++ b/src/testing/configurable.test.ts @@ -1,3 +1,4 @@ +import {describe, expect, jest, test} from "@jest/globals"; import {createConfigurableNamespaces} from "./configurable"; import {RAW_CAPABILITY_COVERAGE} from "./coverage"; import {createLastErrorController} from "./internal"; diff --git a/src/testing/event.test.ts b/src/testing/event.test.ts index 3ce249b..31b9c86 100644 --- a/src/testing/event.test.ts +++ b/src/testing/event.test.ts @@ -1,3 +1,4 @@ +import {describe, expect, jest, test} from "@jest/globals"; import {createBrowserEvent} from "./event"; describe("createBrowserEvent", () => { diff --git a/src/testing/method.test.ts b/src/testing/method.test.ts index 338312b..ad26bf5 100644 --- a/src/testing/method.test.ts +++ b/src/testing/method.test.ts @@ -1,3 +1,4 @@ +import {describe, expect, jest, test} from "@jest/globals"; import {createBrowserMethod} from "./method"; type SyncApi = (value: string) => number; From ec506b367b6fe453acd6920af7a330cdda3ea22c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:20:08 +0300 Subject: [PATCH 08/11] feat(testing): support URL match patterns and host permissions --- docs/testing.md | 1 + docs/testing/harness.md | 4 + docs/testing/limitations.md | 13 +- docs/testing/match-patterns.md | 95 +++++++ src/testing/coverage.ts | 7 +- .../match-patterns.integration.test.ts | 255 ++++++++++++++++++ src/testing/match-patterns.test.ts | 137 ++++++++++ src/testing/match-patterns.ts | 145 ++++++++++ src/testing/permissions.ts | 34 ++- src/testing/stateful.integration.test.ts | 4 +- src/testing/tabs.ts | 11 +- tests/consumer-types/cjs.cjs | 9 + tests/consumer-types/esm.mjs | 9 + tests/consumer-types/index.ts | 8 +- 14 files changed, 707 insertions(+), 25 deletions(-) create mode 100644 docs/testing/match-patterns.md create mode 100644 src/testing/match-patterns.integration.test.ts create mode 100644 src/testing/match-patterns.test.ts create mode 100644 src/testing/match-patterns.ts diff --git a/docs/testing.md b/docs/testing.md index 213f144..8836e55 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -42,6 +42,7 @@ they read are supplied by the test. - [Primitives](testing/primitives.md) provide runner-independent methods and browser events. - [Harness and globals](testing/harness.md) provide browser profiles, state, capabilities, reset, and exact global restoration. +- [URL patterns and host permissions](testing/match-patterns.md) cover wildcard tab queries and granted-origin checks. - [Jest usage](testing/jest.md) shows how to combine the kit with Jest without making the kit depend on Jest. - [Limitations](testing/limitations.md) describes intentional differences from real browsers. diff --git a/docs/testing/harness.md b/docs/testing/harness.md index 27a8a65..9fa07c6 100644 --- a/docs/testing/harness.md +++ b/docs/testing/harness.md @@ -57,6 +57,10 @@ harness.calls; Use `.browser` instead of `.chrome` when configuring a Firefox or Safari profile. `harness.configurable.active` follows the last profile selected by `installBrowserGlobals()`. +`tabs.query` selects fixture URLs with a documented match-pattern subset. `permissions.contains` checks whether +explicitly granted origins cover the requested patterns; manifest declarations do not grant access automatically. +See [URL patterns and host permissions](match-patterns.md) for examples, validation, and scope limits. + ## Download validation delay The production `download(options)` helper waits 100 ms before checking the created download. A fresh harness preserves diff --git a/docs/testing/limitations.md b/docs/testing/limitations.md index be54e3b..518063e 100644 --- a/docs/testing/limitations.md +++ b/docs/testing/limitations.md @@ -7,8 +7,15 @@ Chrome, Firefox, Safari, Opera, or any other real browser. - `tabs.query()` uses AND equality matching for `status`, `lastFocusedWindow`, `windowId`, `windowType`, `active`, `index`, `currentWindow`, `highlighted`, `discarded`, `frozen`, `autoDiscardable`, `pinned`, `splitViewId`, `audible`, - `muted`, `groupId`, `title`, and `url`. `title` and `url` accept literal values only in v1; browser match patterns - and wildcards fail explicitly instead of returning a potentially incorrect result. Use `tabs.get()` for an ID. + `muted`, `groupId`, and `title`. `title` accepts literal values only; title wildcards fail explicitly. `url` supports + the documented [HTTP/HTTPS/file match-pattern subset](match-patterns.md), with OR inside URL arrays. Use `tabs.get()` + for an ID. URL/title visibility is not gated by permissions, and no implicit active/frozen filter is applied. +- The matcher is profile-independent. `` covers only HTTP, HTTPS and file in this kit; other pattern + schemes and unsupported syntax fail explicitly. Serialized paths/queries are compared without Chromium's + percent-decoding equivalence rules; URL fragments are ignored. This is not a full vendor pattern engine. +- `permissions.contains()` models pattern containment for explicitly granted origins, ignoring paths. It does not + infer grants from the manifest or simulate prompts, restricted pages, file-access toggles or user site-access + policy. Grant storage and removal remain exact-entry operations, without partial wildcard subtraction. - Complex APIs outside runtime, permissions, tabs, windows, and the scripting content-script registry are configurable stubs. They do not simulate the browser unless the test supplies an implementation or result. - `tabs.sendMessage()` and `tabs.connect()` are configurable stubs. The kit does not create content-script contexts, @@ -46,5 +53,5 @@ Raw `createBrowserEvent().emit()` waits for Promises and arbitrary thenables and `captureListenerErrors` only structures the existing `console.error` calls. It does not hook listeners directly and is never enabled by default. -Use real-browser integration tests for permissions prompts, URL-pattern semantics, service-worker suspension, +Use real-browser integration tests for permissions prompts, full vendor URL-pattern semantics, service-worker suspension, cross-context messaging, content-script injection, browser UI, security boundaries, and browser-specific timing. diff --git a/docs/testing/match-patterns.md b/docs/testing/match-patterns.md new file mode 100644 index 0000000..ae0b2b7 --- /dev/null +++ b/docs/testing/match-patterns.md @@ -0,0 +1,95 @@ +# URL patterns and host permissions + +The harness has an internal match-pattern parser shared by `tabs.query()` and `permissions.contains()`. Application +code still calls the real `@addon-core/browser` wrappers. No matcher package, runner mock, or new production API is +required. + +## Selecting tabs + +```ts +import assert from "node:assert/strict"; +import {containsPermissions, queryTabs} from "@addon-core/browser"; +import {createBrowserHarness, createTabFixture, installBrowserGlobals} from "@addon-core/browser/testing"; + +const harness = createBrowserHarness({ + tabs: [ + createTabFixture({id: 1, active: false, url: "http://127.0.0.1:62778/top.html"}), + createTabFixture({id: 2, frozen: true, url: "https://shop.example.com/page#section"}), + createTabFixture({id: 3, discarded: true, url: "https://shop.example.com/old"}), + ], + permissions: {origins: ["https://*.example.com/*"]}, +}); +const restore = installBrowserGlobals(harness, {profile: "chrome"}); + +try { + const tabs = await queryTabs({ + url: ["http://127.0.0.1/*", "https://*.example.com/*"], + status: "complete", + discarded: false, + }); + assert.deepEqual(tabs.map(tab => tab.id), [1, 2]); + assert.equal(await containsPermissions({origins: ["https://shop.example.com/*"]}), true); + assert.equal(await containsPermissions({origins: ["http://shop.example.com/*"]}), false); +} finally { + restore(); +} +``` + +`url` accepts one pattern or an array. Alternatives within the array are OR; other query fields are AND. An empty +array matches nothing. Inactive or frozen tabs are not excluded unless the corresponding filter is supplied. +Missing or malformed fixture URLs do not match. All patterns are validated before filtering, including for an empty +tab collection or an array beginning with ``. + +## Supported grammar + +The initial subset follows [Chrome's match-pattern structure](https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns): + +- Lowercase `http`, `https`, and hostless `file:///` patterns are supported. `*://` means HTTP or HTTPS only. +- `` means all three supported schemes in this kit, not every browser-specific URL scheme. +- Hosts may be exact, `*`, or `*.example.com`. A subdomain wildcard includes the apex and nested subdomains, but + never `notexample.com` or `example.com.evil.test`. Hostname case, IDNA, IP spelling and trailing dots are normalized. +- An omitted port or `:*` matches any port. An explicit numeric port requires an explicit HTTP/HTTPS scheme; + default ports are recognized; leading-zero port spellings fail explicitly. IPv4 and bracketed IPv6 are supported; + subdomain wildcards on IPs are not. +- Paths are case-sensitive. Only `*` is a wildcard; punctuation such as `?`, `+`, `.`, `(` and `[` is literal. + `/foo/*` also matches `/foo`. Path and query string (including a bare `?`) are matched together; the URL fragment is ignored. +- Patterns require a path. Whitespace, backslashes, fragments in patterns, credentials, malformed hosts/ports and + unsupported schemes fail explicitly with the pattern and API/control name in the error. + +Paths/queries are compared against the URL's serialized `pathname + search`. Use percent-encoded non-ASCII path +characters; raw non-ASCII patterns fail explicitly. The kit does not emulate Chromium's percent-decoding equivalence +rules: for example, it does not equate `%61` with `a` or normalize the case of percent escapes. This is a documented +boundary, not complete browser URL canonicalization. Unicode hostnames are supported independently of this limitation. + +The same subset is used in every harness profile without reading `navigator`. It is not intended to reproduce each +vendor's scheme list, `file` aliases, restricted pages, or injection eligibility. `title` remains literal-only. + +## Granted origins are pattern sets + +`permissions.contains()` checks membership for named permissions and containment for origin patterns. Every requested +permission and origin must be covered. An individual requested origin must be contained by a granted pattern; the +kit does not synthesize new wildcard grants from multiple narrower entries. + +For example, `https://*.example.com/*` covers `https://shop.example.com/*`, but the reverse is false. Scheme and port +scope also matter. Host-permission paths are required but ignored: a grant ending in `/one` covers a request ending +in `/two` on the same origin. This operation is deliberately separate from matching a concrete tab URL. + +Only `harness.permissions` represents actual grants. Manifest `permissions`, `host_permissions`, and optional +declarations do not grant access automatically. `grant()`, `revoke()`, `set()`, `request()`, and `remove()` update this +state; `reset()` restores constructor values. Invalid origin batches are rejected before any mutation or event. + +`getAll()` and permission events retain the original strings. Removal/revocation uses exact stored entries: revoking +`https://shop.example.com/*` does not subtract that site from `https://*.example.com/*`. There is no partial wildcard +subtraction, permission prompt, manifest eligibility check, or user site-access policy simulation. + +## Controls and error handling + +Both methods retain `calls`, `setResult()`, `setImplementation()`, `failNext()`, and `reset()`. Explicit overrides bypass +the default matcher, making unsupported/vendor-specific cases configurable. With default implementations, malformed +arguments throw for raw callback calls and reject for Promise calls; they do not create `runtime.lastError`. +Configured `failNext()` errors retain the normal callback-scoped `lastError` behavior. + +For install/activation tests, emit `harness.runtime.events.onInstalled`, and let your application handler return its +Promise so `emit()` can await it. Configure CSS/JS results through `harness.scripting`; the kit does not execute scripts. +To verify CSS-before-JS, hold the CSS implementation until the test releases it, assert that `executeScript.calls` is +still empty, then release and await dispatch. Call history order alone does not prove the application awaited CSS. diff --git a/src/testing/coverage.ts b/src/testing/coverage.ts index 0d3fe56..2d2d0d9 100644 --- a/src/testing/coverage.ts +++ b/src/testing/coverage.ts @@ -724,7 +724,10 @@ export const RAW_CAPABILITY_COVERAGE: readonly RawCapabilityEntry[] = [ "addHostAccessRequest", "removeHostAccessRequest", ]), - ...methodCapabilities("permissions", "stateful", callbackInvocation, ["contains", "getAll", "remove", "request"]), + ...methodCapabilities("permissions", "stateful", callbackInvocation, ["contains", "getAll", "remove", "request"], { + contains: ["named permissions (membership)", "origins (http/https/file pattern containment; paths ignored)"], + remove: ["exact stored entries; no wildcard subtraction"], + }), ...eventCapabilities("permissions", ["onAdded", "onRemoved"]), ...propertyCapabilities("runtime", "stateful", ["id", "lastError"]), @@ -801,7 +804,7 @@ export const RAW_CAPABILITY_COVERAGE: readonly RawCapabilityEntry[] = [ "splitViewId", "status", "title (literal only)", - "url (literal only)", + "url (http/https/file match-pattern subset; OR within arrays)", "windowId", "windowType", ], diff --git a/src/testing/match-patterns.integration.test.ts b/src/testing/match-patterns.integration.test.ts new file mode 100644 index 0000000..3633411 --- /dev/null +++ b/src/testing/match-patterns.integration.test.ts @@ -0,0 +1,255 @@ +import {afterEach, describe, expect, test} from "@jest/globals"; +import {containsPermissions, getAllPermissions, removePermissions, requestPermissions} from "../permissions"; +import {onInstalled} from "../runtime"; +import {executeScript, insertCss} from "../scripting"; +import {queryTabs} from "../tabs"; +import { + createBrowserHarness, + createInstalledDetailsFixture, + createManifestFixture, + createTabFixture, + installBrowserGlobals, +} from "./index"; + +const restorers: Array<() => void> = []; +afterEach(() => { + while (restorers.length) restorers.pop()?.(); +}); + +describe.each(["chrome", "firefox"] as const)("match patterns through real wrappers: %s", profile => { + const install = (options: Parameters[0] = {}) => { + const harness = createBrowserHarness(options); + restorers.push(installBrowserGlobals(harness, {profile})); + return harness; + }; + + test("combines URL alternatives with AND filters without hiding inactive or frozen tabs", async () => { + const harness = install({ + tabs: [ + createTabFixture({id: 1, active: false, url: "http://127.0.0.1:62778/top.html"}), + createTabFixture({id: 2, active: false, frozen: true, url: "https://shop.example.com/page#section"}), + createTabFixture({id: 3, url: "https://example.com/page", status: "loading"}), + createTabFixture({id: 4, url: "https://example.com/page", discarded: true}), + createTabFixture({id: 5, url: "https://example.com.evil.test/page"}), + ], + }); + const query: chrome.tabs.QueryInfo = { + url: ["http://127.0.0.1/*", "https://*.example.com/*"], + status: "complete", + discarded: false, + }; + + expect((await queryTabs(query)).map(tab => tab.id)).toEqual([1, 2]); + expect((await queryTabs({...query, frozen: false})).map(tab => tab.id)).toEqual([1]); + expect(harness.tabs.query.calls[0]?.args).toEqual([query]); + expect(harness.tabs.values.find(tab => tab.id === 2)?.frozen).toBe(true); + }); + + test("ignores fragments but retains literal query-string characters", async () => { + install({tabs: [createTabFixture({id: 1, url: "https://example.com/search?q=a+b#part"})]}); + await expect(queryTabs({url: "https://example.com/search?q=a+b"})).resolves.toHaveLength(1); + await expect(queryTabs({url: "https://example.com/search?q=a*"})).resolves.toHaveLength(1); + await expect(queryTabs({url: "https://example.com/search"})).resolves.toEqual([]); + }); + + test("validates every query pattern before filtering even an empty tab collection", async () => { + install(); + await expect(queryTabs({url: ["", "https://bad*host/*"]})).rejects.toThrow("tabs.query"); + await expect(queryTabs({url: "ws://example.com/*"})).rejects.toThrow("Unsupported match pattern"); + await expect(queryTabs({title: "page*"})).rejects.toThrow("title match patterns are not supported"); + await expect(queryTabs({id: 1} as chrome.tabs.QueryInfo)).rejects.toThrow('filter "id"'); + }); + + test.each([ + ["", "https://example.com/*", true], + ["https://*.example.com/*", "https://shop.example.com/*", true], + ["https://shop.example.com/*", "https://*.example.com/*", false], + ["https://example.com/*", "http://example.com/*", false], + ["https://example.com/a", "https://example.com/b", true], + ["http://127.0.0.1/*", "http://127.0.0.1:62778/*", true], + ["http://127.0.0.1:62778/*", "http://127.0.0.1/*", false], + ] as const)("checks origin coverage: %s covers %s = %s", async (granted, requested, expected) => { + install({permissions: {origins: [granted]}}); + await expect(containsPermissions({origins: [requested]})).resolves.toBe(expected); + }); + + test("requires every named permission and every requested origin", async () => { + install({permissions: {permissions: ["tabs", "scripting"], origins: ["https://*.example.com/*"]}}); + await expect( + containsPermissions({ + permissions: ["tabs", "scripting"], + origins: ["https://example.com/*", "https://shop.example.com/*"], + }) + ).resolves.toBe(true); + await expect(containsPermissions({permissions: ["tabs", "storage"]})).resolves.toBe(false); + await expect(containsPermissions({origins: ["https://example.com/*", "http://example.com/*"]})).resolves.toBe( + false + ); + await expect(containsPermissions({})).resolves.toBe(true); + // Validation must not be masked by an absent named permission or an earlier matching pattern. + await expect(containsPermissions({permissions: ["storage"], origins: ["invalid"]})).rejects.toThrow( + "permissions.contains" + ); + }); + + test("uses explicitly granted permissions, not manifest declarations", async () => { + const harness = install({ + manifest: createManifestFixture({ + host_permissions: [""], + optional_host_permissions: ["https://*.example.com/*"], + }), + }); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(false); + await harness.permissions.grant({origins: ["https://*.example.com/*"]}); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(true); + await harness.permissions.revoke({origins: ["https://*.example.com/*"]}); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(false); + }); + + test("updates coverage after request/remove/set/reset without changing getAll representation", async () => { + const harness = install({permissions: {origins: ["https://example.com/original"]}}); + const added: chrome.permissions.Permissions[] = []; + harness.permissions.onAdded.on(value => added.push(value)); + await requestPermissions({origins: ["http://*.example.com/*"]}); + await expect(containsPermissions({origins: ["http://shop.example.com/path"]})).resolves.toBe(true); + expect(added).toEqual([{origins: ["http://*.example.com/*"], permissions: []}]); + await expect(removePermissions({origins: ["http://*.example.com/*"]})).resolves.toBe(true); + await expect(containsPermissions({origins: ["http://shop.example.com/path"]})).resolves.toBe(false); + harness.permissions.set({origins: [""]}); + await expect(containsPermissions({origins: ["http://other.example/*"]})).resolves.toBe(true); + harness.reset(); + await expect(getAllPermissions()).resolves.toEqual({ + origins: ["https://example.com/original"], + permissions: [], + }); + await expect(containsPermissions({origins: ["http://other.example/*"]})).resolves.toBe(false); + await expect(containsPermissions({origins: ["https://example.com/new"]})).resolves.toBe(true); + }); + + test("revocation remains exact-entry removal, not subtraction from a wildcard grant", async () => { + const harness = install({permissions: {origins: ["https://*.example.com/*"]}}); + await expect(removePermissions({origins: ["https://shop.example.com/*"]})).resolves.toBe(false); + await expect(containsPermissions({origins: ["https://shop.example.com/*"]})).resolves.toBe(true); + expect(harness.permissions.value.origins).toEqual(["https://*.example.com/*"]); + }); + + test("retains result overrides, failNext, reset and callback-scoped lastError", async () => { + const harness = install({permissions: {origins: [""]}}); + harness.permissions.contains.setResult(false); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(false); + harness.permissions.contains.failNext(new Error("Permission lookup failed")); + let observed: string | undefined; + harness.chrome.permissions.contains({}, () => { + observed = harness.runtime.lastError?.message; + }); + expect(observed).toBe("Permission lookup failed"); + expect(harness.runtime.lastError).toBeUndefined(); + harness.reset(); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(true); + }); + + test("supports raw callback and Promise calls, with synchronous callback argument validation", async () => { + const harness = install({ + tabs: [createTabFixture({url: "https://example.com/path"})], + permissions: {origins: ["https://*.example.com/*"]}, + }); + const api = profile === "chrome" ? harness.chrome : harness.browser; + let callbackTabs: chrome.tabs.Tab[] = []; + expect( + api.tabs.query({url: "https://*.example.com/*"}, tabs => { + callbackTabs = tabs; + }) + ).toBeUndefined(); + expect(callbackTabs).toHaveLength(1); + await expect(api.tabs.query({url: "https://*.example.com/*"})).resolves.toHaveLength(1); + let callbackPermission: boolean | undefined; + expect( + api.permissions.contains({origins: ["https://example.com/*"]}, result => { + callbackPermission = result; + }) + ).toBeUndefined(); + expect(callbackPermission).toBe(true); + await expect(api.permissions.contains({origins: ["https://example.com/*"]})).resolves.toBe(true); + for (const method of [api.permissions.contains, api.permissions.request, api.permissions.remove]) { + expect(() => method({origins: ["bad"]}, () => undefined)).toThrow("Invalid match pattern"); + await expect(method({origins: ["bad"]})).rejects.toThrow("Invalid match pattern"); + } + expect(() => api.tabs.query({url: "bad"}, () => undefined)).toThrow("tabs.query"); + expect(harness.runtime.lastError).toBeUndefined(); + expect(harness.permissions.value.origins).toEqual(["https://*.example.com/*"]); + }); + + test("an application install handler waits for CSS before JS on matching, permitted tabs", async () => { + const harness = install({ + tabs: [createTabFixture({id: 1, active: false, url: "https://shop.example.com/page"})], + permissions: {origins: ["https://*.example.com/*"]}, + }); + let releaseCss: () => void = () => undefined; + let cssStarted: () => void = () => undefined; + const started = new Promise(resolve => { + cssStarted = resolve; + }); + const cssCompletion = new Promise(resolve => { + releaseCss = resolve; + }); + harness.scripting.insertCSS.setImplementation( + (_injection: chrome.scripting.CSSInjection, callback?: () => void) => { + cssStarted(); + return cssCompletion.then(() => { + callback?.(); + }); + } + ); + harness.scripting.executeScript.setResult([]); + + // Representative application code; these are real package wrappers, not module mocks. + const unsubscribe = onInstalled(async () => { + if (!(await containsPermissions({origins: ["https://shop.example.com/*"]}))) return; + const tabs = await queryTabs({url: "https://*.example.com/*", status: "complete", discarded: false}); + for (const tab of tabs) { + if (tab.id === undefined) continue; + const target = {tabId: tab.id}; + await insertCss({target, files: ["content.css"]}); + await executeScript({target, files: ["content.js"]}); + } + }); + const dispatch = harness.runtime.events.onInstalled.emit(createInstalledDetailsFixture()); + try { + await started; + expect(harness.scripting.executeScript.calls).toHaveLength(0); + releaseCss(); + await dispatch; + expect(harness.scripting.executeScript.calls.map(call => call.args)).toEqual([ + [{target: {tabId: 1}, files: ["content.js"]}], + ]); + } finally { + releaseCss(); + await dispatch; + unsubscribe(); + } + }); +}); + +test("invalid grant batches and set are rejected without partial mutation", async () => { + const harness = createBrowserHarness({permissions: {permissions: ["tabs"]}}); + expect(() => harness.permissions.set({origins: ["https://ok.test/*", "bad"]})).toThrow("match pattern"); + await expect(harness.permissions.grant({permissions: ["scripting"], origins: ["bad"]})).rejects.toThrow( + "match pattern" + ); + expect(harness.permissions.value).toEqual({origins: [], permissions: ["tabs"]}); + expect(() => createBrowserHarness({permissions: {origins: ["bad"]}})).toThrow("match pattern"); +}); + +test("URL overrides/reset and origin state stay isolated between harnesses", async () => { + const first = createBrowserHarness({ + tabs: [createTabFixture({id: 1, url: "https://example.com/page"})], + permissions: {origins: [""]}, + }); + const second = createBrowserHarness(); + first.tabs.query.setResult([]); + await expect(first.chrome.tabs.query({url: "https://example.com/*"})).resolves.toEqual([]); + first.reset(); + await expect(first.chrome.tabs.query({url: "https://example.com/*"})).resolves.toHaveLength(1); + await expect(second.chrome.tabs.query({url: "https://example.com/*"})).resolves.toEqual([]); + await expect(second.chrome.permissions.contains({origins: ["https://example.com/*"]})).resolves.toBe(false); +}); diff --git a/src/testing/match-patterns.test.ts b/src/testing/match-patterns.test.ts new file mode 100644 index 0000000..76e90bf --- /dev/null +++ b/src/testing/match-patterns.test.ts @@ -0,0 +1,137 @@ +import {describe, expect, test} from "@jest/globals"; +import {coversOrigin, createUrlMatcher, parseMatchPattern} from "./match-patterns"; + +describe("URL match-pattern subset", () => { + test.each([ + ["*://example.com/*", "http://example.com/", true], + ["*://example.com/*", "https://example.com/", true], + ["*://example.com/*", "ftp://example.com/", false], + ["*://*/*", "file:///tmp/test.html", false], + ["", "file:///tmp/test.html", true], + ["", "https://example.com/", true], + ["", "http://example.com/", true], + ["", "chrome://extensions/", false], + ["", "about:blank", false], + ["", "data:text/plain,hello", false], + ["", "ws://example.com/", false], + ["", "not a URL", false], + ["https://*.example.com/*", "https://example.com/", true], + ["https://*.example.com/*", "https://a.b.example.com/", true], + ["https://*.example.com/*", "https://example.com.evil.test/", false], + ["https://*.example.com/*", "https://notexample.com/", false], + ["https://example.com/*", "https://sub.example.com/", false], + ["https://EXAMPLE.com/*", "https://example.COM./", true], + ["https://bücher.example/*", "https://xn--bcher-kva.example/", true], + ["http://127.0.0.1/*", "http://127.0.0.1:62778/page", true], + ["http://localhost:*/*", "http://localhost:3000/", true], + ["http://localhost:3000/*", "http://localhost:3001/", false], + ["http://localhost:3000/*", "http://localhost:3000/", true], + ["http://localhost:80/*", "http://localhost/", true], + ["https://localhost:443/*", "https://localhost/", true], + ["http://[::1]/*", "http://[::1]:62778/page", true], + ["http://[0:0:0:0:0:0:0:1]:80/*", "http://[::1]/", true], + ["http://[::1]:1234/*", "http://[::1]:1235/", false], + ["http://*/*", "http://127.0.0.1/", true], + ["file:///tmp/*", "file:///tmp/test.html#fragment", true], + ["file:///tmp/*", "file:///other/test.html", false], + ["https://example.com/foo/*", "https://example.com/foo", true], + ["https://example.com/foo/*", "https://example.com/foobar", false], + ["https://example.com/foo*bar", "https://example.com/foobar", true], + ["https://example.com/foo*bar", "https://example.com/foo/a/bar", true], + ["https://example.com/foo*bar", "https://example.com/foo/a/bar/more", false], + ["https://example.com/a*b*c", "https://example.com/aXXbYYc", true], + ["https://example.com/a**b*c", "https://example.com/abc", true], + ["https://example.com/aa*aa", "https://example.com/aaa", false], + ["https://example.com/aa*aa", "https://example.com/aaaa", true], + ["https://example.com/a+b.(c)[d]$", "https://example.com/a+b.(c)[d]$", true], + ["https://example.com/a+b", "https://example.com/aaab", false], + ["https://example.com/search?q=a+b", "https://example.com/search?q=a+b#part", true], + ["https://example.com/search?q=*", "https://example.com/search?q=test&next=yes", true], + ["https://example.com/search?q=*", "https://example.com/searchXq=test", false], + ["https://example.com/search", "https://example.com/search?q=test", false], + ["https://example.com/search?", "https://example.com/search?#fragment", true], + ["https://example.com/search?", "https://example.com/search", false], + ["https://example.com/search", "https://example.com/search?", false], + ["https://example.com/Case", "https://example.com/case", false], + ["https://example.com/a%2Fb", "https://example.com/a%2Fb", true], + ["https://example.com/a%2Fb", "https://example.com/a/b", false], + ["https://example.com/%C3%A9", "https://example.com/é", true], + ] as const)("%s matches %s = %s", (pattern, url, expected) => { + expect(createUrlMatcher([pattern], "tabs.query")(url)).toBe(expected); + }); + + test.each([ + "", + "https://example.com", + "https:///path", + "https://*./*", + "https://foo*bar/*", + "https://example.*/*", + "https://*.*.example.com/*", + "https://user@example.com/*", + "https://example.com:/*", + "https://example.com:-1/*", + "https://example.com:65536/*", + "https://example.com:abc/*", + "https://example.com:1:2/*", + "http://[::1/*", + "http://[]/*", + "http://[not-ip]/*", + "https://example.com/*#fragment", + "https://example.com/a b", + "https://example.com/\\*", + "https://example%2Ecom/*", + "http://*.[::1]/*", + ])("rejects malformed input with API and pattern: %s", pattern => { + expect(() => parseMatchPattern(pattern, "tabs.query")).toThrow("Invalid match pattern"); + expect(() => parseMatchPattern(pattern, "tabs.query")).toThrow("tabs.query"); + expect(() => parseMatchPattern(pattern, "tabs.query")).toThrow(JSON.stringify(pattern)); + }); + + test.each([ + "ws://example.com/*", + "wss://example.com/*", + "ftp://example.com/*", + "chrome://extensions/*", + "HTTPS://example.com/*", + "file://localhost/*", + "*://localhost:3000/*", + "http://*.127.0.0.1/*", + "https://example.com/é", + "https://localhost:0443/*", + ])("rejects unsupported patterns explicitly: %s", pattern => { + expect(() => parseMatchPattern(pattern, "tabs.query")).toThrow("Unsupported match pattern"); + }); + + test("validates all OR alternatives and treats an empty list as matching nothing", () => { + expect(() => createUrlMatcher(["", "bad"], "tabs.query")).toThrow("Invalid match pattern"); + expect(createUrlMatcher([], "tabs.query")("https://example.com/")).toBe(false); + }); +}); + +describe("host-permission pattern containment (not URL matching)", () => { + test.each([ + ["", "*://*/*", true], + ["", "file:///any/path", true], + ["*://*/*", "", false], + ["*://*.example.com/*", "https://shop.example.com/*", true], + ["https://*.example.com/*", "*://shop.example.com/*", false], + ["https://*.example.com/a", "https://example.com/b", true], + ["https://*.example.com/*", "https://*.shop.example.com/*", true], + ["https://*.shop.example.com/*", "https://*.example.com/*", false], + ["https://example.com/*", "https://*.example.com/*", false], + ["https://*.example.com/*", "https://*/*", false], + ["https://*.example.com/*", "https://notexample.com/*", false], + ["https://*.example.com/*", "https://example.com.evil.test/*", false], + ["https://EXAMPLE.com./a", "https://example.com/b", true], + ["https://example.com/*", "http://example.com/*", false], + ["http://localhost/*", "http://localhost:3000/*", true], + ["http://localhost:3000/*", "http://localhost/*", false], + ["http://localhost:3000/*", "http://localhost:3000/*", true], + ["http://localhost:3000/*", "http://localhost:3001/*", false], + ["file:///one", "file:///two", true], + ["file:///one", "https://example.com/*", false], + ] as const)("%s covers %s = %s", (granted, requested, expected) => { + expect(coversOrigin(parseMatchPattern(granted, "test"), parseMatchPattern(requested, "test"))).toBe(expected); + }); +}); diff --git a/src/testing/match-patterns.ts b/src/testing/match-patterns.ts new file mode 100644 index 0000000..9416a3e --- /dev/null +++ b/src/testing/match-patterns.ts @@ -0,0 +1,145 @@ +// Internal, environment-independent subset shared by URL matching and host-permission containment. +// Deliberately not exported from either public entrypoint. See docs/testing/match-patterns.md. +type Scheme = "http" | "https" | "file"; +type Host = {readonly kind: "any" | "exact" | "subdomains"; readonly name: string}; + +export interface MatchPattern { + readonly schemes: readonly Scheme[]; + readonly host: Host; + readonly port: string; + readonly path: string; + readonly pathParts: readonly string[]; +} + +const supportedSchemes: readonly Scheme[] = ["http", "https", "file"]; +const stripTrailingDots = (host: string): string => host.replace(/\.+$/, ""); +const isIpAddress = (host: string): boolean => host.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(host); + +const patternError = (pattern: string, api: string, reason: string, unsupported = false): Error => + new Error( + `${unsupported ? "Unsupported" : "Invalid"} match pattern ${JSON.stringify(pattern)} for ${api}: ${reason}` + ); + +export const parseMatchPattern = (pattern: string, api: string): MatchPattern => { + if (pattern === "") { + return { + schemes: [...supportedSchemes], + host: {kind: "any", name: ""}, + port: "*", + path: "/*", + pathParts: ["/", ""], + }; + } + if (typeof pattern !== "string" || /[\s\\#]/u.test(pattern)) { + throw patternError(pattern, api, "expected a string without whitespace, backslashes or a fragment"); + } + const parts = /^([^:]+):\/\/([^/]*)(\/.*)$/.exec(pattern); + if (!parts) throw patternError(pattern, api, "expected :///"); + const [, scheme, authority, path] = parts; + if (scheme !== "*" && !supportedSchemes.includes(scheme as Scheme)) { + throw patternError(pattern, api, "supported schemes are http, https, file and * (http/https)", true); + } + // URL.pathname/search are serialized. Do not silently pretend to implement vendor-specific decoding rules. + if (/[^\x21-\x7e]/u.test(path)) { + throw patternError(pattern, api, "use percent-encoded non-ASCII path/query characters", true); + } + const schemes: readonly Scheme[] = scheme === "*" ? ["http", "https"] : [scheme as Scheme]; + const base = {schemes, path, pathParts: path.split("*")}; + if (scheme === "file") { + if (authority) throw patternError(pattern, api, "only hostless file:/// patterns are supported", true); + return {...base, host: {kind: "any", name: ""}, port: "*"}; + } + + const hostPort = /^(\[[^\]]+\]|[^:]+)(?::([^:]*))?$/.exec(authority); + if (!hostPort) throw patternError(pattern, api, "missing or malformed host/port"); + const [, hostText, portText = "*"] = hostPort; + if (portText !== "*" && (!/^\d+$/.test(portText) || Number(portText) > 65535)) { + throw patternError(pattern, api, "port must be * or an integer from 0 to 65535"); + } + if (portText !== "*" && portText !== String(Number(portText))) { + throw patternError(pattern, api, "use a canonical decimal port without leading zeroes", true); + } + if (scheme === "*" && portText !== "*") { + throw patternError(pattern, api, "an explicit port requires an explicit http or https scheme", true); + } + const port = portText === "*" ? "*" : String(Number(portText)); + if (hostText === "*") return {...base, host: {kind: "any", name: ""}, port}; + + const subdomains = hostText.startsWith("*."); + const rawHost = subdomains ? hostText.slice(2) : hostText; + if (!rawHost || /[*@?#%]/.test(rawHost)) throw patternError(pattern, api, "invalid host or host wildcard"); + let name: string; + try { + // URL provides case/IDNA/IP normalization; no hand-written general URL parser or Node-only dependency. + name = stripTrailingDots(new URL(`http://${rawHost}/`).hostname); + } catch { + throw patternError(pattern, api, "invalid hostname"); + } + if (!name) throw patternError(pattern, api, "empty hostname"); + if (subdomains && isIpAddress(name)) { + throw patternError(pattern, api, "subdomain wildcards on IP addresses are not supported", true); + } + return {...base, host: {kind: subdomains ? "subdomains" : "exact", name}, port}; +}; + +const matchesHost = (host: Host, name: string): boolean => + host.kind === "any" || + name === host.name || + (host.kind === "subdomains" && !isIpAddress(name) && name.endsWith(`.${host.name}`)); + +const matchesPath = (pattern: MatchPattern, value: string): boolean => { + // Chromium also matches /foo/* against /foo, not only /foo/ and its descendants. + if (pattern.path.endsWith("/*") && value === pattern.path.slice(0, -2)) return true; + const parts = pattern.pathParts; + if (parts.length === 1) return value === parts[0]; + if (!value.startsWith(parts[0])) return false; + let offset = parts[0].length; + // Literal segments avoid regex injection and backtracking over user-supplied patterns. + for (const part of parts.slice(1, -1)) { + const next = value.indexOf(part, offset); + if (next === -1) return false; + offset = next + part.length; + } + const suffix = parts[parts.length - 1]; + return value.length - suffix.length >= offset && value.endsWith(suffix); +}; + +export const matchesUrl = (pattern: MatchPattern, value: URL): boolean => { + const scheme = value.protocol.slice(0, -1) as Scheme; + if (!pattern.schemes.includes(scheme)) return false; + const port = value.port || (scheme === "https" ? "443" : scheme === "http" ? "80" : ""); + // URL.search is empty for both no query and a bare '?'; the serialized URL preserves the distinction. + const query = value.search || (value.href.split("#", 1)[0].endsWith("?") ? "?" : ""); + return ( + matchesHost(pattern.host, stripTrailingDots(value.hostname)) && + (pattern.port === "*" || pattern.port === port) && + matchesPath(pattern, value.pathname + query) + ); +}; + +export const createUrlMatcher = (patterns: readonly string[], api: string): ((url: string) => boolean) => { + // Compile/validate every alternative, even with zero tabs or an earlier alternative. + const parsed = patterns.map(pattern => parseMatchPattern(pattern, api)); + return (value: string): boolean => { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + return parsed.some(pattern => matchesUrl(pattern, url)); + }; +}; + +export const coversOrigin = (granted: MatchPattern, requested: MatchPattern): boolean => { + // This is set containment, not matching a representative URL. Host permission paths are ignored. + if (!requested.schemes.every(scheme => granted.schemes.includes(scheme))) return false; + if (granted.port !== "*" && granted.port !== requested.port) return false; + if (granted.host.kind === "any") return true; + if (requested.host.kind === "any") return false; + if (requested.host.kind === "subdomains" && granted.host.kind !== "subdomains") return false; + return matchesHost(granted.host, requested.host.name); +}; + +export const parseOrigins = (origins: readonly string[] | undefined, api: string): MatchPattern[] => + (origins ?? []).map(origin => parseMatchPattern(origin, api)); diff --git a/src/testing/permissions.ts b/src/testing/permissions.ts index aa51253..caf7003 100644 --- a/src/testing/permissions.ts +++ b/src/testing/permissions.ts @@ -1,5 +1,6 @@ import {type BrowserEventHarness, createBrowserEvent} from "./event"; import {createPermissionsFixture} from "./fixtures"; +import {coversOrigin, parseOrigins} from "./match-patterns"; import {type BrowserMethod, createBrowserMethod} from "./method"; import type {PermissionsTestApi, RuntimeLastErrorController} from "./types"; @@ -31,6 +32,7 @@ export const createPermissionsHarness = ( nextSequence?: () => number ): PermissionsHarness => { const initial = createPermissionsFixture(initialValue); + parseOrigins(initial.origins, "permissions initial state"); let permissions = new Set(initial.permissions ?? []); let origins = new Set(initial.origins ?? []); @@ -56,7 +58,11 @@ export const createPermissionsHarness = ( const contains = createBrowserMethod({ callback: "last", implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { - const result = includesAll(permissions, value.permissions) && includesAll(origins, value.origins); + const requested = parseOrigins(value.origins, "permissions.contains"); + const granted = parseOrigins([...origins], "permissions.contains"); + const result = + includesAll(permissions, value.permissions) && + requested.every(origin => granted.some(grant => coversOrigin(grant, origin))); callback?.(result); return result; }) as unknown as typeof chrome.permissions.contains, @@ -107,10 +113,13 @@ export const createPermissionsHarness = ( const request = createBrowserMethod({ callback: "last", - implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { - await apply(value, "grant"); - callback?.(true); - return true; + implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { + // Validate synchronously before mutation, including when the caller supplied a callback. + parseOrigins(value.origins, "permissions.request"); + return apply(value, "grant").then(() => { + callback?.(true); + return true; + }); }) as unknown as typeof chrome.permissions.request, invocation: "dual", lastError, @@ -119,11 +128,13 @@ export const createPermissionsHarness = ( }); const remove = createBrowserMethod({ callback: "last", - implementation: (async (value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { - const changed = await apply(value, "revoke"); - const result = Boolean(changed.permissions?.length || changed.origins?.length); - callback?.(result); - return result; + implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { + parseOrigins(value.origins, "permissions.remove"); + return apply(value, "revoke").then(changed => { + const result = Boolean(changed.permissions?.length || changed.origins?.length); + callback?.(result); + return result; + }); }) as unknown as typeof chrome.permissions.remove, invocation: "dual", lastError, @@ -158,9 +169,11 @@ export const createPermissionsHarness = ( return {origins: [...origins], permissions: [...permissions]}; }, async grant(value): Promise { + parseOrigins(value.origins, "permissions.grant"); await apply(value, "grant"); }, async revoke(value): Promise { + parseOrigins(value.origins, "permissions.revoke"); await apply(value, "revoke"); }, reset(): void { @@ -173,6 +186,7 @@ export const createPermissionsHarness = ( onRemoved.reset(); }, set(value): void { + parseOrigins(value.origins, "permissions.set"); permissions = new Set(value.permissions ?? []); origins = new Set(value.origins ?? []); }, diff --git a/src/testing/stateful.integration.test.ts b/src/testing/stateful.integration.test.ts index 9907ce2..50fc19c 100644 --- a/src/testing/stateful.integration.test.ts +++ b/src/testing/stateful.integration.test.ts @@ -158,9 +158,7 @@ describe("stateful browser test harness", () => { expect.objectContaining({id: createdTab.id, url: "https://literal.example/path"}), ]); await expect(queryTabs({url: "https://literal.example/path"})).resolves.toHaveLength(1); - await expect(queryTabs({url: "https://*.example/*"})).rejects.toThrow( - "tabs.query url match patterns are not supported" - ); + await expect(queryTabs({url: "https://literal.example/*"})).resolves.toHaveLength(1); await expect(queryTabs({id: createdTab.id} as chrome.tabs.QueryInfo)).rejects.toThrow( 'tabs.query filter "id" is not supported' ); diff --git a/src/testing/tabs.ts b/src/testing/tabs.ts index 3391a51..850166d 100644 --- a/src/testing/tabs.ts +++ b/src/testing/tabs.ts @@ -1,6 +1,7 @@ import {type BrowserEventHarness, createBrowserEvent} from "./event"; import {createTabFixture} from "./fixtures"; import {missingEntityError} from "./internal"; +import {createUrlMatcher} from "./match-patterns"; import {type BrowserMethod, createBrowserMethod} from "./method"; import type {BrowserMemoryState} from "./browser-state"; import type {RuntimeLastErrorController, TabsTestApi} from "./types"; @@ -76,7 +77,7 @@ const supportedQueryFields = new Set([ "windowType", ]); -const assertExactPattern = (field: "title" | "url", value: string): void => { +const assertExactPattern = (field: "title", value: string): void => { if (value === "" || value.includes("*")) { throw new Error(`tabs.query ${field} match patterns are not supported; use an exact value`); } @@ -285,10 +286,8 @@ export const createTabsHarness = ( if (!supportedQueryFields.has(key)) throw new Error(`tabs.query filter "${key}" is not supported`); } - const exactUrls = typeof queryInfo.url === "string" ? [queryInfo.url] : queryInfo.url; - exactUrls?.forEach(url => { - assertExactPattern("url", url); - }); + const urls = typeof queryInfo.url === "string" ? [queryInfo.url] : queryInfo.url; + const matchesUrl = urls === undefined ? undefined : createUrlMatcher(urls, "tabs.query"); if (queryInfo.title) assertExactPattern("title", queryInfo.title); const currentWindowId = state.currentWindowId(); @@ -323,7 +322,7 @@ export const createTabsHarness = ( return false; if (queryInfo.groupId !== undefined && tab.groupId !== queryInfo.groupId) return false; if (queryInfo.title !== undefined && tab.title !== queryInfo.title) return false; - if (exactUrls && (!tab.url || !exactUrls.includes(tab.url))) return false; + if (matchesUrl && (!tab.url || !matchesUrl(tab.url))) return false; return true; }) .sort((left, right) => left.windowId - right.windowId || left.index - right.index) diff --git a/tests/consumer-types/cjs.cjs b/tests/consumer-types/cjs.cjs index d693bbd..e2f334b 100644 --- a/tests/consumer-types/cjs.cjs +++ b/tests/consumer-types/cjs.cjs @@ -11,6 +11,8 @@ assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeB async function checkConsumer() { const harness = testing.createBrowserHarness({ manifest: testing.createManifestFixture({name: "CJS consumer"}), + tabs: [testing.createTabFixture({id: 7, url: "http://127.0.0.1:62778/top.html#part"})], + permissions: {origins: ["https://*.example.com/*"]}, }); harness.delays.downloadValidation.setResult(undefined); harness.configurable.chrome.downloads.download.setResult(42); @@ -19,6 +21,13 @@ async function checkConsumer() { try { assert.equal(production.getManifest().name, "CJS consumer"); + assert.deepEqual( + (await production.queryTabs({url: ["http://127.0.0.1/*"], status: "complete"})).map(tab => tab.id), + [7] + ); + assert.equal(await production.containsPermissions({origins: ["https://shop.example.com/*"]}), true); + assert.equal(await production.containsPermissions({origins: ["http://shop.example.com/*"]}), false); + await assert.rejects(production.queryTabs({url: "https://bad*host/*"}), /tabs.query/); assert.equal(typeof harness.runtime.closeMessageChannels, "function"); assert.equal(await production.download({url: "https://example.test/cjs.zip"}), 42); assert.deepEqual( diff --git a/tests/consumer-types/esm.mjs b/tests/consumer-types/esm.mjs index 8c4c61d..d653cef 100644 --- a/tests/consumer-types/esm.mjs +++ b/tests/consumer-types/esm.mjs @@ -9,6 +9,8 @@ assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeCh assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); const harness = testing.createBrowserHarness({ manifest: testing.createManifestFixture({name: "ESM consumer"}), + tabs: [testing.createTabFixture({id: 7, url: "http://127.0.0.1:62778/top.html#part"})], + permissions: {origins: ["https://*.example.com/*"]}, }); harness.delays.downloadValidation.setResult(undefined); harness.configurable.chrome.downloads.download.setResult(41); @@ -18,6 +20,13 @@ const unsubscribe = harness.runtime.events.onMessage.on(() => true); try { assert.equal(production.getManifest().name, "ESM consumer"); + assert.deepEqual( + (await production.queryTabs({url: ["http://127.0.0.1/*"], status: "complete"})).map(tab => tab.id), + [7] + ); + assert.equal(await production.containsPermissions({origins: ["https://shop.example.com/*"]}), true); + assert.equal(await production.containsPermissions({origins: ["http://shop.example.com/*"]}), false); + await assert.rejects(production.queryTabs({url: "https://bad*host/*"}), /tabs.query/); assert.equal(await production.download({url: "https://example.test/esm.zip"}), 41); assert.deepEqual( harness.delays.downloadValidation.calls.map(call => call.args), diff --git a/tests/consumer-types/index.ts b/tests/consumer-types/index.ts index bc4bc91..8517e39 100644 --- a/tests/consumer-types/index.ts +++ b/tests/consumer-types/index.ts @@ -1,4 +1,4 @@ -import {getManifest, onTabUpdated, queryTabs} from "@addon-core/browser"; +import {containsPermissions, getManifest, onTabUpdated, queryTabs} from "@addon-core/browser"; import { type BrowserHarness, type BrowserMethod, @@ -15,6 +15,10 @@ const harness: BrowserHarness = createBrowserHarness({ const restore = installBrowserGlobals(harness, {profile: "firefox"}); const manifestName: string = getManifest().name; const queryResult: Promise = queryTabs({active: true}); +const matchedTabs: Promise = harness.browser.tabs.query({ + url: ["http://127.0.0.1/*", "https://*.example.com/*"], +}); +const hasHostAccess: Promise = containsPermissions({origins: ["https://shop.example.com/*"]}); const browserQuery: typeof chrome.tabs.query = harness.browser.tabs.query; const downloadValidationDelay: BrowserMethod<(milliseconds: number) => Promise, void> = harness.delays.downloadValidation; @@ -31,6 +35,8 @@ downloadValidationDelay.setResult(undefined); void browserQuery; void manifestName; void queryResult; +void matchedTabs; +void hasHostAccess; restore(); onTabUpdated((tabId, changeInfo, tab) => { From 9a4cf11123c91cd8c1ca46e8bc4cbdc0c299f97b Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:20:42 +0300 Subject: [PATCH 09/11] test(testing): integrate Chromium smoke checks into CI --- .github/workflows/ci.yml | 44 +++- CONTRIBUTING.md | 33 +++ docs/testing/match-patterns.md | 17 ++ package.json | 1 + tests/browser-match-patterns/check.mjs | 203 ++++++++++++++++++ tests/browser-match-patterns/launcher.mjs | 43 ++++ .../browser-match-patterns/launcher.test.mjs | 47 ++++ 7 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 tests/browser-match-patterns/check.mjs create mode 100644 tests/browser-match-patterns/launcher.mjs create mode 100644 tests/browser-match-patterns/launcher.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d7ab4..85dae33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,42 @@ on: default: false jobs: + # One real-browser check, not an OS x Node matrix. Release also waits for this reusable CI job. + browser-match-patterns: + name: Browser match-pattern smoke + # Keep Chromium's sandbox enabled; Ubuntu 23.10+ restricts user namespaces for downloaded binaries. + runs-on: ubuntu-22.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install Chrome for Testing + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + install-dependencies: true + + - name: Run browser match-pattern smoke + env: + CHROME_FOR_TESTING_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run test:browser-match-patterns -- "$CHROME_FOR_TESTING_PATH" + compute-matrix: name: Compute matrix runs-on: ubuntu-latest @@ -28,11 +64,11 @@ jobs: - id: set run: | if [[ "${{ inputs.full }}" == "true" ]]; then - echo 'matrix={"os":["ubuntu-latest","windows-latest"],"node":[18,20,22]}' >> $GITHUB_OUTPUT - echo 'name_suffix=(full matrix)' >> $GITHUB_OUTPUT + echo 'matrix={"os":["ubuntu-latest","windows-latest"],"node":[18,20,22]}' >> "$GITHUB_OUTPUT" + echo 'name_suffix=(full matrix)' >> "$GITHUB_OUTPUT" else - echo 'matrix={"os":["ubuntu-latest"],"node":[20]}' >> $GITHUB_OUTPUT - echo 'name_suffix=' >> $GITHUB_OUTPUT + echo 'matrix={"os":["ubuntu-latest"],"node":[20]}' >> "$GITHUB_OUTPUT" + echo 'name_suffix=' >> "$GITHUB_OUTPUT" fi build-and-test: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94c2b2c..52166d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,7 @@ npm ci - `npm run format` — format with Biome - `npm run typecheck` — type-check with tsc - `npm test` / `npm run test:ci` — tests (Jest) +- `npm run test:browser-match-patterns -- /absolute/path/to/browser` — real-browser match-pattern smoke (build first) Minimum Node.js version: current LTS (at release time). @@ -137,6 +138,35 @@ In CI use `npm run test:ci`. All test scripts (`npm test`, `npm run test:ci`, an Import Jest helpers explicitly in test files, for example `import {describe, expect, jest, test} from "@jest/globals"`. In ESM, the `jest` object is not a global. These imports belong only in test suites; the published `@addon-core/browser/testing` runtime remains runner-independent. +### Browser match-pattern smoke + +Before releasing changes to the URL matcher or host-permission fake, run the real-browser smoke in addition to unit +and clean-consumer tests. Obtain the full **Chrome for Testing** executable from the +[official downloads](https://googlechromelabs.github.io/chrome-for-testing/) or use a Chromium build with extension +support. No ChromeDriver, Playwright, or other automation package is needed. Do not use `chrome-headless-shell`. + +```sh +npm run build +npm run test:browser-match-patterns -- "/absolute/path/to/chrome-for-testing" +``` + +On macOS, pass the executable inside the app bundle, for example +`/path/to/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`, not the `.app` directory. +The script verifies `--version` before starting. Regular Google Chrome is intentionally rejected: +[Chrome 137+ removed `--load-extension` from branded builds](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ). +A remaining timeout includes the selected binary/version, missing extension results, a setup hint, and bounded stderr. + +The smoke compares the built harness with real MV3 extension APIs, using a temporary browser profile and loopback +HTTP server. It never uses your personal profile. It is separate from `npm test` so local unit tests need no browser. +If the browser is unavailable locally, report the smoke as **not run**, not as passed. + +`.github/workflows/ci.yml` runs this command in one dedicated Ubuntu 22.04/Node 22 job using stable Chrome for Testing +provided by `browser-actions/setup-chrome` (action revision pinned). The installed version is printed in the log. +This runner keeps Chromium's sandbox enabled without working around the +[AppArmor restrictions on downloaded binaries in Ubuntu 23.10+](https://pptr.dev/troubleshooting#issues-with-apparmor-on-ubuntu). +The release workflow calls the same CI workflow and cannot publish if this job fails. This focused Chrome check is +not Firefox/Safari validation or complete browser parity. See [scope and examples](docs/testing/match-patterns.md). + --- ## Documentation @@ -151,6 +181,9 @@ Import Jest helpers explicitly in test files, for example `import {describe, exp Releases are performed by maintainers. +The reusable CI workflow includes the [browser match-pattern smoke](#browser-match-pattern-smoke). Keep it green +alongside unit, type, build, and consumer checks before publishing; no separate manual browser-test waiver is implied. + Flow (aligned with GitFlow): 1) Merge features into `develop` via PRs. 2) Create a `release/x.y.z` branch from `develop`. Preview autogenerated CHANGELOG: diff --git a/docs/testing/match-patterns.md b/docs/testing/match-patterns.md index ae0b2b7..a18bd39 100644 --- a/docs/testing/match-patterns.md +++ b/docs/testing/match-patterns.md @@ -93,3 +93,20 @@ For install/activation tests, emit `harness.runtime.events.onInstalled`, and let Promise so `emit()` can await it. Configure CSS/JS results through `harness.scripting`; the kit does not execute scripts. To verify CSS-before-JS, hold the CSS implementation until the test releases it, assert that `executeScript.calls` is still empty, then release and await dispatch. Call history order alone does not prove the application awaited CSS. + +## Real-browser check + +After building, contributors can compare selected queries and permission cases with a local Chromium/Chrome for +Testing executable: + +```sh +npm run build +npm run test:browser-match-patterns -- "/absolute/path/to/chrome-for-testing" +``` + +The script uses a temporary MV3 extension/profile and loopback HTTP server, compares the real browser with the built +harness, and removes its temporary files. It needs no automation package and is separate from the normal unit tests. +It requires the full Chrome for Testing or Chromium executable, not regular Google Chrome or `chrome-headless-shell`. +The launcher verifies the browser's `--version` before starting the smoke. A dedicated CI job runs it automatically, +including before release. See [contributor setup and troubleshooting](../../CONTRIBUTING.md#browser-match-pattern-smoke). +This focused smoke does not establish complete Chrome, Firefox, or Safari parity. diff --git a/package.json b/package.json index ea25f01..5a6927b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "fix": "biome check --write --unsafe .", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:ci": "npm test -- --ci --passWithNoTests --coverage", + "test:browser-match-patterns": "node ./tests/browser-match-patterns/check.mjs", "test:consumer-types": "npm run build && node ./tests/consumer-types/check.mjs", "test:related": "npm test -- --bail --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/tests/browser-match-patterns/check.mjs b/tests/browser-match-patterns/check.mjs new file mode 100644 index 0000000..8c146b3 --- /dev/null +++ b/tests/browser-match-patterns/check.mjs @@ -0,0 +1,203 @@ +// Real-Chromium smoke (standalone locally; required in CI). Only an isolated temporary profile is used. +// Run after npm run build: npm run test:browser-match-patterns -- /path/to/chrome-for-testing +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"; +import {createServer} from "node:http"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {createBrowserHarness, createTabFixture} from "../../dist/testing/index.js"; +import {browserSmokeError, inspectBrowser} from "./launcher.mjs"; + +// Reject unsupported binaries before opening a server, creating a profile or waiting for extension results. +const browserInfo = await inspectBrowser(process.argv[2]); +console.log(`Browser smoke: ${browserInfo.version} (${browserInfo.path})`); +const temporary = await mkdtemp(join(tmpdir(), "browser-match-patterns-")); +const profiles = [ + {name: "wildcard", origins: ["https://*.example.com/*", "http://127.0.0.1/*"]}, + {name: "narrow", origins: ["https://shop.example.com/*", "http://127.0.0.1/*"]}, + {name: "all", origins: [""]}, +]; +const requestedOrigins = [ + "https://example.com/*", + "https://shop.example.com/*", + "https://*.example.com/*", + "http://shop.example.com/*", + "https://other.test/*", + "http://127.0.0.1:62778/*", + "https://shop.example.com/ignored-path", +]; +const results = new Map(); +let complete; +let fail; +const finished = new Promise((resolveResult, reject) => { + complete = resolveResult; + fail = reject; +}); +const server = createServer(async (request, response) => { + if (request.method === "POST") { + let body = ""; + for await (const chunk of request) body += chunk; + try { + const result = JSON.parse(body); + assert.ok( + profiles.some(profile => profile.name === result.name), + "Unknown browser result profile" + ); + results.set(result.name, result); + response.end("ok"); + if (results.size === profiles.length) complete(); + } catch (error) { + response.writeHead(400).end(); + fail(error); + } + } else { + response.setHeader("Content-Type", "text/html"); + response.end("Match-pattern smoke"); + } +}); + +// Serialized into the disposable extension, not executed in Node or supplied by a website. +async function probe(config) { + const report = {name: config.name}; + try { + const tab = await chrome.tabs.create({url: `${config.base}/page?q=a+b#part`, active: false}); + const deadline = Date.now() + 10000; + while ((await chrome.tabs.get(tab.id)).status !== "complete") { + if (Date.now() > deadline) throw new Error("Local test tab did not load"); + await new Promise(resolveDelay => setTimeout(resolveDelay, 25)); + } + report.tab = await chrome.tabs.get(tab.id); + report.queries = []; + for (const url of config.patterns) { + const tabs = await chrome.tabs.query({url, active: false, status: "complete", discarded: false}); + report.queries.push(tabs.some(candidate => candidate.id === tab.id)); + } + report.permissions = []; + for (const origin of config.requestedOrigins) { + report.permissions.push(await chrome.permissions.contains({origins: [origin]})); + } + await chrome.tabs.remove(tab.id); + } catch (error) { + report.error = String(error.stack || error); + } + await fetch(`${config.base}/results`, {method: "POST", body: JSON.stringify(report)}); +} + +let browser; +let browserExit; +let timeout; +try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = server.address().port; + const base = `http://127.0.0.1:${port}`; + const patterns = [ + "http://127.0.0.1/*", + "*://127.0.0.1/*", + "", + `${base}/page?q=a+b`, + `${base}/page?q=*`, + `${base}/page`, + "https://127.0.0.1/*", + "http://127.0.0.1:1/*", + ["https://other.test/*", "http://127.0.0.1/*"], + ]; + const extensions = []; + for (const profile of profiles) { + const directory = join(temporary, profile.name); + await mkdir(directory); + await writeFile( + join(directory, "manifest.json"), + JSON.stringify({ + manifest_version: 3, + name: `Match smoke ${profile.name}`, + version: "1.0.0", + permissions: ["tabs"], + host_permissions: profile.origins, + background: {service_worker: "worker.js"}, + }) + ); + await writeFile( + join(directory, "worker.js"), + `chrome.runtime.onInstalled.addListener(() => (${probe.toString()})(${JSON.stringify({name: profile.name, base, patterns, requestedOrigins})}));` + ); + extensions.push(directory); + } + browser = spawn( + browserInfo.path, + [ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + "--no-proxy-server", + `--user-data-dir=${join(temporary, "profile")}`, + `--disable-extensions-except=${extensions.join(",")}`, + `--load-extension=${extensions.join(",")}`, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + let diagnostics = ""; + browser.stderr.on("data", chunk => { + diagnostics = (diagnostics + chunk).slice(-4000); + }); + browser.once("error", fail); + browserExit = once(browser, "exit"); + browserExit.then(([code]) => fail(browserSmokeError(`Browser exited (${code}).`, browserInfo, diagnostics)), fail); + timeout = setTimeout(() => { + const missing = profiles.filter(profile => !results.has(profile.name)).map(profile => profile.name); + fail( + browserSmokeError( + `Browser smoke timed out after 30 seconds; missing results: ${missing.join(", ")}.`, + browserInfo, + diagnostics + ) + ); + }, 30000); + await finished; + let assertions = 0; + for (const profile of profiles) { + const result = results.get(profile.name); + assert.equal(result.error, undefined, result.error); + const harness = createBrowserHarness({ + tabs: [createTabFixture(result.tab)], + permissions: {origins: profile.origins}, + }); + for (const [index, url] of patterns.entries()) { + const tabs = await harness.chrome.tabs.query({url, active: false, status: "complete", discarded: false}); + assert.equal( + tabs.length === 1, + result.queries[index], + `${profile.name}: tabs.query ${JSON.stringify(url)}` + ); + assertions++; + } + for (const [index, origin] of requestedOrigins.entries()) { + assert.equal( + await harness.chrome.permissions.contains({origins: [origin]}), + result.permissions[index], + `${profile.name}: contains ${origin}` + ); + assertions++; + } + } + console.log( + `Real Chromium smoke: ${assertions} harness/browser comparisons passed across ${profiles.length} permission profiles.` + ); +} finally { + clearTimeout(timeout); + if (browser && browser.exitCode === null) { + browser.kill("SIGTERM"); + const killTimeout = setTimeout(() => browser.kill("SIGKILL"), 3000); + await browserExit?.catch(() => undefined); + clearTimeout(killTimeout); + } + server.closeAllConnections(); + await new Promise(resolveClose => server.close(resolveClose)); + await rm(temporary, {recursive: true, force: true}); +} diff --git a/tests/browser-match-patterns/launcher.mjs b/tests/browser-match-patterns/launcher.mjs new file mode 100644 index 0000000..d9c8a43 --- /dev/null +++ b/tests/browser-match-patterns/launcher.mjs @@ -0,0 +1,43 @@ +import {execFile} from "node:child_process"; +import {resolve} from "node:path"; +import {promisify} from "node:util"; + +const run = promisify(execFile); +const browserHint = + "Use the full Chrome for Testing or Chromium executable, not regular Google Chrome or chrome-headless-shell. " + + "Regular Google Chrome 137+ disables --load-extension, which this smoke requires. " + + "See CONTRIBUTING.md#browser-match-pattern-smoke for setup."; + +export const assertSupportedBrowser = (version, path) => { + if (!/^(?:Google Chrome for Testing|Chromium) \d+\./.test(version)) { + throw new Error(`Unsupported browser at ${JSON.stringify(path)}: ${JSON.stringify(version)}. ${browserHint}`); + } +}; + +export const inspectBrowser = async binary => { + if (!binary) { + throw new Error(`Usage: npm run test:browser-match-patterns -- /absolute/path/to/browser. ${browserHint}`); + } + const path = resolve(binary); + let version; + try { + const {stdout} = await run(path, ["--version"], { + encoding: "utf8", + timeout: 5000, + killSignal: "SIGKILL", + maxBuffer: 4096, + }); + version = stdout.trim(); + } catch (error) { + const reason = error.killed ? "--version did not finish within 5 seconds" : error.code || error.message; + throw new Error(`Cannot inspect browser at ${JSON.stringify(path)} (${reason}). ${browserHint}`); + } + assertSupportedBrowser(version, path); + return {path, version}; +}; + +export const browserSmokeError = (reason, browser, diagnostics = "") => + new Error( + `${reason}\nBrowser: ${browser.version} (${browser.path})\n${browserHint}` + + (diagnostics ? `\nBrowser stderr (last 1200 characters):\n${diagnostics.slice(-1200)}` : "") + ); diff --git a/tests/browser-match-patterns/launcher.test.mjs b/tests/browser-match-patterns/launcher.test.mjs new file mode 100644 index 0000000..c5fcb30 --- /dev/null +++ b/tests/browser-match-patterns/launcher.test.mjs @@ -0,0 +1,47 @@ +import {describe, expect, test} from "@jest/globals"; +import {assertSupportedBrowser, browserSmokeError, inspectBrowser} from "./launcher.mjs"; + +describe("browser smoke launcher diagnostics", () => { + test.each(["Google Chrome for Testing 148.0.7778.96", "Chromium 148.0.7778.96"])("accepts %s", version => { + expect(() => assertSupportedBrowser(version, "/test/browser")).not.toThrow(); + }); + + test.each([ + "Google Chrome 152.0.7977.65", + "Google Chrome Canary 152.0.0.0", + "HeadlessChrome/148.0.0.0", + "", + ])("rejects unsupported/unknown brand before starting the smoke: %s", version => { + expect(() => assertSupportedBrowser(version, "/test/browser")).toThrow("Chrome for Testing or Chromium"); + expect(() => assertSupportedBrowser(version, "/test/browser")).toThrow("--load-extension"); + }); + + test("missing arguments show the npm command", async () => { + await expect(inspectBrowser()).rejects.toThrow("npm run test:browser-match-patterns --"); + }); + + test("missing executable has an actionable error instead of a smoke timeout", async () => { + await expect(inspectBrowser("/nonexistent-browser-match-patterns/executable")).rejects.toThrow( + /Cannot inspect browser.*ENOENT.*Chrome for Testing/ + ); + }); + + test("an actual non-browser executable is rejected after reading its version", async () => { + await expect(inspectBrowser(process.execPath)).rejects.toThrow("Unsupported browser"); + }); + + test("timeout diagnostics lead with the remedy, retain the browser identity and bound stderr", () => { + const error = browserSmokeError( + "Browser smoke timed out after 30 seconds; missing results: wildcard, narrow, all.", + {path: "/test/browser", version: "Chromium 148.0.0.0"}, + `${"updater noise".repeat(1000)}last diagnostic` + ); + expect(error.message).toContain("Chrome for Testing or Chromium"); + expect(error.message).toContain("--load-extension"); + expect(error.message).toContain("Chromium 148.0.0.0 (/test/browser)"); + expect(error.message).toContain("missing results: wildcard, narrow, all"); + expect(error.message.indexOf("Chrome for Testing")).toBeLessThan(error.message.indexOf("Browser stderr")); + expect(error.message).toMatch(/last diagnostic$/); + expect(error.message.length).toBeLessThan(2000); + }); +}); From 492b682647026003fa6d64d214bd947ba7d4b227 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:49:59 +0300 Subject: [PATCH 10/11] chore(tooling): migrate to ESLint and staged formatting Replace Biome with ESLint, TypeScript support, and Stylistic formatting. Enforce multiline padding and filename conventions while keeping errors in their owning modules. Normalize source and documentation filenames and update imports and links. Add staged autofixes in Husky and regression tests for formatting and Git index safety. --- .husky/pre-commit | 6 +- .husky/pre-push | 3 +- .release-it.cjs | 13 +- CONTRIBUTING.md | 59 +- README.md | 16 +- biome.json | 70 - ...owserDetection.md => browser-detection.md} | 0 docs/{browsingData.md => browsing-data.md} | 0 docs/{contextMenus.md => context-menus.md} | 0 docs/{documentScan.md => document-scan.md} | 0 docs/{tabCapture.md => tab-capture.md} | 0 docs/{userScripts.md => user-scripts.md} | 0 docs/{webNavigation.md => web-navigation.md} | 2 +- docs/{webRequest.md => web-request.md} | 0 eslint.config.js | 153 + jest.config.js | 12 +- package-lock.json | 4951 ++++++++++++----- package.json | 19 +- scripts/eslint/file-naming.mjs | 180 + scripts/eslint/padding-around-multiline.mjs | 72 + scripts/verify-build.mjs | 10 + ...tion.test.ts => browser-detection.test.ts} | 11 +- ...owserDetection.ts => browser-detection.ts} | 0 src/{browsingData.ts => browsing-data.ts} | 0 src/{contextMenus.ts => context-menus.ts} | 0 src/{documentScan.ts => document-scan.ts} | 0 src/env.test.ts | 6 + src/env.ts | 2 +- src/identity.test.ts | 20 + src/identity.ts | 3 +- src/index.ts | 16 +- src/offscreen.test.ts | 4 + src/runtime.ts | 2 +- src/sidebar.ts | 3 +- src/{tabCapture.ts => tab-capture.ts} | 0 src/testing/browser-state.ts | 15 + src/testing/configurable.test.ts | 6 + src/testing/configurable.ts | 58 +- src/testing/coverage.test.ts | 21 +- src/testing/coverage.ts | 1 + src/testing/delays.test.ts | 11 +- src/testing/event.test.ts | 6 +- src/testing/event.ts | 2 + src/testing/fixtures.test.ts | 3 + src/testing/globals.integration.test.ts | 26 +- src/testing/globals.ts | 28 +- src/testing/harness.ts | 30 +- src/testing/index.ts | 40 +- src/testing/internal.ts | 1 + src/testing/listener-errors.ts | 2 + .../match-patterns.integration.test.ts | 35 + src/testing/match-patterns.ts | 42 + src/testing/method.test.ts | 20 + src/testing/method.ts | 16 +- src/testing/permissions.ts | 21 + src/testing/production.integration.test.ts | 15 +- src/testing/runtime.messaging.test.ts | 19 +- src/testing/runtime.ts | 25 + src/testing/scripting.ts | 23 + src/testing/stateful.integration.test.ts | 20 + src/testing/tabs.ts | 81 +- src/testing/windows.ts | 53 +- src/{userScripts.ts => user-scripts.ts} | 0 src/utils.test.ts | 5 +- src/{webNavigation.ts => web-navigation.ts} | 0 src/{webRequest.ts => web-request.ts} | 0 tests/browser-match-patterns/check.mjs | 43 + tests/browser-match-patterns/launcher.mjs | 6 + .../browser-match-patterns/launcher.test.mjs | 1 + tests/consumer-types/check.mjs | 8 + tests/consumer-types/cjs.cjs | 8 + tests/consumer-types/esm.mjs | 10 + tests/consumer-types/index.ts | 6 + tests/tooling/eslint.test.mjs | 213 + tests/tooling/multiline-spacing.test.mjs | 135 + tests/tooling/pre-commit.test.mjs | 157 + 76 files changed, 5296 insertions(+), 1549 deletions(-) delete mode 100644 biome.json rename docs/{browserDetection.md => browser-detection.md} (100%) rename docs/{browsingData.md => browsing-data.md} (100%) rename docs/{contextMenus.md => context-menus.md} (100%) rename docs/{documentScan.md => document-scan.md} (100%) rename docs/{tabCapture.md => tab-capture.md} (100%) rename docs/{userScripts.md => user-scripts.md} (100%) rename docs/{webNavigation.md => web-navigation.md} (99%) rename docs/{webRequest.md => web-request.md} (100%) create mode 100644 eslint.config.js create mode 100644 scripts/eslint/file-naming.mjs create mode 100644 scripts/eslint/padding-around-multiline.mjs rename src/{browserDetection.test.ts => browser-detection.test.ts} (99%) rename src/{browserDetection.ts => browser-detection.ts} (100%) rename src/{browsingData.ts => browsing-data.ts} (100%) rename src/{contextMenus.ts => context-menus.ts} (100%) rename src/{documentScan.ts => document-scan.ts} (100%) rename src/{tabCapture.ts => tab-capture.ts} (100%) rename src/{userScripts.ts => user-scripts.ts} (100%) rename src/{webNavigation.ts => web-navigation.ts} (100%) rename src/{webRequest.ts => web-request.ts} (100%) create mode 100644 tests/tooling/eslint.test.mjs create mode 100644 tests/tooling/multiline-spacing.test.mjs create mode 100644 tests/tooling/pre-commit.test.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 41dc752..11865b1 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# Husky pre-commit hook: run tests and check linting/formatting -npm run test:related -npm run lint +# Husky pre-commit hook: fix staged files, then run tests +npm run lint:staged || exit 1 +npm run test:related || exit 1 diff --git a/.husky/pre-push b/.husky/pre-push index 677d131..e210b0f 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,7 +1,8 @@ #!/usr/bin/env sh -# Husky pre-push hook: typecheck, run full tests, and build +# Husky pre-push hook: check linting/formatting, typecheck, run full tests, and build +npm run lint || exit 1 npm run typecheck || exit 1 npm run test || exit 1 npm run build || exit 1 diff --git a/.release-it.cjs b/.release-it.cjs index 7fde1d9..79b2758 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -70,13 +70,16 @@ function getContributors() { if (existing) { existing.count += count; + if (!existing.login && gh.login) { existing.login = gh.login; existing.url = gh.url; } + if (!existing.name && displayName) { existing.name = displayName; } + if (!existing.email && displayEmail) { existing.email = displayEmail; } @@ -118,19 +121,19 @@ module.exports = () => { requireUpstream: false, requireBranch: false, commit: true, - // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it placeholder + // release-it placeholder commitMessage: "chore(release): v${version}", tag: true, - // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it placeholder + // release-it placeholder tagName: "v${version}", - // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it placeholder + // release-it placeholder tagAnnotation: "v${version}", push: true, }, github: { release: true, - // biome-ignore lint/suspicious/noTemplateCurlyInString: release-it placeholder + // release-it placeholder releaseName: "v${version}", autoGenerate: false, // Ensure GitHub receives exactly the generated changelog body @@ -192,7 +195,9 @@ module.exports = () => { } if (isMajor) return {level: 0}; + if (isMinor) return {level: 1}; + if (isPatch) return {level: 2}; return null; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52166d1..b207041 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,9 @@ npm ci 3) Useful scripts - `npm run dev` — build in watch mode (tsup) - `npm run build` — production build (tsup) -- `npm run lint` / `npm run lint:fix` — check/fix with Biome -- `npm run format` — format with Biome +- `npm run lint` — check code, formatting, and filenames with ESLint; does not edit files +- `npm run fix` — apply available ESLint fixes and report remaining violations +- `npm run lint:staged` — fix/check staged files and automatically stage successful fixes (also run by pre-commit) - `npm run typecheck` — type-check with tsc - `npm test` / `npm run test:ci` — tests (Jest) - `npm run test:browser-match-patterns -- /absolute/path/to/browser` — real-browser match-pattern smoke (build first) @@ -95,7 +96,7 @@ The goal is to cover as much of the WebExtensions/Chrome API surface as possible How to add a new API wrapper: 1) Implementation -- Create `src/.ts`. +- Create `src/.ts`. - Wrap callback‑style APIs into `Promise` and call `checkLastError()` inside callbacks. - Events must return an unsubscribe function `() => void` (see `handleListener`/`safeListener`). - Use precise types from `@types/chrome` (avoid `Parameters<>` in the final documentation — show real argument types). @@ -106,7 +107,7 @@ How to add a new API wrapper: - Re-export from `src/index.ts`. 3) Documentation -- Create `docs/.md` following the template: “Documentation → Methods/Events (links to sections) → sections with real TypeScript signatures”. +- Create `docs/.md` following the template: “Documentation → Methods/Events (links to sections) → sections with real TypeScript signatures”. - Update the list in `README.md` (link to the new file and add a brief description where it helps). 4) Tests @@ -118,9 +119,55 @@ See the list of not-yet-covered APIs in the "Not yet covered" section of `README ## Code quality: lint, format, types -- Formatting/linting: [Biome](https://biomejs.dev/) — `npm run format`, `npm run lint`. +- Formatting/linting: [ESLint](https://eslint.org/) with TypeScript support and + [ESLint Stylistic](https://eslint.style/). The single configuration is `eslint.config.js`. +- `npm run fix` applies available fixes; `npm run lint` only checks and fails on errors or warnings. - Type checking: `npm run typecheck`. -- Husky + lint-staged run pre-commit checks (Biome and `jest --findRelatedTests`). +- Husky pre-commit runs `npm run lint:staged`, then `npm run test:related`. + `lint-staged` applies ESLint fixes to staged files and stages those fixes automatically. It temporarily hides + unstaged edits in partially staged files, then restores them without adding them to the commit. + Non-fixable lint errors (including filename errors) stop the commit; lint-staged restores the pre-lint state + on task failure. If tests fail after lint-staged succeeds, the formatting fixes remain staged for review. +- Pre-commit checks formatting only for staged files, so unrelated unstaged formatting does not block a commit. + Tests still run against the working tree. Use `npm run lint` for a full-project check. +- Husky pre-push runs lint, typecheck, full tests, and build without modifying source files. + +Formatting rules: + +- Four-space indentation, double quotes (except when escaping would be needed), semicolons, LF line endings, + no spaces inside object/import braces, and optional parentheses around a single untyped arrow parameter. +- Trailing commas in multiline arrays, objects, imports, exports, enums, tuples, and type parameters, but not function arguments. +- One blank line before `return` and before/after `if`, `for`, `while`, `do`, and `switch` statements. + No extra padding at block boundaries or between `if` and `else`. Consecutive single-line variable declarations stay together. +- One blank line before and after any statement or declaration spanning two or more lines, including variable + declarations, calls, assignments, functions, classes, and TypeScript types (`project/padding-around-multiline`). + Only neighboring statements are separated: no padding at file/block boundaries, between arguments, or between + object/type/class members. Import and re-export groups retain their existing sorting/grouping rules. +- At most one consecutive blank line; no trailing whitespace. Imports are sorted and separated from following code. +- Recommended JavaScript/TypeScript correctness checks. Explicit `any` is allowed; unused parameters, catch bindings, + and variables prefixed with `_` are allowed. Other unused bindings are reported, not silently deleted. +- JSON/JSONC: two-space indentation and expanded nonempty objects/arrays. JSON remains strict; JSONC permits comments. +- The former 120-column width is a readability guideline, not a failing `max-len` rule: ESLint does not automatically + wrap arbitrary long expressions like a dedicated formatter. + +Filename rules (`project/file-naming`): + +- A module defining and exporting a regular class must use the exact class name in PascalCase: `BrowserClient.ts`. + A module defining multiple exported classes must split them into separate matching files. Re-export barrels may + keep names such as `index.ts` or `sidebar.ts`. +- Exception classes extending `Error` (including native error subclasses and local inheritance chains) stay in their + owning module and do not determine its filename. For example, `SidebarError` stays in `sidebar.ts`. +- Other files use kebab-case, including documentation: `browser-detection.ts`, `browser-detection.md`. +- Tests use the subject's casing: `BrowserClient.test.ts` or `browser-detection.test.ts`. + Dot-separated suffixes such as `.integration.test`, `.spec`, `.config`, and `.d` stay lowercase. +- Standard project metadata names (`README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, + `LICENSE`, `LICENSE.md`, and `AGENTS.md`) are exempt. Names such as `package.json` and `tsconfig.json` already comply. +- The local naming rule also checks non-code filenames; it does not format Markdown/YAML or rename files. + Renames require updating imports and links. Generated output, dependencies, coverage, the lockfile, and local + environment/editor files are excluded. + +The configuration regression tests in `tests/tooling/` run with the regular Jest suite. Hook tests use temporary +Git clones to verify staging, partial staging, and rollback without modifying the current checkout's Git state. PRs with lint/type/build errors won’t be accepted. diff --git a/README.md b/README.md index 5bcc2cc..a1fb3bf 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ pnpm add @addon-core/browser - [action](docs/action.md) — MV2/MV3 compatible; under the hood uses `chrome.action` (MV3) or `chrome.browserAction` (MV2) automatically. - [alarms](docs/alarms.md) - [audio](docs/audio.md) -- [browsingData](docs/browsingData.md) +- [browsingData](docs/browsing-data.md) - [commands](docs/commands.md) -- [contextMenus](docs/contextMenus.md) +- [contextMenus](docs/context-menus.md) - [cookies](docs/cookies.md) -- [documentScan](docs/documentScan.md) +- [documentScan](docs/document-scan.md) - [downloads](docs/downloads.md) - [extension](docs/extension.md) - [history](docs/history.md) @@ -60,11 +60,11 @@ pnpm add @addon-core/browser - [scripting](docs/scripting.md) - [sidebar](docs/sidebar.md) — Unified helpers for Chrome Side Panel (MV3) and Firefox/Opera `sidebarAction`. - [storage](https://github.com/addon-stack/storage) — via separate package: [@addon-core/storage](https://www.npmjs.com/package/@addon-core/storage) -- [tabCapture](docs/tabCapture.md) +- [tabCapture](docs/tab-capture.md) - [tabs](docs/tabs.md) -- [userScripts](docs/userScripts.md) -- [webNavigation](docs/webNavigation.md) -- [webRequest](docs/webRequest.md) +- [userScripts](docs/user-scripts.md) +- [webNavigation](docs/web-navigation.md) +- [webRequest](docs/web-request.md) - [windows](docs/windows.md) ## Why this package @@ -131,7 +131,7 @@ const off = onContextMenusClicked(async (info, tab) => { ## Helpers -- [browserDetection](docs/browserDetection.md) — Best-effort browser detection with `BrowserName`, `BrowserFamily`, `guessBrowser()`, `isBrowser()`, and `isBrowserFamily()`. +- [browserDetection](docs/browser-detection.md) — Best-effort browser detection with `BrowserName`, `BrowserFamily`, `guessBrowser()`, `isBrowser()`, and `isBrowserFamily()`. ## Utilities diff --git a/biome.json b/biome.json deleted file mode 100644 index 53b2fa4..0000000 --- a/biome.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "assist": { - "actions": { - "source": { - "organizeImports": { - "options": { - "groups": [ - { - "type": false - } - ] - }, - "level": "on" - } - } - } - }, - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "includes": [ - "src/**/*.{ts,tsx,js,jsx}", - "**/*.{json,jsonc,md,mdx,cjs,mjs}", - "!dist/**/*", - "!addon/**/*" - ] - }, - "formatter": { - "indentStyle": "space", - "indentWidth": 4, - "lineWidth": 120, - "bracketSpacing": false, - "lineEnding": "lf" - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always", - "trailingCommas": "es5", - "arrowParentheses": "asNeeded" - } - }, - "json": { - "formatter": { - "indentWidth": 2, - "expand": "always" - }, - "linter": { - "enabled": false - } - }, - "css": { - "parser": { - "cssModules": true - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off" - } - } - } -} diff --git a/docs/browserDetection.md b/docs/browser-detection.md similarity index 100% rename from docs/browserDetection.md rename to docs/browser-detection.md diff --git a/docs/browsingData.md b/docs/browsing-data.md similarity index 100% rename from docs/browsingData.md rename to docs/browsing-data.md diff --git a/docs/contextMenus.md b/docs/context-menus.md similarity index 100% rename from docs/contextMenus.md rename to docs/context-menus.md diff --git a/docs/documentScan.md b/docs/document-scan.md similarity index 100% rename from docs/documentScan.md rename to docs/document-scan.md diff --git a/docs/tabCapture.md b/docs/tab-capture.md similarity index 100% rename from docs/tabCapture.md rename to docs/tab-capture.md diff --git a/docs/userScripts.md b/docs/user-scripts.md similarity index 100% rename from docs/userScripts.md rename to docs/user-scripts.md diff --git a/docs/webNavigation.md b/docs/web-navigation.md similarity index 99% rename from docs/webNavigation.md rename to docs/web-navigation.md index a4820cf..4bb7908 100644 --- a/docs/webNavigation.md +++ b/docs/web-navigation.md @@ -157,4 +157,4 @@ onWebNavigationTabReplaced( ): () => void ``` -Adds a listener that is called when a tab is replaced by another tab. \ No newline at end of file +Adds a listener that is called when a tab is replaced by another tab. diff --git a/docs/webRequest.md b/docs/web-request.md similarity index 100% rename from docs/webRequest.md rename to docs/web-request.md diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..eb29ff5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,153 @@ +import js from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; +import jsonc from "eslint-plugin-jsonc"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import project from "./scripts/eslint/file-naming.mjs"; + +const codeFiles = ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}"]; +const typeScriptFiles = ["**/*.{ts,cts,mts,tsx}"]; +const jsonFiles = ["**/*.{json,jsonc}"]; +const controlStatements = ["if", "for", "while", "do", "switch"]; + +export default tseslint.config( + { + ignores: [ + "**/node_modules/**", "**/.git/**", "**/dist/**", "**/coverage/**", "**/addon/**", + "**/package/**", "**/.cache/**", "**/.output/**", "**/.idea/**", "**/.vscode/**", + ".husky/_/**", "**/.DS_Store", "**/.env*", "**/.npmrc", "**/*.log", "**/*.tgz", + "**/*.tsbuildinfo", "package-lock.json", "docs/.vitepress/cache/**", + ], + }, + { + files: ["**/*.*", "**/!(*.*)"], + plugins: {project}, + linterOptions: {reportUnusedDisableDirectives: "error"}, + rules: { + "project/file-naming": ["error", { + exceptions: ["README.md", "CONTRIBUTING.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", "SECURITY.md", "LICENSE", "LICENSE.md", "AGENTS.md"], + }], + }, + }, + { + files: ["**/*.*", "**/!(*.*)"], + ignores: [...codeFiles, ...jsonFiles], + processor: "project/filename-only", + }, + { + files: codeFiles, + extends: [js.configs.recommended], + plugins: {"@stylistic": stylistic, "simple-import-sort": simpleImportSort}, + languageOptions: { + ecmaVersion: "latest", + parserOptions: {ecmaFeatures: {jsx: true}}, + }, + rules: { + "project/padding-around-multiline": "error", + // Explicit layout rules keep the existing style, without enabling an unrelated preset. + "@stylistic/array-bracket-spacing": ["error", "never"], + "@stylistic/arrow-parens": ["error", "as-needed"], + "@stylistic/arrow-spacing": "error", + "@stylistic/block-spacing": ["error", "never"], + "@stylistic/brace-style": ["error", "1tbs"], + "@stylistic/comma-dangle": ["error", { + arrays: "always-multiline", objects: "always-multiline", imports: "always-multiline", + exports: "always-multiline", functions: "never", enums: "always-multiline", generics: "always-multiline", tuples: "always-multiline", + }], + "@stylistic/comma-spacing": "error", + "@stylistic/comma-style": ["error", "last"], + "@stylistic/computed-property-spacing": ["error", "never"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/function-call-spacing": ["error", "never"], + "@stylistic/indent": ["error", 4, {SwitchCase: 1}], + "@stylistic/key-spacing": "error", + "@stylistic/keyword-spacing": "error", + "@stylistic/linebreak-style": ["error", "unix"], + "@stylistic/member-delimiter-style": "error", + "@stylistic/no-extra-semi": "error", + "@stylistic/no-floating-decimal": "error", + "@stylistic/no-mixed-spaces-and-tabs": "error", + "@stylistic/no-multi-spaces": "error", + "@stylistic/no-multiple-empty-lines": ["error", {max: 1, maxBOF: 0, maxEOF: 0}], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/object-curly-spacing": ["error", "never"], + "@stylistic/padded-blocks": ["error", "never"], + "@stylistic/padding-line-between-statements": ["error", + {blankLine: "always", prev: "*", next: "return"}, + {blankLine: "always", prev: "*", next: controlStatements}, + {blankLine: "always", prev: controlStatements, next: "*"}, + {blankLine: "always", prev: "import", next: "*"}, + {blankLine: "any", prev: "import", next: "import"}, + ], + "@stylistic/quote-props": ["error", "as-needed"], + "@stylistic/quotes": ["error", "double", {avoidEscape: true, allowTemplateLiterals: true}], + "@stylistic/rest-spread-spacing": ["error", "never"], + "@stylistic/semi": ["error", "always"], + "@stylistic/semi-spacing": "error", + "@stylistic/space-before-blocks": "error", + "@stylistic/space-before-function-paren": ["error", {anonymous: "always", named: "never", asyncArrow: "always"}], + "@stylistic/space-in-parens": ["error", "never"], + "@stylistic/space-infix-ops": "error", + "@stylistic/space-unary-ops": "error", + "@stylistic/template-curly-spacing": ["error", "never"], + "@stylistic/type-annotation-spacing": "error", + "@stylistic/type-generic-spacing": "error", + "@stylistic/type-named-tuple-spacing": "error", + "simple-import-sort/imports": ["error", {groups: [["^\\u0000", "^node:", "^@?\\w", "^", "^\\."]]}], + "simple-import-sort/exports": "error", + }, + }, + { + files: typeScriptFiles, + extends: [tseslint.configs.recommended], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", {argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", varsIgnorePattern: "^_"}], + }, + }, + { + files: ["src/**/*.{js,jsx,ts,tsx}"], + languageOptions: {globals: {...globals.browser, ...globals.webextensions}}, + }, + { + files: ["**/*.{cjs,mjs}", "*.config.{js,ts}", "scripts/**/*.{js,ts}", "tests/**/*.{js,ts}"], + languageOptions: {globals: globals.node}, + }, + { + files: ["**/*.{test,spec}.{js,cjs,mjs,ts,tsx}"], + languageOptions: {globals: {...globals.node, ...globals.jest}}, + }, + { + files: ["tests/browser-match-patterns/check.mjs"], + // This launcher serializes a function that executes inside an extension. + languageOptions: {globals: {chrome: "readonly"}}, + }, + ...jsonc.configs["flat/recommended-with-jsonc"], + { + files: jsonFiles, + plugins: {"@stylistic": stylistic}, + rules: { + "@stylistic/eol-last": ["error", "always"], + "@stylistic/linebreak-style": ["error", "unix"], + "@stylistic/no-multiple-empty-lines": ["error", {max: 1, maxBOF: 0, maxEOF: 0}], + "@stylistic/no-trailing-spaces": "error", + "jsonc/array-bracket-newline": ["error", {minItems: 1}], + "jsonc/array-bracket-spacing": ["error", "never"], + "jsonc/array-element-newline": ["error", "always"], + "jsonc/comma-dangle": ["error", "never"], + "jsonc/comma-style": ["error", "last"], + "jsonc/indent": ["error", 2], + "jsonc/key-spacing": "error", + "jsonc/object-curly-newline": ["error", {multiline: true, minProperties: 1}], + "jsonc/object-curly-spacing": ["error", "never"], + "jsonc/object-property-newline": "error", + "jsonc/quote-props": ["error", "always"], + "jsonc/quotes": ["error", "double"], + }, + }, + { + files: ["**/*.json"], + rules: {"jsonc/no-comments": "error"}, + } +); diff --git a/jest.config.js b/jest.config.js index 0141757..b9e6e03 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,15 +1,15 @@ export default { - preset: 'ts-jest/presets/default-esm', - testEnvironment: 'node', + preset: "ts-jest/presets/default-esm", + testEnvironment: "node", moduleNameMapper: { - '^(\\.\\.?/.*)\\.js$': '$1', + "^(\\.\\.?/.*)\\.js$": "$1", }, transform: { - '^.+\\.tsx?$': [ - 'ts-jest', + "^.+\\.tsx?$": [ + "ts-jest", { useESM: true, - tsconfig: 'tsconfig.json', + tsconfig: "tsconfig.json", }, ], }, diff --git a/package-lock.json b/package-lock.json index 2ab4b5f..fd16ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,24 @@ "@types/chrome": "^0.2.2" }, "devDependencies": { - "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", + "@eslint/js": "9.39.5", "@release-it/conventional-changelog": "^10.0.1", + "@stylistic/eslint-plugin": "4.4.1", "@types/jest": "^30.0.0", + "eslint": "9.39.5", + "eslint-plugin-jsonc": "2.21.1", + "eslint-plugin-simple-import-sort": "14.0.0", + "globals": "16.5.0", "husky": "^9.1.7", "jest": "^30.1.3", + "lint-staged": "15.5.2", "release-it": "^19.0.5", "ts-jest": "^29.4.6", "tsup": "^8.5.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "typescript-eslint": "8.48.1" } }, "node_modules/@babel/code-frame": { @@ -541,169 +548,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@biomejs/biome": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz", - "integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.15", - "@biomejs/cli-darwin-x64": "2.3.15", - "@biomejs/cli-linux-arm64": "2.3.15", - "@biomejs/cli-linux-arm64-musl": "2.3.15", - "@biomejs/cli-linux-x64": "2.3.15", - "@biomejs/cli-linux-x64-musl": "2.3.15", - "@biomejs/cli-win32-arm64": "2.3.15", - "@biomejs/cli-win32-x64": "2.3.15" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", - "integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz", - "integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz", - "integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz", - "integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz", - "integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz", - "integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz", - "integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz", - "integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, "node_modules/@commitlint/cli": { "version": "20.4.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.1.tgz", @@ -1463,184 +1307,479 @@ "node": ">=18" } }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependencies": { - "@types/node": ">=18" + "funding": { + "url": "https://opencollective.com/eslint" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@inquirer/core/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } }, - "node_modules/@inquirer/core/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=8" + "node": "*" } }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ansi-regex": "^5.0.1" + "@eslint/core": "^0.17.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -3027,9 +3166,42 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "node_modules/@stylistic/eslint-plugin": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", + "integrity": "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.32.1", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true, "license": "MIT" @@ -3166,6 +3338,13 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.2.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", @@ -3214,646 +3393,1338 @@ "dev": true, "license": "MIT" }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 4" + } }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/parser": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": ">=0.4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" }, "engines": { - "node": ">= 8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.0.1" + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", "dev": true, "license": "MIT", - "dependencies": { - "retry": "0.13.1" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" } }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "@types/babel__core": "^7.20.5" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "20 || >=22" } }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" + "brace-expansion": "^5.0.8" }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=10.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "Apache-2.0" + "license": "ISC" }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "node_modules/bundle-name": { + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", @@ -4071,7 +4942,49 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18.20" + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4203,6 +5116,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4511,6 +5431,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4603,6 +5530,16 @@ "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -4673,6 +5610,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4767,6 +5717,300 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.6.5.tgz", + "integrity": "sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-json-compat-utils": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.3.tgz", + "integrity": "sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esquery": "^1.6.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": "*", + "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@eslint/json": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsonc": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.21.1.tgz", + "integrity": "sha512-dbNR5iEnQeORwsK2WZzr3QaMtFCY3kKJVMRHPzUpKzMhmVy2zIpVgFDpX8MNoIdoqz6KCpCfOJavhfiSbZbN+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.1", + "diff-sequences": "^27.5.1", + "eslint-compat-utils": "^0.6.4", + "eslint-json-compat-utils": "^0.2.1", + "espree": "^9.6.1 || ^10.3.0", + "graphemer": "^1.4.0", + "jsonc-eslint-parser": "^2.4.0", + "natural-compare": "^1.4.0", + "synckit": "^0.6.2 || ^0.7.3 || ^0.11.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz", + "integrity": "sha512-NUJO0+XFCkk+o5EsAJruTgnfMEpeWrPWeJS15UVF60GgXmqz1BJ9/3hzlvG7lkL8Bubzos5cCLptThbFfPnSMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4781,6 +6025,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4814,6 +6084,13 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4911,6 +6188,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -4948,6 +6232,19 @@ "walk-up-path": "^4.0.0" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4987,6 +6284,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5189,15 +6507,41 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, "engines": { "node": ">=18" }, @@ -5212,6 +6556,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5342,6 +6693,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5487,6 +6848,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5497,947 +6868,1314 @@ "node": ">=8" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-ssh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", + "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", + "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "is-docker": "^3.0.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" }, "bin": { - "is-inside-container": "cli.js" + "jest": "bin/jest.js" }, "engines": { - "node": ">=14.16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, "engines": { - "node": ">=0.12.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, "engines": { - "node": ">=12" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/is-ssh": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", - "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", "dependencies": { - "protocols": "^2.0.1" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { - "is-inside-container": "^1.0.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { - "lodash.capitalize": "^4.2.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.uniqby": "^4.7.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^18.17 || >=20.6.1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest": { + "node_modules/jest-mock": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" - }, - "bin": { - "jest": "bin/jest.js" + "@types/node": "*", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" }, "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "jest-resolve": "*" }, "peerDependenciesMeta": { - "node-notifier": { + "jest-resolve": { "optional": true } } }, - "node_modules/jest-changed-files": { + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.1.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", "jest-util": "30.2.0", - "p-limit": "^3.1.0" + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus": { + "node_modules/jest-resolve-dependencies": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { + "@jest/console": "30.2.0", "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "source-map-support": "0.5.13" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-cli": { + "node_modules/jest-runtime": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", "@jest/types": "30.2.0", + "@types/node": "*", "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" + "slash": "^3.0.0", + "strip-bom": "^4.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/jest-config": { + "node_modules/jest-snapshot": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "expect": "30.2.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", "pretty-format": "30.2.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } } }, - "node_modules/jest-diff": { + "node_modules/jest-util": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "@types/node": "*", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-each": { + "node_modules/jest-validate": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.2.0", + "camelcase": "^6.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", + "leven": "^3.1.0", "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node": { + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", + "@jest/test-result": "30.2.0", "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.2.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "string-length": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-haste-map": { + "node_modules/jest-worker": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", + "@ungap/structured-clone": "^1.3.0", "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, - "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6" } }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "node_modules/jsonc-eslint-parser": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz", + "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "acorn": "^8.5.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "semver": "^7.3.5" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" } }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, "license": "MIT", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6" } }, - "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.8.0" } }, - "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", - "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" } }, - "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "node_modules/lint-staged/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.2.0" - }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=16.17.0" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" + "path-key": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "mimic-fn": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -6531,49 +8269,174 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/lodash.uniqby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", - "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lru-cache": { @@ -7028,6 +8891,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", @@ -7329,6 +9210,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -7407,6 +9301,16 @@ } } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -7479,6 +9383,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -7690,6 +9604,13 @@ "node": ">= 4" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -7855,6 +9776,49 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8006,6 +9970,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8428,6 +10402,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8608,6 +10595,19 @@ "dev": true, "license": "MIT" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8652,6 +10652,171 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", + "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.1", + "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -8763,6 +10928,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -8996,6 +11171,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -9145,6 +11330,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 5a6927b..b5de513 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,9 @@ "build": "tsup && node ./scripts/copy-api-types.mjs && node ./scripts/verify-build.mjs", "prepublishOnly": "npm run build", "dev": "tsup --watch", - "lint": "biome check .", - "fix": "biome check --write --unsafe .", + "lint": "eslint . --max-warnings 0", + "lint:staged": "lint-staged", + "fix": "eslint . --fix --max-warnings 0", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:ci": "npm test -- --ci --passWithNoTests --coverage", "test:browser-match-patterns": "node ./tests/browser-match-patterns/check.mjs", @@ -73,17 +74,27 @@ "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci" }, "devDependencies": { - "@biomejs/biome": "^2.2.4", "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", + "@eslint/js": "9.39.5", "@release-it/conventional-changelog": "^10.0.1", + "@stylistic/eslint-plugin": "4.4.1", "@types/jest": "^30.0.0", + "eslint": "9.39.5", + "eslint-plugin-jsonc": "2.21.1", + "eslint-plugin-simple-import-sort": "14.0.0", + "globals": "16.5.0", "husky": "^9.1.7", "jest": "^30.1.3", + "lint-staged": "15.5.2", "release-it": "^19.0.5", "ts-jest": "^29.4.6", "tsup": "^8.5.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "typescript-eslint": "8.48.1" + }, + "lint-staged": { + "*": "eslint --fix --max-warnings 0 --no-warn-ignored --" }, "overrides": { "test-exclude": { diff --git a/scripts/eslint/file-naming.mjs b/scripts/eslint/file-naming.mjs new file mode 100644 index 0000000..e0c4d6a --- /dev/null +++ b/scripts/eslint/file-naming.mjs @@ -0,0 +1,180 @@ +import path from "node:path"; +import paddingAroundMultiline from "./padding-around-multiline.mjs"; + +const kebabCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const pascalCase = /^[A-Z][a-zA-Z0-9]*$/; +const nativeErrors = new Set(["Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "URIError", "EvalError", "AggregateError"]); + +const memberName = node => node?.name ?? node?.value; + +const isExports = node => node?.type === "Identifier" && node.name === "exports" + || node?.type === "MemberExpression" && node.object.type === "Identifier" + && node.object.name === "module" && memberName(node.property) === "exports"; + +const classBinding = declaration => { + if (declaration?.type === "ClassDeclaration" || declaration?.type === "ClassExpression") { + return declaration.id?.name ?? null; + } + + return undefined; +}; + +const exportedClasses = program => { + const bindings = new Map(); + const exported = new Set(); + + for (const statement of program.body) { + const declaration = statement.declaration ?? statement; + + if (declaration.type === "ClassDeclaration" && declaration.id) { + bindings.set(declaration.id.name, {name: declaration.id.name, declaration}); + } + + if (declaration.type === "VariableDeclaration") { + for (const variable of declaration.declarations) { + if (variable.id.type === "Identifier" && variable.init?.type === "ClassExpression") { + bindings.set(variable.id.name, {name: variable.init.id?.name ?? variable.id.name, declaration: variable.init}); + } + } + } + } + + const isErrorBase = (base, seen = new Set()) => { + if (base?.type === "Identifier") { + if (seen.has(base.name)) { + return false; + } + + const binding = bindings.get(base.name); + + if (binding) { + return isErrorBase(binding.declaration.superClass, new Set([...seen, base.name])); + } + + return nativeErrors.has(base.name); + } + + return base?.type === "MemberExpression" && base.object.type === "Identifier" + && base.object.name === "globalThis" && nativeErrors.has(memberName(base.property)); + }; + + const add = node => { + if (node?.type === "Identifier" && bindings.has(node.name)) { + const binding = bindings.get(node.name); + + if (!isErrorBase(binding.declaration.superClass)) { + exported.add(binding.name); + } + } else if (classBinding(node) !== undefined && !isErrorBase(node.superClass)) { + exported.add(classBinding(node)); + } + }; + + for (const statement of program.body) { + if (statement.type === "ExportNamedDeclaration" && !statement.source) { + add(statement.declaration); + + for (const specifier of statement.specifiers) { + add(specifier.local); + } + + if (statement.declaration?.type === "VariableDeclaration") { + for (const variable of statement.declaration.declarations) { + add(variable.id); + } + } + } else if (statement.type === "ExportDefaultDeclaration") { + add(statement.declaration); + } else if (statement.type === "TSExportAssignment") { + add(statement.expression); + } else if (statement.type === "ExpressionStatement" && statement.expression.type === "AssignmentExpression") { + const {left, right} = statement.expression; + + if (isExports(left) || left.type === "MemberExpression" && isExports(left.object)) { + if (right.type === "ObjectExpression") { + for (const property of right.properties) { + add(property.value); + } + } else { + add(right); + } + } + } + } + + return [...exported]; +}; + +export default { + meta: {name: "browser-project-rules"}, + processors: { + // Non-code files only participate in filename checks, not JS formatting. + "filename-only": { + preprocess: () => [""], + postprocess: messages => messages.flat(), + }, + }, + rules: { + "padding-around-multiline": paddingAroundMultiline, + "file-naming": { + meta: { + type: "suggestion", + docs: {description: "Match exported class filenames and use kebab-case for other files."}, + schema: [{ + type: "object", + properties: {exceptions: {type: "array", items: {type: "string"}}}, + additionalProperties: false, + }], + messages: { + kebab: "Use kebab-case for ordinary files and their dot-separated suffixes: '{{name}}'.", + className: "A file exporting class '{{className}}' must be named '{{className}}.{{extension}}' (PascalCase).", + anonymous: "Name the exported class so its PascalCase name can match the filename.", + multiple: "Export classes from separate matching PascalCase files; this file defines: {{names}}.", + testName: "Test filenames must use kebab-case, or PascalCase for a class under test; keep suffixes such as .integration.test lowercase.", + }, + }, + create(context) { + return { + Program(program) { + const filename = context.physicalFilename; + + if (!filename || filename.startsWith("<")) { + return; + } + + const basename = path.basename(filename); + + if (context.options[0]?.exceptions?.includes(basename)) { + return; + } + + const parts = basename.replace(/^\./, "").split("."); + const extension = parts.length > 1 ? parts.pop() : ""; + const [stem, ...suffixes] = parts; + const classes = exportedClasses(program); + const report = (messageId, data) => context.report({node: program, messageId, data}); + + if (classes.includes(null)) { + report("anonymous"); + } else if (classes.length > 1) { + report("multiple", {names: classes.join(", ")}); + } else if (classes.length === 1) { + const [className] = classes; + const declarationSuffix = suffixes.length === 1 && suffixes[0] === "d"; + + if (stem !== className || !pascalCase.test(className) || (suffixes.length && !declarationSuffix)) { + report("className", {className, extension: declarationSuffix ? `d.${extension}` : extension}); + } + } else if (suffixes.includes("test") || suffixes.includes("spec")) { + if ((!kebabCase.test(stem) && !pascalCase.test(stem)) || suffixes.some(suffix => !kebabCase.test(suffix))) { + report("testName"); + } + } else if (parts.some(part => !kebabCase.test(part))) { + report("kebab", {name: basename}); + } + }, + }; + }, + }, + }, +}; diff --git a/scripts/eslint/padding-around-multiline.mjs b/scripts/eslint/padding-around-multiline.mjs new file mode 100644 index 0000000..0147f3e --- /dev/null +++ b/scripts/eslint/padding-around-multiline.mjs @@ -0,0 +1,72 @@ +const isMultiline = node => node.loc.start.line !== node.loc.end.line; +const isImport = node => node.type === "ImportDeclaration" || node.type === "TSImportEqualsDeclaration"; +const isReExport = node => (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") && node.source; + +export default { + meta: { + type: "layout", + docs: {description: "Separate multiline statements and declarations from adjacent code with a blank line."}, + fixable: "whitespace", + schema: [], + messages: {expectedBlankLine: "Add a blank line before and after a multiline statement or declaration."}, + }, + create(context) { + const sourceCode = context.sourceCode; + + const checkStatements = statements => { + for (let index = 1; index < statements.length; index++) { + const previous = statements[index - 1]; + const next = statements[index]; + + if (!isMultiline(previous) && !isMultiline(next)) { + continue; + } + + // Preserve import/re-export groups managed by simple-import-sort. + if (isImport(previous) && isImport(next) || isReExport(previous) && isReExport(next)) { + continue; + } + + const previousToken = sourceCode.getLastToken(previous); + const nextToken = sourceCode.getFirstToken(next); + const comments = sourceCode.getTokensBetween(previous, next, {includeComments: true}); + const tokens = [previousToken, ...comments, nextToken]; + + const hasBlankLine = tokens.some((token, tokenIndex) => tokenIndex > 0 + && token.loc.start.line > tokens[tokenIndex - 1].loc.end.line + 1); + + if (hasBlankLine) { + continue; + } + + // Keep trailing comments with the previous statement and leading comments with the next one. + let anchor = previousToken; + let following = nextToken; + + for (const comment of comments) { + if (comment.loc.start.line !== anchor.loc.end.line) { + following = comment; + + break; + } + + anchor = comment; + } + + context.report({ + node: next, + messageId: "expectedBlankLine", + fix: fixer => fixer.insertTextAfter(anchor, anchor.loc.end.line === following.loc.start.line ? "\n\n" : "\n"), + }); + } + }; + + return { + Program: node => checkStatements(node.body), + BlockStatement: node => checkStatements(node.body), + StaticBlock: node => checkStatements(node.body), + TSModuleBlock: node => checkStatements(node.body), + SwitchCase: node => checkStatements(node.consequent), + }; + }, +}; diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index c564168..98fa983 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -32,6 +32,7 @@ const getModuleExports = (file, compilerOptions) => { }; const sortNames = values => values.map(value => value.name).sort(); + const sourceExports = getModuleExports(sourceEntry, { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Node10, @@ -39,6 +40,7 @@ const sourceExports = getModuleExports(sourceEntry, { target: ts.ScriptTarget.ESNext, types: ["chrome"], }); + const declarationExports = getModuleExports(declarationEntry, { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Node10, @@ -48,14 +50,17 @@ const declarationExports = getModuleExports(declarationEntry, { }); assert.equal(sourceExports.length, 331, "The source public-export baseline changed; update the coverage matrix first"); + assert.equal( sourceExports.filter(value => value.hasValue).length, 328, "The source runtime-export baseline changed; update the coverage matrix first" ); + assert.deepEqual(sortNames(declarationExports), sortNames(sourceExports), "Source and declaration exports differ"); const expectedTypeOnly = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"]; + assert.deepEqual( sourceExports .filter(value => !value.hasValue) @@ -68,6 +73,7 @@ assert.deepEqual( const esm = await import(`${new URL("../dist/index.js", import.meta.url).href}?verify=${Date.now()}`); const require = createRequire(import.meta.url); const cjs = require(resolve(projectRoot, "dist/index.cjs")); + const sourceValueNames = sourceExports .filter(value => value.hasValue) .map(value => value.name) @@ -85,11 +91,13 @@ const cjsMap = JSON.parse(readFileSync(resolve(projectRoot, "dist/index.cjs.map" assert.doesNotMatch(sourceIndex, /(?:^|\/)testing(?:\/|")/m, "The production source entrypoint imports testing code"); assert.doesNotMatch(esmIndex, /createBrowserHarness/, "The production ESM bundle contains testing code"); assert.doesNotMatch(cjsIndex, /createBrowserHarness/, "The production CJS bundle contains testing code"); + assert.equal( esmMap.sources.some(source => source.includes("/testing/")), false, "The production ESM source map contains testing modules" ); + assert.equal( cjsMap.sources.some(source => source.includes("/testing/")), false, @@ -108,6 +116,7 @@ const testingDeclarations = readFileSync(resolve(projectRoot, "dist/testing/inde assert.match(testingDeclarations, /^\/\/\/ /); assert.match(testingDeclarations, /^\/\/\/ /m); + assert.equal( existsSync(resolve(projectRoot, "dist/testing/index.d.ts.map")), false, @@ -119,6 +128,7 @@ const listRuntimeSources = directory => const file = resolve(directory, entry.name); if (entry.isDirectory()) return listRuntimeSources(file); + if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) return []; return [{file, source: readFileSync(file, "utf8")}]; diff --git a/src/browserDetection.test.ts b/src/browser-detection.test.ts similarity index 99% rename from src/browserDetection.test.ts rename to src/browser-detection.test.ts index e645a6e..d53dfc5 100644 --- a/src/browserDetection.test.ts +++ b/src/browser-detection.test.ts @@ -6,7 +6,7 @@ import { guessBrowser, isBrowser, isBrowserFamily, -} from "./browserDetection"; +} from "./browser-detection"; import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "./testing"; describe("browser detection", () => { @@ -30,6 +30,7 @@ describe("browser detection", () => { vendor: "Mozilla", version: "126.0", }); + restoreGlobals = installBrowserGlobals(harness, {context: "none", profile: "firefox"}); await expect(guessBrowser()).resolves.toEqual({ @@ -40,6 +41,7 @@ describe("browser detection", () => { vendor: "Mozilla", version: "126.0", }); + expect(harness.runtime.getBrowserInfo.calls).toMatchObject([ {args: [], callback: undefined, invocation: "promise"}, ]); @@ -54,6 +56,7 @@ describe("browser detection", () => { ], }) ); + restoreGlobals = installBrowserGlobals(harness, { context: "none", globals: { @@ -77,11 +80,13 @@ describe("browser detection", () => { source: BrowserGuessSource.UserAgentData, version: "126.0.2592.87", }); + expect(getHighEntropyValues).toHaveBeenCalledWith(["fullVersionList"]); }); test("guesses Brave before generic Chromium brands", async () => { const isBrave = jest.fn(() => Promise.resolve(true)); + restoreGlobals = installBrowserGlobals(harness, { context: "none", globals: { @@ -98,6 +103,7 @@ describe("browser detection", () => { name: BrowserName.Brave, source: BrowserGuessSource.NavigatorBrave, }); + expect(isBrave).toHaveBeenCalledTimes(1); }); @@ -133,6 +139,7 @@ describe("browser detection", () => { name: BrowserName.Opera, source: BrowserGuessSource.BrowserGlobal, }); + expect(globalThis.opr).toBeDefined(); expect(globalThis.safari).toBeUndefined(); }); @@ -149,6 +156,7 @@ describe("browser detection", () => { name: BrowserName.Safari, source: BrowserGuessSource.BrowserGlobal, }); + expect(globalThis.safari).toBeDefined(); expect(globalThis.opr).toBeUndefined(); }); @@ -165,6 +173,7 @@ describe("browser detection", () => { name: BrowserName.Chromium, source: BrowserGuessSource.ExtensionUrl, }); + expect(harness.runtime.getURL.calls[0]?.args).toEqual([""]); expect("getBrowserInfo" in globalThis.chrome.runtime).toBe(false); }); diff --git a/src/browserDetection.ts b/src/browser-detection.ts similarity index 100% rename from src/browserDetection.ts rename to src/browser-detection.ts diff --git a/src/browsingData.ts b/src/browsing-data.ts similarity index 100% rename from src/browsingData.ts rename to src/browsing-data.ts diff --git a/src/contextMenus.ts b/src/context-menus.ts similarity index 100% rename from src/contextMenus.ts rename to src/context-menus.ts diff --git a/src/documentScan.ts b/src/document-scan.ts similarity index 100% rename from src/documentScan.ts rename to src/document-scan.ts diff --git a/src/env.test.ts b/src/env.test.ts index 1b7373f..223d426 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -71,6 +71,7 @@ describe("isBackground", () => { ...harness.chrome, runtime: {...harness.chrome.runtime, getManifest: "manifest"}, } as unknown as BrowserTestApi; + restoreGlobals = installGlobals({ browser: undefined, chrome: chromeApi, @@ -85,6 +86,7 @@ describe("isBackground", () => { harness.runtime.setManifest( createManifestFixture({background: {service_worker: "service-worker.js"}, manifest_version: 3}) ); + installProfile("serviceWorker"); expect(isBackground()).toBe(true); @@ -94,6 +96,7 @@ describe("isBackground", () => { harness.runtime.setManifest( createManifestFixture({background: {service_worker: "service-worker.js"}, manifest_version: 3}) ); + installProfile("extensionPage"); expect(isBackground()).toBe(false); @@ -103,6 +106,7 @@ describe("isBackground", () => { harness.runtime.setManifest( createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) ); + installProfile("backgroundPage"); expect(isBackground()).toBe(true); @@ -112,6 +116,7 @@ describe("isBackground", () => { harness.runtime.setManifest( createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) ); + installProfile("extensionPage"); expect(isBackground()).toBe(false); @@ -121,6 +126,7 @@ describe("isBackground", () => { harness.runtime.setManifest( createManifestFixture({background: {scripts: ["background.js"]}, manifest_version: 2}) ); + restoreGlobals = installBrowserGlobals(harness, { context: "extensionPage", globals: {location: {}}, diff --git a/src/env.ts b/src/env.ts index 1fe926f..1365b85 100644 --- a/src/env.ts +++ b/src/env.ts @@ -23,7 +23,7 @@ export const isBackground = (): boolean => { return false; } - //@ts-expect-error + // @ts-expect-error Chrome's manifest union does not expose legacy background scripts on MV3. if (manifest.manifest_version === 3 && !manifest.background.scripts) { return typeof window === "undefined"; } diff --git a/src/identity.test.ts b/src/identity.test.ts index d297ae3..1c4ada7 100644 --- a/src/identity.test.ts +++ b/src/identity.test.ts @@ -30,6 +30,7 @@ describe("identity", () => { extensionId: "chrome-extension-id", manifest: createManifestFixture({manifest_version: 3}), }); + restoreGlobals = installGlobals({ browser: undefined, chrome: harness.chrome, @@ -46,6 +47,7 @@ describe("identity", () => { const installChromeWithNavigator = (navigator: NavigatorTestValue): void => { restoreGlobals(); + restoreGlobals = installGlobals({ browser: undefined, chrome: harness.chrome, @@ -57,6 +59,7 @@ describe("identity", () => { const installFirefox = (): void => { restoreGlobals(); + restoreGlobals = installGlobals({ browser: harness.browser, chrome: harness.chrome, @@ -72,6 +75,7 @@ describe("identity", () => { ); expect(getIdentityRedirectUrl("oauth")).toBe("https://chrome-extension-id.chromiumapp.org/oauth"); + expect(harness.configurable.chrome.identity.getRedirectURL.calls).toMatchObject([ {args: ["oauth"], callback: undefined, invocation: "sync"}, ]); @@ -83,6 +87,7 @@ describe("identity", () => { const details = {interactive: true, url: "https://accounts.example/oauth"}; await expect(launchWebAuthFlow(details)).resolves.toBe(redirectUrl); + expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls).toMatchObject([ { args: [details], @@ -97,9 +102,11 @@ describe("identity", () => { installChromeWithNavigator({ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", }); + harness.configurable.chrome.identity.launchWebAuthFlow.setResult(redirectUrl); await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).resolves.toBe(redirectUrl); + expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls[0]).toMatchObject({ callback: expect.any(Function), invocation: "callback", @@ -110,15 +117,18 @@ describe("identity", () => { installFirefox(); const firefoxRedirect = "https://extension-id.extensions.allizom.org/oauth?code=123"; harness.configurable.browser.identity.launchWebAuthFlow.setResult(firefoxRedirect); + const details = { redirect_uri: "https://extension-id.extensions.allizom.org/oauth", url: "https://accounts.example/oauth", }; await expect(launchWebAuthFlow(details)).resolves.toBe(firefoxRedirect); + expect(harness.configurable.browser.identity.launchWebAuthFlow.calls).toMatchObject([ {args: [details], callback: undefined, callbackCalls: [], invocation: "promise"}, ]); + expect(harness.runtime.getBrowserInfo.calls).toHaveLength(1); expect(harness.configurable.chrome.identity.launchWebAuthFlow.calls).toHaveLength(0); }); @@ -129,15 +139,19 @@ describe("identity", () => { invocation: "promise-tolerant", name: "identity.launchWebAuthFlow", }); + method.setResult(redirectUrl); + const chromeApi = { ...harness.chrome, identity: {...harness.chrome.identity, launchWebAuthFlow: method.api}, } as BrowserTestApi; + restoreGlobals(); restoreGlobals = installGlobals({browser: undefined, chrome: chromeApi}); await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).resolves.toBe(redirectUrl); + expect(method.calls).toMatchObject([ { callback: expect.any(Function), @@ -153,6 +167,7 @@ describe("identity", () => { await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).rejects.toThrow( "Authorization flow failed" ); + expect(harness.runtime.lastError).toBeUndefined(); }); @@ -168,6 +183,7 @@ describe("identity", () => { grantedScopes: ["email", "profile"], token: "access-token", }); + expect(harness.configurable.chrome.identity.getAuthToken.calls).toMatchObject([ { args: [{interactive: true}], @@ -206,15 +222,18 @@ describe("identity", () => { test("should model the hybrid callback and thenable race", async () => { const callbackResult = {grantedScopes: ["email"], token: "callback-token"}; const promiseResult = {grantedScopes: ["profile"], token: "promise-token"}; + harness.configurable.chrome.identity.getAuthToken.setImplementation((( _details: chrome.identity.TokenDetails, callback: (result: chrome.identity.GetAuthTokenResult) => void ) => { callback(callbackResult); + return Promise.resolve(promiseResult); }) as unknown as typeof chrome.identity.getAuthToken); await expect(getAuthToken()).resolves.toBe(callbackResult); + expect(harness.configurable.chrome.identity.getAuthToken.calls).toMatchObject([ {callbackCalls: [[callbackResult]], invocation: "hybrid"}, ]); @@ -240,6 +259,7 @@ describe("identity", () => { harness.configurable.chrome.identity.getProfileUserInfo.setResult(profile); await expect(getProfileUserInfo({accountStatus: "ANY"})).resolves.toBe(profile); + expect(harness.configurable.chrome.identity.getProfileUserInfo.calls[0]?.args).toEqual([ {accountStatus: "ANY"}, ]); diff --git a/src/identity.ts b/src/identity.ts index cf98f17..31087d8 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -1,5 +1,5 @@ import {browser} from "./browser"; -import {BrowserGuessSource, BrowserName, guessBrowser, isBrowser} from "./browserDetection"; +import {BrowserGuessSource, BrowserName, guessBrowser, isBrowser} from "./browser-detection"; import {callWithPromise, checkLastError, handleListener} from "./utils"; type AccountInfo = chrome.identity.AccountInfo; @@ -26,6 +26,7 @@ export const getIdentityRedirectUrl = (path?: string): string => identity().getR export const launchWebAuthFlow = async (details: LaunchWebAuthFlowDetails): Promise => { const browserGuess = await guessBrowser(); + const isFirefoxRuntime = isBrowser(browserGuess, BrowserName.Firefox) && browserGuess.source === BrowserGuessSource.RuntimeBrowserInfo; diff --git a/src/index.ts b/src/index.ts index 83e5d99..2e8babe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,12 @@ export * from "./action"; export * from "./alarms"; export * from "./audio"; export * from "./browser"; -export * from "./browserDetection"; -export * from "./browsingData"; +export * from "./browser-detection"; +export * from "./browsing-data"; export * from "./commands"; -export * from "./contextMenus"; +export * from "./context-menus"; export * from "./cookies"; -export * from "./documentScan"; +export * from "./document-scan"; export * from "./downloads"; export * from "./env"; export * from "./extension"; @@ -22,9 +22,9 @@ export * from "./permissions"; export * from "./runtime"; export * from "./scripting"; export * from "./sidebar"; -export * from "./tabCapture"; +export * from "./tab-capture"; export * from "./tabs"; -export * from "./userScripts"; -export * from "./webNavigation"; -export * from "./webRequest"; +export * from "./user-scripts"; +export * from "./web-navigation"; +export * from "./web-request"; export * from "./windows"; diff --git a/src/offscreen.test.ts b/src/offscreen.test.ts index 855ef0b..a9e0b66 100644 --- a/src/offscreen.test.ts +++ b/src/offscreen.test.ts @@ -39,6 +39,7 @@ describe("offscreen", () => { test("should close the current offscreen document", async () => { await expect(closeOffscreen()).resolves.toBeUndefined(); + expect(harness.configurable.chrome.offscreen.closeDocument.calls).toMatchObject([ {args: [], callbackCalls: [[]], invocation: "callback"}, ]); @@ -52,6 +53,7 @@ describe("offscreen", () => { }; await expect(createOffscreen(parameters)).resolves.toBeUndefined(); + expect(harness.configurable.chrome.offscreen.createDocument.calls).toMatchObject([ {args: [parameters], callbackCalls: [[]], invocation: "callback"}, ]); @@ -72,12 +74,14 @@ describe("offscreen", () => { contextType: "OFFSCREEN_DOCUMENT", documentUrl: "chrome-extension://extension-id/offscreen.html", }); + harness.runtime.setContexts([ createExtensionContextFixture({contextId: "popup-id", contextType: "POPUP"}), offscreenContext, ]); await expect(getOffscreenContext()).resolves.toEqual(offscreenContext); + expect(harness.runtime.getContexts.calls).toMatchObject([ { args: [{contextTypes: ["OFFSCREEN_DOCUMENT"]}], diff --git a/src/runtime.ts b/src/runtime.ts index 3df8621..c6d8baf 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,6 @@ import {browser} from "./browser"; -import {callWithPromise, handleListener} from "./utils"; import type {FirefoxRuntime} from "./types"; +import {callWithPromise, handleListener} from "./utils"; type BrowserInfo = browser.runtime.BrowserInfo; diff --git a/src/sidebar.ts b/src/sidebar.ts index da08cc0..8691351 100644 --- a/src/sidebar.ts +++ b/src/sidebar.ts @@ -1,7 +1,7 @@ import {browser} from "./browser"; import {getContexts} from "./runtime"; -import {callWithPromise} from "./utils"; import type {FirefoxSidebarAction, OperaSidebarAction, SidebarAction} from "./types"; +import {callWithPromise} from "./utils"; type Color = string | ColorArray; type ColorArray = chrome.extensionTypes.ColorArray; @@ -244,6 +244,7 @@ export const setSidebarTitle = (title: string | number, tabId?: number): Promise if (result instanceof Promise) { await result; } + return cb(); } diff --git a/src/tabCapture.ts b/src/tab-capture.ts similarity index 100% rename from src/tabCapture.ts rename to src/tab-capture.ts diff --git a/src/testing/browser-state.ts b/src/testing/browser-state.ts index 0595bc7..a9d7ad3 100644 --- a/src/testing/browser-state.ts +++ b/src/testing/browser-state.ts @@ -23,11 +23,13 @@ export interface BrowserMemoryState { export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): BrowserMemoryState => { const initialWindows = (options.windows ?? []).map(window => cloneRecord(window)); + const nestedTabs = initialWindows.flatMap(window => typeof window.id === "number" ? (window.tabs ?? []).map(tab => cloneRecord({...tab, windowId: window.id as number})) : [] ); + const initialTabs = [...nestedTabs, ...(options.tabs ?? [])].map(tab => cloneRecord(tab)); let tabs = new Map(); let windows = new Map(); @@ -41,13 +43,17 @@ export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): Br for (const window of initialWindows) { if (typeof window.id !== "number") continue; + const copy = cloneRecord(window); delete copy.tabs; windows.set(window.id, copy); } + for (const tab of initialTabs) { if (typeof tab.id !== "number") continue; + tabs.set(tab.id, cloneRecord(tab)); + if (!windows.has(tab.windowId)) { windows.set(tab.windowId, createWindowFixture({focused: false, id: tab.windowId, tabs: undefined})); } @@ -73,6 +79,7 @@ export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): Br }, cloneWindow(window, populate = false) { const copy = cloneRecord(window); + if (populate && typeof copy.id === "number") { copy.tabs = [...tabs.values()] .filter(tab => tab.windowId === copy.id) @@ -81,6 +88,7 @@ export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): Br } else { delete copy.tabs; } + return copy; }, currentWindowId() { @@ -92,27 +100,33 @@ export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): Br }, ensureWindow(windowId) { const requestedId = windowId ?? state.currentWindowId(); + if (typeof requestedId === "number") { const existing = windows.get(requestedId); + if (existing) return existing; } const id = typeof windowId === "number" ? windowId : state.nextWindowId(); const window = createWindowFixture({focused: windows.size === 0, id, tabs: undefined}); windows.set(id, window); + if (window.focused) lastFocusedWindowId = id; + return window; }, nextTabId() { do { tabCounter += 1; } while (tabs.has(tabCounter)); + return tabCounter; }, nextWindowId() { do { windowCounter += 1; } while (windows.has(windowCounter)); + return windowCounter; }, reindexTabs(windowId) { @@ -130,5 +144,6 @@ export const createBrowserMemoryState = (options: BrowserMemoryStateOptions): Br }; reset(); + return state; }; diff --git a/src/testing/configurable.test.ts b/src/testing/configurable.test.ts index f78e77c..95d0a1b 100644 --- a/src/testing/configurable.test.ts +++ b/src/testing/configurable.test.ts @@ -11,6 +11,7 @@ describe("configurable browser namespaces", () => { if (entry.coverage === "configurable" && entry.kind === "method") { expect(configurable.method(entry.path)).toBeDefined(); } + if (entry.kind === "event") { expect(configurable.event(entry.path)).toBeDefined(); } @@ -28,6 +29,7 @@ describe("configurable browser namespaces", () => { }); expect(result).toEqual([item]); + expect(configurable.controls.downloads.search.calls).toMatchObject([ { args: [{id: 7}], @@ -35,11 +37,13 @@ describe("configurable browser namespaces", () => { invocation: "callback", }, ]); + expect(configurable.calls.map(call => call.api)).toEqual(["downloads.search"]); }); test("uses dual Promise behavior for browser facades", async () => { const configurable = createConfigurableNamespaces({facade: "browser"}); + const alarm: chrome.alarms.Alarm = { name: "deterministic-alarm", persistAcrossSessions: false, @@ -49,6 +53,7 @@ describe("configurable browser namespaces", () => { configurable.controls.alarms.getAll.setResult([alarm]); await expect(configurable.api.alarms.getAll()).resolves.toEqual([alarm]); + expect(configurable.controls.alarms.getAll.calls[0]).toMatchObject({ args: [], callback: undefined, @@ -62,6 +67,7 @@ describe("configurable browser namespaces", () => { let observed: chrome.runtime.LastError | undefined; configurable.controls.alarms.getAll.failNext(new Error("alarms unavailable")); + configurable.api.alarms.getAll(() => { observed = lastError.current; }); diff --git a/src/testing/configurable.ts b/src/testing/configurable.ts index 196604e..c770a7a 100644 --- a/src/testing/configurable.ts +++ b/src/testing/configurable.ts @@ -4,6 +4,7 @@ import {type BrowserMethod, type BrowserMethodLastErrorController, createBrowser import type {BrowserHarnessCall} from "./types"; type AnyFunction = (...args: never[]) => unknown; + type BrowserEventLike = { addListener: AnyFunction; removeListener: AnyFunction; @@ -19,19 +20,20 @@ type EventKeys = { }[keyof TApi]; type Last = TValues extends readonly [...infer _, infer TValue] ? TValue : never; + type CallbackArguments = [Last>] extends [never] ? never : Last> extends (...args: infer TArgs) => unknown - ? TArgs - : never; + ? TArgs + : never; type ResultFromCallback = TArgs extends readonly [] ? undefined : TArgs extends readonly [infer TResult] - ? TResult - : TArgs extends readonly [(infer TResult)?] - ? TResult | undefined - : TArgs; + ? TResult + : TArgs extends readonly [(infer TResult)?] + ? TResult | undefined + : TArgs; export type BrowserMethodResult = Awaited> extends void @@ -111,6 +113,7 @@ export type AlarmsConfigurableApi = Pick< typeof chrome.alarms, "clear" | "clearAll" | "create" | "get" | "getAll" | "onAlarm" >; + export type AudioConfigurableApi = Pick< typeof chrome.audio, | "getDevices" @@ -122,6 +125,7 @@ export type AudioConfigurableApi = Pick< | "setMute" | "setProperties" >; + export type BrowsingDataConfigurableApi = Pick< typeof chrome.browsingData, | "remove" @@ -140,15 +144,19 @@ export type BrowsingDataConfigurableApi = Pick< | "removeWebSQL" | "settings" >; + export type CommandsConfigurableApi = Pick; + export type ContextMenusConfigurableApi = Pick< typeof chrome.contextMenus, "create" | "onClicked" | "remove" | "removeAll" | "update" >; + export type CookiesConfigurableApi = Pick< typeof chrome.cookies, "get" | "getAll" | "getAllCookieStores" | "getPartitionKey" | "onChanged" | "remove" | "set" >; + export type DocumentScanConfigurableApi = Pick< typeof chrome.documentScan, | "cancelScan" @@ -161,6 +169,7 @@ export type DocumentScanConfigurableApi = Pick< | "setOptions" | "startScan" >; + export type DownloadsConfigurableApi = Pick< typeof chrome.downloads, | "acceptDanger" @@ -180,18 +189,22 @@ export type DownloadsConfigurableApi = Pick< | "show" | "showDefaultFolder" >; + export type ExtensionConfigurableApi = Pick< typeof chrome.extension, "getBackgroundPage" | "getViews" | "isAllowedFileSchemeAccess" | "isAllowedIncognitoAccess" | "setUpdateUrlData" >; + export type HistoryConfigurableApi = Pick< typeof chrome.history, "addUrl" | "deleteAll" | "deleteRange" | "deleteUrl" | "getVisits" | "onVisited" | "onVisitRemoved" | "search" >; + export type I18nConfigurableApi = Pick< typeof chrome.i18n, "detectLanguage" | "getAcceptLanguages" | "getMessage" | "getUILanguage" >; + export type IdentityConfigurableApi = Pick< typeof chrome.identity, | "clearAllCachedAuthTokens" @@ -203,10 +216,12 @@ export type IdentityConfigurableApi = Pick< | "onSignInChanged" | "removeCachedAuthToken" >; + export type IdleConfigurableApi = Pick< typeof chrome.idle, "getAutoLockDelay" | "onStateChanged" | "queryState" | "setDetectionInterval" >; + export type ManagementConfigurableApi = Pick< typeof chrome.management, | "createAppShortcut" @@ -226,6 +241,7 @@ export type ManagementConfigurableApi = Pick< | "uninstall" | "uninstallSelf" >; + export type NotificationsConfigurableApi = Pick< typeof chrome.notifications, | "clear" @@ -238,14 +254,17 @@ export type NotificationsConfigurableApi = Pick< | "onPermissionLevelChanged" | "update" >; + export type OffscreenConfigurableApi = Pick< typeof chrome.offscreen, "closeDocument" | "createDocument" | "hasDocument" >; + export type PermissionsConfigurableApi = Pick< typeof chrome.permissions, "addHostAccessRequest" | "onAdded" | "onRemoved" | "removeHostAccessRequest" >; + export type RuntimeConfigurableApi = Pick< typeof chrome.runtime, | "connect" @@ -271,15 +290,19 @@ export type RuntimeConfigurableApi = Pick< | "restartAfterDelay" | "setUninstallURL" >; + export type ScriptingConfigurableApi = Pick; + export type SidePanelConfigurableApi = Pick< typeof chrome.sidePanel, "close" | "getOptions" | "getPanelBehavior" | "open" | "setOptions" | "setPanelBehavior" >; + export type TabCaptureConfigurableApi = Pick< typeof chrome.tabCapture, "capture" | "getCapturedTabs" | "getMediaStreamId" | "onStatusChanged" >; + export type TabsConfigurableApi = Pick< typeof chrome.tabs, | "captureVisibleTab" @@ -313,6 +336,7 @@ export type TabsConfigurableApi = Pick< | "setZoomSettings" | "ungroup" >; + export type UserScriptsConfigurableApi = Pick< typeof chrome.userScripts, | "configureWorld" @@ -324,6 +348,7 @@ export type UserScriptsConfigurableApi = Pick< | "unregister" | "update" >; + export type WebNavigationConfigurableApi = Pick< typeof chrome.webNavigation, | "getAllFrames" @@ -338,6 +363,7 @@ export type WebNavigationConfigurableApi = Pick< | "onReferenceFragmentUpdated" | "onTabReplaced" >; + export type WebRequestConfigurableApi = Pick< typeof chrome.webRequest, | "handlerBehaviorChanged" @@ -351,14 +377,17 @@ export type WebRequestConfigurableApi = Pick< | "onResponseStarted" | "onSendHeaders" >; + export type WindowsEventsConfigurableApi = Pick< typeof chrome.windows, "onBoundsChanged" | "onCreated" | "onFocusChanged" | "onRemoved" >; + export type FirefoxSidebarActionConfigurableApi = Pick< typeof browser.sidebarAction, "close" | "getPanel" | "getTitle" | "isOpen" | "open" | "setIcon" | "setPanel" | "setTitle" | "toggle" >; + export type OperaSidebarActionConfigurableApi = Pick< typeof opr.sidebarAction, | "getBadgeBackgroundColor" @@ -619,8 +648,9 @@ export const createConfigurableNamespaces = (options: ConfigurableNamespacesOpti const callbackArgs = NO_RESULT_METHODS.has(entry.path) ? () => [] : MULTI_RESULT_METHODS.has(entry.path) - ? (result: unknown) => result as readonly unknown[] - : (result: unknown) => [result]; + ? (result: unknown) => result as readonly unknown[] + : (result: unknown) => [result]; + const method = createBrowserMethod<(...args: never[]) => unknown, unknown>({ callback: "last", callbackArgs, @@ -642,6 +672,7 @@ export const createConfigurableNamespaces = (options: ConfigurableNamespacesOpti namespaceControl(members, apiNamespaces[namespace]), ]) ) as unknown as ConfigurableBrowserControls; + const api = Object.fromEntries( Object.entries(apiNamespaces).filter( ([namespace]) => namespace !== "sidebarAction" && namespace !== "operaSidebarAction" @@ -659,8 +690,9 @@ export const createConfigurableNamespaces = (options: ConfigurableNamespacesOpti entry.namespace === "browser.sidebarAction" ? "sidebarAction" : entry.namespace === "opr.sidebarAction" - ? "operaSidebarAction" - : entry.namespace; + ? "operaSidebarAction" + : entry.namespace; + const namespaceApi = apiNamespaces[namespace]; const control = namespaceControls[namespace][entry.member] as {api: unknown}; @@ -685,7 +717,9 @@ export const createConfigurableNamespaces = (options: ConfigurableNamespacesOpti path: string ): BrowserEventHarness { const event = events.get(path); + if (!event) throw new Error(`Unknown configurable browser event "${path}".`); + return event as unknown as BrowserEventHarness; }, hasCapability(path): boolean { @@ -695,13 +729,17 @@ export const createConfigurableNamespaces = (options: ConfigurableNamespacesOpti path: string ): BrowserMethod { const method = methods.get(path); + if (!method) throw new Error(`Unknown configurable browser method "${path}".`); + return method as BrowserMethod; }, operaSidebarActionApi: apiNamespaces.operaSidebarAction as unknown as OperaSidebarActionConfigurableApi, reset(): void { for (const method of methods.values()) method.reset(); + for (const event of events.values()) event.reset(); + for (const entry of entries) setCapability(entry.path, true); }, setCapability, diff --git a/src/testing/coverage.test.ts b/src/testing/coverage.test.ts index 1dfe531..30b27a8 100644 --- a/src/testing/coverage.test.ts +++ b/src/testing/coverage.test.ts @@ -1,4 +1,5 @@ import ts from "typescript"; +import type {RawCapabilityEntry} from "./coverage"; import { EXPECTED_ROOT_RUNTIME_EXPORT_COUNT, EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT, @@ -7,7 +8,6 @@ import { TYPE_ONLY_ROOT_EXPORTS, } from "./coverage"; import {createBrowserHarness} from "./harness"; -import type {RawCapabilityEntry} from "./coverage"; import type {BrowserMethod} from "./method"; type Harness = ReturnType; @@ -53,7 +53,9 @@ const directEventNamespace = (harness: Harness, namespace: string): unknown => { const configurableNamespaces = (harness: Harness, namespace: string): readonly unknown[] => { if (namespace === "browser.sidebarAction") return [harness.sidebar.firefox]; + if (namespace === "opr.sidebarAction") return [harness.sidebar.opera]; + return [memberOf(harness.configurable.chrome, namespace), memberOf(harness.configurable.browser, namespace)]; }; @@ -63,6 +65,7 @@ const resolveRawCapability = (harness: Harness, entry: RawCapabilityEntry): read return [harness.chrome, harness.browser].map(facade => { const namespace = memberOf(facade, entry.namespace); const record = asRecord(namespace); + return record ? Object.getOwnPropertyDescriptor(record, entry.member) : undefined; }); } @@ -71,7 +74,9 @@ const resolveRawCapability = (harness: Harness, entry: RawCapabilityEntry): read entry.kind === "method" ? directMethodNamespace(harness, entry.namespace) : directEventNamespace(harness, entry.namespace); + const directControl = memberOf(directNamespace, entry.member); + if (directControl !== undefined) return [directControl]; return configurableNamespaces(harness, entry.namespace).map(namespace => memberOf(namespace, entry.member)); @@ -79,6 +84,7 @@ const resolveRawCapability = (harness: Harness, entry: RawCapabilityEntry): read const isBrowserMethodControl = (value: unknown): value is AnyBrowserMethod => { const record = asRecord(value); + return ( record !== undefined && typeof record.api === "function" && @@ -91,6 +97,7 @@ const isBrowserMethodControl = (value: unknown): value is AnyBrowserMethod => { const isBrowserEventControl = (value: unknown): boolean => { const record = asRecord(value); const api = asRecord(record?.api); + return ( record !== undefined && api !== undefined && @@ -104,12 +111,15 @@ const isBrowserEventControl = (value: unknown): boolean => { const isPropertyDescriptor = (value: unknown): boolean => { const record = asRecord(value); + return record !== undefined && ("value" in record || "get" in record); }; const isValidResolution = (entry: RawCapabilityEntry, control: unknown): boolean => { if (entry.kind === "method") return isBrowserMethodControl(control); + if (entry.kind === "event") return isBrowserEventControl(control); + return isPropertyDescriptor(control); }; @@ -120,12 +130,14 @@ const rootExports = () => { target: ts.ScriptTarget.ESNext, types: ["chrome"], }); + const checker = program.getTypeChecker(); const source = program.getSourceFile("src/index.ts"); if (!source) throw new Error("Unable to load src/index.ts for the public export coverage test"); const moduleSymbol = checker.getSymbolAtLocation(source); + if (!moduleSymbol) throw new Error("Unable to resolve the src/index.ts module symbol"); return {checker, exports: checker.getExportsOfModule(moduleSymbol)}; @@ -146,9 +158,11 @@ describe("testing coverage matrices", () => { test("keeps the three interfaces type-only and the other 328 exports runtime-visible", () => { const {checker, exports} = rootExports(); + const typeOnly = exports .filter(symbol => { const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return !(target.flags & ts.SymbolFlags.Value); }) .map(symbol => symbol.name) @@ -163,6 +177,7 @@ describe("testing coverage matrices", () => { expect(paths).toHaveLength(305); expect(new Set(paths).size).toBe(paths.length); + expect( RAW_CAPABILITY_COVERAGE.filter( entry => entry.kind === "method" && (!entry.browserInvocation || !entry.chromeInvocation) @@ -172,12 +187,14 @@ describe("testing coverage matrices", () => { test("resolves all raw capabilities to their actual harness controls", () => { const harness = createBrowserHarness(); + const resolutions = RAW_CAPABILITY_COVERAGE.map(entry => ({ controls: resolveRawCapability(harness, entry), entry, })); expect(resolutions).toHaveLength(305); + expect( resolutions .filter( @@ -190,10 +207,12 @@ describe("testing coverage matrices", () => { test("keeps stateful coverage equivalent to having a default implementation", () => { const harness = createBrowserHarness(); + const mismatches = RAW_CAPABILITY_COVERAGE.filter(entry => entry.kind === "method") .flatMap(entry => resolveRawCapability(harness, entry).map(control => ({control, entry}))) .filter(({control, entry}) => { if (!isBrowserMethodControl(control)) return true; + return (entry.coverage === "stateful") !== control.hasDefaultImplementation; }) .map(({control, entry}) => ({ diff --git a/src/testing/coverage.ts b/src/testing/coverage.ts index 2d2d0d9..f13c187 100644 --- a/src/testing/coverage.ts +++ b/src/testing/coverage.ts @@ -453,6 +453,7 @@ export const getPublicExportCoverage = (name: string): PublicExportCoverageEntry export type RawCapabilityKind = "method" | "event" | "property"; export type RawCapabilityCoverage = "stateful" | "configurable" | "event"; export type RawMethodInvocation = "sync" | "callback" | "promise" | "dual" | "promise-tolerant" | "hybrid"; + export type RawFailureChannel = | "none" | "sync-throw" diff --git a/src/testing/delays.test.ts b/src/testing/delays.test.ts index 0197d44..d05c07b 100644 --- a/src/testing/delays.test.ts +++ b/src/testing/delays.test.ts @@ -7,6 +7,7 @@ import {type BrowserTestApi, createBrowserHarness, installBrowserGlobals} from " const expectNativeTask = async (pending: Promise): Promise => { let settled = false; + const observed = pending.then(() => { settled = true; }); @@ -36,6 +37,7 @@ describe("download validation delay control", () => { enumerable: false, value: harness.delays.downloadValidation.api, }); + await waitForDownloadValidation(api, 100); } @@ -66,10 +68,12 @@ describe("download validation delay control", () => { harness.delays.downloadValidation.setResult(undefined); expect(facade.downloads).not.toBe(harness[name].downloads); + expect(Object.getOwnPropertyDescriptor(facade.downloads, getDownloadValidationDelayKey())).toMatchObject({ enumerable: false, value: harness.delays.downloadValidation.api, }); + await waitForDownloadValidation(facade.downloads, 100); expect(harness.delays.downloadValidation.calls[0]?.args).toEqual([100]); @@ -84,9 +88,11 @@ describe("download validation delay control", () => { const delay = harness.delays.downloadValidation; delay.setResult(undefined); await delay.api(100); + delay.setImplementation(async () => { throw new Error("Custom delay must be cleared"); }); + delay.queueResult(undefined); delay.failNext(new Error("Queued error must be cleared")); @@ -114,6 +120,7 @@ describe("download validation delay control", () => { const facade = ( profile === "firefox" || profile === "safari" ? globalThis.browser : globalThis.chrome ) as BrowserTestApi; + await waitForDownloadValidation(facade.downloads, 100); expect(harness.delays.downloadValidation.calls[0]?.args).toEqual([100]); expect(Object.getOwnPropertyDescriptor(globalThis, "setTimeout")).toEqual(timerDescriptor); @@ -131,9 +138,10 @@ describe("download validation delay control", () => { test("assimilates custom thenables from a valid hook", async () => { const calls: number[] = []; + const namespace = { [getDownloadValidationDelayKey()]: (milliseconds: number) => ({ - // biome-ignore lint/suspicious/noThenProperty: Exercises assimilation of a custom thenable. + // Exercises assimilation of a custom thenable. then(resolve: () => void) { calls.push(milliseconds); resolve(); @@ -163,6 +171,7 @@ describe("download validation delay control", () => { test("preserves a hook throw without scheduling a fallback wait", async () => { const error = new Error("Scheduler threw"); + const namespace = { [getDownloadValidationDelayKey()]: () => { throw error; diff --git a/src/testing/event.test.ts b/src/testing/event.test.ts index 31b9c86..8727e91 100644 --- a/src/testing/event.test.ts +++ b/src/testing/event.test.ts @@ -28,10 +28,12 @@ describe("createBrowserEvent", () => { event.api.addListener(value => { calls.push(`first:${value}`); + return new Promise(resolve => { release = resolve; }); }); + event.api.addListener(value => { calls.push(`second:${value}`); }); @@ -64,7 +66,7 @@ describe("createBrowserEvent", () => { const failure = new Error("thenable failed"); event.api.addListener(() => ({ - // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable. + // This intentionally models a non-Promise thenable. then(_resolve: (value: unknown) => void, reject: (reason: unknown) => void) { reject(failure); }, @@ -81,6 +83,7 @@ describe("createBrowserEvent", () => { event.api.addListener(() => { throw failure; }); + event.api.addListener(remaining); await expect(event.emit()).rejects.toBe(failure); @@ -95,6 +98,7 @@ describe("createBrowserEvent", () => { event.api.addListener(() => { throw first; }); + event.api.addListener(() => Promise.reject(second)); try { diff --git a/src/testing/event.ts b/src/testing/event.ts index 433baa5..ea4a820 100644 --- a/src/testing/event.ts +++ b/src/testing/event.ts @@ -77,7 +77,9 @@ export function createBrowserEvent< return Promise.reject(error); } }); + const outcomes = await Promise.allSettled(pending); + const errors = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === "rejected") .map(outcome => outcome.reason); diff --git a/src/testing/fixtures.test.ts b/src/testing/fixtures.test.ts index 9caf571..eaea265 100644 --- a/src/testing/fixtures.test.ts +++ b/src/testing/fixtures.test.ts @@ -37,6 +37,7 @@ describe("testing fixtures", () => { discarded: false, groupId: -1, }); + expect(first).not.toBe(second); expect(first.mutedInfo).not.toBe(mutedInfo); expect(first.mutedInfo).not.toBe(second.mutedInfo); @@ -68,6 +69,7 @@ describe("testing fixtures", () => { test("creates installed details with an install reason", () => { expect(createInstalledDetailsFixture()).toEqual({reason: "install"}); + expect(createInstalledDetailsFixture({reason: "update", previousVersion: "0.9.0"})).toEqual({ reason: "update", previousVersion: "0.9.0", @@ -83,6 +85,7 @@ describe("testing fixtures", () => { origin: "chrome-extension://test-extension-id", url: "chrome-extension://test-extension-id/background.html", }); + expect(sender.tab).not.toBe(tab); }); diff --git a/src/testing/globals.integration.test.ts b/src/testing/globals.integration.test.ts index ed4eb87..bc6f980 100644 --- a/src/testing/globals.integration.test.ts +++ b/src/testing/globals.integration.test.ts @@ -1,7 +1,7 @@ -import {BrowserGuessSource, BrowserName, guessBrowser} from "../browserDetection"; +import {BrowserGuessSource, BrowserName, guessBrowser} from "../browser-detection"; import {getI18nUILanguage} from "../i18n"; import {getId} from "../runtime"; -import {canOpenSidebar, getSidebarTitle, openSidebar, SidebarError, setSidebarTitle} from "../sidebar"; +import {canOpenSidebar, getSidebarTitle, openSidebar, setSidebarTitle, SidebarError} from "../sidebar"; import {onTabCreated} from "../tabs"; import {createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "./index"; @@ -51,19 +51,23 @@ describe("transactional browser globals", () => { test("distinguishes omitted globals from explicit undefined and restores profile markers", () => { const outerHarness = createBrowserHarness(); + const outerRestore = installGlobals({ browser: outerHarness.browser, chrome: outerHarness.chrome, opr: {}, safari: {marker: "original"}, }); + restorers.push(outerRestore); + const outerDescriptors = { browser: Reflect.getOwnPropertyDescriptor(globalThis, "browser"), chrome: Reflect.getOwnPropertyDescriptor(globalThis, "chrome"), opr: Reflect.getOwnPropertyDescriptor(globalThis, "opr"), safari: Reflect.getOwnPropertyDescriptor(globalThis, "safari"), }; + const profileHarness = createBrowserHarness(); const restoreProfile = installBrowserGlobals(profileHarness, {profile: "chrome"}); restorers.push(restoreProfile); @@ -100,6 +104,7 @@ describe("browser profiles and routing", () => { pathname: "/content/page.html", protocol: "https:", }); + expect(globalThis.window.location).toBe(globalThis.location); }); @@ -115,10 +120,12 @@ describe("browser profiles and routing", () => { restoreFirefox(); const browserWithoutRuntimeId = harness.createProfileFacade("browser", true); Reflect.deleteProperty(browserWithoutRuntimeId.runtime, "id"); + const restoreFallback = installBrowserGlobals(harness, { globals: {browser: browserWithoutRuntimeId, chrome: harness.chrome}, profile: "custom", }); + restorers.push(restoreFallback); expect(getI18nUILanguage()).toBe("chrome-language"); @@ -126,6 +133,7 @@ describe("browser profiles and routing", () => { test("throws a clear production error when neither namespace is available", () => { const harness = createBrowserHarness(); + restorers.push( installBrowserGlobals(harness, { globals: {browser: undefined, chrome: undefined}, @@ -196,17 +204,20 @@ describe("browser profiles and routing", () => { await expect(setSidebarTitle("Configured title", 4)).resolves.toBeUndefined(); await expect(getSidebarTitle(4)).resolves.toBe("Opera title"); + expect(operaHarness.sidebar.opera.setTitle.calls[0]).toMatchObject({ args: [{tabId: 4, title: "Configured title"}], callback: undefined, invocation: "sync", }); + expect(operaHarness.sidebar.opera.getTitle.calls[0]).toMatchObject({invocation: "callback"}); }); test("none flavor exposes the real SidebarError path", async () => { const harness = createBrowserHarness(); harness.sidebar.flavor = "none"; + restorers.push( installBrowserGlobals(harness, { globals: {browser: undefined, chrome: harness.chrome, opr: undefined, safari: undefined}, @@ -215,6 +226,7 @@ describe("browser profiles and routing", () => { ); await expect(openSidebar({windowId: 1})).rejects.toBeInstanceOf(SidebarError); + await expect(openSidebar({windowId: 1})).rejects.toThrow( "The sidebarAction.open API is not supported in this browser" ); @@ -231,16 +243,20 @@ describe("raw events and production listener error handling", () => { const tab = createTabFixture(); const rawFailure = new Error("raw listener failed"); + harness.tabs.events.onCreated.api.addListener(() => { throw rawFailure; }); + await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(rawFailure); harness.tabs.events.onCreated.reset(); const syncFailure = new Error("sync listener failed"); + const unsubscribeSync = onTabCreated(() => { throw syncFailure; }); + await expect(harness.tabs.events.onCreated.emit(tab)).resolves.toBeUndefined(); unsubscribeSync(); expect(harness.listenerErrors.entries).toEqual([{args: [], error: syncFailure, kind: "sync"}]); @@ -248,9 +264,11 @@ describe("raw events and production listener error handling", () => { harness.tabs.events.onCreated.reset(); harness.listenerErrors.reset(); const promiseFailure = new Error("promise listener failed"); + const unsubscribePromise = onTabCreated(async () => { throw promiseFailure; }); + await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(promiseFailure); unsubscribePromise(); expect(harness.listenerErrors.entries).toEqual([{args: [], error: promiseFailure, kind: "promise"}]); @@ -258,12 +276,14 @@ describe("raw events and production listener error handling", () => { harness.tabs.events.onCreated.reset(); harness.listenerErrors.reset(); const thenableFailure = new Error("custom thenable failed"); + const thenable = { - // biome-ignore lint/suspicious/noThenProperty: This intentionally models a non-Promise thenable. + // This intentionally models a non-Promise thenable. then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void { reject(thenableFailure); }, }; + const unsubscribeThenable = onTabCreated((() => thenable) as unknown as Parameters[0]); await expect(harness.tabs.events.onCreated.emit(tab)).rejects.toBe(thenableFailure); unsubscribeThenable(); diff --git a/src/testing/globals.ts b/src/testing/globals.ts index 3573db5..46c3879 100644 --- a/src/testing/globals.ts +++ b/src/testing/globals.ts @@ -39,6 +39,7 @@ const applyDescriptor = (target: object, key: PropertyKey, value: unknown): void if (!Reflect.deleteProperty(target, key)) { throw new Error(`Unable to remove global ${String(key)}`); } + return; } @@ -82,6 +83,7 @@ export const installGlobals = (values: TestGlobalValues): (() => void) => { key: "error", target: console, }); + applyDescriptor(console, "error", values.consoleError); } } catch (error) { @@ -93,6 +95,7 @@ export const installGlobals = (values: TestGlobalValues): (() => void) => { return (): void => { if (restored) return; + restored = true; restoreChanges(changes); }; @@ -111,19 +114,19 @@ export const createContextGlobals = (kind: ExtensionContextKind): ContextGlobals const location = kind === "contentScript" ? ({ - hash: "", - host: "example.test", - hostname: "example.test", - href: "https://example.test/content/page.html", - origin: "https://example.test", - pathname: "/content/page.html", - port: "", - protocol: "https:", - search: "", - } satisfies LocationTestValue) + hash: "", + host: "example.test", + hostname: "example.test", + href: "https://example.test/content/page.html", + origin: "https://example.test", + pathname: "/content/page.html", + port: "", + protocol: "https:", + search: "", + } satisfies LocationTestValue) : ({ - pathname: kind === "backgroundPage" ? "/_generated_background_page.html" : "/index.html", - } satisfies LocationTestValue); + pathname: kind === "backgroundPage" ? "/_generated_background_page.html" : "/index.html", + } satisfies LocationTestValue); return { location, @@ -221,6 +224,7 @@ export const installBrowserGlobals = ( harness.setActiveProfile(profile); harness.setProfileSidebarFlavor(sidebarDefaultForProfile(profile)); + if (profile !== "custom") { harness.setProfileCapability("runtime.getBrowserInfo", profile === "firefox"); } diff --git a/src/testing/harness.ts b/src/testing/harness.ts index 5e39ea3..194703d 100644 --- a/src/testing/harness.ts +++ b/src/testing/harness.ts @@ -7,12 +7,11 @@ import { import {type BrowserDelaysHarness, createBrowserDelaysHarness} from "./delays"; import {createLastErrorController} from "./internal"; import {createListenerErrorCapture, type ListenerErrorBuffer} from "./listener-errors"; +import type {BrowserMethodCall} from "./method"; import {createPermissionsHarness, type PermissionsHarness} from "./permissions"; import {createRuntimeHarness, type RuntimeHarness} from "./runtime"; import {createScriptingHarness, type ScriptingHarness} from "./scripting"; import {createTabsHarness, type TabsHarness} from "./tabs"; -import {createWindowsHarness, type WindowsHarness} from "./windows"; -import type {BrowserMethodCall} from "./method"; import type { BrowserHarnessCall, BrowserProfile, @@ -20,6 +19,7 @@ import type { OperaSidebarActionTestApi, SidebarFlavor, } from "./types"; +import {createWindowsHarness, type WindowsHarness} from "./windows"; export interface BrowserHarnessOptions { extensionId?: string; @@ -89,6 +89,7 @@ interface NamedMethodCalls { const methodCalls = ({namespace, source}: NamedMethodCalls): BrowserHarnessCall[] => Object.entries(source).flatMap(([member, control]) => { if (!control || typeof control !== "object" || !("calls" in control)) return []; + return (control as {calls: readonly BrowserMethodCall[]}).calls.map(call => ({ ...call, api: `${namespace}.${member}`, @@ -97,19 +98,24 @@ const methodCalls = ({namespace, source}: NamedMethodCalls): BrowserHarnessCall[ const cloneFacade = (api: BrowserTestApi): BrowserTestApi => { const copy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(api)) as BrowserTestApi; + for (const [namespace, value] of Object.entries(api)) { if (value && typeof value === "object") { const namespaceCopy = Object.defineProperties({}, Object.getOwnPropertyDescriptors(value)); Reflect.set(copy as object, namespace, namespaceCopy); } } + return copy; }; const sidebarDefaultForProfile = (profile: BrowserProfile): SidebarFlavor => { if (profile === "firefox") return "firefoxSidebarAction"; + if (profile === "opera") return "operaSidebarAction"; + if (profile === "chrome") return "sidePanel"; + return "none"; }; @@ -169,10 +175,12 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(chromeNamespace))) { ownedChrome[`${namespace}.${member}`] = descriptor; } + for (const [member, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(browserNamespace))) { ownedBrowser[`${namespace}.${member}`] = descriptor; } } + ownedBrowser["runtime.getBrowserInfo"] = Object.getOwnPropertyDescriptor( runtime.browserApi, "getBrowserInfo" @@ -196,22 +204,28 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows const setOwnedCapability = (path: string, enabled: boolean): boolean => { if (!(path in ownedChrome) && !(path in ownedBrowser)) return false; + const [namespace, member] = path.split("."); const chromeNamespace = (chrome as unknown as Record>)[namespace]; const browserNamespace = (browser as unknown as Record>)[namespace]; + if (enabled) { if (path in ownedChrome) Object.defineProperty(chromeNamespace, member, ownedChrome[path]); + if (path in ownedBrowser) Object.defineProperty(browserNamespace, member, ownedBrowser[path]); } else { Reflect.deleteProperty(chromeNamespace, member); Reflect.deleteProperty(browserNamespace, member); } + return true; }; const applyCapability = (path: string, enabled: boolean): void => { if (setOwnedCapability(path, enabled)) return; + let recognized = false; + for (const config of [configChrome, configBrowser]) { try { config.setCapability(path, enabled); @@ -220,18 +234,22 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows // The other facade or a stateful namespace may own this path. } } + if (!recognized) throw new Error(`Unknown browser capability "${path}"`); }; const capabilities: BrowserCapabilitiesHarness = { has(path): boolean { const [namespace, member] = path.split("."); + const chromeNamespace = (chrome as unknown as Record | undefined>)[ namespace ]; + const browserNamespace = (browser as unknown as Record | undefined>)[ namespace ]; + return ( Boolean(chromeNamespace && member in chromeNamespace) || Boolean(browserNamespace && member in browserNamespace) @@ -298,11 +316,14 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows }, createProfileFacade(facade, includeBrowserInfo) { const result = cloneFacade(facade === "chrome" ? chrome : browser); + if (!includeBrowserInfo) Reflect.deleteProperty(result.runtime, "getBrowserInfo"); + return result; }, getListenerErrorHandler(forward) { if (forward) listenerCapture.setForward(forward); + return listenerCapture.handler; }, getOperaSidebarAction() { @@ -314,8 +335,10 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows sequence = 0; state.reset(); runtime.reset(); + if (activeProfile === "firefox") runtime.setUrlScheme("moz-extension"); else if (activeProfile === "safari") runtime.setUrlScheme("safari-web-extension"); + permissions.reset(); tabs.reset(); windows.reset(); @@ -327,9 +350,11 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows listenerCapture.reset(); explicitCapabilities.clear(); mergeStateful(); + for (const path of new Set([...Object.keys(ownedChrome), ...Object.keys(ownedBrowser)])) { setOwnedCapability(path, true); } + applyCapability("runtime.getBrowserInfo", activeProfile === "firefox"); sidebarExplicit = false; sidebarFlavor = sidebarDefaultForProfile(activeProfile); @@ -337,6 +362,7 @@ export const createBrowserHarness = (options: BrowserHarnessOptions = {}): Brows }, setActiveProfile(profile) { activeProfile = profile; + if (profile === "firefox") runtime.setUrlScheme("moz-extension"); else if (profile === "safari") runtime.setUrlScheme("safari-web-extension"); else if (profile !== "custom") runtime.setUrlScheme("chrome-extension"); diff --git a/src/testing/index.ts b/src/testing/index.ts index ef1bb1b..caeb5e2 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1,3 +1,13 @@ +export type { + PublicExportCoverage, + PublicExportCoverageEntry, + PublicExportKind, + RawCapabilityCoverage, + RawCapabilityEntry, + RawCapabilityKind, + RawFailureChannel, + RawMethodInvocation, +} from "./coverage"; export { EXPECTED_ROOT_RUNTIME_EXPORT_COUNT, EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT, @@ -7,6 +17,13 @@ export { RAW_CAPABILITY_COVERAGE, TYPE_ONLY_ROOT_EXPORTS, } from "./coverage"; +export type {BrowserDelaysHarness} from "./delays"; +export type { + BrowserEventApi, + BrowserEventHarness, + BrowserEventListener, + BrowserEventRegistration, +} from "./event"; export {createBrowserEvent} from "./event"; export { createExtensionContextFixture, @@ -18,26 +35,6 @@ export { createTabFixture, createWindowFixture, } from "./fixtures"; -export {installBrowserGlobals, installGlobals} from "./globals"; -export {createBrowserHarness} from "./harness"; -export {createBrowserMethod} from "./method"; -export type { - PublicExportCoverage, - PublicExportCoverageEntry, - PublicExportKind, - RawCapabilityCoverage, - RawCapabilityEntry, - RawCapabilityKind, - RawFailureChannel, - RawMethodInvocation, -} from "./coverage"; -export type {BrowserDelaysHarness} from "./delays"; -export type { - BrowserEventApi, - BrowserEventHarness, - BrowserEventListener, - BrowserEventRegistration, -} from "./event"; export type { InstallBrowserGlobalsOptions, LocationTestValue, @@ -45,6 +42,7 @@ export type { TestGlobalValues, WindowTestValue, } from "./globals"; +export {installBrowserGlobals, installGlobals} from "./globals"; export type { BrowserCapabilitiesHarness, BrowserHarness, @@ -52,6 +50,7 @@ export type { ConfigurableHarness, SidebarHarness, } from "./harness"; +export {createBrowserHarness} from "./harness"; export type {ListenerErrorBuffer, ListenerErrorKind, ListenerErrorRecord} from "./listener-errors"; export type { BrowserMethod, @@ -62,6 +61,7 @@ export type { BrowserMethodObservedInvocation, BrowserMethodOptions, } from "./method"; +export {createBrowserMethod} from "./method"; export type { BrowserHarnessCall, BrowserProfile, diff --git a/src/testing/internal.ts b/src/testing/internal.ts index 254e6c7..32bf5fa 100644 --- a/src/testing/internal.ts +++ b/src/testing/internal.ts @@ -19,6 +19,7 @@ export const cloneRecord = (value: T): T => { const errorMessage = (error: unknown): string => { if (error instanceof Error) return error.message; + if (typeof error === "string") return error; return "Unknown browser API error"; diff --git a/src/testing/listener-errors.ts b/src/testing/listener-errors.ts index 466ba77..25aa687 100644 --- a/src/testing/listener-errors.ts +++ b/src/testing/listener-errors.ts @@ -26,12 +26,14 @@ export const createListenerErrorCapture = (forward?: (...args: unknown[]) => voi let original = forward ?? console.error.bind(console); const entries: ListenerErrorRecord[] = []; const raw: Array = []; + const handler = (...args: unknown[]): void => { const [prefix, error, ...details] = args; const kind = typeof prefix === "string" ? prefixes[prefix] : undefined; if (kind && args.length >= 2) { entries.push({args: details, error, kind}); + return; } diff --git a/src/testing/match-patterns.integration.test.ts b/src/testing/match-patterns.integration.test.ts index 3633411..998225b 100644 --- a/src/testing/match-patterns.integration.test.ts +++ b/src/testing/match-patterns.integration.test.ts @@ -12,6 +12,7 @@ import { } from "./index"; const restorers: Array<() => void> = []; + afterEach(() => { while (restorers.length) restorers.pop()?.(); }); @@ -20,6 +21,7 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp const install = (options: Parameters[0] = {}) => { const harness = createBrowserHarness(options); restorers.push(installBrowserGlobals(harness, {profile})); + return harness; }; @@ -33,6 +35,7 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp createTabFixture({id: 5, url: "https://example.com.evil.test/page"}), ], }); + const query: chrome.tabs.QueryInfo = { url: ["http://127.0.0.1/*", "https://*.example.com/*"], status: "complete", @@ -75,17 +78,22 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp test("requires every named permission and every requested origin", async () => { install({permissions: {permissions: ["tabs", "scripting"], origins: ["https://*.example.com/*"]}}); + await expect( containsPermissions({ permissions: ["tabs", "scripting"], origins: ["https://example.com/*", "https://shop.example.com/*"], }) ).resolves.toBe(true); + await expect(containsPermissions({permissions: ["tabs", "storage"]})).resolves.toBe(false); + await expect(containsPermissions({origins: ["https://example.com/*", "http://example.com/*"]})).resolves.toBe( false ); + await expect(containsPermissions({})).resolves.toBe(true); + // Validation must not be masked by an absent named permission or an earlier matching pattern. await expect(containsPermissions({permissions: ["storage"], origins: ["invalid"]})).rejects.toThrow( "permissions.contains" @@ -99,6 +107,7 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp optional_host_permissions: ["https://*.example.com/*"], }), }); + await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(false); await harness.permissions.grant({origins: ["https://*.example.com/*"]}); await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(true); @@ -118,10 +127,12 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp harness.permissions.set({origins: [""]}); await expect(containsPermissions({origins: ["http://other.example/*"]})).resolves.toBe(true); harness.reset(); + await expect(getAllPermissions()).resolves.toEqual({ origins: ["https://example.com/original"], permissions: [], }); + await expect(containsPermissions({origins: ["http://other.example/*"]})).resolves.toBe(false); await expect(containsPermissions({origins: ["https://example.com/new"]})).resolves.toBe(true); }); @@ -139,9 +150,11 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp await expect(containsPermissions({origins: ["https://example.com/*"]})).resolves.toBe(false); harness.permissions.contains.failNext(new Error("Permission lookup failed")); let observed: string | undefined; + harness.chrome.permissions.contains({}, () => { observed = harness.runtime.lastError?.message; }); + expect(observed).toBe("Permission lookup failed"); expect(harness.runtime.lastError).toBeUndefined(); harness.reset(); @@ -153,27 +166,34 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp tabs: [createTabFixture({url: "https://example.com/path"})], permissions: {origins: ["https://*.example.com/*"]}, }); + const api = profile === "chrome" ? harness.chrome : harness.browser; let callbackTabs: chrome.tabs.Tab[] = []; + expect( api.tabs.query({url: "https://*.example.com/*"}, tabs => { callbackTabs = tabs; }) ).toBeUndefined(); + expect(callbackTabs).toHaveLength(1); await expect(api.tabs.query({url: "https://*.example.com/*"})).resolves.toHaveLength(1); let callbackPermission: boolean | undefined; + expect( api.permissions.contains({origins: ["https://example.com/*"]}, result => { callbackPermission = result; }) ).toBeUndefined(); + expect(callbackPermission).toBe(true); await expect(api.permissions.contains({origins: ["https://example.com/*"]})).resolves.toBe(true); + for (const method of [api.permissions.contains, api.permissions.request, api.permissions.remove]) { expect(() => method({origins: ["bad"]}, () => undefined)).toThrow("Invalid match pattern"); await expect(method({origins: ["bad"]})).rejects.toThrow("Invalid match pattern"); } + expect(() => api.tabs.query({url: "bad"}, () => undefined)).toThrow("tabs.query"); expect(harness.runtime.lastError).toBeUndefined(); expect(harness.permissions.value.origins).toEqual(["https://*.example.com/*"]); @@ -184,41 +204,53 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp tabs: [createTabFixture({id: 1, active: false, url: "https://shop.example.com/page"})], permissions: {origins: ["https://*.example.com/*"]}, }); + let releaseCss: () => void = () => undefined; let cssStarted: () => void = () => undefined; + const started = new Promise(resolve => { cssStarted = resolve; }); + const cssCompletion = new Promise(resolve => { releaseCss = resolve; }); + harness.scripting.insertCSS.setImplementation( (_injection: chrome.scripting.CSSInjection, callback?: () => void) => { cssStarted(); + return cssCompletion.then(() => { callback?.(); }); } ); + harness.scripting.executeScript.setResult([]); // Representative application code; these are real package wrappers, not module mocks. const unsubscribe = onInstalled(async () => { if (!(await containsPermissions({origins: ["https://shop.example.com/*"]}))) return; + const tabs = await queryTabs({url: "https://*.example.com/*", status: "complete", discarded: false}); + for (const tab of tabs) { if (tab.id === undefined) continue; + const target = {tabId: tab.id}; await insertCss({target, files: ["content.css"]}); await executeScript({target, files: ["content.js"]}); } }); + const dispatch = harness.runtime.events.onInstalled.emit(createInstalledDetailsFixture()); + try { await started; expect(harness.scripting.executeScript.calls).toHaveLength(0); releaseCss(); await dispatch; + expect(harness.scripting.executeScript.calls.map(call => call.args)).toEqual([ [{target: {tabId: 1}, files: ["content.js"]}], ]); @@ -233,9 +265,11 @@ describe.each(["chrome", "firefox"] as const)("match patterns through real wrapp test("invalid grant batches and set are rejected without partial mutation", async () => { const harness = createBrowserHarness({permissions: {permissions: ["tabs"]}}); expect(() => harness.permissions.set({origins: ["https://ok.test/*", "bad"]})).toThrow("match pattern"); + await expect(harness.permissions.grant({permissions: ["scripting"], origins: ["bad"]})).rejects.toThrow( "match pattern" ); + expect(harness.permissions.value).toEqual({origins: [], permissions: ["tabs"]}); expect(() => createBrowserHarness({permissions: {origins: ["bad"]}})).toThrow("match pattern"); }); @@ -245,6 +279,7 @@ test("URL overrides/reset and origin state stay isolated between harnesses", asy tabs: [createTabFixture({id: 1, url: "https://example.com/page"})], permissions: {origins: [""]}, }); + const second = createBrowserHarness(); first.tabs.query.setResult([]); await expect(first.chrome.tabs.query({url: "https://example.com/*"})).resolves.toEqual([]); diff --git a/src/testing/match-patterns.ts b/src/testing/match-patterns.ts index 9416a3e..f4d50d6 100644 --- a/src/testing/match-patterns.ts +++ b/src/testing/match-patterns.ts @@ -30,55 +30,77 @@ export const parseMatchPattern = (pattern: string, api: string): MatchPattern => pathParts: ["/", ""], }; } + if (typeof pattern !== "string" || /[\s\\#]/u.test(pattern)) { throw patternError(pattern, api, "expected a string without whitespace, backslashes or a fragment"); } + const parts = /^([^:]+):\/\/([^/]*)(\/.*)$/.exec(pattern); + if (!parts) throw patternError(pattern, api, "expected :///"); + const [, scheme, authority, path] = parts; + if (scheme !== "*" && !supportedSchemes.includes(scheme as Scheme)) { throw patternError(pattern, api, "supported schemes are http, https, file and * (http/https)", true); } + // URL.pathname/search are serialized. Do not silently pretend to implement vendor-specific decoding rules. if (/[^\x21-\x7e]/u.test(path)) { throw patternError(pattern, api, "use percent-encoded non-ASCII path/query characters", true); } + const schemes: readonly Scheme[] = scheme === "*" ? ["http", "https"] : [scheme as Scheme]; const base = {schemes, path, pathParts: path.split("*")}; + if (scheme === "file") { if (authority) throw patternError(pattern, api, "only hostless file:/// patterns are supported", true); + return {...base, host: {kind: "any", name: ""}, port: "*"}; } const hostPort = /^(\[[^\]]+\]|[^:]+)(?::([^:]*))?$/.exec(authority); + if (!hostPort) throw patternError(pattern, api, "missing or malformed host/port"); + const [, hostText, portText = "*"] = hostPort; + if (portText !== "*" && (!/^\d+$/.test(portText) || Number(portText) > 65535)) { throw patternError(pattern, api, "port must be * or an integer from 0 to 65535"); } + if (portText !== "*" && portText !== String(Number(portText))) { throw patternError(pattern, api, "use a canonical decimal port without leading zeroes", true); } + if (scheme === "*" && portText !== "*") { throw patternError(pattern, api, "an explicit port requires an explicit http or https scheme", true); } + const port = portText === "*" ? "*" : String(Number(portText)); + if (hostText === "*") return {...base, host: {kind: "any", name: ""}, port}; const subdomains = hostText.startsWith("*."); const rawHost = subdomains ? hostText.slice(2) : hostText; + if (!rawHost || /[*@?#%]/.test(rawHost)) throw patternError(pattern, api, "invalid host or host wildcard"); + let name: string; + try { // URL provides case/IDNA/IP normalization; no hand-written general URL parser or Node-only dependency. name = stripTrailingDots(new URL(`http://${rawHost}/`).hostname); } catch { throw patternError(pattern, api, "invalid hostname"); } + if (!name) throw patternError(pattern, api, "empty hostname"); + if (subdomains && isIpAddress(name)) { throw patternError(pattern, api, "subdomain wildcards on IP addresses are not supported", true); } + return {...base, host: {kind: subdomains ? "subdomains" : "exact", name}, port}; }; @@ -90,26 +112,38 @@ const matchesHost = (host: Host, name: string): boolean => const matchesPath = (pattern: MatchPattern, value: string): boolean => { // Chromium also matches /foo/* against /foo, not only /foo/ and its descendants. if (pattern.path.endsWith("/*") && value === pattern.path.slice(0, -2)) return true; + const parts = pattern.pathParts; + if (parts.length === 1) return value === parts[0]; + if (!value.startsWith(parts[0])) return false; + let offset = parts[0].length; + // Literal segments avoid regex injection and backtracking over user-supplied patterns. for (const part of parts.slice(1, -1)) { const next = value.indexOf(part, offset); + if (next === -1) return false; + offset = next + part.length; } + const suffix = parts[parts.length - 1]; + return value.length - suffix.length >= offset && value.endsWith(suffix); }; export const matchesUrl = (pattern: MatchPattern, value: URL): boolean => { const scheme = value.protocol.slice(0, -1) as Scheme; + if (!pattern.schemes.includes(scheme)) return false; + const port = value.port || (scheme === "https" ? "443" : scheme === "http" ? "80" : ""); // URL.search is empty for both no query and a bare '?'; the serialized URL preserves the distinction. const query = value.search || (value.href.split("#", 1)[0].endsWith("?") ? "?" : ""); + return ( matchesHost(pattern.host, stripTrailingDots(value.hostname)) && (pattern.port === "*" || pattern.port === port) && @@ -120,13 +154,16 @@ export const matchesUrl = (pattern: MatchPattern, value: URL): boolean => { export const createUrlMatcher = (patterns: readonly string[], api: string): ((url: string) => boolean) => { // Compile/validate every alternative, even with zero tabs or an earlier alternative. const parsed = patterns.map(pattern => parseMatchPattern(pattern, api)); + return (value: string): boolean => { let url: URL; + try { url = new URL(value); } catch { return false; } + return parsed.some(pattern => matchesUrl(pattern, url)); }; }; @@ -134,10 +171,15 @@ export const createUrlMatcher = (patterns: readonly string[], api: string): ((ur export const coversOrigin = (granted: MatchPattern, requested: MatchPattern): boolean => { // This is set containment, not matching a representative URL. Host permission paths are ignored. if (!requested.schemes.every(scheme => granted.schemes.includes(scheme))) return false; + if (granted.port !== "*" && granted.port !== requested.port) return false; + if (granted.host.kind === "any") return true; + if (requested.host.kind === "any") return false; + if (requested.host.kind === "subdomains" && granted.host.kind !== "subdomains") return false; + return matchesHost(granted.host, requested.host.name); }; diff --git a/src/testing/method.test.ts b/src/testing/method.test.ts index ad26bf5..736001e 100644 --- a/src/testing/method.test.ts +++ b/src/testing/method.test.ts @@ -4,10 +4,12 @@ import {createBrowserMethod} from "./method"; type SyncApi = (value: string) => number; type CallbackApi = (value: string, callback: (result: number) => void) => void; type PromiseApi = (value: string) => Promise; + type DualApi = { (value: string): Promise; (value: string, callback: (result: number) => void): void; }; + type PromiseTolerantApi = (value: string, callback?: (result: number) => void) => Promise; type HybridApi = (value: string, callback: (result: number) => void) => Promise | undefined; @@ -18,6 +20,7 @@ describe("createBrowserMethod", () => { method.setResult(3); expect(method.api("input")).toBe(3); + expect(method.calls).toEqual([ { sequence: 1, @@ -27,6 +30,7 @@ describe("createBrowserMethod", () => { callbackCalls: [], }, ]); + expect(Object.isFrozen(method.calls)).toBe(true); expect(Object.isFrozen(method.calls[0].args)).toBe(true); }); @@ -38,6 +42,7 @@ describe("createBrowserMethod", () => { expect(() => syncMethod.api("input")).toThrow( 'Browser method "runtime.sync" was called without a configured result or implementation.' ); + await expect(promiseMethod.api("input")).rejects.toThrow( 'Browser method "tabs.query" was called without a configured result or implementation.' ); @@ -59,6 +64,7 @@ describe("createBrowserMethod", () => { test("turns synchronous implementation errors into Promise rejections", async () => { const failure = new Error("implementation failed"); + const method = createBrowserMethod({ name: "tabs.get", invocation: "promise", @@ -75,12 +81,14 @@ describe("createBrowserMethod", () => { name: "tabs.callback", invocation: "callback", }); + const callback = jest.fn(); method.setResult(5); expect(method.api("input", callback)).toBeUndefined(); expect(callback).toHaveBeenCalledWith(5); + expect(method.calls[0]).toMatchObject({ args: ["input"], callback, @@ -91,11 +99,13 @@ describe("createBrowserMethod", () => { test("supports callback argument mapping for void and multiple result callbacks", () => { type MultiCallbackApi = (callback: (left: string, right: number) => void) => void; + const method = createBrowserMethod({ name: "runtime.multi", invocation: "callback", callbackArgs: value => value, }); + const callback = jest.fn(); method.setResult(["value", 7]); @@ -121,6 +131,7 @@ describe("createBrowserMethod", () => { name: "runtime.promiseTolerant", invocation: "promise-tolerant", }); + const callback = jest.fn(); method.setResult(9); @@ -139,6 +150,7 @@ describe("createBrowserMethod", () => { await expect(unsafeApi("invalid", jest.fn())).rejects.toThrow( 'Browser method "tabs.promise" is promise-only and does not accept a callback argument.' ); + await expect(method.api("valid")).resolves.toBe(10); }); @@ -152,6 +164,7 @@ describe("createBrowserMethod", () => { expect(() => unsafeApi("invalid")).toThrow( 'Browser method "tabs.callback" requires a callback as its final argument.' ); + method.api("valid", callback); expect(callback).toHaveBeenCalledWith(11); }); @@ -163,6 +176,7 @@ describe("createBrowserMethod", () => { method.setImplementation((_value, implementationCallback) => { implementationCallback(12); + return returned; }); @@ -174,9 +188,11 @@ describe("createBrowserMethod", () => { test("exposes lastError only while a failed callback runs", () => { let lastError: unknown; + const controller = { runWithLastError(error: unknown, callback: () => T): T { lastError = error; + try { return callback(); } finally { @@ -184,11 +200,13 @@ describe("createBrowserMethod", () => { } }, }; + const method = createBrowserMethod({ name: "tabs.get", invocation: "callback", lastError: controller, }); + const failure = new Error("missing tab"); const observed: unknown[] = []; @@ -211,6 +229,7 @@ describe("createBrowserMethod", () => { test("keeps a default implementation across reset while clearing user configuration", () => { const defaultImplementation: SyncApi = value => value.length; + const method = createBrowserMethod({ name: "runtime.default", invocation: "sync", @@ -244,6 +263,7 @@ describe("createBrowserMethod", () => { test("supports a shared sequence source", () => { let sequence = 40; + const method = createBrowserMethod({ name: "runtime.sharedSequence", invocation: "sync", diff --git a/src/testing/method.ts b/src/testing/method.ts index ac69d38..1a4e439 100644 --- a/src/testing/method.ts +++ b/src/testing/method.ts @@ -110,10 +110,13 @@ export function createBrowserMethod const invoke = (...rawArgs: unknown[]): unknown => { const possibleCallback = recognizesCallback ? rawArgs.at(-1) : undefined; + const callback = typeof possibleCallback === "function" ? (possibleCallback as BrowserMethodCallback) : undefined; + const args = callback ? rawArgs.slice(0, -1) : [...rawArgs]; const invocation = observedInvocation(options.invocation, callback !== undefined); + const call: MutableBrowserMethodCall = { sequence: options.nextSequence?.() ?? ++sequence, args, @@ -121,13 +124,15 @@ export function createBrowserMethod invocation, callbackCalls: [], }; + calls.push(call); const trackedCallback: BrowserMethodCallback | undefined = callback ? (...callbackArgs) => { - call.callbackCalls.push([...callbackArgs]); - return callback(...callbackArgs); - } + call.callbackCalls.push([...callbackArgs]); + + return callback(...callbackArgs); + } : undefined; if (options.invocation === "callback" && !callback) { @@ -144,6 +149,7 @@ export function createBrowserMethod options.invocation === "callback" || (options.invocation === "dual" && callback !== undefined) || (options.invocation === "hybrid" && callback !== undefined); + const isPromiseInvocation = options.invocation === "promise" || options.invocation === "promise-tolerant" || @@ -175,9 +181,11 @@ export function createBrowserMethod } const hasQueuedResult = queuedResults.length > 0; + const configuredResult = hasQueuedResult ? ({configured: true, value: queuedResults.shift() as TResult} satisfies ConfiguredResult) : result; + const activeImplementation = implementation ?? defaultImplementation; if (configuredResult.configured && (hasQueuedResult || !implementation)) { @@ -185,7 +193,9 @@ export function createBrowserMethod const callbackArgs = options.callbackArgs?.(configuredResult.value) ?? (typeof configuredResult.value === "undefined" ? [] : [configuredResult.value]); + trackedCallback(...callbackArgs); + return undefined; } diff --git a/src/testing/permissions.ts b/src/testing/permissions.ts index caf7003..2232d2e 100644 --- a/src/testing/permissions.ts +++ b/src/testing/permissions.ts @@ -47,6 +47,7 @@ export const createPermissionsHarness = ( name: "permissions.addHostAccessRequest", nextSequence, }); + const removeHostAccessRequest = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -55,15 +56,19 @@ export const createPermissionsHarness = ( name: "permissions.removeHostAccessRequest", nextSequence, }); + const contains = createBrowserMethod({ callback: "last", implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { const requested = parseOrigins(value.origins, "permissions.contains"); const granted = parseOrigins([...origins], "permissions.contains"); + const result = includesAll(permissions, value.permissions) && requested.every(origin => granted.some(grant => coversOrigin(grant, origin))); + callback?.(result); + return result; }) as unknown as typeof chrome.permissions.contains, invocation: "dual", @@ -71,11 +76,13 @@ export const createPermissionsHarness = ( name: "permissions.contains", nextSequence, }); + const getAll = createBrowserMethod({ callback: "last", implementation: ((callback?: (result: chrome.permissions.Permissions) => void) => { const result = {origins: [...origins], permissions: [...permissions]}; callback?.(result); + return result; }) as unknown as typeof chrome.permissions.getAll, invocation: "dual", @@ -90,24 +97,31 @@ export const createPermissionsHarness = ( ): Promise => { const changedPermissions: chrome.runtime.ManifestPermission[] = []; const changedOrigins: string[] = []; + const mutate = (set: Set, item: T): boolean => { if (action === "revoke") return set.delete(item); + if (set.has(item)) return false; + set.add(item); + return true; }; for (const permission of value.permissions ?? []) { if (mutate(permissions, permission)) changedPermissions.push(permission); } + for (const origin of value.origins ?? []) { if (mutate(origins, origin)) changedOrigins.push(origin); } const changed = {origins: changedOrigins, permissions: changedPermissions}; + if (changedPermissions.length || changedOrigins.length) { await (action === "grant" ? onAdded : onRemoved).emit(changed); } + return changed; }; @@ -116,8 +130,10 @@ export const createPermissionsHarness = ( implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { // Validate synchronously before mutation, including when the caller supplied a callback. parseOrigins(value.origins, "permissions.request"); + return apply(value, "grant").then(() => { callback?.(true); + return true; }); }) as unknown as typeof chrome.permissions.request, @@ -126,13 +142,16 @@ export const createPermissionsHarness = ( name: "permissions.request", nextSequence, }); + const remove = createBrowserMethod({ callback: "last", implementation: ((value: chrome.permissions.Permissions, callback?: (result: boolean) => void) => { parseOrigins(value.origins, "permissions.remove"); + return apply(value, "revoke").then(changed => { const result = Boolean(changed.permissions?.length || changed.origins?.length); callback?.(result); + return result; }); }) as unknown as typeof chrome.permissions.remove, @@ -179,9 +198,11 @@ export const createPermissionsHarness = ( reset(): void { permissions = new Set(initial.permissions ?? []); origins = new Set(initial.origins ?? []); + methods.forEach(method => { method.reset(); }); + onAdded.reset(); onRemoved.reset(); }, diff --git a/src/testing/production.integration.test.ts b/src/testing/production.integration.test.ts index c412874..722b296 100644 --- a/src/testing/production.integration.test.ts +++ b/src/testing/production.integration.test.ts @@ -1,6 +1,6 @@ import {BlockDownloadError, download} from "../downloads"; import {findTabById, getTab, getTabUrl} from "../tabs"; -import {getUserScripts} from "../userScripts"; +import {getUserScripts} from "../user-scripts"; import {createBrowserHarness, createTabFixture, installBrowserGlobals, installGlobals} from "./index"; const restorers: Array<() => void> = []; @@ -13,12 +13,15 @@ describe("current production behavior through the browser harness", () => { test("uses the callback-schema getScripts method without weakening promise-only methods", async () => { const harness = createBrowserHarness(); restorers.push(installGlobals({browser: undefined, chrome: harness.chrome})); + const scripts: chrome.userScripts.RegisteredUserScript[] = [ {id: "configured-script", js: [{file: "content.js"}], matches: ["https://example.test/*"]}, ]; + harness.configurable.chrome.userScripts.getScripts.setResult(scripts); await expect(getUserScripts(["configured-script"])).resolves.toEqual(scripts); + expect(harness.configurable.chrome.userScripts.getScripts.calls[0]).toMatchObject({ args: [{ids: ["configured-script"]}], callback: expect.any(Function), @@ -31,6 +34,7 @@ describe("current production behavior through the browser harness", () => { beforeEach(() => { harness = createBrowserHarness(); + restorers.push( installGlobals({ browser: facade === "browser" ? harness.browser : undefined, @@ -74,6 +78,7 @@ describe("current production behavior through the browser harness", () => { describe.each(["chrome", "firefox"] as const)("download with a controlled delay in %s", profile => { let harness: ReturnType; const url = "https://download.example/file.zip"; + const createDownloadItemFixture = ( overrides: Partial = {} ): chrome.downloads.DownloadItem => ({ @@ -110,9 +115,11 @@ describe("current production behavior through the browser harness", () => { expect(harness.delays.downloadValidation.calls).toMatchObject([ {args: [100], callback: undefined, invocation: "promise"}, ]); + expect(harness.configurable.active.downloads.download.calls[0]?.args).toEqual([ {conflictAction: "uniquify", url}, ]); + expect(harness.calls.map(call => call.api)).toEqual([ "downloads.download", "delays.downloadValidation", @@ -123,18 +130,23 @@ describe("current production behavior through the browser harness", () => { test("does not search until the test releases the delay", async () => { let release: () => void = () => undefined; let signalStarted: () => void = () => undefined; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { signalStarted = resolve; }); + harness.delays.downloadValidation.setImplementation(() => { signalStarted(); + return gate; }); const pending = download({url}); + try { await started; expect(harness.configurable.active.downloads.search.calls).toHaveLength(0); @@ -182,6 +194,7 @@ describe("current production behavior through the browser harness", () => { harness.configurable.active.downloads.search.setResult([ createDownloadItemFixture({error: "NETWORK_FAILED", state: "interrupted"}), ]); + const pending = download({url}); await expect(pending).rejects.toHaveProperty("message", "Download error: NETWORK_FAILED"); diff --git a/src/testing/runtime.messaging.test.ts b/src/testing/runtime.messaging.test.ts index c8fe91b..cc267ac 100644 --- a/src/testing/runtime.messaging.test.ts +++ b/src/testing/runtime.messaging.test.ts @@ -2,6 +2,7 @@ import {onMessage, sendMessage} from "../runtime"; import {createBrowserHarness, createMessageSenderFixture, createTabFixture, installGlobals} from "./index"; const restorers: Array<() => void> = []; + const CHANNEL_CLOSED_MESSAGE = 'Browser method "runtime.sendMessage" message channel closed before a response was received.'; @@ -21,6 +22,7 @@ describe("stateful runtime messaging", () => { harness.runtime.setMessageSender(sender); installChromeHarness(harness); + const unsubscribe = onMessage((message, actualSender, sendResponse) => { received.push({message, sender: actualSender}); sendResponse({kind: "pong"}); @@ -28,6 +30,7 @@ describe("stateful runtime messaging", () => { await expect(sendMessage({kind: "ping"})).resolves.toEqual({kind: "pong"}); expect(received).toEqual([{message: {kind: "ping"}, sender}]); + expect(harness.runtime.sendMessage.calls[0]).toMatchObject({ args: [{kind: "ping"}], invocation: "callback", @@ -45,6 +48,7 @@ describe("stateful runtime messaging", () => { calls.push("first"); sendResponse("first response"); }); + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { calls.push("second"); sendResponse("second response"); @@ -57,14 +61,18 @@ describe("stateful runtime messaging", () => { const asyncHarness = createBrowserHarness(); let resolveSlow: (value: string) => void = () => undefined; + asyncHarness.runtime.events.onMessage.on(() => { calls.push("slow started"); + return new Promise(resolve => { resolveSlow = resolve; }); }); + asyncHarness.runtime.events.onMessage.on(() => { calls.push("fast started"); + return Promise.resolve("fast response"); }); @@ -78,14 +86,17 @@ describe("stateful runtime messaging", () => { test("holds the response channel only when a listener returns true", async () => { const heldHarness = createBrowserHarness(); + heldHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { queueMicrotask(() => sendResponse("async response")); + return true; }); await expect(heldHarness.browser.runtime.sendMessage("ping")).resolves.toBe("async response"); const closedHarness = createBrowserHarness(); + closedHarness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { queueMicrotask(() => sendResponse("too late")); }); @@ -100,8 +111,9 @@ describe("stateful runtime messaging", () => { await expect(promiseHarness.runtime.emitMessage("ping")).resolves.toEqual({source: "promise"}); const thenableHarness = createBrowserHarness(); + thenableHarness.runtime.events.onMessage.on(() => ({ - // biome-ignore lint/suspicious/noThenProperty: this intentionally models a non-Promise thenable. + // This intentionally models a non-Promise thenable. then(resolve: (value: unknown) => void) { resolve({source: "thenable"}); }, @@ -139,8 +151,10 @@ describe("stateful runtime messaging", () => { test("explicitly closes every pending message channel and ignores late responses", async () => { const harness = createBrowserHarness(); const lateResponses: Array<(response?: unknown) => void> = []; + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { lateResponses.push(sendResponse); + return true; }); @@ -154,6 +168,7 @@ describe("stateful runtime messaging", () => { lateResponses.forEach(sendResponse => { sendResponse("too late"); }); + harness.runtime.closeMessageChannels(); await expect(first).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); await expect(second).rejects.toThrow(CHANNEL_CLOSED_MESSAGE); @@ -181,8 +196,10 @@ describe("stateful runtime messaging", () => { test("reset rejects pending channels but leaves already settled dispatches unchanged", async () => { const harness = createBrowserHarness(); let holdOpen = false; + harness.runtime.events.onMessage.on((_message, _sender, sendResponse) => { if (holdOpen) return true; + sendResponse("settled response"); }); diff --git a/src/testing/runtime.ts b/src/testing/runtime.ts index 9d9ed41..b8fcdd7 100644 --- a/src/testing/runtime.ts +++ b/src/testing/runtime.ts @@ -127,6 +127,7 @@ export const createRuntimeHarness = ( } const messageChannels = new Set(); + const messageChannelClosedError = (): Error => new Error('Browser method "runtime.sendMessage" message channel closed before a response was received.'); @@ -150,6 +151,7 @@ export const createRuntimeHarness = ( const settle = (callback: () => void): void => { if (settled) return; + settled = true; messageChannels.delete(channel); callback(); @@ -178,6 +180,7 @@ export const createRuntimeHarness = ( const sendResponse = (response?: unknown): void => { resolveFirst(response); }; + const sender = cloneRecord(messageSender); const registrations = events.onMessage.registrations(); messageChannels.add(channel); @@ -212,6 +215,7 @@ export const createRuntimeHarness = ( if (typeof then === "function") { pendingResponses += 1; + Promise.resolve(listenerResult).then( response => { resolveFirst(response); @@ -237,6 +241,7 @@ export const createRuntimeHarness = ( const isMessageOptions = (value: unknown): boolean => { if (value === undefined) return true; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; return Object.keys(value).every(key => key === "includeTlsChannelId"); @@ -244,6 +249,7 @@ export const createRuntimeHarness = ( const messageFromSendArguments = (args: readonly unknown[]): unknown => { if (args.length < 2) return args[0]; + if (args.length === 2 && isMessageOptions(args[1])) return args[0]; return args[1]; @@ -274,11 +280,13 @@ export const createRuntimeHarness = ( name: "runtime.connect", nextSequence, }); + const connectNative = createBrowserMethod({ invocation: "sync", name: "runtime.connectNative", nextSequence, }); + const getContexts = createBrowserMethod({ callback: "last", implementation: (( @@ -288,7 +296,9 @@ export const createRuntimeHarness = ( const result = contexts .filter(context => matchesContextFilter(context, filter)) .map(context => cloneRecord(context)); + callback?.(result); + return result; }) as unknown as typeof chrome.runtime.getContexts, invocation: "dual", @@ -296,12 +306,14 @@ export const createRuntimeHarness = ( name: "runtime.getContexts", nextSequence, }); + const getManifest = createBrowserMethod({ implementation: (() => cloneRecord(manifest)) as typeof chrome.runtime.getManifest, invocation: "sync", name: "runtime.getManifest", nextSequence, }); + const getPackageDirectoryEntry = createBrowserMethod< typeof chrome.runtime.getPackageDirectoryEntry, FileSystemDirectoryEntry @@ -312,6 +324,7 @@ export const createRuntimeHarness = ( name: "runtime.getPackageDirectoryEntry", nextSequence, }); + const getPlatformInfo = createBrowserMethod({ callback: "last", invocation: "dual", @@ -319,6 +332,7 @@ export const createRuntimeHarness = ( name: "runtime.getPlatformInfo", nextSequence, }); + const getBrowserInfo = createBrowserMethod({ implementation: (() => Promise.resolve({ @@ -331,15 +345,18 @@ export const createRuntimeHarness = ( name: "runtime.getBrowserInfo", nextSequence, }); + const getURL = createBrowserMethod({ implementation: ((path: string) => { const normalized = path.replace(/^\/+/, ""); + return `${urlScheme}://${extensionId}/${normalized}`; }) as typeof chrome.runtime.getURL, invocation: "sync", name: "runtime.getURL", nextSequence, }); + const openOptionsPage = createBrowserMethod({ callback: "last", invocation: "dual", @@ -347,11 +364,13 @@ export const createRuntimeHarness = ( name: "runtime.openOptionsPage", nextSequence, }); + const reload = createBrowserMethod({ invocation: "sync", name: "runtime.reload", nextSequence, }); + const requestUpdateCheck = createBrowserMethod({ callback: "last", callbackArgs: result => [result.status, result.details], @@ -360,11 +379,13 @@ export const createRuntimeHarness = ( name: "runtime.requestUpdateCheck", nextSequence, }); + const restart = createBrowserMethod({ invocation: "sync", name: "runtime.restart", nextSequence, }); + const restartAfterDelay = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -373,6 +394,7 @@ export const createRuntimeHarness = ( name: "runtime.restartAfterDelay", nextSequence, }); + const sendMessage = createBrowserMethod({ callback: "last", implementation: sendRuntimeMessage, @@ -381,6 +403,7 @@ export const createRuntimeHarness = ( name: "runtime.sendMessage", nextSequence, }); + const setUninstallURL = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -496,9 +519,11 @@ export const createRuntimeHarness = ( manifest = cloneRecord(initialManifest); contexts = initialContexts.map(context => cloneRecord(context)); messageSender = cloneRecord(initialSender); + methods.forEach(method => { method.reset(); }); + Object.values(events).forEach(event => { event.reset(); }); diff --git a/src/testing/scripting.ts b/src/testing/scripting.ts index 77a2d17..ac0d2df 100644 --- a/src/testing/scripting.ts +++ b/src/testing/scripting.ts @@ -34,6 +34,7 @@ export const createScriptingHarness = ( typeof chrome.scripting.executeScript, chrome.scripting.InjectionResult[] >({callback: "last", invocation: "dual", lastError, name: "scripting.executeScript", nextSequence}); + const insertCSS = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -42,6 +43,7 @@ export const createScriptingHarness = ( name: "scripting.insertCSS", nextSequence, }); + const removeCSS = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -50,6 +52,7 @@ export const createScriptingHarness = ( name: "scripting.removeCSS", nextSequence, }); + const getRegisteredContentScripts = createBrowserMethod< typeof chrome.scripting.getRegisteredContentScripts, chrome.scripting.RegisteredContentScript[] @@ -63,10 +66,13 @@ export const createScriptingHarness = ( ) => { const filter = typeof filterOrCallback === "function" ? {} : (filterOrCallback ?? {}); const callback = typeof filterOrCallback === "function" ? filterOrCallback : possibleCallback; + const result = [...scripts.values()] .filter(script => !filter.ids || filter.ids.includes(script.id)) .map(script => cloneRecord(script)); + callback?.(result); + return result; }) as unknown as typeof chrome.scripting.getRegisteredContentScripts, invocation: "dual", @@ -74,19 +80,25 @@ export const createScriptingHarness = ( name: "scripting.getRegisteredContentScripts", nextSequence, }); + const registerContentScripts = createBrowserMethod({ callback: "last", callbackArgs: () => [], implementation: ((values: chrome.scripting.RegisteredContentScript[], callback?: () => void) => { const duplicate = values.find(script => scripts.has(script.id)); + if (duplicate) { const error = new Error(`Content script "${duplicate.id}" is already registered`); + if (callback) return lastError.runWithLastError(error, callback); + throw error; } + values.forEach(script => { scripts.set(script.id, cloneRecord(script)); }); + callback?.(); }) as unknown as typeof chrome.scripting.registerContentScripts, invocation: "dual", @@ -94,19 +106,25 @@ export const createScriptingHarness = ( name: "scripting.registerContentScripts", nextSequence, }); + const updateContentScripts = createBrowserMethod({ callback: "last", callbackArgs: () => [], implementation: ((values: chrome.scripting.RegisteredContentScript[], callback?: () => void) => { const missing = values.find(script => !scripts.has(script.id)); + if (missing) { const error = new Error(`Content script "${missing.id}" is not registered`); + if (callback) return lastError.runWithLastError(error, callback); + throw error; } + values.forEach(script => { scripts.set(script.id, {...scripts.get(script.id), ...cloneRecord(script)}); }); + callback?.(); }) as unknown as typeof chrome.scripting.updateContentScripts, invocation: "dual", @@ -114,6 +132,7 @@ export const createScriptingHarness = ( name: "scripting.updateContentScripts", nextSequence, }); + const unregisterContentScripts = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -123,11 +142,13 @@ export const createScriptingHarness = ( ) => { const filter = typeof filterOrCallback === "function" ? {} : filterOrCallback; const callback = typeof filterOrCallback === "function" ? filterOrCallback : possibleCallback; + if (filter?.ids) { filter.ids.forEach(id => { scripts.delete(id); }); } else scripts.clear(); + callback?.(); }) as unknown as typeof chrome.scripting.unregisterContentScripts, invocation: "dual", @@ -145,6 +166,7 @@ export const createScriptingHarness = ( unregisterContentScripts: unregisterContentScripts.api, updateContentScripts: updateContentScripts.api, } as ScriptingTestApi; + const methods = [ executeScript, getRegisteredContentScripts, @@ -169,6 +191,7 @@ export const createScriptingHarness = ( }, reset(): void { scripts = new Map(initial.map(script => [script.id, cloneRecord(script)])); + methods.forEach(method => { method.reset(); }); diff --git a/src/testing/stateful.integration.test.ts b/src/testing/stateful.integration.test.ts index 50fc19c..be0701c 100644 --- a/src/testing/stateful.integration.test.ts +++ b/src/testing/stateful.integration.test.ts @@ -54,12 +54,15 @@ describe("stateful browser test harness", () => { let startupCount = 0; const messages: Array<{message: unknown; sender: chrome.runtime.MessageSender}> = []; const unsubscribeInstalled = onInstalled(details => installed.push(details)); + const unsubscribeStartup = onStartup(() => { startupCount += 1; }); + const unsubscribeMessage = onMessage((message, sender) => { messages.push({message, sender}); }); + const sender = createMessageSenderFixture({id: "sender-extension-id", tab: createTabFixture({id: 31})}); harness.runtime.setMessageSender(sender); @@ -92,6 +95,7 @@ describe("stateful browser test harness", () => { manifest: createManifestFixture({name: "Runtime Test"}), permissions: createPermissionsFixture({permissions: ["storage"]}), }); + installChromeHarness(harness); expect(getId()).toBe("runtime-test-id"); @@ -106,15 +110,18 @@ describe("stateful browser test harness", () => { await expect(requestPermissions({origins: ["https://example.test/*"], permissions: ["tabs"]})).resolves.toBe( true ); + await expect( containsPermissions({origins: ["https://example.test/*"], permissions: ["storage", "tabs"]}) ).resolves.toBe(true); + await expect(removePermissions({permissions: ["tabs"]})).resolves.toBe(true); expect(await getAllPermissions()).toEqual({ origins: ["https://example.test/*"], permissions: ["storage"], }); + expect(added).toEqual([{origins: ["https://example.test/*"], permissions: ["tabs"]}]); expect(removed).toEqual([{origins: [], permissions: ["tabs"]}]); @@ -137,12 +144,14 @@ describe("stateful browser test harness", () => { ], windows: [createWindowFixture({focused: true, id: 7})], }); + installChromeHarness(harness); const createdWindow = await createWindow({ focused: true, url: ["https://one.example/page", "https://two.example/page"], }); + expect(createdWindow?.tabs).toHaveLength(2); const windows = await getAllWindows({populate: true}); @@ -154,11 +163,14 @@ describe("stateful browser test harness", () => { url: "https://literal.example/path", windowId: createdWindow?.id, }); + await expect(queryTabs({active: true, currentWindow: true})).resolves.toEqual([ expect.objectContaining({id: createdTab.id, url: "https://literal.example/path"}), ]); + await expect(queryTabs({url: "https://literal.example/path"})).resolves.toHaveLength(1); await expect(queryTabs({url: "https://literal.example/*"})).resolves.toHaveLength(1); + await expect(queryTabs({id: createdTab.id} as chrome.tabs.QueryInfo)).rejects.toThrow( 'tabs.query filter "id" is not supported' ); @@ -175,20 +187,24 @@ describe("stateful browser test harness", () => { const harness = createBrowserHarness({ registeredContentScripts: [{id: "initial", js: ["initial.js"], matches: ["https://initial.example/*"]}], }); + installChromeHarness(harness); harness.scripting.executeScript.setResult([createInjectionResultFixture({frameId: 3, result: "executed"})]); + await expect(executeScript({func: () => "production function", target: {tabId: 1}})).resolves.toEqual([ expect.objectContaining({frameId: 3, result: "executed"}), ]); await registerContentScripts([{id: "added", js: ["added.js"], matches: ["https://added.example/*"]}]); await updateContentScripts([{id: "added", js: ["updated.js"]}]); + await expect(getRegisteredContentScripts({ids: ["added"]})).resolves.toEqual([ expect.objectContaining({id: "added", js: ["updated.js"], matches: ["https://added.example/*"]}), ]); await unregisterContentScripts({ids: ["added"]}); + await expect(getRegisteredContentScripts()).resolves.toEqual([ expect.objectContaining({id: "initial", js: ["initial.js"]}), ]); @@ -208,11 +224,13 @@ describe("stateful browser test harness", () => { tabs: [createTabFixture({id: 1, url: "https://callback.example/", windowId: 1})], windows: [createWindowFixture({id: 1})], }); + let callbackLastError: chrome.runtime.LastError | undefined; const callbackResult = await new Promise(resolve => { harness.chrome.tabs.query({active: true}, resolve); }); + expect(callbackResult).toEqual([expect.objectContaining({id: 1})]); await expect(harness.browser.tabs.query({active: true})).resolves.toEqual([expect.objectContaining({id: 1})]); @@ -236,6 +254,7 @@ describe("stateful browser test harness", () => { tabs: [createTabFixture({id: 1, windowId: 1})], windows: [createWindowFixture({id: 1})], }); + const second = createBrowserHarness({extensionId: "second-id"}); await first.permissions.grant({permissions: ["tabs"]}); @@ -272,6 +291,7 @@ describe("stateful browser test harness", () => { harness.windows.set([createWindowFixture({id: 8, tabs: [createTabFixture({id: 81, windowId: 5})]})]); expect(harness.tabs.values).toEqual([expect.objectContaining({id: 81, windowId: 8})]); + expect(harness.windows.values).toEqual([ expect.objectContaining({id: 8, tabs: [expect.objectContaining({id: 81, windowId: 8})]}), ]); diff --git a/src/testing/tabs.ts b/src/testing/tabs.ts index 850166d..59144b7 100644 --- a/src/testing/tabs.ts +++ b/src/testing/tabs.ts @@ -1,9 +1,9 @@ +import type {BrowserMemoryState} from "./browser-state"; import {type BrowserEventHarness, createBrowserEvent} from "./event"; import {createTabFixture} from "./fixtures"; import {missingEntityError} from "./internal"; import {createUrlMatcher} from "./match-patterns"; import {type BrowserMethod, createBrowserMethod} from "./method"; -import type {BrowserMemoryState} from "./browser-state"; import type {RuntimeLastErrorController, TabsTestApi} from "./types"; type ListenerArgs unknown, ...args: never[]): unknown}> = @@ -112,11 +112,13 @@ export const createTabsHarness = ( name: "tabs.captureVisibleTab", nextSequence, }); + const connect = createBrowserMethod({ invocation: "sync", name: "tabs.connect", nextSequence, }); + const create = createBrowserMethod({ callback: "last", implementation: ((properties: chrome.tabs.CreateProperties, callback?: (tab: chrome.tabs.Tab) => void) => { @@ -128,6 +130,7 @@ export const createTabsHarness = ( for (const tab of existing) { if (tab.index >= index) tab.index += 1; + if (properties.active !== false) tab.active = false; } @@ -142,14 +145,17 @@ export const createTabsHarness = ( url: properties.url, windowId, }); + state.tabs.set(id, tab); state.reindexTabs(windowId); const result = state.cloneTab(tab); callback?.(result); ignoreAutoEventError(events.onCreated.emit(state.cloneTab(tab))); + if (tab.active) { ignoreAutoEventError(events.onActivated.emit({tabId: id, windowId})); } + return result; }) as unknown as typeof chrome.tabs.create, invocation: "dual", @@ -157,6 +163,7 @@ export const createTabsHarness = ( name: "tabs.create", nextSequence, }); + const detectLanguage = createBrowserMethod({ callback: "last", invocation: "dual", @@ -164,6 +171,7 @@ export const createTabsHarness = ( name: "tabs.detectLanguage", nextSequence, }); + const discard = createBrowserMethod({ callback: "last", invocation: "dual", @@ -171,6 +179,7 @@ export const createTabsHarness = ( name: "tabs.discard", nextSequence, }); + const duplicate = createBrowserMethod({ callback: "last", invocation: "dual", @@ -178,6 +187,7 @@ export const createTabsHarness = ( name: "tabs.duplicate", nextSequence, }); + const executeScript = createBrowserMethod({ callback: "last", invocation: "dual", @@ -185,20 +195,27 @@ export const createTabsHarness = ( name: "tabs.executeScript", nextSequence, }); + const get = createBrowserMethod({ callback: "last", implementation: ((tabId: number, callback?: (tab: chrome.tabs.Tab) => void) => { const tab = state.tabs.get(tabId); + if (!tab) { const error = missingEntityError("tab", tabId); + if (callback) { lastError.runWithLastError(error, () => callback(undefined as unknown as chrome.tabs.Tab)); + return undefined; } + throw error; } + const result = state.cloneTab(tab); callback?.(result); + return result; }) as unknown as typeof chrome.tabs.get, invocation: "dual", @@ -206,6 +223,7 @@ export const createTabsHarness = ( name: "tabs.get", nextSequence, }); + const getCurrent = createBrowserMethod({ callback: "last", implementation: ((callback?: (tab?: chrome.tabs.Tab) => void) => { @@ -213,6 +231,7 @@ export const createTabsHarness = ( const tab = [...state.tabs.values()].find(item => item.windowId === windowId && item.active); const result = tab ? state.cloneTab(tab) : undefined; callback?.(result); + return result; }) as unknown as typeof chrome.tabs.getCurrent, invocation: "dual", @@ -220,6 +239,7 @@ export const createTabsHarness = ( name: "tabs.getCurrent", nextSequence, }); + const getZoom = createBrowserMethod({ callback: "last", invocation: "dual", @@ -227,6 +247,7 @@ export const createTabsHarness = ( name: "tabs.getZoom", nextSequence, }); + const getZoomSettings = createBrowserMethod({ callback: "last", invocation: "dual", @@ -234,6 +255,7 @@ export const createTabsHarness = ( name: "tabs.getZoomSettings", nextSequence, }); + const goBack = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -242,6 +264,7 @@ export const createTabsHarness = ( name: "tabs.goBack", nextSequence, }); + const goForward = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -250,6 +273,7 @@ export const createTabsHarness = ( name: "tabs.goForward", nextSequence, }); + const group = createBrowserMethod({ callback: "last", invocation: "dual", @@ -257,6 +281,7 @@ export const createTabsHarness = ( name: "tabs.group", nextSequence, }); + const highlight = createBrowserMethod({ callback: "last", invocation: "dual", @@ -264,6 +289,7 @@ export const createTabsHarness = ( name: "tabs.highlight", nextSequence, }); + const insertCSS = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -272,6 +298,7 @@ export const createTabsHarness = ( name: "tabs.insertCSS", nextSequence, }); + const move = createBrowserMethod({ callback: "last", invocation: "dual", @@ -279,6 +306,7 @@ export const createTabsHarness = ( name: "tabs.move", nextSequence, }); + const query = createBrowserMethod({ callback: "last", implementation: ((queryInfo: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) => { @@ -288,47 +316,69 @@ export const createTabsHarness = ( const urls = typeof queryInfo.url === "string" ? [queryInfo.url] : queryInfo.url; const matchesUrl = urls === undefined ? undefined : createUrlMatcher(urls, "tabs.query"); + if (queryInfo.title) assertExactPattern("title", queryInfo.title); const currentWindowId = state.currentWindowId(); const requestedWindowId = queryInfo.windowId === -2 ? currentWindowId : queryInfo.windowId; + const result = [...state.tabs.values()] .filter(tab => { const window = state.windows.get(tab.windowId); + if (queryInfo.status !== undefined && tab.status !== queryInfo.status) return false; + if ( queryInfo.lastFocusedWindow !== undefined && (tab.windowId === state.lastFocusedWindowId) !== queryInfo.lastFocusedWindow ) return false; + if (requestedWindowId !== undefined && tab.windowId !== requestedWindowId) return false; + if (queryInfo.windowType !== undefined && window?.type !== queryInfo.windowType) return false; + if (queryInfo.active !== undefined && tab.active !== queryInfo.active) return false; + if (queryInfo.index !== undefined && tab.index !== queryInfo.index) return false; + if ( queryInfo.currentWindow !== undefined && (tab.windowId === currentWindowId) !== queryInfo.currentWindow ) return false; + if (queryInfo.highlighted !== undefined && tab.highlighted !== queryInfo.highlighted) return false; + if (queryInfo.discarded !== undefined && tab.discarded !== queryInfo.discarded) return false; + if (queryInfo.frozen !== undefined && tab.frozen !== queryInfo.frozen) return false; + if (queryInfo.autoDiscardable !== undefined && tab.autoDiscardable !== queryInfo.autoDiscardable) return false; + if (queryInfo.pinned !== undefined && tab.pinned !== queryInfo.pinned) return false; + if (queryInfo.splitViewId !== undefined && tab.splitViewId !== queryInfo.splitViewId) return false; + if (queryInfo.audible !== undefined && Boolean(tab.audible) !== queryInfo.audible) return false; + if (queryInfo.muted !== undefined && Boolean(tab.mutedInfo?.muted) !== queryInfo.muted) return false; + if (queryInfo.groupId !== undefined && tab.groupId !== queryInfo.groupId) return false; + if (queryInfo.title !== undefined && tab.title !== queryInfo.title) return false; + if (matchesUrl && (!tab.url || !matchesUrl(tab.url))) return false; + return true; }) .sort((left, right) => left.windowId - right.windowId || left.index - right.index) .map(tab => state.cloneTab(tab)); callback?.(result); + return result; }) as unknown as typeof chrome.tabs.query, invocation: "dual", @@ -336,6 +386,7 @@ export const createTabsHarness = ( name: "tabs.query", nextSequence, }); + const reload = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -344,28 +395,36 @@ export const createTabsHarness = ( name: "tabs.reload", nextSequence, }); + const remove = createBrowserMethod({ callback: "last", callbackArgs: () => [], implementation: ((ids: number | number[], callback?: () => void) => { const tabIds = Array.isArray(ids) ? ids : [ids]; const missingId = tabIds.find(id => !state.tabs.has(id)); + if (typeof missingId === "number") { const error = missingEntityError("tab", missingId); + if (callback) { lastError.runWithLastError(error, callback); + return; } + throw error; } for (const id of tabIds) { const tab = state.tabs.get(id); + if (!tab) continue; + state.tabs.delete(id); state.reindexTabs(tab.windowId); ignoreAutoEventError(events.onRemoved.emit(id, {isWindowClosing: false, windowId: tab.windowId})); } + callback?.(); }) as typeof chrome.tabs.remove, invocation: "dual", @@ -373,6 +432,7 @@ export const createTabsHarness = ( name: "tabs.remove", nextSequence, }); + const removeCSS = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -381,6 +441,7 @@ export const createTabsHarness = ( name: "tabs.removeCSS", nextSequence, }); + const sendMessage = createBrowserMethod({ callback: "last", invocation: "dual", @@ -388,6 +449,7 @@ export const createTabsHarness = ( name: "tabs.sendMessage", nextSequence, }); + const setZoom = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -396,6 +458,7 @@ export const createTabsHarness = ( name: "tabs.setZoom", nextSequence, }); + const setZoomSettings = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -404,6 +467,7 @@ export const createTabsHarness = ( name: "tabs.setZoomSettings", nextSequence, }); + const ungroup = createBrowserMethod({ callback: "last", callbackArgs: () => [], @@ -412,6 +476,7 @@ export const createTabsHarness = ( name: "tabs.ungroup", nextSequence, }); + const update = createBrowserMethod({ callback: "last", implementation: (( @@ -420,27 +485,37 @@ export const createTabsHarness = ( callback?: (tab?: chrome.tabs.Tab) => void ) => { const tab = state.tabs.get(tabId); + if (!tab) { const error = missingEntityError("tab", tabId); + if (callback) { lastError.runWithLastError(error, () => callback(undefined)); + return undefined; } + throw error; } + if (properties.active) { for (const other of state.tabs.values()) { if (other.windowId === tab.windowId) other.active = other.id === tab.id; } } + Object.assign(tab, properties); + if (typeof properties.highlighted === "boolean") tab.selected = properties.highlighted; + const result = state.cloneTab(tab); callback?.(result); ignoreAutoEventError(events.onUpdated.emit(tabId, {...properties}, state.cloneTab(tab))); + if (properties.active) { ignoreAutoEventError(events.onActivated.emit({tabId, windowId: tab.windowId})); } + return result; }) as unknown as typeof chrome.tabs.update, invocation: "dual", @@ -555,17 +630,21 @@ export const createTabsHarness = ( methods.forEach(method => { method.reset(); }); + Object.values(events).forEach(event => { event.reset(); }); }, set(tabs): void { state.tabs.clear(); + for (const tab of tabs) { if (typeof tab.id !== "number") throw new Error("A test tab must have a numeric id"); + state.ensureWindow(tab.windowId); state.tabs.set(tab.id, state.cloneTab(tab)); } + for (const windowId of new Set(tabs.map(tab => tab.windowId))) state.reindexTabs(windowId); }, }; diff --git a/src/testing/windows.ts b/src/testing/windows.ts index ca57f7f..4848b6d 100644 --- a/src/testing/windows.ts +++ b/src/testing/windows.ts @@ -1,13 +1,14 @@ +import type {BrowserMemoryState} from "./browser-state"; import {type BrowserEventHarness, createBrowserEvent} from "./event"; import {createWindowFixture} from "./fixtures"; import {missingEntityError} from "./internal"; import {type BrowserMethod, createBrowserMethod} from "./method"; -import type {BrowserMemoryState} from "./browser-state"; import type {TabsHarness} from "./tabs"; import type {RuntimeLastErrorController, WindowsTestApi} from "./types"; type ListenerArgs unknown, ...args: never[]): unknown}> = Parameters[0]>; + type WindowEventRegistrationArgs = [filter?: {windowTypes: `${chrome.windows.WindowType}`[]}]; export interface WindowsEventsHarness { @@ -59,16 +60,22 @@ export const createWindowsHarness = ( ): chrome.windows.Window | undefined => { const actualId = windowId === -2 ? state.currentWindowId() : windowId; const window = typeof actualId === "number" ? state.windows.get(actualId) : undefined; + if (!window) { const error = missingEntityError("window", windowId); + if (callback) { lastError.runWithLastError(error, () => callback(undefined as unknown as chrome.windows.Window)); + return undefined; } + throw error; } + const result = state.cloneWindow(window, populate); callback?.(result); + return result; }; @@ -80,9 +87,11 @@ export const createWindowsHarness = ( ) => { const id = state.nextWindowId(); const focused = createData.focused ?? true; + if (focused) { for (const existing of state.windows.values()) existing.focused = false; } + const window = createWindowFixture({ focused, height: createData.height, @@ -95,11 +104,14 @@ export const createWindowsHarness = ( type: (createData.type ?? "normal") as `${chrome.windows.WindowType}`, width: createData.width, }); + state.windows.set(id, window); + if (focused) state.setLastFocusedWindow(id); if (typeof createData.tabId === "number") { const tab = state.tabs.get(createData.tabId); + if (tab) { const oldWindowId = tab.windowId; tab.windowId = id; @@ -109,6 +121,7 @@ export const createWindowsHarness = ( } const urls = typeof createData.url === "string" ? [createData.url] : createData.url; + for (const [index, url] of (urls ?? []).entries()) { await tabs.create.api({active: index === 0, index, url, windowId: id}); } @@ -116,7 +129,9 @@ export const createWindowsHarness = ( const result = state.cloneWindow(window, true); callback?.(result); ignoreAutoEventError(events.onCreated.emit(state.cloneWindow(window, true))); + if (focused) ignoreAutoEventError(events.onFocusChanged.emit(id)); + return result; }) as unknown as typeof chrome.windows.create, invocation: "dual", @@ -124,6 +139,7 @@ export const createWindowsHarness = ( name: "windows.create", nextSequence, }); + const get = createBrowserMethod({ callback: "last", implementation: (( @@ -133,6 +149,7 @@ export const createWindowsHarness = ( ) => { const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(windowId, query.populate ?? false, callback); }) as unknown as typeof chrome.windows.get, invocation: "dual", @@ -140,6 +157,7 @@ export const createWindowsHarness = ( name: "windows.get", nextSequence, }); + const getAll = createBrowserMethod({ callback: "last", implementation: (( @@ -148,10 +166,13 @@ export const createWindowsHarness = ( ) => { const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + const result = [...state.windows.values()] .filter(window => !query.windowTypes || (window.type && query.windowTypes.includes(window.type))) .map(window => state.cloneWindow(window, query.populate ?? false)); + callback?.(result); + return result; }) as unknown as typeof chrome.windows.getAll, invocation: "dual", @@ -159,6 +180,7 @@ export const createWindowsHarness = ( name: "windows.getAll", nextSequence, }); + const getCurrent = createBrowserMethod({ callback: "last", implementation: (( @@ -167,6 +189,7 @@ export const createWindowsHarness = ( ) => { const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(state.currentWindowId() ?? -1, query.populate ?? false, callback); }) as unknown as typeof chrome.windows.getCurrent, invocation: "dual", @@ -174,6 +197,7 @@ export const createWindowsHarness = ( name: "windows.getCurrent", nextSequence, }); + const getLastFocused = createBrowserMethod({ callback: "last", implementation: (( @@ -182,6 +206,7 @@ export const createWindowsHarness = ( ) => { const query = typeof queryOrCallback === "function" ? {} : (queryOrCallback ?? {}); const callback = typeof queryOrCallback === "function" ? queryOrCallback : possibleCallback; + return resolveWindow(state.lastFocusedWindowId ?? -1, query.populate ?? false, callback); }) as unknown as typeof chrome.windows.getLastFocused, invocation: "dual", @@ -189,31 +214,43 @@ export const createWindowsHarness = ( name: "windows.getLastFocused", nextSequence, }); + const remove = createBrowserMethod({ callback: "last", callbackArgs: () => [], implementation: ((windowId: number, callback?: () => void) => { const window = state.windows.get(windowId); + if (!window) { const error = missingEntityError("window", windowId); + if (callback) { lastError.runWithLastError(error, callback); + return; } + throw error; } + const tabIds = [...state.tabs.values()].filter(tab => tab.windowId === windowId).map(tab => tab.id); state.windows.delete(windowId); + for (const tabId of tabIds) { if (typeof tabId !== "number") continue; + state.tabs.delete(tabId); ignoreAutoEventError(tabs.events.onRemoved.emit(tabId, {isWindowClosing: true, windowId})); } + if (state.lastFocusedWindowId === windowId) { const nextWindow = [...state.windows.values()][0]; + if (nextWindow) nextWindow.focused = true; + state.setLastFocusedWindow(nextWindow?.id); } + callback?.(); ignoreAutoEventError(events.onRemoved.emit(windowId)); }) as typeof chrome.windows.remove, @@ -222,6 +259,7 @@ export const createWindowsHarness = ( name: "windows.remove", nextSequence, }); + const update = createBrowserMethod({ callback: "last", implementation: (( @@ -230,20 +268,26 @@ export const createWindowsHarness = ( callback?: (window: chrome.windows.Window) => void ) => { const window = state.windows.get(windowId); + if (!window) return resolveWindow(windowId, false, callback); + if (updateInfo.focused) { for (const existing of state.windows.values()) existing.focused = existing.id === windowId; + state.setLastFocusedWindow(windowId); } else if (updateInfo.focused === false) { window.focused = false; } + Object.assign(window, updateInfo); const result = state.cloneWindow(window, false); callback?.(result); ignoreAutoEventError(events.onBoundsChanged.emit(state.cloneWindow(window))); + if (typeof updateInfo.focused === "boolean") { ignoreAutoEventError(events.onFocusChanged.emit(updateInfo.focused ? windowId : -1)); } + return result; }) as unknown as typeof chrome.windows.update, invocation: "dual", @@ -287,6 +331,7 @@ export const createWindowsHarness = ( methods.forEach(method => { method.reset(); }); + Object.values(events).forEach(event => { event.reset(); }); @@ -295,6 +340,7 @@ export const createWindowsHarness = ( const replacementWindowIds = new Set( windows.flatMap(window => (typeof window.id === "number" ? [window.id] : [])) ); + const windowsWithExplicitTabs = new Set( windows.flatMap(window => typeof window.id === "number" && Array.isArray(window.tabs) ? [window.id] : [] @@ -308,18 +354,23 @@ export const createWindowsHarness = ( } state.windows.clear(); + for (const window of windows) { if (typeof window.id !== "number") throw new Error("A test window must have a numeric id"); + const copy = state.cloneWindow(window); delete copy.tabs; state.windows.set(window.id, copy); for (const tab of window.tabs ?? []) { if (typeof tab.id !== "number") throw new Error("A test tab must have a numeric id"); + state.tabs.set(tab.id, state.cloneTab({...tab, windowId: window.id})); } + state.reindexTabs(window.id); } + state.setLastFocusedWindow( [...state.windows.values()].find(window => window.focused)?.id ?? [...state.windows.keys()][0] ); diff --git a/src/userScripts.ts b/src/user-scripts.ts similarity index 100% rename from src/userScripts.ts rename to src/user-scripts.ts diff --git a/src/utils.test.ts b/src/utils.test.ts index a7cc8de..19621ad 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -102,6 +102,7 @@ describe("utils", () => { setGlobals({consoleError: capture.handler}); const error = new Error("Sync fail"); const event = createBrowserEvent<[]>(); + event.api.addListener( safeListener(() => { throw error; @@ -127,12 +128,14 @@ describe("utils", () => { const capture = createListenerErrorCapture(); setGlobals({consoleError: capture.handler}); const error = new Error("Thenable fail"); + const thenable = { - // biome-ignore lint/suspicious/noThenProperty: This test intentionally models a non-Promise thenable. + // This test intentionally models a non-Promise thenable. then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void { reject(error); }, }; + const event = createBrowserEvent<[]>(); event.api.addListener(safeListener(() => thenable)); diff --git a/src/webNavigation.ts b/src/web-navigation.ts similarity index 100% rename from src/webNavigation.ts rename to src/web-navigation.ts diff --git a/src/webRequest.ts b/src/web-request.ts similarity index 100% rename from src/webRequest.ts rename to src/web-request.ts diff --git a/tests/browser-match-patterns/check.mjs b/tests/browser-match-patterns/check.mjs index 8c146b3..9fe58d6 100644 --- a/tests/browser-match-patterns/check.mjs +++ b/tests/browser-match-patterns/check.mjs @@ -14,11 +14,13 @@ import {browserSmokeError, inspectBrowser} from "./launcher.mjs"; const browserInfo = await inspectBrowser(process.argv[2]); console.log(`Browser smoke: ${browserInfo.version} (${browserInfo.path})`); const temporary = await mkdtemp(join(tmpdir(), "browser-match-patterns-")); + const profiles = [ {name: "wildcard", origins: ["https://*.example.com/*", "http://127.0.0.1/*"]}, {name: "narrow", origins: ["https://shop.example.com/*", "http://127.0.0.1/*"]}, {name: "all", origins: [""]}, ]; + const requestedOrigins = [ "https://example.com/*", "https://shop.example.com/*", @@ -28,25 +30,33 @@ const requestedOrigins = [ "http://127.0.0.1:62778/*", "https://shop.example.com/ignored-path", ]; + const results = new Map(); let complete; let fail; + const finished = new Promise((resolveResult, reject) => { complete = resolveResult; fail = reject; }); + const server = createServer(async (request, response) => { if (request.method === "POST") { let body = ""; + for await (const chunk of request) body += chunk; + try { const result = JSON.parse(body); + assert.ok( profiles.some(profile => profile.name === result.name), "Unknown browser result profile" ); + results.set(result.name, result); response.end("ok"); + if (results.size === profiles.length) complete(); } catch (error) { response.writeHead(400).end(); @@ -61,38 +71,49 @@ const server = createServer(async (request, response) => { // Serialized into the disposable extension, not executed in Node or supplied by a website. async function probe(config) { const report = {name: config.name}; + try { const tab = await chrome.tabs.create({url: `${config.base}/page?q=a+b#part`, active: false}); const deadline = Date.now() + 10000; + while ((await chrome.tabs.get(tab.id)).status !== "complete") { if (Date.now() > deadline) throw new Error("Local test tab did not load"); + await new Promise(resolveDelay => setTimeout(resolveDelay, 25)); } + report.tab = await chrome.tabs.get(tab.id); report.queries = []; + for (const url of config.patterns) { const tabs = await chrome.tabs.query({url, active: false, status: "complete", discarded: false}); report.queries.push(tabs.some(candidate => candidate.id === tab.id)); } + report.permissions = []; + for (const origin of config.requestedOrigins) { report.permissions.push(await chrome.permissions.contains({origins: [origin]})); } + await chrome.tabs.remove(tab.id); } catch (error) { report.error = String(error.stack || error); } + await fetch(`${config.base}/results`, {method: "POST", body: JSON.stringify(report)}); } let browser; let browserExit; let timeout; + try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const port = server.address().port; const base = `http://127.0.0.1:${port}`; + const patterns = [ "http://127.0.0.1/*", "*://127.0.0.1/*", @@ -104,10 +125,13 @@ try { "http://127.0.0.1:1/*", ["https://other.test/*", "http://127.0.0.1/*"], ]; + const extensions = []; + for (const profile of profiles) { const directory = join(temporary, profile.name); await mkdir(directory); + await writeFile( join(directory, "manifest.json"), JSON.stringify({ @@ -119,12 +143,15 @@ try { background: {service_worker: "worker.js"}, }) ); + await writeFile( join(directory, "worker.js"), `chrome.runtime.onInstalled.addListener(() => (${probe.toString()})(${JSON.stringify({name: profile.name, base, patterns, requestedOrigins})}));` ); + extensions.push(directory); } + browser = spawn( browserInfo.path, [ @@ -142,15 +169,20 @@ try { ], {stdio: ["ignore", "ignore", "pipe"]} ); + let diagnostics = ""; + browser.stderr.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-4000); }); + browser.once("error", fail); browserExit = once(browser, "exit"); browserExit.then(([code]) => fail(browserSmokeError(`Browser exited (${code}).`, browserInfo, diagnostics)), fail); + timeout = setTimeout(() => { const missing = profiles.filter(profile => !results.has(profile.name)).map(profile => profile.name); + fail( browserSmokeError( `Browser smoke timed out after 30 seconds; missing results: ${missing.join(", ")}.`, @@ -159,44 +191,55 @@ try { ) ); }, 30000); + await finished; let assertions = 0; + for (const profile of profiles) { const result = results.get(profile.name); assert.equal(result.error, undefined, result.error); + const harness = createBrowserHarness({ tabs: [createTabFixture(result.tab)], permissions: {origins: profile.origins}, }); + for (const [index, url] of patterns.entries()) { const tabs = await harness.chrome.tabs.query({url, active: false, status: "complete", discarded: false}); + assert.equal( tabs.length === 1, result.queries[index], `${profile.name}: tabs.query ${JSON.stringify(url)}` ); + assertions++; } + for (const [index, origin] of requestedOrigins.entries()) { assert.equal( await harness.chrome.permissions.contains({origins: [origin]}), result.permissions[index], `${profile.name}: contains ${origin}` ); + assertions++; } } + console.log( `Real Chromium smoke: ${assertions} harness/browser comparisons passed across ${profiles.length} permission profiles.` ); } finally { clearTimeout(timeout); + if (browser && browser.exitCode === null) { browser.kill("SIGTERM"); const killTimeout = setTimeout(() => browser.kill("SIGKILL"), 3000); await browserExit?.catch(() => undefined); clearTimeout(killTimeout); } + server.closeAllConnections(); await new Promise(resolveClose => server.close(resolveClose)); await rm(temporary, {recursive: true, force: true}); diff --git a/tests/browser-match-patterns/launcher.mjs b/tests/browser-match-patterns/launcher.mjs index d9c8a43..0a21cd0 100644 --- a/tests/browser-match-patterns/launcher.mjs +++ b/tests/browser-match-patterns/launcher.mjs @@ -3,6 +3,7 @@ import {resolve} from "node:path"; import {promisify} from "node:util"; const run = promisify(execFile); + const browserHint = "Use the full Chrome for Testing or Chromium executable, not regular Google Chrome or chrome-headless-shell. " + "Regular Google Chrome 137+ disables --load-extension, which this smoke requires. " + @@ -18,8 +19,10 @@ export const inspectBrowser = async binary => { if (!binary) { throw new Error(`Usage: npm run test:browser-match-patterns -- /absolute/path/to/browser. ${browserHint}`); } + const path = resolve(binary); let version; + try { const {stdout} = await run(path, ["--version"], { encoding: "utf8", @@ -27,12 +30,15 @@ export const inspectBrowser = async binary => { killSignal: "SIGKILL", maxBuffer: 4096, }); + version = stdout.trim(); } catch (error) { const reason = error.killed ? "--version did not finish within 5 seconds" : error.code || error.message; throw new Error(`Cannot inspect browser at ${JSON.stringify(path)} (${reason}). ${browserHint}`); } + assertSupportedBrowser(version, path); + return {path, version}; }; diff --git a/tests/browser-match-patterns/launcher.test.mjs b/tests/browser-match-patterns/launcher.test.mjs index c5fcb30..acfe62a 100644 --- a/tests/browser-match-patterns/launcher.test.mjs +++ b/tests/browser-match-patterns/launcher.test.mjs @@ -36,6 +36,7 @@ describe("browser smoke launcher diagnostics", () => { {path: "/test/browser", version: "Chromium 148.0.0.0"}, `${"updater noise".repeat(1000)}last diagnostic` ); + expect(error.message).toContain("Chrome for Testing or Chromium"); expect(error.message).toContain("--load-extension"); expect(error.message).toContain("Chromium 148.0.0.0 (/test/browser)"); diff --git a/tests/consumer-types/check.mjs b/tests/consumer-types/check.mjs index 21ba1cd..5d6d744 100644 --- a/tests/consumer-types/check.mjs +++ b/tests/consumer-types/check.mjs @@ -9,6 +9,7 @@ const fixtureDirectory = dirname(fileURLToPath(import.meta.url)); const packageDirectory = join(fixtureDirectory, "../.."); const temporaryDirectory = mkdtempSync(join(tmpdir(), "addon-core-browser-consumer-")); const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const npmOptions = { env: {...process.env, npm_config_cache: join(temporaryDirectory, "npm-cache")}, shell: process.platform === "win32", @@ -22,11 +23,13 @@ try { encoding: "utf8", }) ); + const archive = join(temporaryDirectory, filename); const consumerDirectory = join(temporaryDirectory, "consumer"); cpSync(fixtureDirectory, consumerDirectory, {recursive: true}); writeFileSync(join(consumerDirectory, "package.json"), '{"private":true,"type":"module"}\n'); + execFileSync(npm, ["install", "--ignore-scripts", "--no-package-lock", "--no-save", archive], { ...npmOptions, cwd: consumerDirectory, @@ -39,6 +42,7 @@ try { assert.equal(installedPackage.dependencies?.["@types/chrome"], "^0.2.2"); assert.equal(installedPackage.peerDependencies?.["@types/chrome"], undefined); assert.equal(installedPackage.types, "dist/index.d.ts"); + assert.deepEqual(installedPackage.exports?.["./testing"], { types: "./dist/testing/index.d.ts", import: "./dist/testing/index.js", @@ -51,12 +55,15 @@ try { assert.match(declarations, /^\/\/\/ /); assert.match(testingDeclarations, /^\/\/\/ /); assert.match(testingDeclarations, /^\/\/\/ /m); + for (const file of ["dist/testing/index.js", "dist/testing/index.cjs"]) { assert.equal(existsSync(join(installedPackageDirectory, file)), true, `${file} is missing from the tarball`); } + for (const file of ["dist/testing/index.js.map", "dist/testing/index.cjs.map"]) { assert.equal(existsSync(join(installedPackageDirectory, file)), false, `${file} must not be in the tarball`); } + assert.equal(existsSync(join(consumerDirectory, "node_modules/@types/chrome")), true); execFileSync( @@ -66,6 +73,7 @@ try { stdio: "inherit", } ); + execFileSync(process.execPath, [join(consumerDirectory, "esm.mjs")], {cwd: consumerDirectory, stdio: "inherit"}); execFileSync(process.execPath, [join(consumerDirectory, "cjs.cjs")], {cwd: consumerDirectory, stdio: "inherit"}); } finally { diff --git a/tests/consumer-types/cjs.cjs b/tests/consumer-types/cjs.cjs index e2f334b..fca58a8 100644 --- a/tests/consumer-types/cjs.cjs +++ b/tests/consumer-types/cjs.cjs @@ -14,6 +14,7 @@ async function checkConsumer() { tabs: [testing.createTabFixture({id: 7, url: "http://127.0.0.1:62778/top.html#part"})], permissions: {origins: ["https://*.example.com/*"]}, }); + harness.delays.downloadValidation.setResult(undefined); harness.configurable.chrome.downloads.download.setResult(42); harness.configurable.chrome.downloads.search.setResult([{exists: true, id: 42, state: "in_progress"}]); @@ -21,27 +22,34 @@ async function checkConsumer() { try { assert.equal(production.getManifest().name, "CJS consumer"); + assert.deepEqual( (await production.queryTabs({url: ["http://127.0.0.1/*"], status: "complete"})).map(tab => tab.id), [7] ); + assert.equal(await production.containsPermissions({origins: ["https://shop.example.com/*"]}), true); assert.equal(await production.containsPermissions({origins: ["http://shop.example.com/*"]}), false); await assert.rejects(production.queryTabs({url: "https://bad*host/*"}), /tabs.query/); assert.equal(typeof harness.runtime.closeMessageChannels, "function"); assert.equal(await production.download({url: "https://example.test/cjs.zip"}), 42); + assert.deepEqual( harness.delays.downloadValidation.calls.map(call => call.args), [[100]] ); + harness.configurable.chrome.downloads.search.setResult([ {error: "USER_CANCELED", exists: true, id: 42, state: "interrupted"}, ]); + await assert.rejects(production.download({url: "https://example.test/blocked-cjs.zip"}), error => { assert.ok(error instanceof production.BlockDownloadError); assert.equal(error.message, "Requires user permission to upload"); + return true; }); + assert.deepEqual( harness.delays.downloadValidation.calls.map(call => call.args), [[100], [100]] diff --git a/tests/consumer-types/esm.mjs b/tests/consumer-types/esm.mjs index d653cef..29ee30c 100644 --- a/tests/consumer-types/esm.mjs +++ b/tests/consumer-types/esm.mjs @@ -7,11 +7,13 @@ const testing = await import("@addon-core/browser/testing"); assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "chrome"), beforeChrome); assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, "browser"), beforeBrowser); + const harness = testing.createBrowserHarness({ manifest: testing.createManifestFixture({name: "ESM consumer"}), tabs: [testing.createTabFixture({id: 7, url: "http://127.0.0.1:62778/top.html#part"})], permissions: {origins: ["https://*.example.com/*"]}, }); + harness.delays.downloadValidation.setResult(undefined); harness.configurable.chrome.downloads.download.setResult(41); harness.configurable.chrome.downloads.search.setResult([{exists: true, id: 41, state: "in_progress"}]); @@ -20,26 +22,33 @@ const unsubscribe = harness.runtime.events.onMessage.on(() => true); try { assert.equal(production.getManifest().name, "ESM consumer"); + assert.deepEqual( (await production.queryTabs({url: ["http://127.0.0.1/*"], status: "complete"})).map(tab => tab.id), [7] ); + assert.equal(await production.containsPermissions({origins: ["https://shop.example.com/*"]}), true); assert.equal(await production.containsPermissions({origins: ["http://shop.example.com/*"]}), false); await assert.rejects(production.queryTabs({url: "https://bad*host/*"}), /tabs.query/); assert.equal(await production.download({url: "https://example.test/esm.zip"}), 41); + assert.deepEqual( harness.delays.downloadValidation.calls.map(call => call.args), [[100]] ); + harness.configurable.chrome.downloads.search.setResult([ {error: "USER_CANCELED", exists: true, id: 41, state: "interrupted"}, ]); + await assert.rejects(production.download({url: "https://example.test/blocked-esm.zip"}), error => { assert.ok(error instanceof production.BlockDownloadError); assert.equal(error.message, "Requires user permission to upload"); + return true; }); + assert.deepEqual( harness.delays.downloadValidation.calls.map(call => call.args), [[100], [100]] @@ -47,6 +56,7 @@ try { const pendingResponse = production.sendMessage({kind: "unanswered"}); harness.runtime.closeMessageChannels(); + await assert.rejects(pendingResponse, { message: 'Browser method "runtime.sendMessage" message channel closed before a response was received.', }); diff --git a/tests/consumer-types/index.ts b/tests/consumer-types/index.ts index 8517e39..a7f2ce1 100644 --- a/tests/consumer-types/index.ts +++ b/tests/consumer-types/index.ts @@ -12,24 +12,30 @@ const harness: BrowserHarness = createBrowserHarness({ manifest: createManifestFixture({name: "Typed consumer"}), tabs: [createTabFixture({id: 7})], }); + const restore = installBrowserGlobals(harness, {profile: "firefox"}); const manifestName: string = getManifest().name; const queryResult: Promise = queryTabs({active: true}); + const matchedTabs: Promise = harness.browser.tabs.query({ url: ["http://127.0.0.1/*", "https://*.example.com/*"], }); + const hasHostAccess: Promise = containsPermissions({origins: ["https://shop.example.com/*"]}); const browserQuery: typeof chrome.tabs.query = harness.browser.tabs.query; + const downloadValidationDelay: BrowserMethod<(milliseconds: number) => Promise, void> = harness.delays.downloadValidation; harness.tabs.query.setResult([]); harness.configurable.browser.downloads.search.setResult([]); harness.runtime.closeMessageChannels(); + downloadValidationDelay.setImplementation(async milliseconds => { const duration: number = milliseconds; void duration; }); + downloadValidationDelay.setResult(undefined); void browserQuery; diff --git a/tests/tooling/eslint.test.mjs b/tests/tooling/eslint.test.mjs new file mode 100644 index 0000000..504b731 --- /dev/null +++ b/tests/tooling/eslint.test.mjs @@ -0,0 +1,213 @@ +import {fileURLToPath} from "node:url"; +import {describe, expect, test} from "@jest/globals"; +import {ESLint} from "eslint"; +import config from "../../eslint.config.js"; + +const cwd = fileURLToPath(new URL("../../", import.meta.url)); +const createLinter = fix => new ESLint({cwd, fix, overrideConfigFile: true, overrideConfig: config}); +const checker = createLinter(false); +const fixer = createLinter(true); + +const messagesFor = async (source, filePath) => { + const [result] = await checker.lintText(source, {filePath}); + + return result.messages.filter(message => message.ruleId === "project/file-naming"); +}; + +describe("ESLint project configuration", () => { + test("checks formatting without editing and fixes blank lines and whitespace idempotently", async () => { + const source = "export function collect (items: string[]) {\nconst result=[]\nif (!items.length) {\nreturn result\n}\nfor (const item of items) {\nresult.push(item)\n}\nreturn result\n}\n"; + const filePath = "src/collect.ts"; + const [checked] = await checker.lintText(source, {filePath}); + expect(checked.messages.some(message => message.ruleId === "@stylistic/padding-line-between-statements")).toBe(true); + expect(checked.output).toBeUndefined(); + + const [fixed] = await fixer.lintText(source, {filePath}); + expect(fixed.messages).toEqual([]); + + expect(fixed.output).toBe([ + "export function collect(items: string[]) {", + " const result = [];", + "", + " if (!items.length) {", + " return result;", + " }", + "", + " for (const item of items) {", + " result.push(item);", + " }", + "", + " return result;", + "}", + "", + ].join("\n")); + + const [again] = await fixer.lintText(fixed.output, {filePath}); + expect(again.messages).toEqual([]); + expect(again.output).toBeUndefined(); + }); + + test.each([ + "if (value) { value--; }", + "for (let i = 0; i < value; i++) { value--; }", + "for (const item of [value]) { value += item; }", + "while (value > 0) { value--; }", + "do { value--; } while (value > 0);", + "switch (value) { case 1: value--; break; }", + ])("separates both sides of %s", async statement => { + const source = `export function run() {\nlet value = 1;\n${statement}\nvalue++;\nreturn value;\n}\n`; + const [fixed] = await fixer.lintText(source, {filePath: "src/run.ts"}); + expect(fixed.messages).toEqual([]); + expect(fixed.output).toMatch(/let value = 1;\n\n/); + expect(fixed.output).toMatch(/\n\n {4}value\+\+;\n\n {4}return value;/); + }); + + test("keeps declarations together, has no padding inside blocks and does not split if/else", async () => { + const source = "export function run(flag: boolean) {\nconst a = 1;\nconst b = 2;\nif (flag) {\nreturn a;\n} else {\nreturn b;\n}\n}\n"; + const [result] = await fixer.lintText(source, {filePath: "src/run.ts"}); + expect(result.messages).toEqual([]); + expect(result.output).toContain("const a = 1;\n const b = 2;\n\n if"); + expect(result.output).toContain("} else {\n return b;\n }"); + }); + + test("formats quotes, semicolons, brackets and sorts imports", async () => { + const source = "import { z } from './z';\nimport { a } from './a';\nexport const result = { a: a, z: z, label: 'ok' }\n"; + const [result] = await fixer.lintText(source, {filePath: "src/sorted.ts"}); + expect(result.messages).toEqual([]); + expect(result.output).toBe('import {a} from "./a";\nimport {z} from "./z";\n\nexport const result = {a: a, z: z, label: "ok"};\n'); + }); + + test("fixes multiline spacing with exactly one blank line and preserves import/re-export groups", async () => { + const source = [ + "import {", + " a,", + '} from "./a";', + 'import {b} from "./b";', + "export const enabled = true;", + "export const options = {", + " a,", + " b,", + "};", + "export const done = true;", + "export {", + " c,", + '} from "./c";', + 'export {d} from "./d";', + "", + ].join("\n"); + + const filePath = "src/spacing.ts"; + const [checked] = await checker.lintText(source, {filePath}); + expect(checked.messages.some(message => message.ruleId === "project/padding-around-multiline")).toBe(true); + expect(checked.output).toBeUndefined(); + + const [result] = await fixer.lintText(source, {filePath}); + expect(result.messages).toEqual([]); + + expect(result.output).toBe(source + .replace('import {b} from "./b";\n', 'import {b} from "./b";\n\n') + .replace("export const enabled = true;\n", "export const enabled = true;\n\n") + .replace("};\n", "};\n\n") + .replace("export const done = true;\n", "export const done = true;\n\n")); + + const [again] = await fixer.lintText(result.output, {filePath}); + expect(again.messages).toEqual([]); + expect(again.output).toBeUndefined(); + + const [extraLines] = await fixer.lintText(result.output.replaceAll("\n\n", "\n\n\n"), {filePath}); + expect(extraLines.messages).toEqual([]); + expect(extraLines.output).toBe(result.output); + }); + + test("formats JSON and JSONC, but preserves the strict JSON/JSONC boundary", async () => { + const [result] = await fixer.lintText('{ "enabled":true,"items":["a","b"]}', {filePath: "settings.json"}); + expect(result.messages).toEqual([]); + expect(result.output).toBe('{\n "enabled": true,\n "items": [\n "a",\n "b"\n ]\n}\n'); + + const [again] = await fixer.lintText(result.output, {filePath: "settings.json"}); + expect(again.messages).toEqual([]); + expect(again.output).toBeUndefined(); + + const comment = '// configuration\n{ "enabled":true }\n'; + const [jsonc] = await fixer.lintText(comment, {filePath: "settings.jsonc"}); + expect(jsonc.messages).toEqual([]); + expect(jsonc.output).toContain("// configuration"); + + const [json] = await checker.lintText(comment, {filePath: "settings.json"}); + expect(json.messages.some(message => message.ruleId === "jsonc/no-comments")).toBe(true); + }); + + test("retains any and reports non-fixable unused variables", async () => { + const [result] = await fixer.lintText("export function identity(value: any) { const unused = 1; return value; }", {filePath: "src/identity.ts"}); + expect(result.messages.some(message => message.ruleId === "@typescript-eslint/no-unused-vars")).toBe(true); + expect(result.messages.some(message => message.ruleId === "@typescript-eslint/no-explicit-any")).toBe(false); + }); + + test.each(["dist/output.js", "coverage/report.js", "addon/main.ts", "package-lock.json", "node_modules/example/index.js"])("ignores generated/dependency file %s", async filePath => { + expect(await checker.isPathIgnored(filePath)).toBe(true); + }); + + test.each(["src/module-name.ts", "src/api.d.ts", "src/module-name.test.ts", "src/Example.test.ts", "src/Example.integration.test.ts", "tests/module-name.spec.mjs", "tsup.config.ts"])("accepts ordinary/test filename %s", async filePath => { + expect(await messagesFor("export {};\n", filePath)).toEqual([]); + }); + + test.each([ + "export class Example {}", + "export default class Example {}", + "class Example {} export {Example};", + "class Example {} export default Example;", + "export const Example = class {};", + "export abstract class Example {}", + "export declare class Example {}", + ])("requires an exact PascalCase filename for %s", async source => { + expect(await messagesFor(source, "src/Example.ts")).toEqual([]); + expect(await messagesFor(source, "src/example.ts")).toHaveLength(1); + expect(await messagesFor(source, "src/Other.ts")).toHaveLength(1); + }); + + test.each([ + "class Example {} module.exports = Example;", + "exports.Example = class Example {};", + "class Example {} module.exports = {Example};", + ])("handles CommonJS class exports: %s", async source => { + expect(await messagesFor(source, "src/Example.cjs")).toEqual([]); + expect(await messagesFor(source, "src/example.cjs")).toHaveLength(1); + }); + + test.each([ + "export class DownloadError extends Error {}", + "export class SidebarError extends globalThis.Error {}", + "export class ValidationError extends TypeError {}", + "class BaseError extends Error {} export class DownloadError extends BaseError {}", + "class DownloadError extends Error {} export {DownloadError};", + "export default class extends Error {}", + ])("keeps exception classes in their owning module: %s", async source => { + expect(await messagesFor(source, "src/downloads.ts")).toEqual([]); + }); + + test("ignores exception classes alongside a regular exported class", async () => { + const source = "export class Example {} export class ExampleError extends Error {}"; + expect(await messagesFor(source, "src/Example.ts")).toEqual([]); + expect(await messagesFor(source, "src/Other.ts")).toHaveLength(1); + }); + + test("allows class re-exports from a kebab-case barrel", async () => { + expect(await messagesFor('export {Example} from "./Example";\n', "src/index.ts")).toEqual([]); + }); + + test("rejects anonymous, multiple and non-PascalCase exported classes", async () => { + expect((await messagesFor("export default class {}", "src/Example.ts"))[0].messageId).toBe("anonymous"); + expect((await messagesFor("export class One {} export class Two {}", "src/One.ts"))[0].messageId).toBe("multiple"); + expect(await messagesFor("export class example {}", "src/example.ts")).toHaveLength(1); + }); + + test.each(["src/moduleName.ts", "src/ModuleName.ts", "src/module_name.ts", "src/moduleName.test.ts", "src/Example.Integration.test.ts", "docs/moduleName.md", ".github/workflows/buildCheck.yml"])("rejects filename %s without renaming it", async filePath => { + const [result] = await fixer.lintText("", {filePath}); + expect(result.messages.filter(message => message.ruleId === "project/file-naming")).toHaveLength(1); + expect(result.output).toBeUndefined(); + }); + + test.each(["README.md", "CONTRIBUTING.md", "LICENSE.md", ".gitignore", ".husky/pre-commit", "package.json"])("preserves standard filename %s", async filePath => { + expect(await messagesFor(filePath.endsWith(".json") ? "{}\n" : "", filePath)).toEqual([]); + }); +}); diff --git a/tests/tooling/multiline-spacing.test.mjs b/tests/tooling/multiline-spacing.test.mjs new file mode 100644 index 0000000..b3ee61b --- /dev/null +++ b/tests/tooling/multiline-spacing.test.mjs @@ -0,0 +1,135 @@ +import {describe, expect, test} from "@jest/globals"; +import {Linter} from "eslint"; +import tseslint from "typescript-eslint"; +import paddingAroundMultiline from "../../scripts/eslint/padding-around-multiline.mjs"; + +const linter = new Linter(); + +const config = [{ + files: ["**/*.ts"], + languageOptions: {parser: tseslint.parser}, + plugins: {project: {rules: {"padding-around-multiline": paddingAroundMultiline}}}, + rules: {"project/padding-around-multiline": "error"}, +}]; + +const options = {filename: "spacing.ts"}; + +const assertFix = (source, expected) => { + const result = linter.verifyAndFix(source, config, options); + expect(result.messages).toEqual([]); + expect(result.output).toBe(expected); + expect(linter.verifyAndFix(result.output, config, options).fixed).toBe(false); +}; + +describe("multiline statement padding", () => { + test.each([ + "const options = {\n retries: 3,\n};", + "const items = [\n 1,\n];", + "const {\n value,\n} = options;", + "let value =\n compute();", + "var value =\n compute();", + "const run = () => {\n work();\n};", + "const value = ready\n ? first\n : second;", + "const message = `first\nsecond`;", + "start(\n options\n);", + "await start(\n options\n);", + "items\n .filter(Boolean)\n .map(convert);", + "value = {\n enabled: true,\n};", + "function run() {\n work();\n}", + "class Client {\n run() {}\n}", + "interface Options {\n enabled: boolean;\n}", + "type Options = {\n enabled: boolean;\n};", + "type Choice =\n | string\n | number;", + "enum Choice {\n First,\n Second,\n}", + "export const options = {\n enabled: true,\n};", + "export default {\n enabled: true,\n};", + "export function run() {\n work();\n}", + "export class Client {\n run() {}\n}", + "export type Options = {\n enabled: boolean;\n};", + "try {\n work();\n} catch {\n recover();\n}", + ])("separates both sides of %s", statement => { + const source = `before();\n${statement}\nafter();\n`; + expect(linter.verify(source, config, options)).toHaveLength(2); + assertFix(source, `before();\n\n${statement}\n\nafter();\n`); + }); + + test.each([ + ["function run() {\n", "\n}"], + ["class Client { static {\n", "\n} }"], + ["namespace Example {\n", "\n}"], + ["switch (value) { case 1:\n", "\n}"], + ])("separates neighboring statements inside %s", (prefix, suffix) => { + const statement = "const options = {\n enabled: true,\n};"; + + assertFix(`${prefix}before();\n${statement}\nafter();${suffix}\n`, + `${prefix}before();\n\n${statement}\n\nafter();${suffix}\n`); + }); + + test("does not pad file/block boundaries or separate arguments and object/type/class members", () => { + const source = [ + "export function run() {", + " const options = {", + " first: {", + " enabled: true,", + " },", + " second: true,", + " };", + "", + " start(", + " options,", + " {", + " retries: 3,", + " },", + " finish", + " );", + "}", + "", + "interface Options {", + " first: {", + " enabled: boolean;", + " };", + " second: boolean;", + "}", + "", + "class Client {", + " run() {", + " work();", + " }", + " stop() {}", + "}", + "", + ].join("\n"); + + assertFix(source, source); + }); + + test("keeps single-line statements together", () => { + const source = "const a = 1;\nconst b = 2;\nstart(a, b);\n"; + assertFix(source, source); + }); + + test("inserts only one shared blank line between consecutive multiline statements", () => { + const first = "const options = {\n enabled: true,\n};"; + const second = "start(\n options\n);"; + assertFix(`${first}\n${second}\n`, `${first}\n\n${second}\n`); + }); + + test("preserves trailing comments, JSDoc and ESLint directive attachment", () => { + const source = "const options = {\n enabled: true,\n}; // options\n// eslint-disable-next-line no-console\nconsole.log(options);\n/** Run the task. */\nfunction run() {\n work();\n}\nfinish();\n"; + const expected = "const options = {\n enabled: true,\n}; // options\n\n// eslint-disable-next-line no-console\nconsole.log(options);\n\n/** Run the task. */\nfunction run() {\n work();\n}\n\nfinish();\n"; + const directiveConfig = [{...config[0], linterOptions: {reportUnusedDisableDirectives: false}}]; + const result = linter.verifyAndFix(source, directiveConfig, options); + expect(result.messages).toEqual([]); + expect(result.output).toBe(expected); + expect(linter.verifyAndFix(result.output, directiveConfig, options).fixed).toBe(false); + }); + + test("handles adjacent code on the same line", () => { + assertFix("before(); const options = {\n}; after();\n", "before();\n\n const options = {\n};\n\n after();\n"); + }); + + test("does not add another blank line if comments are already separated", () => { + const source = "const options = {\n};\n\n// Options are ready.\nstart(options);\n"; + assertFix(source, source); + }); +}); diff --git a/tests/tooling/pre-commit.test.mjs b/tests/tooling/pre-commit.test.mjs new file mode 100644 index 0000000..af097e1 --- /dev/null +++ b/tests/tooling/pre-commit.test.mjs @@ -0,0 +1,157 @@ +import {spawnSync} from "node:child_process"; +import {cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {dirname, join} from "node:path"; +import {fileURLToPath} from "node:url"; +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; + +jest.setTimeout(30000); + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +// A parent Git hook may export index/worktree paths. Never use them in the test clone. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + +test("pre-commit fixes staged files before testing without formatting the whole worktree", () => { + const commands = readFileSync(join(root, ".husky/pre-commit"), "utf8").split("\n").filter(line => line.startsWith("npm ")); + expect(commands).toEqual(["npm run lint:staged || exit 1", "npm run test:related || exit 1"]); + expect(packageJson.scripts["lint:staged"]).toBe("lint-staged"); + expect(packageJson["lint-staged"]).toEqual({"*": "eslint --fix --max-warnings 0 --no-warn-ignored --"}); +}); + +describe("pre-commit staged formatting in an isolated Git clone", () => { + let temporaryDirectory; + let cwd; + + const run = (command, args) => spawnSync(command, args, {cwd, env, encoding: "utf8", timeout: 30000}); + + const git = (...args) => { + const result = run("git", ["-c", "core.autocrlf=false", "-c", `core.hooksPath=${join(temporaryDirectory, "no-hooks")}`, ...args]); + + if (result.status !== 0) { + throw new Error(`Fixture git ${args[0]} failed: ${result.error?.message ?? result.stderr}`); + } + + return result.stdout; + }; + + const write = (file, source) => { + const path = join(cwd, file); + mkdirSync(dirname(path), {recursive: true}); + writeFileSync(path, source); + }; + + const read = file => readFileSync(join(cwd, file), "utf8"); + const staged = file => git("show", `:${file}`); + const lintStaged = () => run(process.execPath, [join(root, "node_modules/lint-staged/bin/lint-staged.js"), "--quiet"]); + + beforeEach(() => { + temporaryDirectory = mkdtempSync(join(tmpdir(), "browser-pre-commit-")); + cwd = temporaryDirectory; + // Reuse existing history: no test commits, and no writes to the real project's Git metadata. + git("clone", "--shared", "--quiet", "--", root, "checkout"); + cwd = join(temporaryDirectory, "checkout"); + git("config", "user.name", "Hook Test"); + git("config", "user.email", "hook-test@example.invalid"); + git("config", "core.autocrlf", "false"); + git("config", "core.hooksPath", join(temporaryDirectory, "no-hooks")); + + for (const file of ["package.json", "eslint.config.js", "scripts/eslint"]) { + cpSync(join(root, file), join(cwd, file), {recursive: true}); + } + + symlinkSync(join(root, "node_modules"), join(cwd, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + }); + + afterEach(() => { + if (temporaryDirectory) { + rmSync(temporaryDirectory, {recursive: true, force: true}); + } + }); + + test("formats and stages JS/TS and JSON, preserves other files and handles spaces in paths", () => { + const file = "src/work in progress/ready.ts"; + const source = "export const ready=true\nexport const settings={\nretries:3\n}\nexport const done='yes'\n"; + const expected = "export const ready = true;\n\nexport const settings = {\n retries: 3,\n};\n\nexport const done = \"yes\";\n"; + const unrelated = "export const unfinished=\n"; + write(file, source); + write("settings.json", '{"enabled":true}\n'); + write("src/unfinished.ts", unrelated); + git("add", "--", file, "settings.json"); + + const result = lintStaged(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(staged(file)).toBe(expected); + expect(read(file)).toBe(expected); + expect(staged("settings.json")).toBe('{\n "enabled": true\n}\n'); + expect(read("src/unfinished.ts")).toBe(unrelated); + expect(git("diff", "--cached", "--name-only").trim().split("\n").sort()).toEqual(["settings.json", file]); + expect(git("stash", "list")).toBe(""); + }); + + test("preserves unstaged edits in a partially staged file without committing or formatting them", () => { + const file = "src/browser.ts"; + const middle = Array.from({length: 12}, (_, index) => `export const marker${index} = ${index};`).join("\n"); + const source = `export const ready=true\n${middle}\nexport const draft = "staged";\n`; + const draft = "export const draft='unstaged'\n"; + write(file, source); + git("add", "--", file); + write(file, source.replace('export const draft = "staged";\n', draft)); + + const result = lintStaged(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + + const expected = source.replace("ready=true\n", "ready = true;\n"); + expect(staged(file)).toBe(expected); + expect(read(file)).toBe(expected.replace('export const draft = "staged";\n', draft)); + expect(git("diff", "--name-only").trim().split("\n")).toContain(file); + expect(git("stash", "list")).toBe(""); + }); + + test.each([ + ["src/BadName.ts", "export const ready=true\n", "project/file-naming"], + ["src/invalid.ts", "export const =\n", "Parsing error"], + ["docs/BadName.md", "Documentation stays unchanged.\n", "project/file-naming"], + ])("blocks %s and restores the staged/unstaged state when linting fails", (file, source, message) => { + const goodFile = "src/good-name.ts"; + const goodSource = "export const ready=true\n"; + write(file, source); + write(goodFile, goodSource); + git("add", "--", file, goodFile); + write(goodFile, `${goodSource}\n// Keep this edit unstaged.\n`); + + const stagedBefore = git("diff", "--cached", "--binary"); + const unstagedBefore = git("diff", "--binary"); + const result = lintStaged(); + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain(message); + expect(git("diff", "--cached", "--binary")).toBe(stagedBefore); + expect(git("diff", "--binary")).toBe(unstagedBefore); + expect(read(file)).toBe(source); + expect(staged(goodFile)).toBe(goodSource); + expect(git("stash", "list")).toBe(""); + }); + + test("allows ignored lockfiles and staged deletions without ignored-file warnings", () => { + const source = read("package-lock.json"); + write("package-lock.json", `${source}\n`); + git("add", "--", "package-lock.json"); + git("rm", "--", "src/browser.ts"); + + const result = lintStaged(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(staged("package-lock.json")).toBe(`${source}\n`); + expect(git("diff", "--cached", "--name-status")).toContain("D\tsrc/browser.ts"); + }); + + test("allows an empty staged selection without formatting anything", () => { + const before = git("diff", "--binary"); + const result = lintStaged(); + expect(result.status).toBe(0); + expect(git("diff", "--binary")).toBe(before); + expect(git("diff", "--cached")).toBe(""); + }); +}); From a23ca47f3067a738dd2b25315a2144b97b08b3d2 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:17:25 +0300 Subject: [PATCH 11/11] feat(alarms): add helpers and preserve async command handlers --- docs/alarms.md | 45 +++++++++++++++- docs/commands.md | 2 + scripts/verify-build.mjs | 6 +-- src/alarms.test.ts | 98 +++++++++++++++++++++++++++++++++++ src/alarms.ts | 20 +++++++ src/commands.test.ts | 60 +++++++++++++++++++++ src/commands.ts | 2 +- src/testing/coverage.test.ts | 2 +- src/testing/coverage.ts | 7 +-- tests/consumer-types/index.ts | 18 ++++++- 10 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 src/alarms.test.ts create mode 100644 src/commands.test.ts diff --git a/docs/alarms.md b/docs/alarms.md index 23f7f44..9b98e09 100644 --- a/docs/alarms.md +++ b/docs/alarms.md @@ -9,12 +9,14 @@ A promise-based wrapper for the Chrome `alarms` API. - [clearAlarm(name)](#clearAlarm) - [clearAllAlarm()](#clearAllAlarm) - [createAlarm(name, info)](#createAlarm) +- [createAlarmIfNotExists(name, info)](#createAlarmIfNotExists) - [getAlarm(name)](#getAlarm) - [getAllAlarm()](#getAllAlarm) ## Events - [onAlarm(callback)](#onAlarm) +- [onSpecificAlarm(name, callback)](#onSpecificAlarm) @@ -46,6 +48,25 @@ createAlarm(name: string, info: chrome.alarms.AlarmCreateInfo): Promise Creates a new alarm or updates an existing one with the given name and scheduling options. + + +### createAlarmIfNotExists + +```ts +createAlarmIfNotExists(name: string, info: chrome.alarms.AlarmCreateInfo): Promise +``` + +Creates an alarm only if no alarm with the given name exists. Returns `true` after creation, or `false` if the alarm +already exists. An existing alarm keeps its schedule and the supplied `info` is ignored. Lookup and creation errors +reject the returned Promise. + +The lookup and creation are separate operations, so concurrent calls for the same name are not atomic and may both +attempt to create the alarm. + +```ts +await createAlarmIfNotExists("sync", {periodInMinutes: 5}); +``` + ### getAlarm @@ -74,4 +95,26 @@ Retrieves all set alarms. onAlarm(callback: (alarm: chrome.alarms.Alarm) => void): () => void ``` -Adds a listener that triggers when an alarm goes off. Returns an unsubscribe function. \ No newline at end of file +Adds a listener that triggers when an alarm goes off. Returns an unsubscribe function. + + + +### onSpecificAlarm + +```ts +onSpecificAlarm(name: string, callback: (alarm: chrome.alarms.Alarm) => void): () => void +``` + +Adds a listener that triggers only when the alarm name exactly matches `name`. Passes the complete alarm object to +the callback and returns an unsubscribe function. The callback may be async; synchronous errors and rejected Promises +are logged by the listener wrapper. + +```ts +const unsubscribe = onSpecificAlarm("sync", async alarm => { + console.log("Scheduled time:", alarm.scheduledTime); + await syncData(); +}); + +// Remove this listener when it is no longer needed. +unsubscribe(); +``` diff --git a/docs/commands.md b/docs/commands.md index c17b329..188bd70 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -47,3 +47,5 @@ onSpecificCommand( ``` Adds a listener that triggers only when the specified command is invoked. Returns an unsubscribe function. + +The callback may be async; synchronous errors and rejected Promises are logged by the listener wrapper. diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index 98fa983..7578783 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -49,11 +49,11 @@ const declarationExports = getModuleExports(declarationEntry, { types: ["chrome"], }); -assert.equal(sourceExports.length, 331, "The source public-export baseline changed; update the coverage matrix first"); +assert.equal(sourceExports.length, 333, "The source public-export baseline changed; update the coverage matrix first"); assert.equal( sourceExports.filter(value => value.hasValue).length, - 328, + 330, "The source runtime-export baseline changed; update the coverage matrix first" ); @@ -144,4 +144,4 @@ for (const {file, source} of testingRuntimeSources) { ); } -console.log("Verified 331 TypeScript exports, 328 ESM/CJS runtime exports, and isolated testing bundles."); +console.log("Verified 333 TypeScript exports, 330 ESM/CJS runtime exports, and isolated testing bundles."); diff --git a/src/alarms.test.ts b/src/alarms.test.ts new file mode 100644 index 0000000..46c7b16 --- /dev/null +++ b/src/alarms.test.ts @@ -0,0 +1,98 @@ +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {createAlarmIfNotExists, onSpecificAlarm} from "./alarms"; +import {type BrowserHarness, createBrowserHarness, installBrowserGlobals} from "./testing"; + +describe.each(["chrome", "firefox"] as const)("alarm helpers in %s", profile => { + let harness: BrowserHarness; + let restoreGlobals: () => void; + + const alarm: chrome.alarms.Alarm = { + name: "sync", + periodInMinutes: 5, + persistAcrossSessions: false, + scheduledTime: 123456, + }; + + beforeEach(() => { + harness = createBrowserHarness(); + restoreGlobals = installBrowserGlobals(harness, {profile, captureListenerErrors: true}); + harness.configurable.active.alarms.get.setResult(undefined); + harness.configurable.active.alarms.create.setResult(undefined); + }); + + afterEach(() => { + restoreGlobals(); + }); + + test("creates a missing alarm with the supplied name and schedule", async () => { + const info = {periodInMinutes: 5}; + + await expect(createAlarmIfNotExists("sync", info)).resolves.toBe(true); + + expect(harness.configurable.active.alarms.get.calls).toMatchObject([{args: ["sync"]}]); + expect(harness.configurable.active.alarms.create.calls).toMatchObject([{args: ["sync", info]}]); + }); + + test("does not replace an existing alarm when given a different schedule", async () => { + harness.configurable.active.alarms.get.setResult(alarm); + + await expect(createAlarmIfNotExists("sync", {periodInMinutes: 10})).resolves.toBe(false); + + expect(harness.configurable.active.alarms.create.calls).toHaveLength(0); + }); + + test("propagates lookup errors without attempting creation", async () => { + harness.configurable.active.alarms.get.failNext(new Error("Alarm lookup failed")); + + await expect(createAlarmIfNotExists("sync", {periodInMinutes: 5})).rejects.toThrow("Alarm lookup failed"); + + expect(harness.configurable.active.alarms.create.calls).toHaveLength(0); + }); + + test("propagates creation errors instead of reporting success", async () => { + harness.configurable.active.alarms.create.failNext(new Error("Alarm creation failed")); + + await expect(createAlarmIfNotExists("sync", {periodInMinutes: 5})).rejects.toThrow("Alarm creation failed"); + }); + + test("filters names exactly, forwards the complete alarm, and unsubscribes", async () => { + const callback = jest.fn<(alarm: chrome.alarms.Alarm) => void>(); + const event = harness.configurable.active.alarms.onAlarm; + const unsubscribe = onSpecificAlarm("sync", callback); + + await event.emit({...alarm, name: "sync-other"}); + await event.emit({...alarm, name: "Sync"}); + expect(callback).not.toHaveBeenCalled(); + + await event.emit(alarm); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0]?.[0]).toBe(alarm); + + unsubscribe(); + await event.emit(alarm); + expect(callback).toHaveBeenCalledTimes(1); + expect(event.listenerCount()).toBe(0); + }); + + test("logs synchronous listener errors through the existing wrapper", async () => { + const error = new Error("Alarm listener failed"); + + onSpecificAlarm("sync", () => { + throw error; + }); + + await expect(harness.configurable.active.alarms.onAlarm.emit(alarm)).resolves.toBeUndefined(); + expect(harness.listenerErrors.entries).toEqual([{args: [], error, kind: "sync"}]); + }); + + test("forwards async listener rejections to the existing wrapper", async () => { + const error = new Error("Async alarm listener failed"); + + onSpecificAlarm("sync", async () => { + throw error; + }); + + await expect(harness.configurable.active.alarms.onAlarm.emit(alarm)).rejects.toBe(error); + expect(harness.listenerErrors.entries).toEqual([{args: [], error, kind: "promise"}]); + }); +}); diff --git a/src/alarms.ts b/src/alarms.ts index 37d771b..41f73e3 100644 --- a/src/alarms.ts +++ b/src/alarms.ts @@ -14,6 +14,18 @@ export const clearAllAlarm = (): Promise => callWithPromise(cb => alarm export const createAlarm = (name: string, info: AlarmCreateInfo): Promise => callWithPromise(cb => alarms().create(name, info, cb)); +export const createAlarmIfNotExists = async (name: string, info: AlarmCreateInfo): Promise => { + const alarm = await getAlarm(name); + + if (alarm) { + return false; + } + + await createAlarm(name, info); + + return true; +}; + export const getAlarm = (name: string): Promise => callWithPromise(cb => alarms().get(name, cb)); export const getAllAlarm = (): Promise => callWithPromise(cb => alarms().getAll(cb)); @@ -22,3 +34,11 @@ export const getAllAlarm = (): Promise => callWithPromise(cb => alarms( export const onAlarm = (callback: Parameters[0]): (() => void) => { return handleListener(alarms().onAlarm, callback); }; + +export const onSpecificAlarm = (name: string, callback: Parameters[0]): (() => void) => { + return onAlarm(alarm => { + if (alarm.name === name) { + return callback(alarm); + } + }); +}; diff --git a/src/commands.test.ts b/src/commands.test.ts new file mode 100644 index 0000000..880a38b --- /dev/null +++ b/src/commands.test.ts @@ -0,0 +1,60 @@ +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import {onSpecificCommand} from "./commands"; +import {type BrowserHarness, createBrowserHarness, createTabFixture, installBrowserGlobals} from "./testing"; + +describe.each(["chrome", "firefox"] as const)("specific command listeners in %s", profile => { + let harness: BrowserHarness; + let restoreGlobals: () => void; + + beforeEach(() => { + harness = createBrowserHarness(); + restoreGlobals = installBrowserGlobals(harness, {profile, captureListenerErrors: true}); + }); + + afterEach(() => { + restoreGlobals(); + }); + + test("filters commands, forwards the optional tab, and unsubscribes", async () => { + const tab = createTabFixture({id: 7}); + const callback = jest.fn<(tab?: chrome.tabs.Tab) => void>(); + const event = harness.configurable.active.commands.onCommand; + const unsubscribe = onSpecificCommand("sync", callback); + + await event.emit("sync-other", tab); + expect(callback).not.toHaveBeenCalled(); + + await event.emit("sync", tab); + expect(callback.mock.calls).toEqual([[tab]]); + + await event.emit("sync"); + expect(callback.mock.calls).toEqual([[tab], [undefined]]); + + unsubscribe(); + await event.emit("sync", tab); + expect(callback).toHaveBeenCalledTimes(2); + expect(event.listenerCount()).toBe(0); + }); + + test("keeps synchronous error handling", async () => { + const error = new Error("Command listener failed"); + + onSpecificCommand("sync", () => { + throw error; + }); + + await expect(harness.configurable.active.commands.onCommand.emit("sync")).resolves.toBeUndefined(); + expect(harness.listenerErrors.entries).toEqual([{args: [], error, kind: "sync"}]); + }); + + test("forwards async listener rejections to the existing wrapper", async () => { + const error = new Error("Async command listener failed"); + + onSpecificCommand("sync", async () => { + throw error; + }); + + await expect(harness.configurable.active.commands.onCommand.emit("sync")).rejects.toBe(error); + expect(harness.listenerErrors.entries).toEqual([{args: [], error, kind: "promise"}]); + }); +}); diff --git a/src/commands.ts b/src/commands.ts index 6cd6905..873dd2d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -17,7 +17,7 @@ export const onCommand = (callback: Parameters any): (() => void) => { return onCommand((name, tab) => { if (command === name) { - callback(tab); + return callback(tab); } }); }; diff --git a/src/testing/coverage.test.ts b/src/testing/coverage.test.ts index 30b27a8..367ad61 100644 --- a/src/testing/coverage.test.ts +++ b/src/testing/coverage.test.ts @@ -156,7 +156,7 @@ describe("testing coverage matrices", () => { expect(PUBLIC_EXPORT_COVERAGE.filter(entry => entry.coverage === "unsupported")).toEqual([]); }); - test("keeps the three interfaces type-only and the other 328 exports runtime-visible", () => { + test("keeps the three interfaces type-only and the other 330 exports runtime-visible", () => { const {checker, exports} = rootExports(); const typeOnly = exports diff --git a/src/testing/coverage.ts b/src/testing/coverage.ts index f13c187..28e1840 100644 --- a/src/testing/coverage.ts +++ b/src/testing/coverage.ts @@ -52,7 +52,8 @@ export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [ "getAlarm", "getAllAlarm", ]), - ...entries("alarms", "event-wrapper", "event", ["onAlarm"]), + ...entries("alarms", "method-wrapper", "behavioral", ["createAlarmIfNotExists"]), + ...entries("alarms", "event-wrapper", "event", ["onAlarm", "onSpecificAlarm"]), ...entries("audio", "method-wrapper", "configurable", [ "getAudioDevices", @@ -444,8 +445,8 @@ export const PUBLIC_EXPORT_COVERAGE: readonly PublicExportCoverageEntry[] = [ export const TYPE_ONLY_ROOT_EXPORTS = ["BrowserGuess", "LaunchWebAuthFlowDetails", "WindowEventFilter"] as const; -export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 331; -export const EXPECTED_ROOT_RUNTIME_EXPORT_COUNT = 328; +export const EXPECTED_ROOT_TYPESCRIPT_EXPORT_COUNT = 333; +export const EXPECTED_ROOT_RUNTIME_EXPORT_COUNT = 330; export const getPublicExportCoverage = (name: string): PublicExportCoverageEntry | undefined => PUBLIC_EXPORT_COVERAGE.find(entry => entry.name === name); diff --git a/tests/consumer-types/index.ts b/tests/consumer-types/index.ts index a7f2ce1..b91bb56 100644 --- a/tests/consumer-types/index.ts +++ b/tests/consumer-types/index.ts @@ -1,4 +1,11 @@ -import {containsPermissions, getManifest, onTabUpdated, queryTabs} from "@addon-core/browser"; +import { + containsPermissions, + createAlarmIfNotExists, + getManifest, + onSpecificAlarm, + onTabUpdated, + queryTabs, +} from "@addon-core/browser"; import { type BrowserHarness, type BrowserMethod, @@ -22,6 +29,13 @@ const matchedTabs: Promise = harness.browser.tabs.query({ }); const hasHostAccess: Promise = containsPermissions({origins: ["https://shop.example.com/*"]}); +const alarmCreated: Promise = createAlarmIfNotExists("sync", {periodInMinutes: 5}); + +const unsubscribeAlarm: () => void = onSpecificAlarm("sync", async alarm => { + const currentAlarm: chrome.alarms.Alarm = alarm; + void currentAlarm; +}); + const browserQuery: typeof chrome.tabs.query = harness.browser.tabs.query; const downloadValidationDelay: BrowserMethod<(milliseconds: number) => Promise, void> = @@ -43,6 +57,8 @@ void manifestName; void queryResult; void matchedTabs; void hasHostAccess; +void alarmCreated; +unsubscribeAlarm(); restore(); onTabUpdated((tabId, changeInfo, tab) => {