From e85fc6f43e8fbd7ac2492c875518d6e12e594bff Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:58:12 +0300 Subject: [PATCH 1/5] fix(service): allow offscreen documents to call background services --- .github/workflows/ci.yml | 8 + .gitignore | 1 + CONTRIBUTING.md | 2 +- package-lock.json | 96 ++++- package.json | 12 +- .../providers/ProxyService.context.test.ts | 122 ++++++ .../offscreen-service.integration.test.ts | 407 ++++++++++++++++++ .../browser/offscreen-service/adnbn.config.ts | 7 + .../browser/offscreen-service/package.json | 6 + .../offscreen-service/src/background/index.ts | 11 + .../offscreen-service/src/offscreen/index.ts | 12 + .../src/services/offscreen-test.service.ts | 10 + .../browser/offscreen-service/tsconfig.json | 3 + 13 files changed, 683 insertions(+), 14 deletions(-) create mode 100644 src/service/providers/ProxyService.context.test.ts create mode 100644 tests/integration/browser/offscreen-service.integration.test.ts create mode 100644 tests/integration/browser/offscreen-service/adnbn.config.ts create mode 100644 tests/integration/browser/offscreen-service/package.json create mode 100644 tests/integration/browser/offscreen-service/src/background/index.ts create mode 100644 tests/integration/browser/offscreen-service/src/offscreen/index.ts create mode 100644 tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts create mode 100644 tests/integration/browser/offscreen-service/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9f48b7a..42b94fe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,12 @@ jobs: node-version: ${{ matrix.node }} cache: npm + - name: Set up Chrome + id: chrome + uses: browser-actions/setup-chrome@v2 + with: + chrome-version: stable + - name: Install dependencies run: npm ci @@ -62,6 +68,8 @@ jobs: - name: Test run: npm run test:ci + env: + ADNBN_CHROME_BIN: ${{ steps.chrome.outputs.chrome-path }} - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 7cb22aac..fa7fb772 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ TODOs.md stats.html .tool-versions .cache +/tests/integration/browser/offscreen-service/.adnbn/ *-stats.txt .npmrc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e2ee405..309caaa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ Releases and versioning: 3. Make changes and ensure commits follow Conventional Commits. 4. Before opening a PR, run local checks: - Formatting: `npm run format` - - Tests: `npm test` + - Tests: `npm test` (the full suite includes a Chrome MV3 integration test; use `ADNBN_CHROME_BIN=/path/to/chrome` to override automatic Chrome discovery) - By area (if needed): - `npm run test:relay` - `npm run test:service` diff --git a/package-lock.json b/package-lock.json index 3d34191b..cf83e64d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.6.0", + "@addon-core/browser": "^0.7.1", "@addon-core/inject-script": "^0.3.1", - "@addon-core/storage": "^0.6.0", + "@addon-core/storage": "^0.7.0", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", @@ -66,6 +66,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.1", + "chrome-launcher": "^1.2.1", "cross-env": "^7.0.3", "dotenv": "^16.4.7", "esbuild": "^0.25.10", @@ -117,9 +118,9 @@ "license": "MIT" }, "node_modules/@addon-core/browser": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.6.0.tgz", - "integrity": "sha512-nMnv+q6ObOAaAfiAdgTVmbQddw4pUIume4/AK7n+mUqPrGPtjFsrd06/xjH23n4YLHkxB5ousCXSOeh3XnSc0w==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.1.tgz", + "integrity": "sha512-HLfmofwfx2oU31DrHHz0RZPwE2xh/ra7goOE7bCUZTObIptDGnlxldmiODdswra9D4b5FMQi5UuOK9TRGTAI6g==", "license": "MIT", "peerDependencies": { "@types/chrome": "*" @@ -145,9 +146,9 @@ } }, "node_modules/@addon-core/storage": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@addon-core/storage/-/storage-0.6.0.tgz", - "integrity": "sha512-N483T9Guk+8T1BJEDeOmuXRuzyC26+d57yf5egUJh7bQiOmz+9vftBWBMjKkhbnV23Vk6RI+vSA3EkMmG23SQw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@addon-core/storage/-/storage-0.7.0.tgz", + "integrity": "sha512-MINCKg6sMKKeqRUnhmfPLveYBUwrvDrEvSmZgo+7QMxD0bWrbdmbO8Eb15DJHzNF/dX3M/CBwW4dxSzZXr03Fg==", "license": "MIT", "dependencies": { "@addon-core/browser": "^0.5.0", @@ -9612,6 +9613,67 @@ "fsevents": "~2.3.2" } }, + "node_modules/chrome-launcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", + "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/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/chrome-launcher/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -14951,6 +15013,17 @@ "node": ">=6" } }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -15180,6 +15253,13 @@ "tmpl": "1.0.5" } }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 6d054955..e1d74a6f 100644 --- a/package.json +++ b/package.json @@ -109,10 +109,11 @@ "format": "prettier --write .", "typecheck": "tsc -p tsconfig.json --noEmit", "check:node-version": "node ./scripts/check-node-version.js", - "test": "jest", - "test:ci": "jest --ci --passWithNoTests --coverage", - "test:watch": "jest --watch", + "test": "npm run build && jest", + "test:ci": "npm run test -- --ci --passWithNoTests --coverage", + "test:watch": "jest --watch --testPathIgnorePatterns=tests/integration/browser", "test:offscreen": "jest --testPathPatterns=Offscreen", + "test:chrome": "npm run test -- --testPathPatterns=tests/integration/browser --runInBand", "test:message": "jest --testPathPatterns=src/message", "test:service": "jest --testPathPatterns=Service", "test:relay": "jest --testPathPatterns=Relay", @@ -124,9 +125,9 @@ "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci" }, "dependencies": { - "@addon-core/browser": "^0.6.0", + "@addon-core/browser": "^0.7.1", "@addon-core/inject-script": "^0.3.1", - "@addon-core/storage": "^0.6.0", + "@addon-core/storage": "^0.7.0", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", @@ -177,6 +178,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.1", + "chrome-launcher": "^1.2.1", "cross-env": "^7.0.3", "dotenv": "^16.4.7", "esbuild": "^0.25.10", diff --git a/src/service/providers/ProxyService.context.test.ts b/src/service/providers/ProxyService.context.test.ts new file mode 100644 index 00000000..739e927e --- /dev/null +++ b/src/service/providers/ProxyService.context.test.ts @@ -0,0 +1,122 @@ +/** @jest-environment node */ + +jest.unmock("@addon-core/browser"); + +import {getService} from "@main/service"; + +import ProxyService from "./ProxyService"; + +import {MessageResultEnvelopeProperty, MessageTypeSeparator} from "@typing/message"; + +type ServiceProxy = { + someMethod(value: string): Promise; +}; + +type RuntimeSendMessage = (message: unknown, callback: (response: unknown) => void) => void; + +const serviceName = "test"; + +const setRuntime = (runtime?: {sendMessage?: RuntimeSendMessage; getManifest?: () => chrome.runtime.Manifest}) => { + Object.defineProperty(globalThis, "chrome", { + configurable: true, + writable: true, + value: runtime + ? { + runtime: { + id: "extension-id", + ...runtime, + }, + } + : undefined, + }); +}; + +const setExtensionDocument = (pathname: string) => { + Object.defineProperty(globalThis, "window", {configurable: true, value: {}}); + Object.defineProperty(globalThis, "location", {configurable: true, value: {pathname}}); +}; + +const getTestService = (): ServiceProxy => getService(serviceName as never) as ServiceProxy; + +describe("ProxyService context detection", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test("creates a service proxy in an offscreen-like context with no runtime.getManifest", async () => { + const expectedResult = "background:value"; + const sendMessage = jest.fn((_message, callback) => { + callback({[MessageResultEnvelopeProperty]: true, ok: true, payload: expectedResult}); + }); + + setRuntime({sendMessage}); + setExtensionDocument("/offscreen.html"); + + expect(() => getTestService()).not.toThrow(); + + const service = getTestService(); + + // @ts-expect-error - The runtime proxy marker is intentionally hidden from its public type. + expect(service.__proxy).toBe(true); + await expect(service.someMethod("value")).resolves.toBe(expectedResult); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: `service${MessageTypeSeparator}${serviceName}`, + data: { + path: "someMethod", + args: ["value"], + }, + }), + expect.any(Function) + ); + }); + + test("restores serialized background errors in an offscreen-like context", async () => { + const sendMessage = jest.fn((_message, callback) => { + callback({ + [MessageResultEnvelopeProperty]: true, + ok: false, + error: {name: "TypeError", message: "background failed"}, + }); + }); + + setRuntime({sendMessage}); + setExtensionDocument("/offscreen.html"); + + await expect(getTestService().someMethod("value")).rejects.toThrow("background failed"); + await expect(getTestService().someMethod("value")).rejects.toBeInstanceOf(TypeError); + }); + + test.each([ + ["popup", "/popup.html"], + ["sidebar", "/sidebar.html"], + ["content script", "/content-script.html"], + ])("creates a proxy in %s context", (_context, pathname) => { + setRuntime({ + getManifest: () => + ({manifest_version: 3, background: {service_worker: "background.js"}}) as chrome.runtime.Manifest, + }); + setExtensionDocument(pathname); + + expect(() => getTestService()).not.toThrow(); + }); + + test("creates a proxy when the WebExtension API is unavailable", () => { + setRuntime(); + setExtensionDocument("/standalone.html"); + + expect(() => getTestService()).not.toThrow(); + }); + + test("continues to reject service proxies in an MV3 service worker", () => { + setRuntime({ + getManifest: () => + ({manifest_version: 3, background: {service_worker: "background.js"}}) as chrome.runtime.Manifest, + }); + Object.defineProperty(globalThis, "window", {configurable: true, value: undefined}); + + expect(() => new ProxyService(serviceName).get()).toThrow( + `You are trying to get proxy service "${serviceName}" from background. You can get original service instead` + ); + }); +}); diff --git a/tests/integration/browser/offscreen-service.integration.test.ts b/tests/integration/browser/offscreen-service.integration.test.ts new file mode 100644 index 00000000..8ea0c89a --- /dev/null +++ b/tests/integration/browser/offscreen-service.integration.test.ts @@ -0,0 +1,407 @@ +/** @jest-environment node */ + +import {mkdir, mkdtemp, rm, symlink} from "fs/promises"; +import {createServer} from "net"; +import os from "os"; +import path from "path"; +import {spawn, spawnSync, type ChildProcess} from "child_process"; + +type CdpMessage = { + id?: number; + method?: string; + params?: Record; + result?: Record; + error?: {message: string}; + sessionId?: string; +}; + +type CdpTarget = { + id: string; + type: string; + url: string; + webSocketDebuggerUrl: string; +}; + +const rootDir = path.resolve(__dirname, "..", "..", ".."); +const fixtureDir = path.join(__dirname, "offscreen-service"); +const extensionDir = path.join(fixtureDir, "dist", "myapp-chrome-mv3"); +const findChromeBinary = (): string | undefined => { + if (process.env.ADNBN_CHROME_BIN) { + return process.env.ADNBN_CHROME_BIN; + } + + const result = spawnSync( + process.execPath, + [path.join(rootDir, "node_modules", "chrome-launcher", "bin", "print-chrome-path.cjs")], + {encoding: "utf8"} + ); + const chromePath = result.status === 0 ? result.stdout.trim() : ""; + + return chromePath || undefined; +}; + +const chromeBinary = findChromeBinary(); + +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); + +const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { + const deadline = Date.now() + timeout; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const value = await callback(); + + if (value !== undefined) { + return value; + } + } catch (error) { + lastError = error; + } + + await delay(100); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + + throw new Error(`Timed out waiting for Chrome${detail}`); +}; + +const getFreePort = (): Promise => { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Unable to reserve a debugging port")); + + return; + } + + server.close(error => (error ? reject(error) : resolve(address.port))); + }); + }); +}; + +const run = (command: string, args: string[], cwd: string): Promise => { + return new Promise((resolve, reject) => { + const process = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); + let output = ""; + + process.stdout.on("data", chunk => (output += chunk)); + process.stderr.on("data", chunk => (output += chunk)); + process.once("error", reject); + process.once("exit", code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`)); + } + }); + }); +}; + +class CdpClient { + private nextId = 1; + private readonly pending = new Map< + number, + {resolve: (value: Record) => void; reject: (error: Error) => void} + >(); + private readonly listeners = new Set<(message: CdpMessage) => void>(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", event => this.receive(JSON.parse(String(event.data)))); + socket.addEventListener("close", () => this.rejectPending(new Error("Chrome DevTools connection closed"))); + socket.addEventListener("error", () => this.rejectPending(new Error("Chrome DevTools connection failed"))); + } + + public static async connect(url: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + + socket.addEventListener("open", () => resolve(new CdpClient(socket)), {once: true}); + socket.addEventListener( + "error", + () => reject(new Error(`Unable to connect to Chrome DevTools at ${url}`)), + { + once: true, + } + ); + }); + } + + public onMessage(listener: (message: CdpMessage) => void): () => void { + this.listeners.add(listener); + + return () => this.listeners.delete(listener); + } + + public send( + method: string, + params: Record = {}, + sessionId?: string + ): Promise> { + const id = this.nextId++; + + this.socket.send(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})})); + + return new Promise((resolve, reject) => this.pending.set(id, {resolve, reject})); + } + + public async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) { + return; + } + + await new Promise(resolve => { + const timeout = setTimeout(resolve, 1_000); + + this.socket.addEventListener( + "close", + () => { + clearTimeout(timeout); + resolve(); + }, + {once: true} + ); + this.socket.close(); + }); + } + + private receive(message: CdpMessage): void { + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + + if (!pending) { + return; + } + + this.pending.delete(message.id); + + if (message.error) { + pending.reject(new Error(message.error.message)); + } else { + pending.resolve(message.result ?? {}); + } + + return; + } + + this.listeners.forEach(listener => listener(message)); + } + + private rejectPending(error: Error): void { + this.pending.forEach(pending => pending.reject(error)); + this.pending.clear(); + } +} + +const targets = async (port: number): Promise => { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + + return response.json() as Promise; +}; + +const browserVersion = async (port: number): Promise<{webSocketDebuggerUrl: string}> => { + const response = await fetch(`http://127.0.0.1:${port}/json/version`); + + return response.json() as Promise<{webSocketDebuggerUrl: string}>; +}; + +const stop = async (process: ChildProcess): Promise => { + if (process.exitCode !== null || process.killed) { + return; + } + + let timeout: NodeJS.Timeout | undefined; + const exited = new Promise(resolve => { + process.once("exit", () => { + if (timeout) { + clearTimeout(timeout); + } + + resolve(true); + }); + timeout = setTimeout(() => resolve(false), 5_000); + }); + + process.kill("SIGTERM"); + const stopped = await exited; + + if (!stopped && process.exitCode === null) { + process.kill("SIGKILL"); + await new Promise(resolve => process.once("exit", () => resolve())); + } +}; + +jest.setTimeout(60_000); + +test("Chrome MV3 offscreen calls the registered background service", async () => { + if (!chromeBinary || !path.isAbsolute(chromeBinary)) { + throw new Error( + "Chrome is not installed or could not be found. Install Chrome or set ADNBN_CHROME_BIN to its absolute executable path." + ); + } + + const frameworkLink = path.join(fixtureDir, "node_modules", "adnbn"); + const userDataDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-offscreen-service-")); + const debuggingPort = await getFreePort(); + let chrome: ChildProcess | undefined; + let browser: CdpClient | undefined; + let serviceWorker: CdpClient | undefined; + let chromeOutput = ""; + + try { + await mkdir(path.dirname(frameworkLink), {recursive: true}); + await symlink(rootDir, frameworkLink, "dir"); + await run(process.execPath, [path.join(rootDir, "bin", "adnbn.js"), "build", ".", "-b", "chrome"], fixtureDir); + + chrome = spawn( + chromeBinary, + [ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--enable-logging=stderr", + "--v=0", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${userDataDir}`, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + chrome.stderr?.on("data", chunk => (chromeOutput += chunk)); + + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(debuggingPort)); + browser = await CdpClient.connect(webSocketDebuggerUrl); + + const errors: string[] = []; + const extensionSessions = new Set(); + + browser.onMessage(message => { + if (message.method === "Target.attachedToTarget") { + const targetInfo = message.params?.targetInfo as {url?: string} | undefined; + const sessionId = message.params?.sessionId as string | undefined; + + if (sessionId) { + if (targetInfo?.url?.startsWith("chrome-extension://")) { + extensionSessions.add(sessionId); + void browser! + .send("Runtime.enable", {}, sessionId) + .then(() => browser!.send("Log.enable", {}, sessionId)) + .finally(() => browser!.send("Runtime.runIfWaitingForDebugger", {}, sessionId)) + .catch(() => undefined); + } else { + void browser!.send("Runtime.runIfWaitingForDebugger", {}, sessionId).catch(() => undefined); + } + } + + return; + } + + if (!message.sessionId || !extensionSessions.has(message.sessionId)) { + return; + } + + if (message.method === "Runtime.exceptionThrown") { + const details = message.params?.exceptionDetails as + | {text?: string; exception?: {description?: string}} + | undefined; + + errors.push(details?.exception?.description ?? details?.text ?? "Unhandled extension exception"); + } + + if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") { + const args = (message.params.args as Array<{value?: unknown; description?: string}> | undefined) ?? []; + + errors.push(args.map(argument => String(argument.value ?? argument.description ?? "")).join(" ")); + } + + if (message.method === "Log.entryAdded") { + const entry = message.params?.entry as {level?: string; text?: string} | undefined; + + if (entry?.level === "error") { + errors.push(entry.text ?? "Chrome extension log error"); + } + } + }); + + await browser.send("Target.setAutoAttach", {autoAttach: true, waitForDebuggerOnStart: true, flatten: true}); + const extension = await browser.send("Extensions.loadUnpacked", {path: extensionDir}); + const extensionId = extension.id as string | undefined; + + if (!extensionId) { + throw new Error("Chrome did not return an extension ID after loading the MV3 fixture"); + } + + let chromeTargets: CdpTarget[] = []; + let worker: CdpTarget; + + try { + worker = await waitFor(async () => { + chromeTargets = await targets(debuggingPort); + + return chromeTargets.find( + target => + target.type === "service_worker" && + target.url === `chrome-extension://${extensionId}/js/background.js` + ); + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; CDP targets: ${JSON.stringify( + chromeTargets.map(target => ({type: target.type, url: target.url})) + )}; Chrome output: ${chromeOutput}` + ); + } + + serviceWorker = await CdpClient.connect(worker.webSocketDebuggerUrl); + let backgroundState: unknown; + + try { + await waitFor(async () => { + const ready = await serviceWorker!.send("Runtime.evaluate", { + expression: + "({entrypoint: typeof globalThis.__adnbnRunOffscreenRoundTrip, runtime: typeof chrome?.runtime})", + returnByValue: true, + }); + + backgroundState = ready.result.value; + + return (ready.result.value as {entrypoint?: string}).entrypoint === "function" ? ready : undefined; + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; background state: ${JSON.stringify(backgroundState)}` + ); + } + + const result = await serviceWorker.send("Runtime.evaluate", { + expression: "globalThis.__adnbnRunOffscreenRoundTrip()", + awaitPromise: true, + returnByValue: true, + }); + + expect(result.exceptionDetails).toBeUndefined(); + expect(result.result.value).toBe("background:ping"); + + await delay(250); + expect(errors).toEqual([]); + } finally { + await serviceWorker?.close(); + await browser?.close(); + + if (chrome) { + await stop(chrome); + } + + await rm(path.join(fixtureDir, "node_modules"), {recursive: true, force: true}); + await rm(path.join(fixtureDir, ".adnbn"), {recursive: true, force: true}); + await rm(path.join(fixtureDir, "dist"), {recursive: true, force: true}); + await rm(userDataDir, {recursive: true, force: true}); + } +}); diff --git a/tests/integration/browser/offscreen-service/adnbn.config.ts b/tests/integration/browser/offscreen-service/adnbn.config.ts new file mode 100644 index 00000000..2cbe9a6c --- /dev/null +++ b/tests/integration/browser/offscreen-service/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Offscreen Service Integration", + description: "MV3 fixture for the offscreen-to-background service transport.", + jsFilename: "[name].js", +}); diff --git a/tests/integration/browser/offscreen-service/package.json b/tests/integration/browser/offscreen-service/package.json new file mode 100644 index 00000000..0b3b2b5a --- /dev/null +++ b/tests/integration/browser/offscreen-service/package.json @@ -0,0 +1,6 @@ +{ + "name": "adnbn-offscreen-service-integration", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/browser/offscreen-service/src/background/index.ts b/tests/integration/browser/offscreen-service/src/background/index.ts new file mode 100644 index 00000000..6643dcc4 --- /dev/null +++ b/tests/integration/browser/offscreen-service/src/background/index.ts @@ -0,0 +1,11 @@ +import {defineBackground, getOffscreen} from "adnbn"; + +declare global { + var __adnbnRunOffscreenRoundTrip: (() => Promise) | undefined; +} + +export default defineBackground({ + main() { + globalThis.__adnbnRunOffscreenRoundTrip = () => getOffscreen("offscreenTest").callBackground(); + }, +}); diff --git a/tests/integration/browser/offscreen-service/src/offscreen/index.ts b/tests/integration/browser/offscreen-service/src/offscreen/index.ts new file mode 100644 index 00000000..96507c5b --- /dev/null +++ b/tests/integration/browser/offscreen-service/src/offscreen/index.ts @@ -0,0 +1,12 @@ +import {defineOffscreen, getService, OffscreenReason} from "adnbn"; + +export default defineOffscreen({ + name: "offscreenTest", + reasons: [OffscreenReason.DOMParser], + justification: "Use DOM APIs while exercising the offscreen-to-background service transport.", + init: () => ({ + async callBackground() { + return getService("offscreenTest").echo("ping"); + }, + }), +}); diff --git a/tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts b/tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts new file mode 100644 index 00000000..f1bc08f4 --- /dev/null +++ b/tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts @@ -0,0 +1,10 @@ +import {defineService} from "adnbn"; + +export default defineService({ + name: "offscreenTest", + init: () => ({ + echo(value: string) { + return `background:${value}`; + }, + }), +}); diff --git a/tests/integration/browser/offscreen-service/tsconfig.json b/tests/integration/browser/offscreen-service/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/offscreen-service/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} From 2d7d8e7e1c96191832c5440bf9d0c4316f826364 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:44:44 +0300 Subject: [PATCH 2/5] docs(readme): refresh package overview --- README.md | 74 ++++++++++++++++++++----------------------------------- 1 file changed, 27 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 38a058cd..6bf827d5 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,40 @@ -# adnbn +# Addon Bone -[![npm version](https://img.shields.io/npm/v/adnbn.svg)](https://www.npmjs.com/package/adnbn) -[![npm downloads](https://img.shields.io/npm/dm/adnbn.svg)](https://www.npmjs.com/package/adnbn) +> **🚧 Active development** — the public API and documentation may change between releases. Full guides and API reference are being prepared at [addonbone.com](https://addonbone.com). -## Addon Bone - Cross-Browser Web Extension Framework with shared code base +[![npm version](https://img.shields.io/npm/v/adnbn.svg?logo=npm&style=for-the-badge)](https://www.npmjs.com/package/adnbn) +[![npm downloads](https://img.shields.io/npm/dm/adnbn.svg?style=for-the-badge&color=blue)](https://www.npmjs.com/package/adnbn) +[![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/addon-bone/ci.yml?style=for-the-badge)](https://github.com/addon-stack/addon-bone/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE.md) -**Cross-browser framework** for developing extensions on a single code base. Enables creating multiple production-grade extensions with different localizations, icons, and designs while maintaining identical core functionality. +A TypeScript framework for building browser extensions from a shared codebase. +Define extension entrypoints in source files; Addon Bone discovers them and creates +the browser-specific build and manifest. -### Core Concept +## Install -- **Single code base**: maintain one set of source files for all extensions. -- **Flexibility**: supports both multi-package setups (multiple extensions in one repository) and standalone projects. -- **Modern workflow**: automatic merging of styles, scripts, content scripts, and background scripts. +```bash +npm i -D adnbn +``` -### Supported Platforms & Manifests +## Get started -- **Browsers**: Chrome, Firefox, Opera, Safari, Edge. -- **Manifest Versions**: Manifest V2 and V3. +Create a project, then build it for production: -### Entry Points +```bash +npx adnbn init +npx adnbn build +``` -- **Background Page** -- **Content Scripts** -- **Commands** -- **Localization** -- **Icons** -- **Messages** -- **Services** -- **Relay** -- **Offscreen** -- **Popup** -- **Sidebar** +For project structure, entrypoint guides, and the API reference, visit +[addonbone.com](https://addonbone.com). -### Plugins +## Links -- **Extensibility via Plugins**: write modules for background pages, content scripts, and any supported entry points. -- **Automatic Integration**: plugins are automatically included in the build process and update the manifest. +- [Documentation](https://addonbone.com) +- [Contributing](CONTRIBUTING.md) +- [Issues](https://github.com/addon-stack/addon-bone/issues) -### Services & Relay +## License -- **Services**: class-based layer for background interactions without boilerplate. Service methods are available across all extension layers via simple calls. -- **Relay**: similar mechanism for content pages, bypassing CSP and getUserGesture restrictions by leveraging scripting contexts. - -### Offscreen - -- **Manifest V2**: offscreen support via `