From 5076ccc23da714842d098ee7e372b2e8d6ec1c47 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:11:58 +0300 Subject: [PATCH 1/9] test: parallelize suites and harden test infrastructure --- jest.config.ts | 66 ++++++- package-lock.json | 3 + package.json | 45 +++-- scripts/merge-coverage.mjs | 12 ++ scripts/test-inventory.mjs | 38 ++++ scripts/test-run.mjs | 25 +++ .../IsolatedStylesPlugin.test.ts | 86 +++++---- .../entrypoint/parser/ContentParser.test.ts | 18 +- tests/build-output.test.ts | 4 +- .../css-fonts.firefox.integration.test.ts | 1 - .../content/css-fonts.integration.test.ts | 1 - ...rypoint-assets.firefox.integration.test.ts | 8 +- .../entrypoint-assets.integration.test.ts | 13 +- .../src/probe/async/index.ts | 2 +- .../entrypoint-assets/src/probe/index.ts | 6 +- .../frame-pages.firefox.integration.test.ts | 1 - .../content/frame-pages.integration.test.ts | 1 - .../browser/content/isolated-styles-utils.ts | 19 +- ...olation-iframe.firefox.integration.test.ts | 1 - .../isolation-iframe.integration.test.ts | 1 - .../src/probe.content/Panel.tsx | 6 +- .../src/probe.content/index.tsx | 2 +- ...ion-shadow-mv2.firefox.integration.test.ts | 7 + ...ion-shadow-mv3.firefox.integration.test.ts | 7 + ...olation-shadow.firefox.integration.test.ts | 12 -- .../isolation-shadow.integration.test.ts | 2 - .../content/isolation-shadow/README.md | 2 +- .../src/probe.content/index.ts | 13 +- .../browser/content/isolation-spike-utils.ts | 4 +- ...solation-spike.firefox.integration.test.ts | 1 - .../isolation-spike.integration.test.ts | 1 - .../browser/content/relay-styles-utils.ts | 52 ++++++ .../relay-styles.firefox.integration.test.ts | 6 + .../content/relay-styles.integration.test.ts | 58 +----- tests/integration/browser/content/utils.ts | 2 +- .../locale/chunks.firefox.integration.test.ts | 12 +- .../browser/locale/chunks.integration.test.ts | 15 +- .../dynamic.firefox.integration.test.ts | 2 - .../locale/dynamic.integration.test.ts | 5 +- tests/integration/browser/locale/utils.ts | 6 +- .../offscreen/service.integration.test.ts | 5 +- .../options/options.integration.test.ts | 5 +- tests/integration/browser/utils/CdpClient.ts | 151 ++++++++++++++++ .../integration/browser/utils/browser.test.ts | 21 +++ tests/integration/browser/utils/browser.ts | 14 +- tests/integration/browser/utils/chrome.ts | 152 ---------------- tests/integration/browser/utils/session.ts | 3 +- tests/integration/browser/utils/site.test.ts | 1 - .../isolation-watch.integration.test.ts | 1 - .../chunks-disabled.integration.test.ts | 8 + .../chunks-threshold.integration.test.ts | 14 ++ .../integration/build/locale/chunks-utils.ts | 153 ++++++++++++++++ .../build/locale/chunks.integration.test.ts | 169 +----------------- .../build/locale/dynamic-fixture/package.json | 2 +- .../build/locale/dynamic.integration.test.ts | 2 - .../build/locale/fixture/adnbn.config.ts | 12 ++ .../build/locale/locale.integration.test.ts | 31 +++- .../build/options/options.integration.test.ts | 2 - .../relay-runtime.integration.test.ts | 1 - .../routing-spike.integration.test.ts | 2 - .../style-routing.integration.test.ts | 1 - .../virtual-modules.integration.test.ts | 1 - tests/integration/prepare.ts | 7 +- tests/integration/typecheck.ts | 14 +- tests/integration/utils/fixture.ts | 12 +- .../utils/fixtures/process-tree.cjs | 18 ++ tests/integration/utils/process.test.ts | 77 ++++++++ tests/integration/utils/process.ts | 85 +++++++-- tests/integration/utils/queue.test.ts | 28 +++ tests/integration/utils/queue.ts | 34 ++++ ...message.setup.ts => jest-message.setup.ts} | 0 ...modules.setup.ts => jest-modules.setup.ts} | 0 ...est.relay.setup.ts => jest-relay.setup.ts} | 0 tests/jest.setup.ts | 6 +- ...icy.test.ts => main-source-policy.test.ts} | 0 tests/pre-push.ts | 34 ++++ ...ion.test.ts => release-automation.test.ts} | 0 tsconfig.json | 1 + 78 files changed, 1053 insertions(+), 580 deletions(-) create mode 100644 scripts/merge-coverage.mjs create mode 100644 scripts/test-inventory.mjs create mode 100644 scripts/test-run.mjs create mode 100644 tests/integration/browser/content/isolation-shadow-mv2.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/isolation-shadow-mv3.firefox.integration.test.ts delete mode 100644 tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/relay-styles-utils.ts create mode 100644 tests/integration/browser/content/relay-styles.firefox.integration.test.ts create mode 100644 tests/integration/browser/utils/CdpClient.ts create mode 100644 tests/integration/browser/utils/browser.test.ts create mode 100644 tests/integration/build/locale/chunks-disabled.integration.test.ts create mode 100644 tests/integration/build/locale/chunks-threshold.integration.test.ts create mode 100644 tests/integration/build/locale/chunks-utils.ts create mode 100644 tests/integration/utils/fixtures/process-tree.cjs create mode 100644 tests/integration/utils/process.test.ts create mode 100644 tests/integration/utils/queue.test.ts create mode 100644 tests/integration/utils/queue.ts rename tests/{jest.message.setup.ts => jest-message.setup.ts} (100%) rename tests/{jest.modules.setup.ts => jest-modules.setup.ts} (100%) rename tests/{jest.relay.setup.ts => jest-relay.setup.ts} (100%) rename tests/{mainSourcePolicy.test.ts => main-source-policy.test.ts} (100%) create mode 100644 tests/pre-push.ts rename tests/{releaseAutomation.test.ts => release-automation.test.ts} (100%) diff --git a/jest.config.ts b/jest.config.ts index 0aff96b1..abb69e4c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,8 +1,9 @@ import type {Config} from "jest"; +import {availableParallelism} from "node:os"; -const config: Config = { - verbose: true, - testEnvironment: "jsdom", +const shared: Config = { + rootDir: import.meta.dirname, + testEnvironment: "node", globals: {ADNBN_TEST_ROOT: import.meta.dirname}, setupFiles: ["/tests/jest.setup.ts"], modulePathIgnorePatterns: ["/.cache/"], @@ -40,7 +41,64 @@ const config: Config = { }, ], }, - testMatch: ["**/*.test.ts"], +}; + +const buildTests = [ + "/src/cli/bundler/plugins/**/*.test.ts", + "/src/cli/index.test.ts", + "/src/cli/virtual/virtual.test.ts", + "/src/cli/plugins/content/RelayDeclaration.test.ts", + "/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts", + "/tests/build-output.test.ts", + "/tests/integration/build/**/*.test.ts", +]; +const domTests = [ + "/src/entry/**/*.test.ts", + "/src/frame/**/*.test.ts", + "/src/sandbox/providers/**/*.test.ts", + "/src/offscreen/OffscreenBridge.test.ts", + "/src/locale/adapters/**/*.test.ts", + "/src/message/adapters/**/*.test.ts", +]; +const exclude = (patterns: string[]) => + patterns.map(pattern => `!${pattern.replace("", import.meta.dirname.replaceAll("\\", "/"))}`); + +const config: Config = { + verbose: false, + maxWorkers: Math.max(1, Math.min(8, availableParallelism() - 1)), + coverageProvider: "babel", + projects: [ + { + ...shared, + displayName: "unit-node", + testMatch: [ + "/src/**/*.test.ts", + "/tests/*.test.ts", + "/tests/integration/utils/**/*.test.ts", + "/tests/integration/browser/utils/**/*.test.ts", + ...exclude(buildTests), + ...exclude(domTests), + ], + }, + {...shared, displayName: "unit-dom", testEnvironment: "jsdom", testMatch: domTests}, + {...shared, displayName: "build", testMatch: buildTests}, + {...shared, displayName: "types", testMatch: ["/tests/integration/types/**/*.test.ts"]}, + { + ...shared, + displayName: "chrome", + setupFiles: [], + testMatch: [ + "/tests/integration/browser/**/*.integration.test.ts", + ...exclude(["/tests/integration/browser/**/*.firefox.integration.test.ts"]), + ], + }, + { + ...shared, + displayName: "firefox", + setupFiles: [], + testMatch: ["/tests/integration/browser/**/*.firefox.integration.test.ts"], + }, + ], }; export default config; diff --git a/package-lock.json b/package-lock.json index 04b265fb..4a5525f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,9 @@ "glob": "^13.0.1", "globby": "^14.1.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.0.0", "jest-webextension-mock": "^4.0.0", diff --git a/package.json b/package.json index a49cc5a3..fa9b52ed 100644 --- a/package.json +++ b/package.json @@ -116,26 +116,32 @@ "format": "prettier --write .", "typecheck": "tsc -p tsconfig.json --noEmit", "prepare:integration": "npm run build && tsx tests/integration/prepare.ts", - "typecheck:integration": "npm run prepare:integration && tsx tests/integration/typecheck.ts", - "test:integration": "npm run test -- --testPathPatterns=tests/integration --runInBand", - "test:integration:build": "npm run test -- --testPathPatterns=tests/integration/build --runInBand", + "typecheck:integration": "npm run build && tsx tests/integration/typecheck.ts --prepare", + "test:integration": "npm run build && node scripts/test-run.mjs --selectProjects build types chrome firefox", + "test:integration:build": "npm run test:build --", "check:node-version": "node ./scripts/check-node-version.js", - "test": "npm run build && jest", - "test:ci": "npm run test -- --ci --passWithNoTests --coverage", - "test:ci:non-browser": "npm run test -- --ci --passWithNoTests --coverage --testPathIgnorePatterns=tests/integration/browser", - "test:watch": "jest --watch --testPathIgnorePatterns=tests/integration/browser", - "test:offscreen": "jest --testPathPatterns=Offscreen", - "test:chrome": "npm run test -- --testPathPatterns=tests/integration/browser --testPathIgnorePatterns=firefox.integration.test --runInBand", - "test:firefox": "npm run test -- --testPathPatterns=firefox.integration.test --runInBand", - "test:message": "jest --testPathPatterns=src/message", - "test:service": "jest --testPathPatterns=Service", - "test:relay": "jest --testPathPatterns=Relay", - "test:locale": "jest --testPathPatterns=Locale", - "test:manifest": "jest --testPathPatterns=src/cli/builders/manifest", - "test:entrypoint": "jest --testPathPatterns=src/cli/entrypoint", - "test:plugins": "jest --testPathPatterns=src/cli/plugins", + "test": "npm run build && node scripts/test-run.mjs", + "test:ci": "npm run test -- --ci --coverage", + "test:ci:non-browser": "npm run test -- --ci --coverage --selectProjects unit-node unit-dom build types", + "test:watch": "node scripts/test-run.mjs --selectProjects unit-node unit-dom --watch", + "test:offscreen": "node scripts/test-run.mjs --testPathPatterns=Offscreen", + "test:chrome": "npm run build && node scripts/test-run.mjs --selectProjects chrome", + "test:firefox": "npm run build && node scripts/test-run.mjs --selectProjects firefox", + "test:message": "node scripts/test-run.mjs --testPathPatterns=src/message", + "test:service": "node scripts/test-run.mjs --testPathPatterns=Service", + "test:relay": "npm run test -- --testPathPatterns=Relay", + "test:locale": "npm run test -- --testPathPatterns=Locale", + "test:manifest": "node scripts/test-run.mjs --testPathPatterns=src/cli/builders/manifest", + "test:entrypoint": "node scripts/test-run.mjs --testPathPatterns=src/cli/entrypoint", + "test:plugins": "npm run test -- --testPathPatterns=src/cli/plugins", "release": "release-it", - "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci" + "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci", + "test:unit": "node scripts/test-run.mjs --selectProjects unit-node unit-dom --verbose=false", + "test:build": "npm run build && node scripts/test-run.mjs --selectProjects build", + "test:types": "npm run typecheck:integration && node scripts/test-run.mjs --selectProjects types", + "test:inventory": "node scripts/test-inventory.mjs", + "test:pre-push": "tsx tests/pre-push.ts", + "test:run": "node scripts/test-run.mjs" }, "dependencies": { "@addon-core/browser": "^0.7.2", @@ -203,6 +209,9 @@ "glob": "^13.0.1", "globby": "^14.1.0", "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.0.0", "jest-webextension-mock": "^4.0.0", diff --git a/scripts/merge-coverage.mjs b/scripts/merge-coverage.mjs new file mode 100644 index 00000000..b4ae5609 --- /dev/null +++ b/scripts/merge-coverage.mjs @@ -0,0 +1,12 @@ +import {readFile} from "node:fs/promises"; +import path from "node:path"; +import coverage from "istanbul-lib-coverage"; +import report from "istanbul-lib-report"; +import reports from "istanbul-reports"; + +const files = process.argv.slice(2); +if (files.length < 2) throw new Error("Pass the unit and build coverage-final.json files"); +const map = coverage.createCoverageMap({}); +for (const file of files) map.merge(JSON.parse(await readFile(file, "utf8"))); +const context = report.createContext({dir: path.resolve("coverage/merged"), coverageMap: map}); +for (const format of ["json", "lcov", "text-summary"]) reports.create(format).execute(context); diff --git a/scripts/test-inventory.mjs b/scripts/test-inventory.mjs new file mode 100644 index 00000000..aa59fbc5 --- /dev/null +++ b/scripts/test-inventory.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import {execFile} from "node:child_process"; +import {existsSync} from "node:fs"; +import path from "node:path"; +import {promisify} from "node:util"; + +const execute = promisify(execFile); +const root = path.resolve(import.meta.dirname, ".."); +const jest = path.join(root, "scripts/test-run.mjs"); +const options = {cwd: root, maxBuffer: 10 * 1024 * 1024}; +const {stdout: configuration} = await execute(process.execPath, [jest, "--showConfig"], options); +const projects = JSON.parse(configuration).configs.map(config => config.displayName.name); +const {stdout: tracked} = await execute( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + options +); +const expected = new Set( + tracked.split("\0").filter(file => file.endsWith(".test.ts") && existsSync(path.join(root, file))) +); +const owners = new Map(); + +for (const project of projects) { + const {stdout} = await execute( + process.execPath, + [jest, "--selectProjects", project, "--listTests", "--json", "--runInBand"], + options + ); + const files = JSON.parse(stdout); + console.info(`${project}: ${files.length} files`); + for (const filename of files) { + const file = path.relative(root, filename).split(path.sep).join("/"); + assert(!owners.has(file), `${file} belongs to both ${owners.get(file)} and ${project}`); + owners.set(file, project); + } +} +assert.deepEqual(new Set(owners.keys()), expected, "Every test file must belong to exactly one Jest project"); +console.info(`${expected.size} test files, each in exactly one project`); diff --git a/scripts/test-run.mjs b/scripts/test-run.mjs new file mode 100644 index 00000000..f417862b --- /dev/null +++ b/scripts/test-run.mjs @@ -0,0 +1,25 @@ +import {spawn} from "node:child_process"; +import {constants} from "node:os"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +// Keep the workaround scoped to the environment where this project reproduces nodejs/node#62393. +const flags = process.platform === "darwin" && process.arch === "arm64" ? ["--no-sparkplug"] : []; +const child = spawn( + process.execPath, + [...flags, path.join(root, "node_modules/jest/bin/jest.js"), ...process.argv.slice(2)], + {stdio: "inherit"} +); + +const forward = signal => child.kill(signal); +const signals = ["SIGINT", "SIGTERM", "SIGHUP"]; +for (const signal of signals) process.on(signal, forward); +child.once("error", error => { + console.error(error); + process.exitCode = 1; +}); +child.once("close", (code, signal) => { + for (const signal of signals) process.off(signal, forward); + if (signal) console.error(`Jest terminated with ${signal}`); + process.exitCode = code ?? (signal ? 128 + constants.signals[signal] : 1); +}); diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts index f4a9fca6..8f6059da 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts @@ -846,53 +846,61 @@ test("switches the selected CSS runtime from shadow to normal and back during wa selected: entry => entry === "shadow" && shadow, }); const observations: boolean[] = []; - const watchErrors: string[] = []; - - const watcher = compiler.watch({poll: 50}, (error, stats) => { - if (error) { - watchErrors.push(error.message); - return; - } - - if (!stats || stats.hasErrors()) { - watchErrors.push(stats?.toString({all: false, errors: true, errorDetails: true}) ?? "missing stats"); - return; - } - - observations.push( - source(stats, entryFile(stats, "shadow", ".js")).includes("var isolatedStyleRoots = new Map()") - ); - - if (observations.length === 1) { - shadow = false; - fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = false;\n"); - watcher.invalidate(); - } else if (observations.length === 2) { - shadow = true; - fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); - watcher.invalidate(); - } - }); - - try { - await new Promise((resolve, reject) => { - const poll = setInterval(() => { - if (observations.length === 3) { - clearInterval(poll); - clearTimeout(timeout); - resolve(); - } - }, 10); + let completed: ((error: Error | null, stats?: Stats) => void) | undefined; + const nextCompilation = (update?: () => void): Promise => + new Promise((resolve, reject) => { const timeout = setTimeout(() => { - clearInterval(poll); + completed = undefined; reject( new Error( - `Rspack watch did not finish three rebuilds: ${JSON.stringify({observations, watchErrors})}` + `Rspack watch did not complete transition ${observations.length + 1}: ${JSON.stringify(observations)}` ) ); }, 5_000); + completed = (error, stats) => { + clearTimeout(timeout); + completed = undefined; + if (error) reject(error); + else if (!stats || stats.hasErrors()) { + reject( + new Error( + stats?.toString({all: false, errors: true, errorDetails: true}) ?? "Missing watch stats" + ) + ); + } else resolve(stats); + }; + try { + update?.(); + } catch (error) { + clearTimeout(timeout); + completed = undefined; + reject(error); + } }); + const initial = nextCompilation(); + const watcher = compiler.watch({poll: 50}, (error, stats) => completed?.(error, stats)); + try { + let stats = await initial; + observations.push( + source(stats, entryFile(stats, "shadow", ".js")).includes("var isolatedStyleRoots = new Map()") + ); + for (const selected of [false, true]) { + // Rspack reconnects its watcher on nextTick after invoking the compilation callback. + // Starting another build inside that callback races with this reconnection. + await new Promise(resolve => setImmediate(resolve)); + stats = await nextCompilation(() => { + shadow = selected; + fs.writeFileSync( + path.join(watchFixtures, "watch-state.js"), + `globalThis.shadowWatchState = ${selected};\n` + ); + watcher.invalidate(); + }); + observations.push( + source(stats, entryFile(stats, "shadow", ".js")).includes("var isolatedStyleRoots = new Map()") + ); + } expect(observations).toEqual([true, false, true]); } finally { await new Promise((resolve, reject) => { diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index f279b5ae..09513f7b 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -16,6 +16,18 @@ const file = (...parts: string[]) => { return {file: filename, import: filename}; }; +const expectParseError = (target: ReturnType, message: RegExp): void => { + try { + parser.options(target); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(message); + expect((error as Error).message).toContain(target.file); + return; + } + throw new Error(`Expected parsing ${target.file} to fail`); +}; + describe("ContentParser", () => { test.each([ ["enum", {mode: ContentScriptShadowMode.Closed}], @@ -39,8 +51,7 @@ describe("ContentParser", () => { ["unknown", /isolation.delegatesFocus is not supported/], ])("rejects invalid Shadow options from %s", (name, error) => { const target = file("invalid", "shadow", name + ".content.ts"); - expect(() => parser.options(target)).toThrow(error); - expect(() => parser.options(target)).toThrow(target.file); + expectParseError(target, error); }); test.each([ "named-function.content.tsx", @@ -54,8 +65,7 @@ describe("ContentParser", () => { "element-object.content.ts", ])("rejects frame navigation with default render: %s", name => { const target = file("invalid", "default-render", name); - expect(() => parser.options(target)).toThrow(/isolation.page\/isolation.src cannot be combined with render/); - expect(() => parser.options(target)).toThrow(target.file); + expectParseError(target, /isolation.page\/isolation.src cannot be combined with render/); }); test.each([ "function.content.tsx", diff --git a/tests/build-output.test.ts b/tests/build-output.test.ts index 9b73bd78..8679b226 100644 --- a/tests/build-output.test.ts +++ b/tests/build-output.test.ts @@ -1,7 +1,7 @@ import {readdir} from "fs/promises"; import path from "path"; -const TestPathSegments = new Set(["tests", "__tests__", "__mocks__"]); +const testPathSegments = new Set(["tests", "__tests__", "__mocks__"]); const collectPaths = async (directory: string): Promise => { const entries = await readdir(directory, {withFileTypes: true}); @@ -28,7 +28,7 @@ describe("build output", () => { const segments = relativePath.split(path.sep); return ( - segments.some(segment => TestPathSegments.has(segment)) || /\.(?:test|spec)\.[^.]+$/.test(relativePath) + segments.some(segment => testPathSegments.has(segment)) || /\.(?:test|spec)\.[^.]+$/.test(relativePath) ); }); diff --git a/tests/integration/browser/content/css-fonts.firefox.integration.test.ts b/tests/integration/browser/content/css-fonts.firefox.integration.test.ts index 62965de3..efe15c4a 100644 --- a/tests/integration/browser/content/css-fonts.firefox.integration.test.ts +++ b/tests/integration/browser/content/css-fonts.firefox.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runCssFontsProbe} from "./css-fonts-utils"; jest.setTimeout(90_000); diff --git a/tests/integration/browser/content/css-fonts.integration.test.ts b/tests/integration/browser/content/css-fonts.integration.test.ts index 0ff383ef..031cee8e 100644 --- a/tests/integration/browser/content/css-fonts.integration.test.ts +++ b/tests/integration/browser/content/css-fonts.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runCssFontsProbe} from "./css-fonts-utils"; jest.setTimeout(90_000); diff --git a/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts b/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts index 836abf25..6620689b 100644 --- a/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts +++ b/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {mkdtemp, readFile, rm, stat} from "fs/promises"; import os from "os"; import path from "path"; @@ -10,7 +8,7 @@ import {findFirefoxBinary} from "../utils/firefox"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; -import {DocumentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; +import {documentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); const fixtureDir = path.join(__dirname, "entrypoint-assets"); @@ -125,7 +123,7 @@ test.each([2, 3] as const)( await browser.send("browsingContext.navigate", {context, url: `${site.origin}/top.html`, wait: "complete"}); const top = await waitFor(async () => { - const state = await browser!.evaluate(context, `${DocumentStateExpression}(document)`); + const state = await browser!.evaluate(context, `${documentStateExpression}(document)`); lastState = state; const statuses = [state?.isolated?.async, state?.main?.async]; return statuses.every(status => status !== undefined && status !== "pending") ? state : undefined; @@ -143,7 +141,7 @@ test.each([2, 3] as const)( `(() => { const child = document.querySelector('[data-testid="child-frame"]')?.contentDocument; if (!child || child.readyState !== 'complete') return undefined; - return {top: ${DocumentStateExpression}(document), child: ${DocumentStateExpression}(child)}; + return {top: ${documentStateExpression}(document), child: ${documentStateExpression}(child)}; })()` ); lastState = state; diff --git a/tests/integration/browser/content/entrypoint-assets.integration.test.ts b/tests/integration/browser/content/entrypoint-assets.integration.test.ts index f5a2ba3c..e7783f9c 100644 --- a/tests/integration/browser/content/entrypoint-assets.integration.test.ts +++ b/tests/integration/browser/content/entrypoint-assets.integration.test.ts @@ -1,16 +1,15 @@ -/** @jest-environment node */ - import {mkdtemp, readFile, rm, stat} from "fs/promises"; import os from "os"; import path from "path"; import {spawn, type ChildProcess} from "child_process"; -import {browserVersion, CdpClient, findChromeBinary, targets} from "../utils/chrome"; +import {browserVersion, findChromeBinary, targets} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; -import {DocumentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; +import {documentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); const fixtureDir = path.join(__dirname, "entrypoint-assets"); @@ -31,7 +30,7 @@ const evaluate = async (browser: CdpClient, sessionId: string, expression: strin }; const readDocumentState = async (browser: CdpClient, sessionId: string): Promise => { - return evaluate(browser, sessionId, `${DocumentStateExpression}(document)`); + return evaluate(browser, sessionId, `${documentStateExpression}(document)`); }; const waitForLoadedDocument = async (browser: CdpClient, sessionId: string): Promise => { @@ -187,8 +186,8 @@ test("Chrome MV3 loads ISOLATED chunks lazily and keeps MAIN dynamic imports in if (!child || child.readyState !== 'complete') return undefined; return { - top: ${DocumentStateExpression}(document), - child: ${DocumentStateExpression}(child), + top: ${documentStateExpression}(document), + child: ${documentStateExpression}(child), }; })()` ); diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts b/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts index 32169683..4851cfcd 100644 --- a/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts @@ -1,7 +1,7 @@ import probeAsset from "./probe.svg"; import styles from "./styles.module.css"; -export const AsyncProbeValue = "loaded"; +export const asyncProbeValue = "loaded"; export const applyAsyncProbe = (root: HTMLElement): void => { root.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts b/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts index 6d7fa14b..53ad9e88 100644 --- a/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts @@ -5,7 +5,7 @@ import styles from "./styles.module.css"; export type ProbeWorld = "ISOLATED" | "MAIN"; -const FullGetterError = "getEntrypointAssetsMap() is available only in the background entrypoint"; +const fullGetterError = "getEntrypointAssetsMap() is available only in the background entrypoint"; export const createProbe = (world: ProbeWorld): HTMLElement => { const root = document.createElement("section"); @@ -39,13 +39,13 @@ export const createProbe = (world: ProbeWorld): HTMLElement => { root.dataset.fullGetter = "unexpected"; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); - root.dataset.fullGetter = message === FullGetterError ? "blocked" : message; + root.dataset.fullGetter = message === fullGetterError ? "blocked" : message; } void import("./async") .then(module => { module.applyAsyncProbe(root); - root.dataset.async = module.AsyncProbeValue; + root.dataset.async = module.asyncProbeValue; }) .catch(cause => { root.dataset.async = `failed: ${cause instanceof Error ? cause.message : String(cause)}`; diff --git a/tests/integration/browser/content/frame-pages.firefox.integration.test.ts b/tests/integration/browser/content/frame-pages.firefox.integration.test.ts index a98121ef..ee6e1c19 100644 --- a/tests/integration/browser/content/frame-pages.firefox.integration.test.ts +++ b/tests/integration/browser/content/frame-pages.firefox.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runFramePagesIntegration} from "./frame-pages-utils"; jest.setTimeout(90_000); test.each([2, 3] as const)( diff --git a/tests/integration/browser/content/frame-pages.integration.test.ts b/tests/integration/browser/content/frame-pages.integration.test.ts index b6b5d476..2b1876bc 100644 --- a/tests/integration/browser/content/frame-pages.integration.test.ts +++ b/tests/integration/browser/content/frame-pages.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runFramePagesIntegration} from "./frame-pages-utils"; jest.setTimeout(90_000); test("Chrome MV3 embeds an extension page and MAIN external source with production frame nodes", async () => { diff --git a/tests/integration/browser/content/isolated-styles-utils.ts b/tests/integration/browser/content/isolated-styles-utils.ts index 2690aad9..6e09453b 100644 --- a/tests/integration/browser/content/isolated-styles-utils.ts +++ b/tests/integration/browser/content/isolated-styles-utils.ts @@ -4,7 +4,8 @@ import os from "os"; import path from "path"; import {getFreePort, stop, waitFor} from "../utils/browser"; -import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import {browserVersion, findChromeBinary} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import BidiClient from "../utils/BidiClient"; import {findFirefoxBinary} from "../utils/firefox"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; @@ -41,7 +42,7 @@ interface FrameState { readonly top: ShadowDocumentState; } -const DocumentStateExpression = `(document => { +const documentStateExpression = `(document => { const view = document.defaultView; const hosts = Array.from(document.querySelectorAll("[data-shadow-probe]")); const probes = hosts.map(host => { @@ -88,7 +89,7 @@ const DocumentStateExpression = `(document => { }; })`; -const StrictCsp = +const strictCsp = "default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'; img-src 'self'"; const isReady = (state: ShadowDocumentState | undefined, expected: number): state is ShadowDocumentState => { @@ -235,7 +236,7 @@ export const runIsolatedStylesIntegration = async ( } const measurements: Array<{policy: string; top: ShadowDocumentState; frames: FrameState}> = []; - for (const policy of [null, StrictCsp]) { + for (const policy of [null, strictCsp]) { site = await startIntegrationSite(path.join(fixture.directory, "site"), policy); const topUrl = site.origin + "/top.html"; const response = await fetch(topUrl); @@ -243,7 +244,7 @@ export const runIsolatedStylesIntegration = async ( await response.arrayBuffer(); await navigate(topUrl); const top = await waitFor(async () => { - const state = await evaluate(`${DocumentStateExpression}(document)`); + const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; return state?.pageUrl === topUrl && isReady(state, 3) ? state : undefined; }, 30_000); @@ -265,7 +266,7 @@ export const runIsolatedStylesIntegration = async ( document.body.appendChild(anchor); })()`); const replaced = await waitFor(async () => { - const state = await evaluate(`${DocumentStateExpression}(document)`); + const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; return isReady(state, 3) && Number(state.probes.find(probe => probe.kind === "secondary")?.instance) > instance @@ -292,7 +293,7 @@ export const runIsolatedStylesIntegration = async ( } })()`); const recovered = await waitFor(async () => { - const state = await evaluate(`${DocumentStateExpression}(document)`); + const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; return isReady(state, 3) && state.probes.every(probe => probe.mounts! > Math.max(...mounts)) ? state @@ -312,7 +313,7 @@ export const runIsolatedStylesIntegration = async ( document.body.append(replacement); })()`); const remounted = await waitFor(async () => { - const state = await evaluate(`${DocumentStateExpression}(document)`); + const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; const instance = Number(state?.probes.find(probe => probe.anchor === "first")?.instance); return isReady(state, 3) && instance > oldInstance ? state : undefined; @@ -325,7 +326,7 @@ export const runIsolatedStylesIntegration = async ( const state = await evaluate(`(() => { const child = document.querySelector('[data-testid="child-frame"]')?.contentDocument; if (!child || child.readyState !== "complete") return undefined; - return {top: ${DocumentStateExpression}(document), child: ${DocumentStateExpression}(child)}; + return {top: ${documentStateExpression}(document), child: ${documentStateExpression}(child)}; })()`); lastState = state; return isReady(state?.top, 2) && isReady(state?.child, 2) ? state : undefined; diff --git a/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts b/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts index e1daff37..d6fedd21 100644 --- a/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts +++ b/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; jest.setTimeout(90_000); test.each([2, 3] as const)( diff --git a/tests/integration/browser/content/isolation-iframe.integration.test.ts b/tests/integration/browser/content/isolation-iframe.integration.test.ts index 511581aa..2638a5ad 100644 --- a/tests/integration/browser/content/isolation-iframe.integration.test.ts +++ b/tests/integration/browser/content/isolation-iframe.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; jest.setTimeout(90_000); test("Chrome MV3 renders production iframe UI, CSS and fonts under strict CSP", async () => { diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx index 6fecf938..2cdf2e16 100644 --- a/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx @@ -3,9 +3,9 @@ import sharedStyles from "../shared/styles.module.css?isolation"; import styles from "./styles.module.css?isolation"; // Also proves that importing before the first iframe exists remembers its async stylesheet. -const Lazy = import("./lazy"); +const lazyModule = import("./lazy"); -export default function Panel({anchor}: {anchor: Element}) { +export function Panel({anchor}: {anchor: Element}) { const ref = useRef(null); useEffect(() => { const root = ref.current!; @@ -14,7 +14,7 @@ export default function Panel({anchor}: {anchor: Element}) { root.dataset.frame = window === window.top ? "top" : "child"; root.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; void (async () => { - const lazy = await Lazy; + const lazy = await lazyModule; if (!active) return; lazy.applyLazyStyle(root); const doc = root.ownerDocument; diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx index 4023fae2..05cc9c93 100644 --- a/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx @@ -1,6 +1,6 @@ import {observeMounts} from "../shared/events"; import React from "react"; -import Panel from "./Panel"; +import {Panel} from "./Panel"; import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; import "./fonts.css?isolation&asis"; diff --git a/tests/integration/browser/content/isolation-shadow-mv2.firefox.integration.test.ts b/tests/integration/browser/content/isolation-shadow-mv2.firefox.integration.test.ts new file mode 100644 index 00000000..94dfad36 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow-mv2.firefox.integration.test.ts @@ -0,0 +1,7 @@ +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; + +jest.setTimeout(90_000); + +test("Firefox MV2 renders production Shadow DOM styles and local fonts under strict page CSP", async () => { + await runIsolatedStylesIntegration("firefox", 2); +}); diff --git a/tests/integration/browser/content/isolation-shadow-mv3.firefox.integration.test.ts b/tests/integration/browser/content/isolation-shadow-mv3.firefox.integration.test.ts new file mode 100644 index 00000000..65abceda --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow-mv3.firefox.integration.test.ts @@ -0,0 +1,7 @@ +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; + +jest.setTimeout(90_000); + +test("Firefox MV3 renders production Shadow DOM styles and local fonts under strict page CSP", async () => { + await runIsolatedStylesIntegration("firefox", 3); +}); diff --git a/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts b/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts deleted file mode 100644 index 31d7dcbb..00000000 --- a/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** @jest-environment node */ - -import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; - -jest.setTimeout(90_000); - -test.each([2, 3] as const)( - "Firefox MV%s renders production Shadow DOM styles and local fonts under strict page CSP", - async version => { - await runIsolatedStylesIntegration("firefox", version); - } -); diff --git a/tests/integration/browser/content/isolation-shadow.integration.test.ts b/tests/integration/browser/content/isolation-shadow.integration.test.ts index 8d53f1a7..c614d85e 100644 --- a/tests/integration/browser/content/isolation-shadow.integration.test.ts +++ b/tests/integration/browser/content/isolation-shadow.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; jest.setTimeout(90_000); diff --git a/tests/integration/browser/content/isolation-shadow/README.md b/tests/integration/browser/content/isolation-shadow/README.md index 904d5805..f9c7aec0 100644 --- a/tests/integration/browser/content/isolation-shadow/README.md +++ b/tests/integration/browser/content/isolation-shadow/README.md @@ -45,7 +45,7 @@ From the repository root: ```sh npm run build npx jest tests/integration/browser/content/isolation-shadow.integration.test.ts --runInBand -npx jest tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts --runInBand +npx jest tests/integration/browser/content/isolation-shadow-mv2.firefox.integration.test.ts --runInBand ``` Set `ADNBN_CHROME_BIN` or `ADNBN_FIREFOX_BIN` when automatic browser discovery selects the wrong diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts index 72070843..7306fbac 100644 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts @@ -4,8 +4,8 @@ import "./fonts.css?asis"; import sharedStyles from "../shared/styles.module.css?isolation"; import styles from "./styles.module.css?isolation"; -const AsyncModule = import("./lazy"); -const ExpectedWidth = 320; +const asyncModule = import("./lazy"); +const expectedWidth = 320; let instance = 0; const width = (element: Element): number => { @@ -59,9 +59,10 @@ export default defineContentScriptAppend({ font.textContent = "AAAA"; root.append(font); - void AsyncModule.then(module => { - module.applyLazyStyle(root); - }) + void asyncModule + .then(module => { + module.applyLazyStyle(root); + }) .then(() => waitForStyles( root, @@ -69,7 +70,7 @@ export default defineContentScriptAppend({ getComputedStyle(root).color === "rgb(17, 85, 153)" && getComputedStyle(root).backgroundColor === "rgb(34, 102, 68)" && getComputedStyle(root).borderTopWidth === "3px" && - Math.abs(width(font) - ExpectedWidth) < 0.1 + Math.abs(width(font) - expectedWidth) < 0.1 ) ) .then(() => { diff --git a/tests/integration/browser/content/isolation-spike-utils.ts b/tests/integration/browser/content/isolation-spike-utils.ts index 557519a5..a12d44bd 100644 --- a/tests/integration/browser/content/isolation-spike-utils.ts +++ b/tests/integration/browser/content/isolation-spike-utils.ts @@ -6,7 +6,7 @@ import {waitFor} from "../utils/browser"; import {startBrowserSession} from "../utils/session"; import {startIntegrationSite} from "../utils/site"; -const State = `Array.from(document.querySelectorAll('[data-probe]'), host => ({...host.dataset, +const stateExpression = `Array.from(document.querySelectorAll('[data-probe]'), host => ({...host.dataset, links: Array.from(host.querySelector('iframe').contentDocument.querySelectorAll('link'), link => link.href)}))`; export const runIsolationSpike = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3) => { @@ -83,7 +83,7 @@ export const runIsolationSpike = async (browser: "chrome" | "firefox", manifestV await session.navigate(site.origin + "/top.html"); const ready = async (previous = 0) => waitFor(async () => { - state = await session!.evaluate(State); + state = await session!.evaluate(stateExpression); if (state.some(probe => probe.ready === "error")) throw new Error(JSON.stringify(state)); return state.length === 2 && state.every(probe => probe.ready === "true" && Number(probe.generation) > previous) diff --git a/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts b/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts index a094ddda..edc42131 100644 --- a/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts +++ b/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runIsolationSpike} from "./isolation-spike-utils"; jest.setTimeout(90_000); diff --git a/tests/integration/browser/content/isolation-spike.integration.test.ts b/tests/integration/browser/content/isolation-spike.integration.test.ts index 76bdc1e4..7f43816b 100644 --- a/tests/integration/browser/content/isolation-spike.integration.test.ts +++ b/tests/integration/browser/content/isolation-spike.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {runIsolationSpike} from "./isolation-spike-utils"; jest.setTimeout(90_000); diff --git a/tests/integration/browser/content/relay-styles-utils.ts b/tests/integration/browser/content/relay-styles-utils.ts new file mode 100644 index 00000000..39ad349e --- /dev/null +++ b/tests/integration/browser/content/relay-styles-utils.ts @@ -0,0 +1,52 @@ +import path from "path"; +import {readFile} from "fs/promises"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; +import {waitFor} from "../utils/browser"; + +export const expectRelayStyles = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3): Promise => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture(root, path.join(__dirname, "relay-styles")); + const site = await startIntegrationSite(path.join(fixture.directory, "site"), null); + let session: Awaited> | undefined; + try { + const extension = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extension, "manifest.json"), "utf8")); + expect(manifest.content_scripts).toHaveLength(3); + session = await startBrowserSession(browser, root, extension); + await session.navigate(site.origin + "/top.html"); + const result = await waitFor(async () => { + const value = await session!.evaluate("document.querySelector('.control-host')?.dataset.result"); + return value ? JSON.parse(value) : undefined; + }).catch(async error => { + throw new Error( + `${String(error)}; state: ${JSON.stringify(await session!.evaluate("({html: document.body.innerHTML, url: location.href})"))}; errors: ${JSON.stringify(session!.errors)}` + ); + }); + expect(result).toEqual({ + shadow: { + color: "rgb(17, 85, 153)", + background: "rgb(34, 102, 68)", + host: "3px", + isolated: true, + mode: "closed", + closed: true, + isolation: {type: "shadow", mode: "closed"}, + }, + iframe: { + color: "rgb(17, 85, 153)", + background: "rgb(34, 102, 68)", + host: "3px", + isolated: true, + isolation: {type: "iframe", width: "100%", height: 150}, + }, + page: "rgb(17, 85, 153)", + }); + expect(session.errors).toEqual([]); + } finally { + await session?.close(); + await site.close(); + await fixture.dispose(); + } +}; diff --git a/tests/integration/browser/content/relay-styles.firefox.integration.test.ts b/tests/integration/browser/content/relay-styles.firefox.integration.test.ts new file mode 100644 index 00000000..140c871c --- /dev/null +++ b/tests/integration/browser/content/relay-styles.firefox.integration.test.ts @@ -0,0 +1,6 @@ +import {expectRelayStyles} from "./relay-styles-utils"; + +jest.setTimeout(90_000); +test.each([2, 3] as const)("firefox MV%s routes Relay CSS and calls its UI through real RPC", version => + expectRelayStyles("firefox", version) +); diff --git a/tests/integration/browser/content/relay-styles.integration.test.ts b/tests/integration/browser/content/relay-styles.integration.test.ts index 91bf2685..f7a71443 100644 --- a/tests/integration/browser/content/relay-styles.integration.test.ts +++ b/tests/integration/browser/content/relay-styles.integration.test.ts @@ -1,58 +1,4 @@ -/** @jest-environment node */ -import path from "path"; -import {readFile} from "fs/promises"; -import {createIntegrationFixture} from "../../utils/fixture"; -import {startBrowserSession} from "../utils/session"; -import {startIntegrationSite} from "../utils/site"; -import {waitFor} from "../utils/browser"; +import {expectRelayStyles} from "./relay-styles-utils"; jest.setTimeout(90_000); -test.each([ - ["chrome", 3], - ["firefox", 2], - ["firefox", 3], -] as const)("%s MV%s routes Relay CSS and calls its UI through real RPC", async (browser, manifestVersion) => { - const root = path.resolve(__dirname, "../../../.."); - const fixture = await createIntegrationFixture(root, path.join(__dirname, "relay-styles")); - const site = await startIntegrationSite(path.join(fixture.directory, "site"), null); - let session: Awaited> | undefined; - try { - const extension = await fixture.build({browser, manifestVersion}); - const manifest = JSON.parse(await readFile(path.join(extension, "manifest.json"), "utf8")); - expect(manifest.content_scripts).toHaveLength(3); - session = await startBrowserSession(browser, root, extension); - await session.navigate(site.origin + "/top.html"); - const result = await waitFor(async () => { - const value = await session!.evaluate("document.querySelector('.control-host')?.dataset.result"); - return value ? JSON.parse(value) : undefined; - }).catch(async error => { - throw new Error( - `${String(error)}; state: ${JSON.stringify(await session!.evaluate("({html: document.body.innerHTML, url: location.href})"))}; errors: ${JSON.stringify(session!.errors)}` - ); - }); - expect(result).toEqual({ - shadow: { - color: "rgb(17, 85, 153)", - background: "rgb(34, 102, 68)", - host: "3px", - isolated: true, - mode: "closed", - closed: true, - isolation: {type: "shadow", mode: "closed"}, - }, - iframe: { - color: "rgb(17, 85, 153)", - background: "rgb(34, 102, 68)", - host: "3px", - isolated: true, - isolation: {type: "iframe", width: "100%", height: 150}, - }, - page: "rgb(17, 85, 153)", - }); - expect(session.errors).toEqual([]); - } finally { - await session?.close(); - await site.close(); - await fixture.dispose(); - } -}); +test("chrome MV3 routes Relay CSS and calls its UI through real RPC", () => expectRelayStyles("chrome", 3)); diff --git a/tests/integration/browser/content/utils.ts b/tests/integration/browser/content/utils.ts index 313e0755..38ae25ee 100644 --- a/tests/integration/browser/content/utils.ts +++ b/tests/integration/browser/content/utils.ts @@ -26,7 +26,7 @@ export interface DocumentState { mainSecondaryRuns?: string; } -export const DocumentStateExpression = ` +export const documentStateExpression = ` (doc => { const probe = world => { const selector = '[data-testid="content-' + world.toLowerCase() + '"]'; diff --git a/tests/integration/browser/locale/chunks.firefox.integration.test.ts b/tests/integration/browser/locale/chunks.firefox.integration.test.ts index 9b255b85..854ed316 100644 --- a/tests/integration/browser/locale/chunks.firefox.integration.test.ts +++ b/tests/integration/browser/locale/chunks.firefox.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {spawn, type ChildProcess} from "child_process"; import {mkdtemp, rm} from "fs/promises"; import os from "os"; @@ -10,14 +8,14 @@ import BidiClient from "../utils/BidiClient"; import {findFirefoxBinary} from "../utils/firefox"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; -import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from "./utils"; +import {changeLanguage, expectPanels, localeFixtureDirectory, readPanels} from "./utils"; jest.setTimeout(90_000); test("Firefox MV3 loads locale in ISOLATED and shares the MAIN catalogue through common content", async () => { const binary = findFirefoxBinary(); if (!binary) throw new Error("Install Firefox or set ADNBN_FIREFOX_BIN"); - const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, localeFixtureDirectory); const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-locale-firefox-")); let process: ChildProcess | undefined; let client: BidiClient | undefined; @@ -40,12 +38,12 @@ test("Firefox MV3 loads locale in ISOLATED and shares the MAIN catalogue through await client.send("browsingContext.navigate", {context, url: site.origin, wait: "complete"}); const contexts = ["isolated", "isolated-secondary", "main", "main-secondary"]; await waitFor(async () => { - expectPanels(await client!.evaluate(context, ReadPanels), contexts); + expectPanels(await client!.evaluate(context, readPanels), contexts); return true; }); expect(await client.evaluate(context, "typeof globalThis.browser?.i18n")).toBe("undefined"); - await client.evaluate(context, ChangeLanguage); - expectPanels(await client.evaluate(context, ReadPanels), contexts, "fr"); + await client.evaluate(context, changeLanguage); + expectPanels(await client.evaluate(context, readPanels), contexts, "fr"); expect(client.runtimeErrors).toEqual([]); } finally { await client?.close(); diff --git a/tests/integration/browser/locale/chunks.integration.test.ts b/tests/integration/browser/locale/chunks.integration.test.ts index ed336074..29ba690c 100644 --- a/tests/integration/browser/locale/chunks.integration.test.ts +++ b/tests/integration/browser/locale/chunks.integration.test.ts @@ -1,22 +1,21 @@ -/** @jest-environment node */ - import {spawn, type ChildProcess} from "child_process"; import {mkdtemp, readFile, rm} from "fs/promises"; import os from "os"; import path from "path"; import {createIntegrationFixture} from "../../utils/fixture"; -import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import {browserVersion, findChromeBinary} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; -import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from "./utils"; +import {changeLanguage, expectPanels, localeFixtureDirectory, readPanels} from "./utils"; jest.setTimeout(90_000); test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue through common content", async () => { const binary = findChromeBinary(ADNBN_TEST_ROOT); if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); - const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, localeFixtureDirectory); const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-locale-chrome-")); let process: ChildProcess | undefined; let client: CdpClient | undefined; @@ -76,14 +75,14 @@ test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue const session = await attach(targetId); await client.send("Page.navigate", {url: page.url}, session); await waitFor(async () => { - expectPanels(await evaluate(session, ReadPanels), page.contexts); + expectPanels(await evaluate(session, readPanels), page.contexts); return true; }); if (page.url === site.origin) { expect(await evaluate(session, "typeof globalThis.chrome?.i18n")).toBe("undefined"); } - await evaluate(session, ChangeLanguage); - expectPanels(await evaluate(session, ReadPanels), page.contexts, "fr"); + await evaluate(session, changeLanguage); + expectPanels(await evaluate(session, readPanels), page.contexts, "fr"); } expect(client.runtimeErrors).toEqual([]); } finally { diff --git a/tests/integration/browser/locale/dynamic.firefox.integration.test.ts b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts index 7c9a1da4..96c46e5f 100644 --- a/tests/integration/browser/locale/dynamic.firefox.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {spawn, type ChildProcess} from "child_process"; import {mkdtemp, rm} from "fs/promises"; import os from "os"; diff --git a/tests/integration/browser/locale/dynamic.integration.test.ts b/tests/integration/browser/locale/dynamic.integration.test.ts index 54d9c4b6..7aa06298 100644 --- a/tests/integration/browser/locale/dynamic.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -1,11 +1,10 @@ -/** @jest-environment node */ - import {spawn, type ChildProcess} from "child_process"; import {mkdtemp, readFile, rm} from "fs/promises"; import os from "os"; import path from "path"; import {createIntegrationFixture} from "../../utils/fixture"; -import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import {browserVersion, findChromeBinary} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {startIntegrationSite, type IntegrationSite} from "../utils/site"; diff --git a/tests/integration/browser/locale/utils.ts b/tests/integration/browser/locale/utils.ts index f244b37c..5ecaf95a 100644 --- a/tests/integration/browser/locale/utils.ts +++ b/tests/integration/browser/locale/utils.ts @@ -1,13 +1,13 @@ import path from "path"; -export const LocaleFixtureDirectory = path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/chunks-fixture"); +export const localeFixtureDirectory = path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/chunks-fixture"); -export const ReadPanels = `Array.from(document.querySelectorAll('[data-locale-context]'), panel => ({ +export const readPanels = `Array.from(document.querySelectorAll('[data-locale-context]'), panel => ({ context: panel.dataset.localeContext, message: panel.querySelector('p').textContent, })).sort((a, b) => a.context.localeCompare(b.context))`; -export const ChangeLanguage = `(() => { +export const changeLanguage = `(() => { for (const select of document.querySelectorAll('[data-locale-context] select')) { select.value = 'fr'; select.dispatchEvent(new Event('change', {bubbles: true})); diff --git a/tests/integration/browser/offscreen/service.integration.test.ts b/tests/integration/browser/offscreen/service.integration.test.ts index dc23c60f..9f7380a0 100644 --- a/tests/integration/browser/offscreen/service.integration.test.ts +++ b/tests/integration/browser/offscreen/service.integration.test.ts @@ -1,11 +1,10 @@ -/** @jest-environment node */ - import {mkdtemp, rm} from "fs/promises"; import os from "os"; import path from "path"; import {spawn, type ChildProcess} from "child_process"; -import {browserVersion, CdpClient, type CdpTarget, findChromeBinary, targets} from "../utils/chrome"; +import {browserVersion, type CdpTarget, findChromeBinary, targets} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; diff --git a/tests/integration/browser/options/options.integration.test.ts b/tests/integration/browser/options/options.integration.test.ts index 603cfa1a..89eaa4d8 100644 --- a/tests/integration/browser/options/options.integration.test.ts +++ b/tests/integration/browser/options/options.integration.test.ts @@ -1,11 +1,10 @@ -/** @jest-environment node */ - import {mkdtemp, readFile, rm} from "fs/promises"; import os from "os"; import path from "path"; import {spawn, type ChildProcess} from "child_process"; -import {browserVersion, CdpClient, findChromeBinary, targets} from "../utils/chrome"; +import {browserVersion, findChromeBinary, targets} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; import {getFreePort, stop, waitFor} from "../utils/browser"; import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; diff --git a/tests/integration/browser/utils/CdpClient.ts b/tests/integration/browser/utils/CdpClient.ts new file mode 100644 index 00000000..db4779aa --- /dev/null +++ b/tests/integration/browser/utils/CdpClient.ts @@ -0,0 +1,151 @@ +type CdpMessage = { + id?: number; + method?: string; + params?: Record; + result?: Record; + error?: {message: string}; + sessionId?: string; +}; + +type CdpPendingRequest = { + resolve: (value: Record) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +export default class CdpClient { + private nextId = 1; + private readonly pending = new Map(); + + public readonly runtimeErrors: string[] = []; + public readonly requests: string[] = []; + + 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, timeout = 15_000): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + const connectTimeout = setTimeout(() => { + socket.close(); + reject(new Error(`Timed out connecting to Chrome DevTools after ${timeout} ms: ${url}`)); + }, timeout); + + socket.addEventListener( + "open", + () => { + clearTimeout(connectTimeout); + resolve(new CdpClient(socket)); + }, + {once: true} + ); + socket.addEventListener( + "error", + () => { + clearTimeout(connectTimeout); + reject(new Error(`Unable to connect to Chrome DevTools at ${url}`)); + }, + { + once: true, + } + ); + }); + } + + public send( + method: string, + params: Record = {}, + sessionId?: string, + timeout = 15_000 + ): Promise> { + const id = this.nextId++; + + return new Promise((resolve, reject) => { + const requestTimeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Chrome DevTools request timed out after ${timeout} ms: ${method}`)); + }, timeout); + + this.pending.set(id, {resolve, reject, timeout: requestTimeout}); + + try { + this.socket.send(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})})); + } catch (error) { + this.pending.delete(id); + clearTimeout(requestTimeout); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + 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.method === "Network.requestWillBeSent") { + const request = message.params?.request as {url: string}; + this.requests.push(request.url); + } + + if (message.method === "Runtime.exceptionThrown") { + const details = message.params?.exceptionDetails as + | {text?: string; exception?: {description?: string}} + | undefined; + + this.runtimeErrors.push(details?.exception?.description ?? details?.text ?? "Unknown runtime exception"); + } + + if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") { + const args = (message.params.args ?? []) as Array<{value?: unknown; description?: string}>; + + this.runtimeErrors.push(args.map(arg => String(arg.value ?? arg.description ?? "")).join(" ")); + } + + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + + if (!pending) { + return; + } + + this.pending.delete(message.id); + clearTimeout(pending.timeout); + + if (message.error) { + pending.reject(new Error(message.error.message)); + } else { + pending.resolve(message.result ?? {}); + } + + return; + } + } + + private rejectPending(error: Error): void { + this.pending.forEach(pending => { + clearTimeout(pending.timeout); + pending.reject(error); + }); + this.pending.clear(); + } +} diff --git a/tests/integration/browser/utils/browser.test.ts b/tests/integration/browser/utils/browser.test.ts new file mode 100644 index 00000000..72712fec --- /dev/null +++ b/tests/integration/browser/utils/browser.test.ts @@ -0,0 +1,21 @@ +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {stop} from "./browser"; + +test("waits for a process that has received a signal but has not exited yet", async () => { + const child = spawn(process.execPath, ["-e", 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], { + stdio: ["ignore", "pipe", "pipe"], + }); + try { + await once(child.stdout!, "data"); + child.kill("SIGTERM"); + expect(child.killed).toBe(true); + await stop(child); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect(child.stdout!.destroyed).toBe(true); + expect(child.stderr!.destroyed).toBe(true); + await stop(child); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } +}); diff --git a/tests/integration/browser/utils/browser.ts b/tests/integration/browser/utils/browser.ts index 80a03289..9185ccf7 100644 --- a/tests/integration/browser/utils/browser.ts +++ b/tests/integration/browser/utils/browser.ts @@ -51,7 +51,14 @@ export const getFreePort = (): Promise => { }; export const stop = async (process: ChildProcess): Promise => { - if (process.exitCode !== null || process.killed) { + const exited = () => process.exitCode !== null || process.signalCode !== null; + const closeStreams = () => { + process.stdin?.destroy(); + process.stdout?.destroy(); + process.stderr?.destroy(); + }; + if (exited()) { + closeStreams(); return; } @@ -83,10 +90,11 @@ export const stop = async (process: ChildProcess): Promise => { stopProcess("SIGTERM"); - if (!(await stopped) && process.exitCode === null) { + if (!(await stopped) && !exited()) { const killed = waitForExit(5_000); stopProcess("SIGKILL"); - await killed; + if (!(await killed) && !exited()) throw new Error(`Process ${process.pid} did not exit after SIGKILL`); } + closeStreams(); }; diff --git a/tests/integration/browser/utils/chrome.ts b/tests/integration/browser/utils/chrome.ts index 4df8663d..9fb72127 100644 --- a/tests/integration/browser/utils/chrome.ts +++ b/tests/integration/browser/utils/chrome.ts @@ -1,27 +1,12 @@ import path from "path"; import {spawnSync} from "child_process"; -type CdpMessage = { - id?: number; - method?: string; - params?: Record; - result?: Record; - error?: {message: string}; - sessionId?: string; -}; - export type CdpTarget = { id: string; type: string; url: string; }; -type CdpPendingRequest = { - resolve: (value: Record) => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; -}; - export const findChromeBinary = (rootDir: string): string | undefined => { if (process.env.ADNBN_CHROME_BIN) { return process.env.ADNBN_CHROME_BIN; @@ -37,143 +22,6 @@ export const findChromeBinary = (rootDir: string): string | undefined => { return chromePath || undefined; }; -export class CdpClient { - private nextId = 1; - private readonly pending = new Map(); - - public readonly runtimeErrors: string[] = []; - public readonly requests: string[] = []; - - 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, timeout = 15_000): Promise { - return new Promise((resolve, reject) => { - const socket = new WebSocket(url); - const connectTimeout = setTimeout(() => { - socket.close(); - reject(new Error(`Timed out connecting to Chrome DevTools after ${timeout} ms: ${url}`)); - }, timeout); - - socket.addEventListener( - "open", - () => { - clearTimeout(connectTimeout); - resolve(new CdpClient(socket)); - }, - {once: true} - ); - socket.addEventListener( - "error", - () => { - clearTimeout(connectTimeout); - reject(new Error(`Unable to connect to Chrome DevTools at ${url}`)); - }, - { - once: true, - } - ); - }); - } - - public send( - method: string, - params: Record = {}, - sessionId?: string, - timeout = 15_000 - ): Promise> { - const id = this.nextId++; - - return new Promise((resolve, reject) => { - const requestTimeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Chrome DevTools request timed out after ${timeout} ms: ${method}`)); - }, timeout); - - this.pending.set(id, {resolve, reject, timeout: requestTimeout}); - - try { - this.socket.send(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})})); - } catch (error) { - this.pending.delete(id); - clearTimeout(requestTimeout); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - 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.method === "Network.requestWillBeSent") { - const request = message.params?.request as {url: string}; - this.requests.push(request.url); - } - - if (message.method === "Runtime.exceptionThrown") { - const details = message.params?.exceptionDetails as - | {text?: string; exception?: {description?: string}} - | undefined; - - this.runtimeErrors.push(details?.exception?.description ?? details?.text ?? "Unknown runtime exception"); - } - - if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") { - const args = (message.params.args ?? []) as Array<{value?: unknown; description?: string}>; - - this.runtimeErrors.push(args.map(arg => String(arg.value ?? arg.description ?? "")).join(" ")); - } - - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - - if (!pending) { - return; - } - - this.pending.delete(message.id); - clearTimeout(pending.timeout); - - if (message.error) { - pending.reject(new Error(message.error.message)); - } else { - pending.resolve(message.result ?? {}); - } - - return; - } - } - - private rejectPending(error: Error): void { - this.pending.forEach(pending => { - clearTimeout(pending.timeout); - pending.reject(error); - }); - this.pending.clear(); - } -} - export const targets = async (port: number): Promise => { const response = await fetch(`http://127.0.0.1:${port}/json/list`, {signal: AbortSignal.timeout(5_000)}); diff --git a/tests/integration/browser/utils/session.ts b/tests/integration/browser/utils/session.ts index 8f7cb286..a3523846 100644 --- a/tests/integration/browser/utils/session.ts +++ b/tests/integration/browser/utils/session.ts @@ -4,7 +4,8 @@ import os from "os"; import path from "path"; import {getFreePort, stop, waitFor} from "./browser"; -import {browserVersion, CdpClient, findChromeBinary} from "./chrome"; +import {browserVersion, findChromeBinary} from "./chrome"; +import CdpClient from "./CdpClient"; import BidiClient from "./BidiClient"; import {findFirefoxBinary} from "./firefox"; diff --git a/tests/integration/browser/utils/site.test.ts b/tests/integration/browser/utils/site.test.ts index c3d5797c..571f055b 100644 --- a/tests/integration/browser/utils/site.test.ts +++ b/tests/integration/browser/utils/site.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {once} from "events"; import {createConnection} from "net"; import path from "path"; diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts index fd00f20f..3e5c8efd 100644 --- a/tests/integration/build/isolation/isolation-watch.integration.test.ts +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {spawn, type ChildProcess} from "child_process"; import {copyFile, readFile, rm, writeFile} from "fs/promises"; import path from "path"; diff --git a/tests/integration/build/locale/chunks-disabled.integration.test.ts b/tests/integration/build/locale/chunks-disabled.integration.test.ts new file mode 100644 index 00000000..93525c1d --- /dev/null +++ b/tests/integration/build/locale/chunks-disabled.integration.test.ts @@ -0,0 +1,8 @@ +import {fixtureEntries, expectLocaleChunks, type Scenario} from "./chunks-utils"; + +jest.setTimeout(90_000); + +test.each([{browser: "chrome", entries: fixtureEntries, enabled: false}])( + "$browser: $entries, bytes=$bytes, commonChunks=$enabled", + expectLocaleChunks +); diff --git a/tests/integration/build/locale/chunks-threshold.integration.test.ts b/tests/integration/build/locale/chunks-threshold.integration.test.ts new file mode 100644 index 00000000..477a4ece --- /dev/null +++ b/tests/integration/build/locale/chunks-threshold.integration.test.ts @@ -0,0 +1,14 @@ +import {fixtureEntries, expectLocaleChunks, type Scenario} from "./chunks-utils"; + +jest.setTimeout(90_000); + +test.each([ + {browser: "chrome", entries: ["main.content", "main-secondary.content"], bytes: 100_000}, + {browser: "chrome", entries: fixtureEntries, bytes: 100_000}, + {browser: "chrome", entries: ["popup"], bytes: 99_999}, + {browser: "chrome", entries: ["popup"], bytes: 100_000}, + {browser: "chrome", entries: ["isolated.content"], bytes: 100_000}, + {browser: "chrome", entries: ["main.content"], bytes: 100_000}, + {browser: "chrome", entries: ["isolated.content", "main.content"], bytes: 100_000}, + {browser: "chrome", entries: [], bytes: 100_000}, +])("$browser: $entries, bytes=$bytes, commonChunks=$enabled", expectLocaleChunks); diff --git a/tests/integration/build/locale/chunks-utils.ts b/tests/integration/build/locale/chunks-utils.ts new file mode 100644 index 00000000..6424fcff --- /dev/null +++ b/tests/integration/build/locale/chunks-utils.ts @@ -0,0 +1,153 @@ +import {readFile, readdir, rm, writeFile} from "fs/promises"; +import path from "path"; +import vm from "vm"; + +import {createLocaleModule} from "@cli/plugins/locale/locale-module"; +import {Language} from "@typing/locale"; +import {createIntegrationFixture} from "../../utils/fixture"; + +const greeting = "Hello from the locale chunk fixture"; +export const fixtureEntries = [ + "popup", + "options", + "isolated.content", + "isolated-secondary.content", + "main.content", + "main-secondary.content", +]; + +export interface Scenario { + browser: "chrome" | "firefox"; + entries: string[]; + bytes?: number; + enabled?: boolean; +} + +export const expectLocaleChunks = async ({browser, entries, bytes, enabled = true}: Scenario): Promise => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "chunks-fixture")); + try { + for (const entry of fixtureEntries) { + if (!entries.includes(entry)) await rm(path.join(fixture.directory, "src", `${entry}.ts`)); + } + if (bytes !== undefined) { + const keys = new Set(["greeting", "padding", "locale"]); + const empty = createLocaleModule( + {en: {greeting: greeting, padding: "", locale: "en"}}, + keys, + Language.English + ); + const padding = "x".repeat(bytes - Buffer.byteLength(empty)); + const messages = {greeting: greeting, padding}; + expect( + Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}, keys, Language.English)) + ).toBe(bytes); + await writeFile(path.join(fixture.directory, "src/locales/en.json"), JSON.stringify(messages)); + await rm(path.join(fixture.directory, "src/locales/fr.json")); + } + if (!enabled) { + const config = path.join(fixture.directory, "adnbn.config.ts"); + await writeFile( + config, + (await readFile(config, "utf8")).replace("commonChunks: true", "commonChunks: false") + ); + } + + const directory = await fixture.build({browser}); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const backgroundFiles: string[] = manifest.background.service_worker + ? [manifest.background.service_worker] + : manifest.background.scripts; + expect(backgroundFiles).toHaveLength(1); + + const sources = new Map(); + for (const filename of await readdir(directory, {recursive: true})) { + if (filename.endsWith(".js")) { + sources.set(filename.split(path.sep).join("/"), await readFile(path.join(directory, filename), "utf8")); + } + } + if (!enabled) expect([...sources.keys()].filter(file => /^js\/locale\.[^.]+\.js$/.test(file))).toEqual([]); + + const catalogueFiles = [...sources.keys()].filter(file => sources.get(file)!.includes(greeting)); + expect(catalogueFiles).toContain(backgroundFiles[0]); + + const sandbox = {catalogue: undefined as Record> | undefined}; + vm.runInNewContext(sources.get(backgroundFiles[0])!, sandbox); + expect(sandbox.catalogue!.en.greeting).toBe(greeting); + if (bytes === undefined) expect(sandbox.catalogue!.fr.greeting).toBe("Bonjour depuis le catalogue partagé"); + for (const [language, messages] of Object.entries(sandbox.catalogue!)) { + const json = JSON.parse( + await readFile(path.join(directory, "_locales", language, "messages.json"), "utf8") + ); + expect(messages).toEqual( + Object.fromEntries( + Object.entries(json).map(([key, value]) => [key, (value as {message: string}).message]) + ) + ); + } + + const consumers: Array<{files: string[]; main: boolean}> = []; + for (const filename of [manifest.action?.default_popup, manifest.options_ui?.page]) { + if (!filename) continue; + const html = await readFile(path.join(directory, filename), "utf8"); + consumers.push({ + files: [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(([, file]) => + path.posix.normalize(path.posix.join(path.posix.dirname(filename), file)) + ), + main: false, + }); + } + consumers.push( + ...(manifest.content_scripts ?? []).map((script: {js: string[]; world: string}) => ({ + files: script.js, + main: script.world === "MAIN", + })) + ); + expect(consumers).toHaveLength(entries.length); + + const mainConsumers = consumers.filter(consumer => consumer.main); + const localeConsumers = consumers.filter(consumer => !consumer.main); + const split = enabled && localeConsumers.length > 0 && (localeConsumers.length >= 2 || (bytes ?? 0) >= 100_000); + // The fixture's shared content runtime exceeds the regular 20,000-byte threshold. + const splitMain = enabled && mainConsumers.length >= 2; + const sharedFiles = new Set(); + const mainFiles = new Set(); + for (const {files, main} of consumers) { + expect(files).not.toContain(backgroundFiles[0]); + for (const file of files) expect(sources.has(file)).toBe(true); + const found = files.filter(file => catalogueFiles.includes(file)); + expect(found).toHaveLength(1); + if (main) { + mainFiles.add(found[0]); + expect(found[0]).not.toMatch(/^js\/locale[.-]/); + if (splitMain) expect(found[0]).toMatch(/^js\/common-main\.content\.[a-f0-9]{8}\.js$/); + } else { + sharedFiles.add(found[0]); + } + if (enabled && !main) { + expect(sources.get(found[0])!.includes("localeContext")).toBe(!split); + if (split) expect(found[0]).toMatch(/^js\/locale\.[a-f0-9]{8}\.js$/); + } + } + const otherFiles = new Set(localeConsumers.flatMap(consumer => consumer.files)); + for (const {files} of mainConsumers) { + expect(files.filter(file => otherFiles.has(file))).toEqual([]); + } + expect([...sources.keys()].some(file => /locale-main/.test(file))).toBe(false); + if (enabled) { + expect(catalogueFiles).toHaveLength( + 1 + (split ? 1 : localeConsumers.length) + (splitMain ? 1 : mainConsumers.length) + ); + expect(mainFiles.size).toBe(splitMain ? 1 : mainConsumers.length); + expect(sharedFiles.size).toBe(split ? 1 : localeConsumers.length); + if (split) { + const source = sources.get([...sharedFiles][0])!; + expect(source.split(greeting)).toHaveLength(2); + } + if (splitMain) { + expect(sources.get([...mainFiles][0])!.split(greeting)).toHaveLength(2); + } + } + } finally { + await fixture.dispose(); + } +}; diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts index 9807a0cb..56e03cbc 100644 --- a/tests/integration/build/locale/chunks.integration.test.ts +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -1,32 +1,7 @@ -/** @jest-environment node */ - -import {readFile, readdir, rm, writeFile} from "fs/promises"; -import path from "path"; -import vm from "vm"; - -import {createLocaleModule} from "@cli/plugins/locale/locale-module"; -import {Language} from "@typing/locale"; -import {createIntegrationFixture} from "../../utils/fixture"; +import {fixtureEntries, expectLocaleChunks, type Scenario} from "./chunks-utils"; jest.setTimeout(90_000); -const Greeting = "Hello from the locale chunk fixture"; -const Entries = [ - "popup", - "options", - "isolated.content", - "isolated-secondary.content", - "main.content", - "main-secondary.content", -]; - -interface Scenario { - browser: "chrome" | "firefox"; - entries: string[]; - bytes?: number; - enabled?: boolean; -} - test.each([ {browser: "chrome", entries: ["popup"]}, {browser: "chrome", entries: ["isolated.content"]}, @@ -34,142 +9,6 @@ test.each([ {browser: "chrome", entries: ["popup", "isolated.content"]}, {browser: "chrome", entries: ["isolated.content", "main.content"]}, {browser: "chrome", entries: ["main.content", "main-secondary.content"]}, - {browser: "chrome", entries: ["main.content", "main-secondary.content"], bytes: 100_000}, - {browser: "chrome", entries: Entries}, - {browser: "chrome", entries: Entries, bytes: 100_000}, - {browser: "chrome", entries: ["popup"], bytes: 99_999}, - {browser: "chrome", entries: ["popup"], bytes: 100_000}, - {browser: "chrome", entries: ["isolated.content"], bytes: 100_000}, - {browser: "chrome", entries: ["main.content"], bytes: 100_000}, - {browser: "chrome", entries: ["isolated.content", "main.content"], bytes: 100_000}, - {browser: "chrome", entries: [], bytes: 100_000}, - {browser: "chrome", entries: Entries, enabled: false}, - {browser: "firefox", entries: Entries}, -])("$browser: $entries, bytes=$bytes, commonChunks=$enabled", async ({browser, entries, bytes, enabled = true}) => { - const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "chunks-fixture")); - try { - for (const entry of Entries) { - if (!entries.includes(entry)) await rm(path.join(fixture.directory, "src", `${entry}.ts`)); - } - if (bytes !== undefined) { - const keys = new Set(["greeting", "padding", "locale"]); - const empty = createLocaleModule( - {en: {greeting: Greeting, padding: "", locale: "en"}}, - keys, - Language.English - ); - const padding = "x".repeat(bytes - Buffer.byteLength(empty)); - const messages = {greeting: Greeting, padding}; - expect( - Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}, keys, Language.English)) - ).toBe(bytes); - await writeFile(path.join(fixture.directory, "src/locales/en.json"), JSON.stringify(messages)); - await rm(path.join(fixture.directory, "src/locales/fr.json")); - } - if (!enabled) { - const config = path.join(fixture.directory, "adnbn.config.ts"); - await writeFile( - config, - (await readFile(config, "utf8")).replace("commonChunks: true", "commonChunks: false") - ); - } - - const directory = await fixture.build({browser}); - const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); - const backgroundFiles: string[] = manifest.background.service_worker - ? [manifest.background.service_worker] - : manifest.background.scripts; - expect(backgroundFiles).toHaveLength(1); - - const sources = new Map(); - for (const filename of await readdir(directory, {recursive: true})) { - if (filename.endsWith(".js")) { - sources.set(filename.split(path.sep).join("/"), await readFile(path.join(directory, filename), "utf8")); - } - } - if (!enabled) expect([...sources.keys()].filter(file => /^js\/locale\.[^.]+\.js$/.test(file))).toEqual([]); - - const catalogueFiles = [...sources.keys()].filter(file => sources.get(file)!.includes(Greeting)); - expect(catalogueFiles).toContain(backgroundFiles[0]); - - const sandbox = {catalogue: undefined as Record> | undefined}; - vm.runInNewContext(sources.get(backgroundFiles[0])!, sandbox); - expect(sandbox.catalogue!.en.greeting).toBe(Greeting); - if (bytes === undefined) expect(sandbox.catalogue!.fr.greeting).toBe("Bonjour depuis le catalogue partagé"); - for (const [language, messages] of Object.entries(sandbox.catalogue!)) { - const json = JSON.parse( - await readFile(path.join(directory, "_locales", language, "messages.json"), "utf8") - ); - expect(messages).toEqual( - Object.fromEntries( - Object.entries(json).map(([key, value]) => [key, (value as {message: string}).message]) - ) - ); - } - - const consumers: Array<{files: string[]; main: boolean}> = []; - for (const filename of [manifest.action?.default_popup, manifest.options_ui?.page]) { - if (!filename) continue; - const html = await readFile(path.join(directory, filename), "utf8"); - consumers.push({ - files: [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(([, file]) => - path.posix.normalize(path.posix.join(path.posix.dirname(filename), file)) - ), - main: false, - }); - } - consumers.push( - ...(manifest.content_scripts ?? []).map((script: {js: string[]; world: string}) => ({ - files: script.js, - main: script.world === "MAIN", - })) - ); - expect(consumers).toHaveLength(entries.length); - - const mainConsumers = consumers.filter(consumer => consumer.main); - const localeConsumers = consumers.filter(consumer => !consumer.main); - const split = enabled && localeConsumers.length > 0 && (localeConsumers.length >= 2 || (bytes ?? 0) >= 100_000); - // The fixture's shared content runtime exceeds the regular 20,000-byte threshold. - const splitMain = enabled && mainConsumers.length >= 2; - const sharedFiles = new Set(); - const mainFiles = new Set(); - for (const {files, main} of consumers) { - expect(files).not.toContain(backgroundFiles[0]); - for (const file of files) expect(sources.has(file)).toBe(true); - const found = files.filter(file => catalogueFiles.includes(file)); - expect(found).toHaveLength(1); - if (main) { - mainFiles.add(found[0]); - expect(found[0]).not.toMatch(/^js\/locale[.-]/); - if (splitMain) expect(found[0]).toMatch(/^js\/common-main\.content\.[a-f0-9]{8}\.js$/); - } else { - sharedFiles.add(found[0]); - } - if (enabled && !main) { - expect(sources.get(found[0])!.includes("localeContext")).toBe(!split); - if (split) expect(found[0]).toMatch(/^js\/locale\.[a-f0-9]{8}\.js$/); - } - } - const otherFiles = new Set(localeConsumers.flatMap(consumer => consumer.files)); - for (const {files} of mainConsumers) { - expect(files.filter(file => otherFiles.has(file))).toEqual([]); - } - expect([...sources.keys()].some(file => /locale-main/.test(file))).toBe(false); - if (enabled) { - expect(catalogueFiles).toHaveLength( - 1 + (split ? 1 : localeConsumers.length) + (splitMain ? 1 : mainConsumers.length) - ); - expect(mainFiles.size).toBe(splitMain ? 1 : mainConsumers.length); - expect(sharedFiles.size).toBe(split ? 1 : localeConsumers.length); - if (split) { - const source = sources.get([...sharedFiles][0])!; - expect(source.split(Greeting)).toHaveLength(2); - } - if (splitMain) { - expect(sources.get([...mainFiles][0])!.split(Greeting)).toHaveLength(2); - } - } - } finally { - await fixture.dispose(); - } -}); + {browser: "chrome", entries: fixtureEntries}, + {browser: "firefox", entries: fixtureEntries}, +])("$browser: $entries, bytes=$bytes, commonChunks=$enabled", expectLocaleChunks); diff --git a/tests/integration/build/locale/dynamic-fixture/package.json b/tests/integration/build/locale/dynamic-fixture/package.json index 05d223a6..87d3578d 100644 --- a/tests/integration/build/locale/dynamic-fixture/package.json +++ b/tests/integration/build/locale/dynamic-fixture/package.json @@ -1,5 +1,5 @@ { - "name": "adnbn-test-locale-chunks", + "name": "adnbn-test-locale-dynamic", "private": true, "type": "module", "version": "1.0.0", diff --git a/tests/integration/build/locale/dynamic.integration.test.ts b/tests/integration/build/locale/dynamic.integration.test.ts index 92f6e5ee..aa13dcfc 100644 --- a/tests/integration/build/locale/dynamic.integration.test.ts +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {copyFile, readFile, readdir, rm} from "fs/promises"; import path from "path"; import vm from "vm"; diff --git a/tests/integration/build/locale/fixture/adnbn.config.ts b/tests/integration/build/locale/fixture/adnbn.config.ts index a0651a1f..b37abf8c 100644 --- a/tests/integration/build/locale/fixture/adnbn.config.ts +++ b/tests/integration/build/locale/fixture/adnbn.config.ts @@ -1,7 +1,19 @@ import {defineConfig} from "adnbn"; +import type {Compiler} from "@rspack/core"; export default defineConfig({ name: "Locale Catalogue Integration", description: "Build fixture for generated locale modules.", version: "1.0.0", + bundler: { + plugins: [ + (compiler: Compiler) => { + compiler.hooks.afterDone.tap("LocaleWatchReady", () => { + if (!compiler.watchMode || !process.send) return; + // Rspack reconnects the filesystem watcher on nextTick after its CLI callback. + setImmediate(() => process.send?.("locale-watch-ready")); + }); + }, + ], + }, }); diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index b1bc10a7..46ab96fd 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {spawn, type ChildProcess} from "child_process"; import {copyFile, readFile, readdir, rm, writeFile} from "fs/promises"; import path from "path"; @@ -95,11 +93,23 @@ test("exports the configured default language even when it is not the first cata test("CLI watch discovers, edits and removes locales and recovers from invalid translations", async () => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + let compilations = 0; + const update = async (change: () => Promise) => { + const previous = compilations; + await change(); + await waitFor( + async () => (compilations > previous ? true : undefined), + 15000, + "locale watch compilation and watcher reconnection after an edit" + ); + }; // Save like an editor: Windows copyFile can preserve the fixture's old mtime and hide an edit from watch. const updateLocale = async (language: string, state: string) => - writeFile( - path.join(fixture.directory, "src/locales", `${language}.json`), - await readFile(path.join(__dirname, "states", state)) + update(async () => + writeFile( + path.join(fixture.directory, "src/locales", `${language}.json`), + await readFile(path.join(__dirname, "states", state)) + ) ); let watcher: ChildProcess | undefined; let output = ""; @@ -108,12 +118,15 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t await rm(path.join(directory, "manifest.json")); watcher = spawn(process.execPath, [path.join(ADNBN_TEST_ROOT, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { cwd: fixture.directory, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe", "ipc"], }); watcher.stdout?.on("data", chunk => (output += chunk)); watcher.stderr?.on("data", chunk => (output += chunk)); - // Assets exist before Rspack finishes the compilation and reconnects its watcher. - await waitFor(async () => output.includes("compiled") || undefined, 15000, "initial locale watch compilation"); + watcher.on("message", message => { + if (message === "locale-watch-ready") compilations++; + }); + // CLI output and emitted files precede watcher reconnection in the child process. + await waitFor(async () => compilations || undefined, 15000, "initial locale watcher readiness"); await waitFor(() => inspect(directory), 15000, "initial locale watch build"); await updateLocale("de", "de.json"); await waitFor( @@ -156,7 +169,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t "updated locale catalogue, languages and JSON" ); - await rm(path.join(fixture.directory, "src/locales/de.json")); + await update(() => rm(path.join(fixture.directory, "src/locales/de.json"))); await waitFor( async () => { expect((await inspect(directory)).languages).toEqual(["en", "fr"]); diff --git a/tests/integration/build/options/options.integration.test.ts b/tests/integration/build/options/options.integration.test.ts index 83ecfb43..320fb97d 100644 --- a/tests/integration/build/options/options.integration.test.ts +++ b/tests/integration/build/options/options.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import {readFile} from "fs/promises"; import path from "path"; diff --git a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts index 0e94f8d9..c5f0f8e7 100644 --- a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts +++ b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {spawn, type ChildProcess} from "child_process"; import {copyFile, readFile, rm, writeFile} from "fs/promises"; import path from "path"; diff --git a/tests/integration/build/style-routing/routing-spike.integration.test.ts b/tests/integration/build/style-routing/routing-spike.integration.test.ts index 31c0b628..a2050e5f 100644 --- a/tests/integration/build/style-routing/routing-spike.integration.test.ts +++ b/tests/integration/build/style-routing/routing-spike.integration.test.ts @@ -1,5 +1,3 @@ -/** @jest-environment node */ - import fs from "fs"; import os from "os"; import path from "path"; diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index d72f5514..f09a2124 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import fs from "fs"; import os from "os"; import path from "path"; diff --git a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts index dadafd43..49238316 100644 --- a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts +++ b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts @@ -1,4 +1,3 @@ -/** @jest-environment node */ import {copyFile, readFile} from "fs/promises"; import path from "path"; import vm from "vm"; diff --git a/tests/integration/prepare.ts b/tests/integration/prepare.ts index e6d07a05..44f6d0c3 100644 --- a/tests/integration/prepare.ts +++ b/tests/integration/prepare.ts @@ -2,11 +2,12 @@ import path from "path"; import {fileURLToPath} from "url"; import {findIntegrationFixtures, prepareIntegrationFixture} from "./utils/fixture"; +import {fixtureWorkers, runQueue} from "./utils/queue"; const directory = fileURLToPath(new URL(".", import.meta.url)); const projectRoot = path.resolve(directory, "..", ".."); -for (const fixture of await findIntegrationFixtures(directory)) { +await runQueue(await findIntegrationFixtures(directory), fixtureWorkers(), async fixture => { console.info(`Preparing ${path.relative(projectRoot, fixture)}`); - await prepareIntegrationFixture(projectRoot, fixture); -} + await prepareIntegrationFixture(projectRoot, fixture, {}, 120_000); +}); diff --git a/tests/integration/typecheck.ts b/tests/integration/typecheck.ts index 415a0df3..ecd53765 100644 --- a/tests/integration/typecheck.ts +++ b/tests/integration/typecheck.ts @@ -1,17 +1,23 @@ import path from "path"; import {fileURLToPath} from "url"; -import {findIntegrationFixtures} from "./utils/fixture"; +import {findIntegrationFixtures, prepareIntegrationFixture} from "./utils/fixture"; import {run} from "./utils/process"; +import {fixtureWorkers, runQueue} from "./utils/queue"; const directory = fileURLToPath(new URL(".", import.meta.url)); const projectRoot = path.resolve(directory, "..", ".."); -for (const fixture of await findIntegrationFixtures(directory)) { +await runQueue(await findIntegrationFixtures(directory), fixtureWorkers(), async fixture => { + if (process.argv.includes("--prepare")) { + console.info(`Preparing ${path.relative(projectRoot, fixture)}`); + await prepareIntegrationFixture(projectRoot, fixture, {}, 120_000); + } console.info(`Typechecking ${path.relative(projectRoot, fixture)}`); await run( process.execPath, [path.join(projectRoot, "node_modules", "typescript", "bin", "tsc"), "--noEmit", "--project", "tsconfig.json"], - fixture + fixture, + 120_000 ); -} +}); diff --git a/tests/integration/utils/fixture.ts b/tests/integration/utils/fixture.ts index 1f73a81f..1b28fa15 100644 --- a/tests/integration/utils/fixture.ts +++ b/tests/integration/utils/fixture.ts @@ -14,7 +14,7 @@ export interface IntegrationFixture { dispose(): Promise; } -const GeneratedDirectories = new Set(["node_modules", ".adnbn", "dist"]); +const generatedDirectories = new Set(["node_modules", ".adnbn", "dist"]); const linkDependency = async (source: string, destination: string): Promise => { const target = await realpath(source); @@ -41,7 +41,8 @@ const linkDependency = async (source: string, destination: string): Promise => { const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")) as { dependencies?: Record; @@ -64,7 +65,8 @@ export const prepareIntegrationFixture = async ( browser, ...(manifestVersion === 2 ? ["--mv2"] : []), ], - directory + directory, + timeout ); return path.join(directory, "dist", `myapp-${browser}-mv${manifestVersion}`); @@ -84,7 +86,7 @@ export const createIntegrationFixture = async ( try { await cp(sourceDirectory, directory, { recursive: true, - filter: source => !GeneratedDirectories.has(path.basename(source)), + filter: source => !generatedDirectories.has(path.basename(source)), }); } catch (error) { await dispose(); @@ -108,7 +110,7 @@ export const findIntegrationFixtures = async (directory: string): Promise 0) { + const child = spawn(process.execPath, [__filename, String(depth - 1)], { + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + child.once("message", descendants => { + const pids = [process.pid, ...descendants]; + if (process.send) process.send(pids); + else writeFileSync(process.argv[3], JSON.stringify(pids)); + }); +} else { + process.send([process.pid]); +} + +setInterval(() => {}, 1000); diff --git a/tests/integration/utils/process.test.ts b/tests/integration/utils/process.test.ts new file mode 100644 index 00000000..6abcc1e0 --- /dev/null +++ b/tests/integration/utils/process.test.ts @@ -0,0 +1,77 @@ +import {mkdtemp, readFile, rm} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {waitFor} from "../browser/utils/browser"; +import {run} from "./process"; + +test("reports the complete diagnostics after a failing child drains its output", async () => { + await expect( + run( + process.execPath, + ["-e", 'process.stdout.write("x".repeat(100000) + "last diagnostic", () => process.exit(2))'], + ADNBN_TEST_ROOT + ) + ).rejects.toThrow(/exited with 2\n[x]+last diagnostic$/); +}); + +test("terminates a fixture that exceeds its deadline", async () => { + await expect(run(process.execPath, ["-e", "setInterval(() => {}, 1000)"], ADNBN_TEST_ROOT, 100)).rejects.toThrow( + "timed out after 100 ms" + ); +}); + +test("reports command failures when stdout and stderr are inherited", async () => { + await expect( + run(process.execPath, ["-e", "process.exit(2)"], ADNBN_TEST_ROOT, 1000, {stdio: "inherit"}) + ).rejects.toThrow("exited with 2"); +}); + +test("terminates a wrapper and all descendants with inherited output on timeout", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "adnbn-process-tree-")); + const marker = path.join(directory, "pids.json"); + let pids: number[] = []; + const outcome = run( + process.execPath, + [path.join(__dirname, "fixtures/process-tree.cjs"), "2", marker], + ADNBN_TEST_ROOT, + 2000, + {stdio: "inherit", killTree: true} + ).catch(error => error as Error); + try { + pids = await waitFor( + async () => JSON.parse(await readFile(marker, "utf8")) as number[], + 5000, + "three ready processes" + ); + expect(pids).toHaveLength(3); + expect(await outcome).toEqual( + expect.objectContaining({message: expect.stringContaining("timed out after 2000 ms")}) + ); + await waitFor( + async () => { + for (const pid of pids) { + try { + process.kill(pid, 0); + return undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + return true; + }, + 5000, + "the wrapper and all descendants to exit" + ); + pids = []; + } finally { + await outcome; + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + await rm(directory, {recursive: true, force: true}); + } +}, 15000); diff --git a/tests/integration/utils/process.ts b/tests/integration/utils/process.ts index 861bdb15..a46c2a25 100644 --- a/tests/integration/utils/process.ts +++ b/tests/integration/utils/process.ts @@ -1,10 +1,41 @@ -import {spawn} from "child_process"; +import {execFileSync, spawn} from "child_process"; -export const run = (command: string, args: string[], cwd: string, timeout = 30_000): Promise => { +const killProcessTree = (pid: number): void => { + if (process.platform === "win32") { + execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {stdio: "pipe", timeout: 5_000}); + } else { + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } +}; + +export const run = ( + command: string, + args: string[], + cwd: string, + timeout = 30_000, + {stdio = "pipe", killTree = false}: {stdio?: "pipe" | "inherit"; killTree?: boolean} = {} +): Promise => { return new Promise((resolve, reject) => { - const child = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); + const child = spawn(command, args, { + cwd, + stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], + // A separate POSIX process group lets a timeout reach wrappers, workers and their children. + detached: killTree && process.platform !== "win32", + }); let output = ""; let settled = false; + let timedOut = false; + let terminating = false; + let interrupted: NodeJS.Signals | undefined; + const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; + const closeOutput = () => { + child.stdout?.destroy(); + child.stderr?.destroy(); + }; const finish = (callback: () => void): void => { if (settled) { @@ -13,21 +44,55 @@ export const run = (command: string, args: string[], cwd: string, timeout = 30_0 settled = true; clearTimeout(runTimeout); + if (killTree) for (const signal of signals) process.off(signal, interrupt); callback(); }; + const terminate = () => { + if (terminating) return; + terminating = true; + try { + if (killTree && child.pid) killProcessTree(child.pid); + else if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (child.exitCode !== null || child.signalCode !== null) closeOutput(); + } catch (error) { + closeOutput(); + finish(() => reject(error)); + } + }; + const interrupt = (signal: NodeJS.Signals) => { + interrupted = signal; + terminate(); + }; + // Detached groups no longer receive terminal signals together with the hook. + if (killTree) for (const signal of signals) process.on(signal, interrupt); const runTimeout = setTimeout(() => { - child.kill("SIGKILL"); - finish(() => reject(new Error(`${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`))); + timedOut = true; + terminate(); }, timeout); - child.stdout.on("data", chunk => (output += chunk)); - child.stderr.on("data", chunk => (output += chunk)); + child.stdout?.on("data", chunk => (output += chunk)); + child.stderr?.on("data", chunk => (output += chunk)); child.once("error", error => finish(() => reject(error))); - child.once("exit", code => { - if (code === 0) { + child.once("exit", () => { + // A descendant can inherit these pipes; it must not keep a timed-out command pending. + if (timedOut || interrupted) closeOutput(); + }); + // exit precedes draining stdout/stderr; close means the fixture process is fully finished. + child.once("close", (code, signal) => { + if (timedOut) { + finish(() => + reject(new Error(`[${cwd}] ${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`)) + ); + } else if (interrupted) { + finish(() => + reject(new Error(`[${cwd}] ${command} ${args.join(" ")} interrupted by ${interrupted}\n${output}`)) + ); + } else if (code === 0) { finish(resolve); } else { - finish(() => reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`))); + finish(() => + reject(new Error(`[${cwd}] ${command} ${args.join(" ")} exited with ${signal ?? code}\n${output}`)) + ); } }); }); diff --git a/tests/integration/utils/queue.test.ts b/tests/integration/utils/queue.test.ts new file mode 100644 index 00000000..3ed24dc3 --- /dev/null +++ b/tests/integration/utils/queue.test.ts @@ -0,0 +1,28 @@ +import {runQueue} from "./queue"; + +test("bounds concurrency and finishes every fixture even when one fails", async () => { + const releases: (() => void)[] = []; + const started: number[] = []; + const finished: number[] = []; + const error = new Error("Invalid fixture"); + const running = runQueue([0, 1, 2, 3], 2, async item => { + started.push(item); + await new Promise(resolve => releases.push(resolve)); + finished.push(item); + if (item === 0) throw error; + }); + const outcome = running.catch(error => error as AggregateError); + expect(started).toEqual([0, 1]); + releases.shift()!(); + await new Promise(resolve => setImmediate(resolve)); + expect(started).toEqual([0, 1, 2]); + releases.shift()!(); + await new Promise(resolve => setImmediate(resolve)); + expect(started).toEqual([0, 1, 2, 3]); + expect(finished).toEqual([0, 1]); + releases.splice(0).forEach(release => release()); + const result = await outcome; + expect(result).toBeInstanceOf(AggregateError); + expect((result as AggregateError).errors).toEqual([error]); + expect(finished).toEqual([0, 1, 2, 3]); +}); diff --git a/tests/integration/utils/queue.ts b/tests/integration/utils/queue.ts new file mode 100644 index 00000000..4ccea74a --- /dev/null +++ b/tests/integration/utils/queue.ts @@ -0,0 +1,34 @@ +import {availableParallelism} from "node:os"; + +export const fixtureWorkers = (): number => { + const value = process.env.ADNBN_TEST_WORKERS; + const workers = value === undefined ? Math.min(4, availableParallelism()) : Number(value); + if (!Number.isSafeInteger(workers) || workers < 1) { + throw new Error("ADNBN_TEST_WORKERS must be a positive integer"); + } + return workers; +}; + +/** Finish running work before reporting failures; never leave child processes behind. */ +export const runQueue = async ( + items: readonly T[], + workers: number, + action: (item: T) => Promise +): Promise => { + if (!Number.isSafeInteger(workers) || workers < 1) throw new Error("workers must be a positive integer"); + let next = 0; + const errors: unknown[] = []; + await Promise.all( + Array.from({length: Math.min(workers, items.length)}, async () => { + while (next < items.length) { + const item = items[next++]; + try { + await action(item); + } catch (error) { + errors.push(error); + } + } + }) + ); + if (errors.length) throw new AggregateError(errors, `${errors.length} fixture task(s) failed`); +}; diff --git a/tests/jest.message.setup.ts b/tests/jest-message.setup.ts similarity index 100% rename from tests/jest.message.setup.ts rename to tests/jest-message.setup.ts diff --git a/tests/jest.modules.setup.ts b/tests/jest-modules.setup.ts similarity index 100% rename from tests/jest.modules.setup.ts rename to tests/jest-modules.setup.ts diff --git a/tests/jest.relay.setup.ts b/tests/jest-relay.setup.ts similarity index 100% rename from tests/jest.relay.setup.ts rename to tests/jest-relay.setup.ts diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts index 9e83f2d2..c2ac7fd3 100644 --- a/tests/jest.setup.ts +++ b/tests/jest.setup.ts @@ -1,5 +1,5 @@ import "jest-webextension-mock"; -import "./jest.message.setup"; -import "./jest.modules.setup"; -import "./jest.relay.setup"; +import "./jest-message.setup"; +import "./jest-modules.setup"; +import "./jest-relay.setup"; diff --git a/tests/mainSourcePolicy.test.ts b/tests/main-source-policy.test.ts similarity index 100% rename from tests/mainSourcePolicy.test.ts rename to tests/main-source-policy.test.ts diff --git a/tests/pre-push.ts b/tests/pre-push.ts new file mode 100644 index 00000000..80190928 --- /dev/null +++ b/tests/pre-push.ts @@ -0,0 +1,34 @@ +import path from "node:path"; +import {run} from "./integration/utils/process"; +import {runQueue} from "./integration/utils/queue"; + +const root = path.resolve(import.meta.dirname, ".."); +const npm = process.env.npm_execpath; +if (!npm) throw new Error("Run this check with npm run test:pre-push"); + +const phase = (tasks: {name: string; args: string[]; stdio?: "inherit"}[]) => + runQueue(tasks, tasks.length, async ({name, args, stdio}) => { + const started = performance.now(); + console.info(`Starting ${name}`); + await run(process.execPath, args, root, 300_000, {stdio, killTree: true}); + console.info(`Passed ${name} (${((performance.now() - started) / 1000).toFixed(1)} s)`); + }); + +// Typecheck reads source declarations; the package build writes dist independently. +await phase([ + {name: "framework typecheck", args: ["node_modules/typescript/bin/tsc", "--noEmit", "-p", "tsconfig.json"]}, + {name: "package build", args: [npm, "run", "build"]}, +]); + +// Jest owns disposable fixture copies. Fixture typechecks prepare the separate editor applications. +await phase([ + { + name: "all non-browser tests", + stdio: "inherit", + args: ["scripts/test-run.mjs", "--selectProjects", "unit-node", "unit-dom", "build", "types"], + }, + { + name: "fixture builds and typechecks", + args: ["node_modules/tsx/dist/cli.mjs", "tests/integration/typecheck.ts", "--prepare"], + }, +]); diff --git a/tests/releaseAutomation.test.ts b/tests/release-automation.test.ts similarity index 100% rename from tests/releaseAutomation.test.ts rename to tests/release-automation.test.ts diff --git a/tsconfig.json b/tsconfig.json index 2e833b0a..03f085d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -87,6 +87,7 @@ "./src/cli/virtual/virtual.d.ts", "node_modules/@types/node/*.d.ts", "tests/**/*.test.ts", + "tests/pre-push.ts", "tests/integration/*.ts", "tests/jest*.ts" ], From d837609b28cd6bcaf25047249cf6c02bc40a5497 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:12:21 +0300 Subject: [PATCH 2/9] ci: split test jobs and streamline git hooks --- .github/workflows/ci.yml | 182 ++++++++++++++++++++++++++++++------ .husky/pre-commit | 9 +- .husky/pre-push | 7 +- scripts/pre-commit.mjs | 22 +++++ tests/README.md | 61 ++++++++++++ tests/integration/README.md | 48 +++++++--- 6 files changed, 273 insertions(+), 56 deletions(-) create mode 100644 scripts/pre-commit.mjs create mode 100644 tests/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 329f0609..26af4ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: echo 'name_suffix=' >> "$GITHUB_OUTPUT" fi - build-and-test: - name: Test and Build (${{ matrix.os }}, Node ${{ matrix.node }}) ${{ needs.compute-matrix.outputs.name_suffix }} + unit: + name: Units (${{ matrix.os }}, Node ${{ matrix.node }}) needs: compute-matrix runs-on: ${{ matrix.os }} permissions: @@ -48,55 +48,175 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Verify test groups + run: npm run test:inventory + - name: Units with coverage (no package build) + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + run: npm run test:run -- --selectProjects unit-node unit-dom --ci --coverage --coverageDirectory=coverage/unit + - name: Units (no package build) + if: matrix.os != 'ubuntu-latest' || matrix.node != 22 + run: npm run test:run -- --selectProjects unit-node unit-dom --ci + - name: Upload unit coverage + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + uses: actions/upload-artifact@v4 + with: + name: coverage-unit-${{ matrix.os }}-node${{ matrix.node }} + path: coverage/unit/coverage-final.json + build: + name: Build integrations (${{ matrix.os }}, Node ${{ matrix.node }}) + needs: compute-matrix + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: npm + - name: Install dependencies + run: npm ci + - name: Build package + run: npm run build + - name: Compiler, CLI and declaration integrations with coverage + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2 --coverage --coverageDirectory=coverage/build + - name: Compiler, CLI and declaration integrations + if: matrix.os != 'ubuntu-latest' || matrix.node != 22 + run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2 + - name: Upload build coverage + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + uses: actions/upload-artifact@v4 + with: + name: coverage-build-${{ matrix.os }}-node${{ matrix.node }} + path: coverage/build/coverage-final.json + fixtures: + name: Fixture types (${{ matrix.os }}, Node ${{ matrix.node }}) + needs: compute-matrix + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install dependencies + run: npm ci + - name: Build package, prepare and typecheck every application + run: npm run typecheck:integration + + browser: + name: Browser (${{ matrix.browser }}, Node ${{ matrix.node }}) + needs: compute-matrix + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.compute-matrix.outputs.matrix).node }} + browser: [chrome, firefox] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install dependencies + run: npm ci - name: Set up Chrome - if: matrix.os == 'ubuntu-latest' + if: matrix.browser == 'chrome' id: chrome uses: browser-actions/setup-chrome@v2 with: chrome-version: stable - - - name: Install dependencies - run: npm ci - - name: Set up Firefox - if: matrix.os == 'ubuntu-latest' + if: matrix.browser == 'firefox' id: firefox uses: browser-actions/setup-firefox@v1 with: firefox-version: latest - - - name: Typecheck - run: npm run typecheck - - - name: Typecheck integration fixtures - run: npm run typecheck:integration - - - name: Test - if: matrix.os == 'ubuntu-latest' - run: npm run test:ci + - name: Build package + run: npm run build + - name: Browser integrations + run: npm run test:run -- --selectProjects ${{ matrix.browser }} --ci --maxWorkers=2 env: ADNBN_CHROME_BIN: ${{ steps.chrome.outputs.chrome-path }} ADNBN_FIREFOX_BIN: ${{ steps.firefox.outputs.firefox-path }} - # Chrome CDP and Firefox BiDi extension scenarios run on the Linux runner. - # The rest of the test suite remains part of the full OS x Node matrix. - - name: Test (non-browser) - if: matrix.os != 'ubuntu-latest' - run: npm run test:ci:non-browser - - - name: Build - run: npm run build - - - name: Upload coverage artifact - if: always() + coverage: + name: Coverage (ubuntu-latest, Node 22) + needs: [compute-matrix, unit, build] + runs-on: ubuntu-latest + 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: Download unit coverage + uses: actions/download-artifact@v4 + with: + name: coverage-unit-ubuntu-latest-node22 + path: coverage/unit + - name: Download build coverage + uses: actions/download-artifact@v4 + with: + name: coverage-build-ubuntu-latest-node22 + path: coverage/build + - name: Merge source coverage from both groups + run: node scripts/merge-coverage.mjs coverage/unit/coverage-final.json coverage/build/coverage-final.json + - name: Upload coverage uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.os }}-node${{ matrix.node }} - path: coverage + name: coverage-ubuntu-latest-node22 + path: coverage/merged + + # Keep the existing required check names while the work runs in independent jobs. + build-and-test: + name: Test and Build (${{ matrix.os }}, Node ${{ matrix.node }}) ${{ needs.compute-matrix.outputs.name_suffix }} + if: ${{ always() && needs.compute-matrix.result == 'success' }} + needs: [compute-matrix, unit, build, fixtures, browser, coverage] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} + steps: + - name: Require all test groups + env: + RESULTS: ${{ join(needs.*.result, ',') }} + run: | + if [[ "$RESULTS" == *failure* || "$RESULTS" == *cancelled* || "$RESULTS" == *skipped* ]]; then + exit 1 + fi diff --git a/.husky/pre-commit b/.husky/pre-commit index cce3a069..17cbfbe0 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,9 +1,4 @@ #!/usr/bin/env sh -# Husky pre-commit hook: format and run related tests on staged files - -npm run format || exit 1 -npm run typecheck || exit 1 -npm run test || exit 1 - - +# Check staged formatting; tests and typechecks run at pre-push and in CI. +node scripts/pre-commit.mjs diff --git a/.husky/pre-push b/.husky/pre-push index 6ea0353e..ec7e6fe2 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,7 +1,4 @@ #!/usr/bin/env sh -# Husky pre-push hook: typecheck, run full tests, and build - -npm run typecheck || exit 1 -npm run test:ci || exit 1 -npm run build || exit 1 +# Browser scenarios and coverage run in CI; build the package only once here. +npm run test:pre-push diff --git a/scripts/pre-commit.mjs b/scripts/pre-commit.mjs new file mode 100644 index 00000000..e229bb0e --- /dev/null +++ b/scripts/pre-commit.mjs @@ -0,0 +1,22 @@ +import {execFileSync} from "node:child_process"; +import path from "node:path"; +import * as prettier from "prettier"; + +const root = path.resolve(import.meta.dirname, ".."); +const git = args => execFileSync("git", args, {cwd: root, encoding: "utf8"}); +const changed = git(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"]).split("\0").filter(Boolean); +const unformatted = []; + +for (const file of changed) { + const filepath = path.join(root, file); + const info = await prettier.getFileInfo(filepath, {ignorePath: path.join(root, ".prettierignore")}); + if (info.ignored || !info.inferredParser) continue; + // Check the staged contents; never rewrite unstaged work or silently stage it. + const contents = git(["show", `:${file}`]); + if (!(await prettier.check(contents, {...(await prettier.resolveConfig(filepath)), filepath}))) + unformatted.push(file); +} +if (unformatted.length) { + console.error(`Format and stage these files before committing:\n${unformatted.join("\n")}`); + process.exit(1); +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..d42fc000 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,61 @@ +# Running tests + +Run commands from the repository root. Node.js 22 and 24 are supported. + +Use the npm test commands below, or `npm run test:run --` to pass arguments directly to Jest. All Jest invocations from these commands, pre-push and inventory checks use `scripts/test-run.mjs`. On macOS arm64 this wrapper launches Jest with `--no-sparkplug`; Linux and Windows keep Node's default JIT settings. + +The observed Node 24 crashes contain `Builtins_BaselineOutOfLinePrologue` and `ClearStaleLeftTrimmedPointerVisitor`, matching the Sparkplug report in [nodejs/node#62393](https://github.com/nodejs/node/issues/62393). The earlier `--no-maglev` workaround did not prevent the crash. Successful repetitions measure observed stability; they do not prove a rare native fault impossible. Keep the workaround scoped to the reproduced environment and reassess it when the upstream fix is verified. Jest workers inherit the flag through `execArgv`; CLI fixture child processes and real browsers keep their normal settings. Tests are never automatically retried or skipped to hide a native crash. + +| Command | Checks | Builds the package | +| ------------------------ | --------------------------------------------------------------------- | ------------------ | +| `npm run test:unit` | Node and DOM unit tests | No | +| `npm run test:build` | Compiler plugins, the built CLI and build integrations | Once | +| `npm run test:types` | All fixture applications and declaration consumer tests | Once | +| `npm run test:chrome` | Real Chrome scenarios | Once | +| `npm run test:firefox` | Real Firefox MV2/MV3 scenarios | Once | +| `npm test` | Every Jest project, including both browsers | Once | +| `npm run test:pre-push` | Framework typecheck, all non-browser tests and all fixture typechecks | Once | +| `npm run test:inventory` | Every test file belongs to exactly one project | No | + +`npm run typecheck` checks framework and test-runner types. `npm run typecheck:integration` builds the package, prepares every fixture application and checks it with its own `tsconfig.json`. Declaration consumers without an application config belong to the Jest `types` project. + +## Groups and workers + +`jest.config.ts` defines six projects: `unit-node`, `unit-dom`, `build`, `types`, `chrome` and `firefox`. Put DOM tests in the `unit-dom` patterns. Tests that execute the built CLI, consume package declarations or compile imports from the built package belong to `build` or `types`. Keep unit tests runnable with no `dist` directory. Each browser file contains scenarios for one browser only. + +After a package build, direct Jest invocations can reuse that build: + +```sh +npm run build +npm run test:run -- --selectProjects build types --maxWorkers=4 +npm run test:run -- --selectProjects chrome --maxWorkers=4 +``` + +Jest uses up to eight workers, leaving one CPU available on smaller machines. Override the pool with `-- --maxWorkers=N`, or use `-- --runInBand` for a serial diagnostic run. The limit applies to the whole Jest invocation; do not pass both flags together. CI sets explicit smaller pools for compiler and browser jobs. + +The trailing `--verbose=false` in `test:unit` terminates Jest's variadic `--selectProjects` option so an appended test filename is treated as a filename, not another project name. + +Preparation and fixture typechecks use a bounded queue of up to four processes. Set `ADNBN_TEST_WORKERS` to a positive integer to override it. Each application keeps its own configuration and TypeScript process. Queued builds and typechecks allow up to 120 seconds per command. Builds inside Jest scenarios retain the 30-second limit so a hung CLI does not outlive its enclosing test deadline. A failure is reported after all running work has finished, with the child process diagnostics preserved. + +See [integration/README.md](integration/README.md) for browser requirements and fixture layout. Browser tests use separate profiles, ports and application copies. Tests that edit or watch a fixture must retain their own copy and build; generated outputs must not be shared between mutable scenarios. + +## Hooks + +Pre-commit checks only formatting of the **staged contents**, without changing the index or unstaged work. Format reported files and stage them before retrying. Tests, typechecking and builds run at pre-push and in CI. During development, run `test:unit` or `test:watch` explicitly for earlier feedback. Tests execute the working tree, so CI remains the full validation of the committed revision. + +Pre-push runs `test:pre-push`, without browser launches or coverage. It includes both framework and fixture typechecks, compiler and CLI checks, and declaration consumers. Framework typechecking runs alongside the package build; after the build, Jest runs alongside the fixture queue. Jest inherits the terminal and streams its output live; fixture diagnostics remain buffered per command. Each fixture is typechecked as soon as its own build finishes. All tasks finish before failures are reported. Each task has a five-minute deadline; timeout or interruption terminates its complete process tree, including wrappers, Jest workers and child CLI processes. This uses a separate process group on macOS/Linux and `taskkill /T /F` on Windows. Browser checks run in CI and are also available through the explicit commands above. + +## CI and coverage + +CI runs units, compiler/build/declaration tests, fixture typechecks, Chrome and Firefox in separate jobs. All non-browser groups retain the Linux/Windows × Node 22/24 matrix; both browsers run on Linux for each Node version. Existing `Test and Build` check names now aggregate the entire matrix, not only the OS/Node named in each check; they fail if any required job fails, is cancelled or is skipped. A full workflow contains 22 jobs including matrix computation and aggregation, with at most 16 test jobs ready together before coverage/aggregation dependencies finish. + +Babel coverage is collected in the Ubuntu/Node 22 unit and build jobs, then merged once with `scripts/merge-coverage.mjs`. The other matrix cells run the same tests without coverage instrumentation. The published report is `coverage-ubuntu-latest-node22`; platform-specific coverage reports are no longer produced. Moving compiler tests between groups must not remove their source coverage. Keep both `coverage-final.json` inputs when merging locally: + +```sh +npm run test:run -- --selectProjects unit-node unit-dom --coverage --coverageDirectory=coverage/unit +npm run build +npm run test:run -- --selectProjects build types --coverage --coverageDirectory=coverage/build --maxWorkers=4 +node scripts/merge-coverage.mjs coverage/unit/coverage-final.json coverage/build/coverage-final.json +``` + +Real CLI child processes and browser execution provide behavioral assertions; their code execution is not automatically instrumented by the parent Jest process. diff --git a/tests/integration/README.md b/tests/integration/README.md index 2e2b1531..fa23485f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -4,43 +4,63 @@ These tests cover application builds, browser execution, and generated TypeScrip ## Layout +Selected scenario files and shared infrastructure: + ```text tests/integration/ -├── build/options/ -│ ├── options.integration.test.ts -│ └── embedded/ +├── build/ +│ ├── locale/ +│ │ ├── locale.integration.test.ts +│ │ ├── dynamic.integration.test.ts +│ │ ├── chunks.integration.test.ts +│ │ ├── chunks-threshold.integration.test.ts +│ │ ├── chunks-disabled.integration.test.ts +│ │ ├── chunks-utils.ts +│ │ ├── fixture/ +│ │ ├── dynamic-fixture/ +│ │ └── chunks-fixture/ +│ ├── options/ +│ │ ├── options.integration.test.ts +│ │ └── embedded/ +│ └── … ├── browser/ │ ├── content/ │ │ ├── entrypoint-assets.integration.test.ts │ │ ├── entrypoint-assets.firefox.integration.test.ts -│ │ ├── utils.ts │ │ ├── entrypoint-assets/ │ │ ├── isolated-styles-utils.ts │ │ ├── isolation-shadow.integration.test.ts -│ │ ├── isolation-shadow.firefox.integration.test.ts +│ │ ├── isolation-shadow-mv2.firefox.integration.test.ts +│ │ ├── isolation-shadow-mv3.firefox.integration.test.ts │ │ ├── isolation-shadow/ │ │ ├── isolation-iframe.integration.test.ts │ │ ├── isolation-iframe.firefox.integration.test.ts -│ │ └── isolation-iframe/ +│ │ ├── isolation-iframe/ +│ │ ├── relay-styles.integration.test.ts +│ │ ├── relay-styles.firefox.integration.test.ts +│ │ ├── relay-styles-utils.ts +│ │ ├── relay-styles/ +│ │ └── … +│ ├── locale/ │ ├── offscreen/ -│ │ ├── service.integration.test.ts -│ │ └── service/ │ ├── options/ -│ │ ├── options.integration.test.ts -│ │ ├── react/ -│ │ └── vanilla/ │ └── utils/ │ ├── BidiClient.ts +│ ├── CdpClient.ts │ ├── browser.ts +│ ├── browser.test.ts │ ├── chrome.ts │ ├── firefox.ts -│ └── site.ts +│ └── … ├── types/ │ ├── registries.integration.test.ts │ └── fixtures/registries/ ├── utils/ │ ├── fixture.ts -│ └── process.ts +│ ├── process.ts +│ ├── process.test.ts +│ ├── queue.ts +│ └── queue.test.ts ├── prepare.ts └── typecheck.ts ``` @@ -86,6 +106,8 @@ npm run test:firefox They run all integration tests, only build checks, only Chrome checks, or only Firefox checks respectively. Each command builds the framework first. Browser tests require Chrome with `Extensions.loadUnpacked` support and Firefox with WebDriver BiDi `webExtension.install` support. Set `ADNBN_CHROME_BIN` or `ADNBN_FIREFOX_BIN` to the browser's absolute executable path if automatic discovery selects the wrong browser. Node must provide the built-in `WebSocket` API. CI installs both browsers on Linux; Windows runs the non-browser suite. +These groups run test files in parallel, with up to eight workers by default (one fewer than the available CPUs on smaller machines). Pass `-- --maxWorkers=N` to tune the pool. Preparation and fixture typechecks run up to four independent applications concurrently; `ADNBN_TEST_WORKERS` overrides that limit. See [the test guide](../README.md) for project selection, hooks, coverage and the complete validation commands. + Tests copy application inputs to unique directories under `.cache/integration`. Prepared dependencies and generated files are excluded from the copy and recreated there. Cleanup removes only the run's copy and temporary Chrome profile; it does not remove the editor environment in the source fixture or change the `addon` playground. ## Coverage From a4396a0d3dce1661e0cd9d86c22cdad3b1ff1d64 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:11:31 +0300 Subject: [PATCH 3/9] refactor: organize content types by adapter --- src/entry/content/README.md | 15 ++++ src/types/content/adapters/index.ts | 2 + src/types/content/adapters/react.ts | 7 ++ src/types/content/adapters/vanilla.ts | 1 + src/types/{content.ts => content/common.ts} | 59 +-------------- src/types/content/definition.ts | 69 +++++++++++++++++ src/types/content/index.ts | 6 ++ src/types/content/render.ts | 9 +++ tests/integration/README.md | 6 +- .../types/content.integration.test.ts | 43 +++++++++++ .../types/fixtures/content/definition.tsx | 75 +++++++++++++++++++ 11 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 src/types/content/adapters/index.ts create mode 100644 src/types/content/adapters/react.ts create mode 100644 src/types/content/adapters/vanilla.ts rename src/types/{content.ts => content/common.ts} (81%) create mode 100644 src/types/content/definition.ts create mode 100644 src/types/content/index.ts create mode 100644 src/types/content/render.ts create mode 100644 tests/integration/types/content.integration.test.ts create mode 100644 tests/integration/types/fixtures/content/definition.tsx diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 9a058242..404ce9a7 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -19,6 +19,21 @@ A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. `adnbn/entry/content/frame` import resolves to the separate frame builder. Internally, `virtual:content-builder` selects either that builder or a renderer adapter. +Content contracts live in `src/types/content`: + +- `common.ts` owns shared options, isolation, props, containers, markers, nodes, and lifecycle contracts. +- `definition.ts` composes entrypoint definitions and preserves the frame-navigation restrictions. +- `adapters/vanilla.ts` and `adapters/react.ts` describe each adapter's render values. +- `adapters/index.ts` exports the types of all adapters for the shared render contract and public exports. +- `render.ts` combines those values into the shared render contract and handler. +- `index.ts` exports the contracts through `@typing/content` and the public `adnbn` entrypoint. + +Keep adapter-internal imports pointed at the owning file. To add an adapter's types, define its +render values under `adapters`, export them from `adapters/index.ts`, and include them in `render.ts`. +`defineContentScript` and `defineContentScriptAppend` continue to accept the combined contract from +`adnbn`; runtime adapter selection remains based on the entrypoint filename. Adding types does not +implement the adapter or its build support. + ## Isolation `isolation` accepts `ContentScriptIsolation.None`, `Shadow`, or `Iframe`, or their string values diff --git a/src/types/content/adapters/index.ts b/src/types/content/adapters/index.ts new file mode 100644 index 00000000..a0b8b4b4 --- /dev/null +++ b/src/types/content/adapters/index.ts @@ -0,0 +1,2 @@ +export type * from "./vanilla"; +export type * from "./react"; diff --git a/src/types/content/adapters/react.ts b/src/types/content/adapters/react.ts new file mode 100644 index 00000000..ceb4c2f1 --- /dev/null +++ b/src/types/content/adapters/react.ts @@ -0,0 +1,7 @@ +import type {FC, ReactNode} from "react"; + +import type {ContentScriptProps} from "../common"; + +export type ContentScriptRenderReactComponent = FC; + +export type ContentScriptReactRenderValue = ReactNode | ContentScriptRenderReactComponent; diff --git a/src/types/content/adapters/vanilla.ts b/src/types/content/adapters/vanilla.ts new file mode 100644 index 00000000..5f1bb988 --- /dev/null +++ b/src/types/content/adapters/vanilla.ts @@ -0,0 +1 @@ +export type ContentScriptVanillaRenderValue = Element | string | number | boolean | null | undefined; diff --git a/src/types/content.ts b/src/types/content/common.ts similarity index 81% rename from src/types/content.ts rename to src/types/content/common.ts index b8d8b3c1..ce4ff224 100644 --- a/src/types/content.ts +++ b/src/types/content/common.ts @@ -1,9 +1,8 @@ import type {PageAlias} from "@typing/page"; -import type {FC, ReactNode} from "react"; -import {Optional} from "utility-types"; +import type {Optional} from "utility-types"; -import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; -import {Awaiter, PickNonFunctionProperties} from "@typing/helpers"; +import type {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; +import type {Awaiter, PickNonFunctionProperties} from "@typing/helpers"; export const ContentScriptMatches = ["http://*/*", "https://*/*"]; @@ -267,13 +266,6 @@ export type ContentScriptMarkerGetter = (options: ContentScriptOptions) => Await export type ContentScriptMarkerResolver = (options: ContentScriptOptions) => Awaiter; -// Render -export type ContentScriptRenderReactComponent = FC; - -export type ContentScriptRenderValue = Element | ReactNode | ContentScriptRenderReactComponent; - -export type ContentScriptRenderHandler = (props: ContentScriptProps) => Awaiter; - // Container export type ContentScriptContainerTag = Exclude; @@ -351,51 +343,6 @@ export interface ContentScriptNode extends ContentScriptMount { export type ContentScriptNodeSet = Set; -// Definition -export interface ContentScriptDefinitionBase extends Partial { - marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; - anchor?: ContentScriptAnchor | ContentScriptAnchorGetter; - mount?: ContentScriptMountFunction; - container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory; - watch?: true | ContentScriptWatchStrategy; - main?: ContentScriptMainFunction; -} - -export type ContentScriptDefinition = ContentScriptDefinitionBase & - ( - | { - isolation?: - | ContentScriptIsolation - | `${ContentScriptIsolation}` - | ContentScriptIsolationNoneOptions - | ContentScriptIsolationShadowOptions - | (ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions); - render?: ContentScriptRenderValue | ContentScriptRenderHandler; - } - | { - isolation: ContentScriptIsolationFrameOptions & - (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions); - render?: never; - } - ); - -export interface ContentScriptResolvedDefinition extends Omit< - ContentScriptDefinitionBase, - "anchor" | "marker" | "mount" | "container" | "watch" -> { - isolation: ContentScriptIsolationOptions; - marker: ContentScriptMarkerResolver; - anchor: ContentScriptAnchorGetter; - mount: ContentScriptMountFunction; - render?: ContentScriptRenderHandler; - container: ContentScriptContainerCreator; - watch: ContentScriptWatchStrategy; -} - -type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; - -export type ContentScriptAppendDefinition = ContentScriptAppendVariant; - // Builder export interface ContentScriptBuilder extends EntrypointBuilder { getContext(): ContentScriptContext; diff --git a/src/types/content/definition.ts b/src/types/content/definition.ts new file mode 100644 index 00000000..b555dfb9 --- /dev/null +++ b/src/types/content/definition.ts @@ -0,0 +1,69 @@ +import type { + ContentScriptAnchor, + ContentScriptAnchorGetter, + ContentScriptAppend, + ContentScriptContainerCreator, + ContentScriptContainerFactory, + ContentScriptContainerOptions, + ContentScriptContainerTag, + ContentScriptFramePageOptions, + ContentScriptFrameRenderOptions, + ContentScriptFrameSourceOptions, + ContentScriptIsolation, + ContentScriptIsolationFrameOptions, + ContentScriptIsolationNoneOptions, + ContentScriptIsolationOptions, + ContentScriptIsolationShadowOptions, + ContentScriptMainFunction, + ContentScriptMarkerGetter, + ContentScriptMarkerResolver, + ContentScriptMarkerType, + ContentScriptMountFunction, + ContentScriptOptions, + ContentScriptWatchStrategy, +} from "./common"; +import type {ContentScriptRenderHandler, ContentScriptRenderValue} from "./render"; + +export interface ContentScriptDefinitionBase extends Partial { + marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; + anchor?: ContentScriptAnchor | ContentScriptAnchorGetter; + mount?: ContentScriptMountFunction; + container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory; + watch?: true | ContentScriptWatchStrategy; + main?: ContentScriptMainFunction; +} + +export type ContentScriptDefinition = ContentScriptDefinitionBase & + ( + | { + isolation?: + | ContentScriptIsolation + | `${ContentScriptIsolation}` + | ContentScriptIsolationNoneOptions + | ContentScriptIsolationShadowOptions + | (ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions); + render?: ContentScriptRenderValue | ContentScriptRenderHandler; + } + | { + isolation: ContentScriptIsolationFrameOptions & + (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions); + render?: never; + } + ); + +export interface ContentScriptResolvedDefinition extends Omit< + ContentScriptDefinitionBase, + "anchor" | "marker" | "mount" | "container" | "watch" +> { + isolation: ContentScriptIsolationOptions; + marker: ContentScriptMarkerResolver; + anchor: ContentScriptAnchorGetter; + mount: ContentScriptMountFunction; + render?: ContentScriptRenderHandler; + container: ContentScriptContainerCreator; + watch: ContentScriptWatchStrategy; +} + +type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; + +export type ContentScriptAppendDefinition = ContentScriptAppendVariant; diff --git a/src/types/content/index.ts b/src/types/content/index.ts new file mode 100644 index 00000000..9abda0b8 --- /dev/null +++ b/src/types/content/index.ts @@ -0,0 +1,6 @@ +export * from "./common"; + +export type * from "./definition"; +export type * from "./render"; + +export type * from "./adapters"; diff --git a/src/types/content/render.ts b/src/types/content/render.ts new file mode 100644 index 00000000..8690b8d3 --- /dev/null +++ b/src/types/content/render.ts @@ -0,0 +1,9 @@ +import type {Awaiter} from "@typing/helpers"; + +import type {ContentScriptProps} from "./common"; +import type {ContentScriptVanillaRenderValue, ContentScriptReactRenderValue} from "./adapters"; + +/** All supported render values; the entrypoint's filename selects the runtime adapter. */ +export type ContentScriptRenderValue = ContentScriptVanillaRenderValue | ContentScriptReactRenderValue; + +export type ContentScriptRenderHandler = (props: ContentScriptProps) => Awaiter; diff --git a/tests/integration/README.md b/tests/integration/README.md index fa23485f..f338a05e 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -53,8 +53,11 @@ tests/integration/ │ ├── firefox.ts │ └── … ├── types/ +│ ├── content.integration.test.ts │ ├── registries.integration.test.ts -│ └── fixtures/registries/ +│ └── fixtures/ +│ ├── content/ +│ └── registries/ ├── utils/ │ ├── fixture.ts │ ├── process.ts @@ -112,6 +115,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. ## Coverage +- `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. diff --git a/tests/integration/types/content.integration.test.ts b/tests/integration/types/content.integration.test.ts new file mode 100644 index 00000000..f0106f91 --- /dev/null +++ b/tests/integration/types/content.integration.test.ts @@ -0,0 +1,43 @@ +import path from "path"; +import ts from "typescript"; + +describe("content contracts", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixture = path.join(__dirname, "fixtures/content/definition.tsx"); + + test.each(["source", "package"])( + "checks shared and adapter render types through the %s API", + mode => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: true, + types: ["node", "chrome"], + }; + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); diff --git a/tests/integration/types/fixtures/content/definition.tsx b/tests/integration/types/fixtures/content/definition.tsx new file mode 100644 index 00000000..d6258755 --- /dev/null +++ b/tests/integration/types/fixtures/content/definition.tsx @@ -0,0 +1,75 @@ +import {ContentScriptAppend, ContentScriptIsolation, defineContentScript, defineContentScriptAppend} from "adnbn"; +import type { + ContentScriptDefinition, + ContentScriptProps, + ContentScriptReactRenderValue, + ContentScriptRenderHandler, + ContentScriptRenderReactComponent, + ContentScriptRenderValue, + ContentScriptVanillaRenderValue, +} from "adnbn"; + +const Panel: ContentScriptRenderReactComponent = props => {props.anchor.tagName}; +const vanillaValues: ContentScriptVanillaRenderValue[] = [ + document.createElement("div"), + "text", + 0, + true, + false, + null, + undefined, +]; +const reactValues: ContentScriptReactRenderValue[] = [Panel, , [, "text"]]; +const values: ContentScriptRenderValue[] = [...vanillaValues, ...reactValues]; + +for (const render of values) { + const definition: ContentScriptDefinition = defineContentScript({render}); + defineContentScriptAppend({...definition, append: ContentScriptAppend.Last}); +} + +defineContentScript({ + anchor: ".product", + isolation: ContentScriptIsolation.Shadow, + container: props => { + const anchor: Element = props.anchor; + // @ts-expect-error: Container factories receive the shared Content props. + props.missing; + return {tagName: "section", title: anchor.tagName}; + }, + render: async props => { + const anchor: Element = props.anchor; + // @ts-expect-error: Async Vanilla handlers retain contextual props. + props.missing; + const element = document.createElement("div"); + element.textContent = anchor.tagName; + return element; + }, +}); + +defineContentScriptAppend({ + anchor: ".product", + append: ContentScriptAppend.After, + isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + render: props => { + const sharedProps: ContentScriptProps = props; + // @ts-expect-error: React handlers receive the same typed anchor. + const anchor: number = props.anchor; + return ; + }, +}); + +const render: ContentScriptRenderHandler = async props => ; +defineContentScript({render}); +defineContentScriptAppend({render, container: "article"}); + +defineContentScript({isolation: {type: "iframe", src: "https://example.com"}}); +defineContentScriptAppend({isolation: {type: "iframe", page: "panel"}}); + +// @ts-expect-error: A frame navigating to a URL cannot also render local UI. +defineContentScript({isolation: {type: "iframe", src: "https://example.com"}, render: Panel}); +// @ts-expect-error: The append definition preserves the page-navigation restriction. +defineContentScriptAppend({isolation: {type: "iframe", page: "panel"}, render: document.createElement("div")}); +// @ts-expect-error: A frame must choose either a page or a URL. +defineContentScript({isolation: {type: "iframe", page: "panel", src: "https://example.com"}}); +// @ts-expect-error: The wide render contract still rejects unsupported object shapes. +defineContentScript({render: {unsupported: true}}); From 4a2a8a5986d8dea0e5b044706925fc3f9d2d9c7e Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:21:33 +0300 Subject: [PATCH 4/9] refactor!: separate content adapters and simplify relay startup Move shared content lifecycle and resolvers to their owners. Expose watch strategies through adnbn/content and keep framework normalization in the selected adapter. Move Relay normalization and content construction into runtime. Replace the old builder contract and update virtual modules, declarations, tests, and documentation. --- jest.config.ts | 14 +- package.json | 8 +- .../entrypoint/parser/ContentParser.test.ts | 2 + src/cli/entrypoint/parser/ContentParser.ts | 25 +- .../default-render/create-element.content.tsx | 4 + .../default-render/local-element.content.tsx | 5 + .../parser/utils/content/default-render.ts | 25 ++ .../plugins/content/RelayDeclaration.test.ts | 7 +- .../tests/fixtures/relay-types/consumer.ts | 6 +- .../tests/fixtures/relay-types/virtual.ts | 7 +- src/cli/virtual/content.ts | 17 +- src/cli/virtual/index.ts | 4 +- src/cli/virtual/relay.ts | 43 +-- src/cli/virtual/virtual.d.ts | 6 +- src/cli/virtual/virtual.test.ts | 90 +++++-- src/content/index.ts | 9 + src/entry/content/README.md | 91 ++++++- .../content/adapters/react/Builder.test.ts | 70 +++++ src/entry/content/adapters/react/Builder.ts | 11 +- src/entry/content/adapters/react/index.ts | 1 + .../react/resolvers/definition.test.ts | 23 ++ .../adapters/react/resolvers/definition.ts | 16 ++ .../content/adapters/react/resolvers/index.ts | 1 - .../adapters/react/resolvers/render.ts | 2 +- .../content/adapters/vanilla/Builder.test.ts | 83 ++++++ src/entry/content/adapters/vanilla/Builder.ts | 24 +- src/entry/content/adapters/vanilla/index.ts | 1 + .../vanilla/resolvers/definition.test.ts | 18 ++ .../adapters/vanilla/resolvers/definition.ts | 8 + .../vanilla}/resolvers/render.ts | 8 +- .../content/core/resolvers/definition.ts | 19 -- .../content/core/resolvers/watch.test.ts | 24 -- src/entry/content/frame/Builder.ts | 14 - src/entry/content/frame/index.ts | 4 - src/entry/content/index.ts | 7 +- .../content/{core => lifecycle}/Builder.ts | 40 +-- .../content/lifecycle/MountBuilder.test.ts | 132 +++++++++ .../{core => lifecycle}/MountBuilder.ts | 16 +- .../context/Context.test.ts | 0 .../{core => lifecycle}/context/Context.ts | 2 +- .../context/EventEmitter.ts | 2 +- .../context/ManagedContext.ts | 2 +- .../{core => lifecycle}/context/index.ts | 0 .../markers/AbstractMarker.ts | 2 +- .../markers/AttributeMarker.test.ts | 2 +- .../markers/AttributeMarker.ts | 2 +- .../markers/WeakMarker.test.ts | 2 +- .../{core => lifecycle}/markers/WeakMarker.ts | 2 +- .../{core => lifecycle}/markers/index.ts | 0 .../markers/marker.test.ts | 0 .../{core => lifecycle}/nodes/EventNode.ts | 2 +- .../nodes/FrameNode.test.ts | 0 .../{core => lifecycle}/nodes/FrameNode.ts | 0 .../{core => lifecycle}/nodes/MarkerNode.ts | 2 +- .../{core => lifecycle}/nodes/MountNode.ts | 2 +- .../content/{core => lifecycle}/nodes/Node.ts | 2 +- .../nodes/ShadowNode.test.ts | 0 .../{core => lifecycle}/nodes/ShadowNode.ts | 0 .../{core => lifecycle}/nodes/index.ts | 0 .../nodes/isolated-styles.ts | 0 .../content/{core => }/resolvers/anchor.ts | 2 +- .../content/{core => }/resolvers/container.ts | 2 +- .../content/resolvers/definition.test.ts | 18 ++ src/entry/content/resolvers/definition.ts | 32 +++ .../content/{core => }/resolvers/index.ts | 1 - .../content/{core => }/resolvers/mount.ts | 2 +- src/entry/content/resolvers/watch.test.ts | 90 +++++++ .../content/{core => }/resolvers/watch.ts | 12 +- src/entry/relay/Builder.test.ts | 252 ++++++++++++++++++ src/entry/relay/Builder.ts | 30 +-- src/entry/relay/index.test.ts | 108 ++++++++ src/entry/relay/index.ts | 13 +- src/entry/relay/resolvers/definition.test.ts | 69 +++++ src/entry/relay/resolvers/definition.ts | 24 ++ src/main/content.ts | 20 +- src/relay/README.md | 12 +- .../src/secondary.content/index.ts | 2 + .../src/secondary.content/index.ts | 3 +- .../content/isolation-spike/src/probe.tsx | 4 +- .../content/content-api.integration.test.ts | 47 ++++ .../types/content.integration.test.ts | 12 +- .../types/fixtures/content/watch.ts | 49 ++++ tsconfig.json | 4 +- 83 files changed, 1433 insertions(+), 284 deletions(-) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/create-element.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-element.content.tsx create mode 100644 src/cli/entrypoint/parser/utils/content/default-render.ts create mode 100644 src/content/index.ts create mode 100644 src/entry/content/adapters/react/Builder.test.ts create mode 100644 src/entry/content/adapters/react/resolvers/definition.test.ts create mode 100644 src/entry/content/adapters/react/resolvers/definition.ts delete mode 100644 src/entry/content/adapters/react/resolvers/index.ts create mode 100644 src/entry/content/adapters/vanilla/Builder.test.ts create mode 100644 src/entry/content/adapters/vanilla/resolvers/definition.test.ts create mode 100644 src/entry/content/adapters/vanilla/resolvers/definition.ts rename src/entry/content/{core => adapters/vanilla}/resolvers/render.ts (65%) delete mode 100644 src/entry/content/core/resolvers/definition.ts delete mode 100644 src/entry/content/core/resolvers/watch.test.ts delete mode 100644 src/entry/content/frame/Builder.ts delete mode 100644 src/entry/content/frame/index.ts rename src/entry/content/{core => lifecycle}/Builder.ts (85%) create mode 100644 src/entry/content/lifecycle/MountBuilder.test.ts rename src/entry/content/{core => lifecycle}/MountBuilder.ts (86%) rename src/entry/content/{core => lifecycle}/context/Context.test.ts (100%) rename src/entry/content/{core => lifecycle}/context/Context.ts (95%) rename src/entry/content/{core => lifecycle}/context/EventEmitter.ts (94%) rename src/entry/content/{core => lifecycle}/context/ManagedContext.ts (81%) rename src/entry/content/{core => lifecycle}/context/index.ts (100%) rename src/entry/content/{core => lifecycle}/markers/AbstractMarker.ts (96%) rename src/entry/content/{core => lifecycle}/markers/AttributeMarker.test.ts (95%) rename src/entry/content/{core => lifecycle}/markers/AttributeMarker.ts (95%) rename src/entry/content/{core => lifecycle}/markers/WeakMarker.test.ts (93%) rename src/entry/content/{core => lifecycle}/markers/WeakMarker.ts (94%) rename src/entry/content/{core => lifecycle}/markers/index.ts (100%) rename src/entry/content/{core => lifecycle}/markers/marker.test.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/EventNode.ts (93%) rename src/entry/content/{core => lifecycle}/nodes/FrameNode.test.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/FrameNode.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/MarkerNode.ts (92%) rename src/entry/content/{core => lifecycle}/nodes/MountNode.ts (94%) rename src/entry/content/{core => lifecycle}/nodes/Node.ts (93%) rename src/entry/content/{core => lifecycle}/nodes/ShadowNode.test.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/ShadowNode.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/index.ts (100%) rename src/entry/content/{core => lifecycle}/nodes/isolated-styles.ts (100%) rename src/entry/content/{core => }/resolvers/anchor.ts (93%) rename src/entry/content/{core => }/resolvers/container.ts (96%) create mode 100644 src/entry/content/resolvers/definition.test.ts create mode 100644 src/entry/content/resolvers/definition.ts rename src/entry/content/{core => }/resolvers/index.ts (83%) rename src/entry/content/{core => }/resolvers/mount.ts (95%) create mode 100644 src/entry/content/resolvers/watch.test.ts rename src/entry/content/{core => }/resolvers/watch.ts (83%) create mode 100644 src/entry/relay/Builder.test.ts create mode 100644 src/entry/relay/index.test.ts create mode 100644 src/entry/relay/resolvers/definition.test.ts create mode 100644 src/entry/relay/resolvers/definition.ts create mode 100644 tests/integration/build/content/content-api.integration.test.ts create mode 100644 tests/integration/types/fixtures/content/watch.ts diff --git a/jest.config.ts b/jest.config.ts index abb69e4c..377bcd83 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,6 @@ import type {Config} from "jest"; import {availableParallelism} from "node:os"; +import {fileURLToPath} from "node:url"; const shared: Config = { rootDir: import.meta.dirname, @@ -80,7 +81,18 @@ const config: Config = { ...exclude(domTests), ], }, - {...shared, displayName: "unit-dom", testEnvironment: "jsdom", testMatch: domTests}, + { + ...shared, + displayName: "unit-dom", + testEnvironment: "jsdom", + // Content builders use the import-only await-lock package; exercise its real implementation. + moduleNameMapper: { + ...shared.moduleNameMapper, + "^await-lock$": fileURLToPath(import.meta.resolve("await-lock")), + }, + transformIgnorePatterns: ["/node_modules/(?!await-lock/)"], + testMatch: domTests, + }, {...shared, displayName: "build", testMatch: buildTests}, {...shared, displayName: "types", testMatch: ["/tests/integration/types/**/*.test.ts"]}, { diff --git a/package.json b/package.json index fa9b52ed..8babf8bc 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,6 @@ "types": "./dist/entry/*/index.d.ts", "default": "./dist/entry/*/index.js" }, - "./entry/content/frame": { - "types": "./dist/entry/content/frame/index.d.ts", - "default": "./dist/entry/content/frame/index.js" - }, "./entry/content/*": { "types": "./dist/entry/content/adapters/*/index.d.ts", "default": "./dist/entry/content/adapters/*/index.js" @@ -67,6 +63,10 @@ "types": "./dist/entry/view/adapters/*/index.d.ts", "default": "./dist/entry/view/adapters/*/index.js" }, + "./content": { + "types": "./dist/content/index.d.ts", + "default": "./dist/content/index.js" + }, "./locale": { "types": "./dist/locale/index.d.ts", "default": "./dist/locale/index.js" diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index 09513f7b..9d3de3a0 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -63,6 +63,8 @@ describe("ContentParser", () => { "jsx.content.tsx", "Panel.content.tsx", "element-object.content.ts", + "create-element.content.tsx", + "local-element.content.tsx", ])("rejects frame navigation with default render: %s", name => { const target = file("invalid", "default-render", name); expectParseError(target, /isolation.page\/isolation.src cannot be combined with render/); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index b5339d5f..d35036b0 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -2,7 +2,7 @@ import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@sh import z from "zod"; import AbstractParser from "./AbstractParser"; -import {ExportValueKind} from "../file"; +import {hasContentScriptDefaultRender} from "./utils/content/default-render"; import { ContentScriptDeclarative, @@ -16,7 +16,9 @@ import { } from "@typing/content"; import {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; -export default class extends AbstractParser { +export default class ContentParser< + O extends EntrypointOptions = ContentScriptEntrypointOptions, +> extends AbstractParser { protected definition(): string | string[] { return ["defineContentScript", "defineContentScriptAppend"]; } @@ -71,6 +73,7 @@ export default class; +export default element; diff --git a/src/cli/entrypoint/parser/utils/content/default-render.ts b/src/cli/entrypoint/parser/utils/content/default-render.ts new file mode 100644 index 00000000..4314bc6e --- /dev/null +++ b/src/cli/entrypoint/parser/utils/content/default-render.ts @@ -0,0 +1,25 @@ +import {ExportValueKind, type ExportValue} from "../../../file"; + +/** Source metadata only: the CLI never loads a UI runtime to validate frame navigation. */ +export const hasContentScriptDefaultRender = (exported?: ExportValue): boolean => { + if (!exported) { + return false; + } + + switch (exported.kind) { + case ExportValueKind.Function: + case ExportValueKind.Class: + case ExportValueKind.Jsx: + case ExportValueKind.Number: + return true; + case ExportValueKind.String: + return exported.value !== ""; + default: + // Literal elements expose their runtime tag; inferred ReactElement/JSX.Element types + // expose the public type/props/key fields instead (including imported element values). + return ( + exported.properties.includes("$$typeof") || + ["type", "props", "key"].every(property => exported.properties.includes(property)) + ); + } +}; diff --git a/src/cli/plugins/content/RelayDeclaration.test.ts b/src/cli/plugins/content/RelayDeclaration.test.ts index eaf5dcd3..645628d8 100644 --- a/src/cli/plugins/content/RelayDeclaration.test.ts +++ b/src/cli/plugins/content/RelayDeclaration.test.ts @@ -115,7 +115,12 @@ describe("Relay declarations", () => { }; expect(exportsOf("adnbn/relay")).toEqual(["RelayName", "RelayRegistry", "RelayTarget", "getRelay"]); - expect(exportsOf("adnbn/entry/relay")).toEqual(["Builder", "RelayUnresolvedDefinition", "default"]); + expect(exportsOf("adnbn/entry/relay")).toEqual([ + "Builder", + "RelayUnresolvedDefinition", + "default", + "resolveDefinition", + ]); expect(exportsOf("adnbn").filter(name => /Relay/.test(name))).toEqual([ "RelayAllFrames", "RelayAllFramesOptions", diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts index b8fb09d0..b3f1c4c2 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts @@ -1,6 +1,7 @@ import * as api from "adnbn"; import * as local from "adnbn/relay"; import * as entry from "adnbn/entry/relay"; +import {Builder as ContentBuilder} from "adnbn/entry/content/vanilla"; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; type Expect = T; @@ -12,9 +13,10 @@ const definition = api.defineRelay({ init: () => ({scan: (text: string) => text.length}), }); -new entry.Builder(definition); +new entry.Builder(definition, ContentBuilder); const unresolved: entry.RelayUnresolvedDefinition> = {}; -new entry.Builder(unresolved); +new entry.Builder(unresolved, ContentBuilder); +entry.default(entry.resolveDefinition({default: definition}, "scanner"), ContentBuilder); const original = local.getRelay("scanner"); const scalar = api.getRelay("scanner", 1); diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts index 3716aaaa..2c90c7e4 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts @@ -1,5 +1,5 @@ import {RelayAllFrames, type ContentScriptDefinition, type RelayDefinition} from "adnbn"; -import {Builder as RelayBuilder, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; +import relay, {Builder as RelayBuilder, resolveDefinition, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; import type {TransportType} from "adnbn/transport"; import {Builder as ContentScriptBuilder} from "virtual:content-builder"; import * as definition from "virtual:relay-entrypoint"; @@ -21,10 +21,11 @@ type DefaultExport = Expect< > >; -new RelayBuilder({allFrames: RelayAllFrames.All}); +new RelayBuilder({allFrames: RelayAllFrames.All}, ContentScriptBuilder); new ContentScriptBuilder({allFrames: true}); +relay(resolveDefinition(definition, "scanner"), ContentScriptBuilder); // @ts-expect-error: The real content constructor requires a boolean, not Relay's response mode. new ContentScriptBuilder({allFrames: RelayAllFrames.All}); // @ts-expect-error: The real Relay constructor does not accept arbitrary modes. -new RelayBuilder({allFrames: "invalid"}); +new RelayBuilder({allFrames: "invalid"}, ContentScriptBuilder); diff --git a/src/cli/virtual/content.ts b/src/cli/virtual/content.ts index 54cbf13d..19e349e5 100644 --- a/src/cli/virtual/content.ts +++ b/src/cli/virtual/content.ts @@ -1,22 +1,9 @@ -import type {ContentScriptDefinition} from "adnbn"; -import {isContentScriptDefinition, isValidContentScriptDefinitionRenderValue} from "adnbn/entry/content"; - -import contentScript from "virtual:content-builder"; +import contentScript, {resolveDefinition} from "virtual:content-builder"; import * as module from "virtual:content-entrypoint"; try { - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: object = otherDefinition; - - if (isContentScriptDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidContentScriptDefinitionRenderValue(defaultDefinition)) { - definition = {...definition, render: defaultDefinition} as ContentScriptDefinition; - } - - contentScript(definition as ContentScriptDefinition); + contentScript(resolveDefinition(module)); } catch (e) { console.error("The content script crashed on startup:", e); } diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index 222e4c31..8b25ef5b 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -44,7 +44,7 @@ export const virtualCommandModule = (file: EntrypointFile, name: string): string export const virtualContentScriptModule = (file: EntrypointFile, navigation = false): string => { // prettier-ignore return getVirtualModule(file, "content") - .replace(`virtual:content-builder`, navigation ? `${PackageName}/entry/content/frame` : getEntryFramework(file, "content")); + .replace(`virtual:content-builder`, navigation ? `${PackageName}/entry/content` : getEntryFramework(file, "content")); }; export const virtualOffscreenModule = (file: EntrypointFile, name: string): string => { @@ -62,7 +62,7 @@ export const virtualRelayModule = (file: EntrypointFile, name: string, navigatio .replace("virtual:relay-name", name) .replace( `virtual:content-builder`, - navigation ? `${PackageName}/entry/content/frame` : getEntryFramework(file, "content") + navigation ? `${PackageName}/entry/content` : getEntryFramework(file, "content") ); }; diff --git a/src/cli/virtual/relay.ts b/src/cli/virtual/relay.ts index 11c596f4..d1b12342 100644 --- a/src/cli/virtual/relay.ts +++ b/src/cli/virtual/relay.ts @@ -1,44 +1,11 @@ -import type {ContentScriptDefinition} from "adnbn"; -import type {TransportType} from "adnbn/transport"; -import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; -import {Builder as RelayBuilder, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; +import relay, {resolveDefinition} from "adnbn/entry/relay"; -import {Builder as ContentScriptBuilder} from "virtual:content-builder"; +import {Builder as ContentBuilder} from "virtual:content-builder"; import * as module from "virtual:relay-entrypoint"; try { - const relayName = "virtual:relay-name"; - - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: RelayUnresolvedDefinition = otherDefinition; - - if (isValidTransportDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, main, name, ...options} = definition; - const {method, allFrames, ...contentOptions} = options; - - new RelayBuilder({ - name: relayName, - init, - main, - ...options, - }) - .content( - new ContentScriptBuilder({ - ...contentOptions, - ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), - } as ContentScriptDefinition) - ) - .build() - .catch(e => { - console.error("Failed to build relay: ", e); - }); -} catch (e) { - console.error("The relay crashed on startup:", e); + relay(resolveDefinition(module, "virtual:relay-name"), ContentBuilder); +} catch (error) { + console.error("The relay crashed on startup:", error); } diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 244f5f7e..da1fe72b 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -33,15 +33,17 @@ declare module "virtual:content-entrypoint" { } declare module "virtual:content-builder" { + export const resolveDefinition: (module: object) => import("@typing/content").ContentScriptDefinition; + export const Builder: | typeof import("@entry/content/adapters/vanilla").Builder | typeof import("@entry/content/adapters/react").Builder - | typeof import("@entry/content/frame").Builder; + | typeof import("@entry/content").Builder; const content: | typeof import("@entry/content/adapters/vanilla").default | typeof import("@entry/content/adapters/react").default - | typeof import("@entry/content/frame").default; + | typeof import("@entry/content").default; export default content; } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 422f5c82..50c5f257 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -3,6 +3,7 @@ import {existsSync, readFileSync} from "fs"; import path from "path"; import {fileURLToPath} from "url"; import ts from "typescript"; +import {build} from "esbuild"; type Generator = keyof typeof import("./index"); @@ -19,7 +20,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualContentScriptModule", - imports: ["adnbn", "adnbn/entry/content", "adnbn/entry/content/{framework}", "{entry}"], + imports: ["adnbn/entry/content/{framework}", "{entry}"], }, { generator: "virtualServiceModule", @@ -42,14 +43,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualRelayModule", - imports: [ - "adnbn", - "adnbn/transport", - "adnbn/entry/transport", - "adnbn/entry/relay", - "adnbn/entry/content/{framework}", - "{entry}", - ], + imports: ["adnbn/entry/relay", "adnbn/entry/content/{framework}", "{entry}"], }, { generator: "virtualSandboxModule", @@ -69,7 +63,7 @@ describe("Built virtual modules", () => { ]; let generated: Record<"ts" | "tsx", Record>; let navigation: Record<"ts" | "tsx", Record<"virtualContentScriptModule" | "virtualRelayModule", string>>; - let frameModule: string; + let contentModule: string; beforeAll(() => { // Run the final JS artifact in Node, without Jest transforms, source aliases, or module mocks. @@ -106,7 +100,7 @@ describe("Built virtual modules", () => { process.stdout.write(JSON.stringify({ generated, navigation, - frameModule: import.meta.resolve("adnbn/entry/content/frame"), + contentModule: import.meta.resolve("adnbn/entry/content"), })); `, ], @@ -115,7 +109,7 @@ describe("Built virtual modules", () => { ); generated = artifacts.generated; navigation = artifacts.navigation; - frameModule = artifacts.frameModule; + contentModule = artifacts.contentModule; }); test("covers every built generator", () => { @@ -129,17 +123,79 @@ describe("Built virtual modules", () => { expect(importedFiles).toContain("../entrypoint/index.js"); }); - test("resolves the public frame entrypoint outside renderer adapters", () => { - const filename = fileURLToPath(frameModule); - expect(filename).toBe(path.join(projectDir, "dist/entry/content/frame/index.js")); + test("resolves the common content entrypoint outside renderer adapters", () => { + const filename = fileURLToPath(contentModule); + expect(filename).toBe(path.join(projectDir, "dist/entry/content/index.js")); expect(existsSync(filename)).toBe(true); expect(existsSync(filename.replace(/\.js$/, ".d.ts"))).toBe(true); }); + test.each([ + "adnbn", + "adnbn/entry/content/vanilla", + "adnbn/entry/content/react", + "adnbn/entry/content", + "adnbn/entry/relay", + ])("%s includes only its own framework dependencies in the bundle graph", async entrypoint => { + const result = await build({ + absWorkingDir: projectDir, + entryPoints: [entrypoint], + bundle: true, + platform: "browser", + format: "esm", + // Resolve the published package exports instead of the source aliases in tsconfig.json. + tsconfigRaw: {}, + write: false, + metafile: true, + logLevel: "silent", + }); + const inputs = Object.keys(result.metafile!.inputs).map(filename => filename.replaceAll("\\", "/")); + const usesReact = entrypoint.endsWith("/react"); + + expect(inputs.some(filename => /node_modules\/react\//.test(filename))).toBe(usesReact); + expect(inputs.some(filename => /node_modules\/react-dom\//.test(filename))).toBe(usesReact); + expect(inputs.some(filename => filename.includes("entry/content/adapters/react/"))).toBe(usesReact); + if (entrypoint === "adnbn/entry/relay") { + expect(inputs.some(filename => filename.includes("entry/content/"))).toBe(false); + } + expect(inputs.some(filename => /entry\/content\/adapters\/vanilla\/(Builder|Node)\.js$/.test(filename))).toBe( + entrypoint.endsWith("/vanilla") + ); + }); + describe.each([ {extension: "ts" as const, framework: "vanilla"}, {extension: "tsx" as const, framework: "react"}, ])("with $framework entrypoints", ({extension, framework}) => { + test("loads definition normalization from the selected content entrypoint", () => { + for (const source of [ + generated[extension].virtualContentScriptModule, + navigation[extension].virtualContentScriptModule, + ]) { + const file = ts.createSourceFile("content.ts", source, ts.ScriptTarget.Latest, true); + const adapterImport = file.statements.find(ts.isImportDeclaration)!; + const bindings = adapterImport.importClause?.namedBindings; + expect( + bindings && ts.isNamedImports(bindings) && bindings.elements.map(element => element.name.text) + ).toEqual(["resolveDefinition"]); + expect(source).toContain("contentScript(resolveDefinition(module))"); + } + }); + + test("delegates Relay normalization and startup to its runtime entrypoint", () => { + for (const source of [generated[extension].virtualRelayModule, navigation[extension].virtualRelayModule]) { + const file = ts.createSourceFile("relay.ts", source, ts.ScriptTarget.Latest, true); + const relayImport = file.statements.find(ts.isImportDeclaration)!; + expect((relayImport.moduleSpecifier as ts.StringLiteral).text).toBe("adnbn/entry/relay"); + expect(relayImport.importClause?.name?.text).toBe("relay"); + const bindings = relayImport.importClause?.namedBindings; + expect( + bindings && ts.isNamedImports(bindings) && bindings.elements.map(element => element.name.text) + ).toEqual(["resolveDefinition"]); + expect(source).toContain('relay(resolveDefinition(module, "example"), ContentBuilder)'); + } + }); + test.each(cases)("$generator preserves package imports and resolves placeholders", ({generator, imports}) => { const source = generated[extension][generator]; const importedFiles = ts.preProcessFile(source).importedFiles.map(file => file.fileName); @@ -154,7 +210,7 @@ describe("Built virtual modules", () => { }); test.each(["virtualContentScriptModule", "virtualRelayModule"] as const)( - "%s selects the frame builder for document navigation", + "%s selects the common content builder for document navigation", generator => { const source = navigation[extension][generator]; const importedFiles = ts.preProcessFile(source).importedFiles.map(file => file.fileName); @@ -162,7 +218,7 @@ describe("Built virtual modules", () => { expect(importedFiles).toEqual( imports.map(specifier => - specifier.replace("{framework}", "frame").replace("{entry}", `./entry.${extension}`) + specifier.replace("/{framework}", "").replace("{entry}", `./entry.${extension}`) ) ); expect(source).not.toContain("virtual:"); diff --git a/src/content/index.ts b/src/content/index.ts new file mode 100644 index 00000000..fc199494 --- /dev/null +++ b/src/content/index.ts @@ -0,0 +1,9 @@ +export {createMutationObserverStrategy, createAwaitFirstStrategy} from "@entry/content/resolvers/watch"; + +export {ContentScriptEvent} from "@typing/content"; +export type { + ContentScriptWatchStrategy, + ContentScriptContext, + ContentScriptEventCallback, + ContentScriptNode, +} from "@typing/content"; diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 404ce9a7..2049a15b 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -1,23 +1,92 @@ # Content entrypoints +## Public API + +Import `defineContentScript`, `defineContentScriptAppend`, and content definition types from `adnbn`. +Import watch-strategy factories and lifecycle subscription contracts from `adnbn/content`: + +```ts title="src/panel.content.ts" +import {defineContentScript} from "adnbn"; +import {createMutationObserverStrategy} from "adnbn/content"; + +export default defineContentScript({ + anchor: "article", + watch: createMutationObserverStrategy({ + attributes: false, + characterData: false, + }), + render: () => "Hello", +}); +``` + +`createMutationObserverStrategy(options?)` creates a debounced DOM-mutation strategy with configurable +`MutationObserverInit` options. `createAwaitFirstStrategy(options?)` provides the strategy used while +waiting for the first content nodes. Creating a strategy does not start observing; the lifecycle +starts it when processing content and invokes its returned unsubscribe function during cleanup. + +`adnbn/content` also exports `ContentScriptEvent` and the `ContentScriptWatchStrategy`, +`ContentScriptContext`, `ContentScriptEventCallback`, and `ContentScriptNode` types. A custom strategy +receives `(update, context)` and returns an unsubscribe function. Use `context.watch(callback)` for +lifecycle events; its returned function removes that subscription, and `context.unwatch()` removes all +context subscriptions. Without a local renderer, anchor processing and watching run only for page/src navigation. + +The public `src/content/index.ts` entrypoint explicitly exports these tools from their runtime owners. +Definition helpers remain in `src/main/content.ts`; normalization and mounting helpers remain internal. +Runtime code imports implementations directly, without depending on the public `adnbn/content` facade. +The separate `adnbn/entry/content` entrypoint supplies the builder, startup function, and definition +resolver required by generated modules. + ## Internal organization -`core/Builder.ts` and `core/MountBuilder.ts` coordinate the content lifecycle. Related implementations -and their tests live together: +`lifecycle/Builder.ts` coordinates main execution, watching, context cleanup, and node events. +`lifecycle/MountBuilder.ts` is a concrete builder that composes mounting and isolation without a UI +renderer. React and Vanilla extend it with rendering. Related implementations and their tests live together: -- `core/nodes`: host mounting, node decorators, ShadowRoot/iframe targets, and the styles-runtime helper. -- `core/markers`: anchor marking and lookup strategies. -- `core/context`: the node collection, lifecycle operations, and event subscriptions. -- `core/resolvers`: normalization of content definitions and option handlers. -- `adapters/react` and `adapters/vanilla`: UI rendering implementations. -- `frame`: document navigation for `isolation.page` and `isolation.src`, without a UI renderer. +- `lifecycle/nodes`: host mounting, node decorators, ShadowRoot/iframe targets, and the styles-runtime helper. +- `lifecycle/markers`: anchor marking and lookup strategies. +- `lifecycle/context`: the node collection, lifecycle operations, and event subscriptions. +- `resolvers`: shared option handlers and definition merging, without framework detection or rendering. +- `adapters/react` and `adapters/vanilla`: default-export interpretation, render normalization, and UI rendering. +- `index.ts`: the common runtime entrypoint, exporting `MountBuilder` as `Builder` and its startup resolver. Pure structural validation and the frame-navigation predicate live in `src/shared/content/isolation.ts`. CLI and runtime import this shared module independently; the parser does not import runtime resolvers. -A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. The public -`adnbn/entry/content/frame` import resolves to the separate frame builder. Internally, -`virtual:content-builder` selects either that builder or a renderer adapter. +A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. The runtime +`adnbn/entry/content` import resolves to the common builder without importing renderer adapters. Internally, +`virtual:content-builder` selects either that builder or a renderer adapter. The selected entrypoint +exports both its builder and `resolveDefinition`; the generated content module imports +them together. `.tsx`/`.jsx` selects React, while the other currently supported script extensions select +Vanilla. `isolation.page`/`isolation.src` selects the common builder regardless of the filename, for both +Content and Relay. Scripts with only `main` retain the filename-based selection. + +Each adapter interprets its own default export before shared code merges options. Default options +override named options; a recognized default render value overrides a named `render`. React uses +`isValidElement` to recognize elements without exposing that dependency to Vanilla or the common runtime. This +refactor preserves the existing render forms; it does not expand rendering to every value in `ReactNode`. +`mergeDefinition` combines exports using the selected resolver's interpretation of default +values. The common `resolveDefinition` accepts configuration without recognizing framework +components; adapters provide their own definition resolvers. The common builder accepts an absent render +handler and reports an error if rendering is requested without an adapter. Vanilla keeps its value check and +async handler normalization together in `adapters/vanilla/resolvers/render.ts`, used internally by the +Vanilla builder. + +The CLI `ContentParser.ts` and its test live beside the other parsers in `src/cli/entrypoint/parser`. +Content fixtures live in `parser/tests/fixtures/content`. The helper in +`parser/utils/content/default-render.ts` interprets source metadata from `entrypoint/file` without loading UI runtimes. In particular, it +rejects known default render exports with frame navigation, including explicit element objects. +The common runtime receives this build-validated configuration and retains the runtime rejection of an explicit +`render` property. Relay keeps its separate default-export interpretation as transport initialization. + +The common lifecycle wraps each complete node in `EventNode`, including any adapter renderer, before +adding it to the context. Mount/unmount events follow the underlying node operations. Without render, +`main` still runs and context cleanup remains available; anchor processing and watching start only for +page/src navigation. `FrameNode` continues to own iframe creation, navigation, and child-document recovery. + +When adding a runtime adapter, implement its definition and render resolvers and expose the definition +resolver from that adapter's `index.ts`. Extend filename/build support and parser metadata interpretation +as required by the framework. Runtime adapters are selected individually; do not aggregate their +implementations through a common barrel. The content define helpers stay at the root `adnbn` import. Content contracts live in `src/types/content`: diff --git a/src/entry/content/adapters/react/Builder.test.ts b/src/entry/content/adapters/react/Builder.test.ts new file mode 100644 index 00000000..50a3bc4d --- /dev/null +++ b/src/entry/content/adapters/react/Builder.test.ts @@ -0,0 +1,70 @@ +import {act} from "@testing-library/react"; +import {createElement, useEffect, useState} from "react"; + +import Builder from "./Builder"; +import {resolveDefinition} from "./resolvers/definition"; +import {ContentScriptEvent, type ContentScriptContext, type ContentScriptProps} from "@typing/content"; + +// Extend the shared random-ID mock for the builders' generated marker attribute. +jest.mock("nanoid", () => ({ + nanoid: jest.fn(() => "mocked-id"), + customAlphabet: () => () => "contentmarker", +})); + +describe("React Builder", () => { + afterEach(() => document.body.replaceChildren()); + + test("React renders a default component with hooks and cleans up its effects", async () => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const events: (ContentScriptEvent | "effect-cleanup")[] = []; + const cleanup = jest.fn(() => { + events.push("effect-cleanup"); + }); + const render = jest.fn(({anchor: target}: ContentScriptProps) => { + const [label] = useState(target.tagName); + useEffect(() => cleanup, []); + return createElement("span", null, label); + }); + const definition = resolveDefinition({ + anchor, + default: render, + main: (context: ContentScriptContext) => { + context.watch(event => events.push(event)); + }, + }); + expect(definition.render).toBe(render); + expect(render).not.toHaveBeenCalled(); + const builder = new Builder(definition); + + try { + await act(() => builder.build()); + expect(anchor.querySelector("span")?.textContent).toBe("ARTICLE"); + expect(render).toHaveBeenCalled(); + } finally { + await act(() => builder.destroy()); + } + expect(cleanup).toHaveBeenCalledTimes(1); + expect(anchor.childElementCount).toBe(0); + expect(events).toEqual([ + ContentScriptEvent.Add, + ContentScriptEvent.Mount, + "effect-cleanup", + ContentScriptEvent.Unmount, + ContentScriptEvent.Remove, + ]); + }); + + test("React recognizes and renders an element through its native API", async () => { + const element = createElement("strong", null, "React element"); + const definition = resolveDefinition({default: element}); + expect(definition).toEqual({render: element}); + const builder = new Builder(definition); + try { + await act(() => builder.build()); + expect(document.querySelector("strong")?.textContent).toBe("React element"); + } finally { + await act(() => builder.destroy()); + } + }); +}); diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index 488aa31c..dfb75465 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,10 +1,9 @@ import {isValidElement} from "react"; -import MountBuilder from "../../core/MountBuilder"; -import EventNode from "../../core/nodes/EventNode"; +import MountBuilder from "../../lifecycle/MountBuilder"; import ReactNode from "./Node"; -import {contentScriptReactRenderResolver} from "./resolvers"; +import {createRenderResolver} from "./resolvers/render"; import { ContentScriptDefinition, @@ -13,7 +12,7 @@ import { ContentScriptRenderValue, } from "@typing/content"; -export default class extends MountBuilder { +export default class Builder extends MountBuilder { constructor(definition: ContentScriptDefinition) { super(definition); } @@ -23,7 +22,7 @@ export default class extends MountBuilder { return; } - return contentScriptReactRenderResolver(render); + return createRenderResolver(render); } protected async createNode(anchor: Element): Promise { @@ -35,6 +34,6 @@ export default class extends MountBuilder { console.warn("Content script react value is not a valid React element"); } - return new EventNode(new ReactNode(await super.createNode(anchor), value), this.emitter); + return new ReactNode(await super.createNode(anchor), value); } } diff --git a/src/entry/content/adapters/react/index.ts b/src/entry/content/adapters/react/index.ts index 230e7fd9..5be31bac 100644 --- a/src/entry/content/adapters/react/index.ts +++ b/src/entry/content/adapters/react/index.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; export {Builder}; +export {resolveDefinition} from "./resolvers/definition"; export default Builder.resolver(); diff --git a/src/entry/content/adapters/react/resolvers/definition.test.ts b/src/entry/content/adapters/react/resolvers/definition.test.ts new file mode 100644 index 00000000..7f535e24 --- /dev/null +++ b/src/entry/content/adapters/react/resolvers/definition.test.ts @@ -0,0 +1,23 @@ +import {resolveDefinition} from "./definition"; + +describe("React content definitions", () => { + test("merges default options over named exports without mutating either object", () => { + const main = jest.fn(); + const defaults = Object.freeze({matches: ["https://default.example/*"], main}); + const module = Object.freeze({default: defaults, matches: ["https://named.example/*"], anchor: "article"}); + + expect(resolveDefinition(module)).toEqual({matches: defaults.matches, main, anchor: "article"}); + expect(module.matches).toEqual(["https://named.example/*"]); + expect(main).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, ""])("preserves named options with an empty default %p", value => { + const main = jest.fn(); + expect(resolveDefinition({default: value, main})).toEqual({main}); + }); + + test("a property named $$typeof alone does not identify a React element", () => { + const definition = {main: jest.fn(), $$typeof: "application metadata"}; + expect(resolveDefinition({default: definition})).toEqual(definition); + }); +}); diff --git a/src/entry/content/adapters/react/resolvers/definition.ts b/src/entry/content/adapters/react/resolvers/definition.ts new file mode 100644 index 00000000..f35b9e9f --- /dev/null +++ b/src/entry/content/adapters/react/resolvers/definition.ts @@ -0,0 +1,16 @@ +import {isValidElement} from "react"; +import {mergeDefinition} from "../../../resolvers/definition"; + +import type {ContentScriptDefinition} from "@typing/content"; + +export const resolveDefinition = (module: object): ContentScriptDefinition => { + return mergeDefinition( + module, + value => + typeof value === "function" || + isValidElement(value) || + typeof value === "number" || + (typeof value === "string" && value.length > 0) || + value instanceof Element + ); +}; diff --git a/src/entry/content/adapters/react/resolvers/index.ts b/src/entry/content/adapters/react/resolvers/index.ts deleted file mode 100644 index a59e3c82..00000000 --- a/src/entry/content/adapters/react/resolvers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./render"; diff --git a/src/entry/content/adapters/react/resolvers/render.ts b/src/entry/content/adapters/react/resolvers/render.ts index b7c36b6d..357f9232 100644 --- a/src/entry/content/adapters/react/resolvers/render.ts +++ b/src/entry/content/adapters/react/resolvers/render.ts @@ -2,7 +2,7 @@ import {createElement, isValidElement} from "react"; import {ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; -export const contentScriptReactRenderResolver = +export const createRenderResolver = (render?: ContentScriptRenderValue): ContentScriptRenderHandler => async (props): Promise => { let resolvedRender = typeof render === "function" ? createElement(render, props) : render; diff --git a/src/entry/content/adapters/vanilla/Builder.test.ts b/src/entry/content/adapters/vanilla/Builder.test.ts new file mode 100644 index 00000000..0aab36fd --- /dev/null +++ b/src/entry/content/adapters/vanilla/Builder.test.ts @@ -0,0 +1,83 @@ +import Builder from "./Builder"; +import {resolveDefinition} from "./resolvers/definition"; +import {defineContentScript, defineContentScriptAppend} from "@main/content"; +import {ContentScriptEvent, type ContentScriptProps} from "@typing/content"; + +// Extend the shared random-ID mock for the builders' generated marker attribute. +jest.mock("nanoid", () => ({ + nanoid: jest.fn(() => "mocked-id"), + customAlphabet: () => () => "contentmarker", +})); + +describe("Vanilla Builder", () => { + afterEach(() => document.body.replaceChildren()); + + test("Vanilla awaits a default handler and preserves props, placement and cleanup", async () => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const render = jest.fn(async ({anchor: target}: ContentScriptProps) => { + await Promise.resolve(); + const element = document.createElement("span"); + element.textContent = target.tagName; + return element; + }); + const unwatch = jest.fn(); + const events: {event: ContentScriptEvent; text: string | null | undefined}[] = []; + const options = defineContentScriptAppend({ + anchor, + watch: () => unwatch, + main: context => { + context.watch((event, node) => events.push({event, text: node.target?.textContent})); + }, + }); + const definition = resolveDefinition({...options, default: render}); + expect(definition.render).toBe(render); + expect(render).not.toHaveBeenCalled(); + const builder = new Builder(definition); + + try { + await builder.build(); + expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledWith(expect.objectContaining({anchor})); + expect(anchor.querySelector("span")?.textContent).toBe("ARTICLE"); + expect(builder.getContext().nodes.size).toBe(1); + } finally { + await builder.destroy(); + } + expect(anchor.childElementCount).toBe(0); + expect(unwatch).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + {event: ContentScriptEvent.Add, text: ""}, + {event: ContentScriptEvent.Mount, text: "ARTICLE"}, + {event: ContentScriptEvent.Unmount, text: undefined}, + {event: ContentScriptEvent.Remove, text: undefined}, + ]); + }); + + test.each(["text", 0, 12])("Vanilla preserves the default render value %p", async value => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const builder = new Builder(resolveDefinition({anchor, default: value})); + try { + await builder.build(); + expect(anchor.textContent).toBe(String(value)); + } finally { + await builder.destroy(); + } + }); + + test("Vanilla preserves the true result used to mark an anchor without a container", async () => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const container = jest.fn(() => document.createElement("section")); + const builder = new Builder(defineContentScript({anchor, render: () => true, container})); + try { + await builder.build(); + expect(container).not.toHaveBeenCalled(); + expect(builder.getContext().nodes.size).toBe(1); + expect(anchor.childElementCount).toBe(0); + } finally { + await builder.destroy(); + } + }); +}); diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index 3a5f21ba..a04fad89 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -1,25 +1,35 @@ -import {isValidContentScriptRenderValue} from "../../core/resolvers"; +import {createRenderResolver, isValidRenderValue} from "./resolvers/render"; -import MountBuilder from "../../core/MountBuilder"; -import EventNode from "../../core/nodes/EventNode"; +import MountBuilder from "../../lifecycle/MountBuilder"; import VanillaNode from "./Node"; -import {ContentScriptDefinition, ContentScriptNode} from "@typing/content"; +import type { + ContentScriptDefinition, + ContentScriptNode, + ContentScriptRenderHandler, + ContentScriptRenderValue, +} from "@typing/content"; -export default class extends MountBuilder { +export default class Builder extends MountBuilder { public constructor(definition: ContentScriptDefinition) { super(definition); } + protected resolveRender( + render?: ContentScriptRenderValue | ContentScriptRenderHandler + ): ContentScriptRenderHandler | undefined { + return render === undefined ? undefined : createRenderResolver(render); + } + protected async createNode(anchor: Element): Promise { let value = await this.getValue(anchor); - if (value !== true && !isValidContentScriptRenderValue(value)) { + if (value !== true && !isValidRenderValue(value)) { value = undefined; console.warn("Content script vanilla value is not a valid render value"); } - return new EventNode(new VanillaNode(await super.createNode(anchor), value), this.emitter); + return new VanillaNode(await super.createNode(anchor), value); } } diff --git a/src/entry/content/adapters/vanilla/index.ts b/src/entry/content/adapters/vanilla/index.ts index 230e7fd9..5be31bac 100644 --- a/src/entry/content/adapters/vanilla/index.ts +++ b/src/entry/content/adapters/vanilla/index.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; export {Builder}; +export {resolveDefinition} from "./resolvers/definition"; export default Builder.resolver(); diff --git a/src/entry/content/adapters/vanilla/resolvers/definition.test.ts b/src/entry/content/adapters/vanilla/resolvers/definition.test.ts new file mode 100644 index 00000000..a1611701 --- /dev/null +++ b/src/entry/content/adapters/vanilla/resolvers/definition.test.ts @@ -0,0 +1,18 @@ +import {resolveDefinition} from "./definition"; + +describe("Vanilla content definitions", () => { + test("merges default options over named exports without mutating either object", () => { + const main = jest.fn(); + const defaults = Object.freeze({matches: ["https://default.example/*"], main}); + const module = Object.freeze({default: defaults, matches: ["https://named.example/*"], anchor: "article"}); + + expect(resolveDefinition(module)).toEqual({matches: defaults.matches, main, anchor: "article"}); + expect(module.matches).toEqual(["https://named.example/*"]); + expect(main).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, ""])("preserves named options with an empty default %p", value => { + const main = jest.fn(); + expect(resolveDefinition({default: value, main})).toEqual({main}); + }); +}); diff --git a/src/entry/content/adapters/vanilla/resolvers/definition.ts b/src/entry/content/adapters/vanilla/resolvers/definition.ts new file mode 100644 index 00000000..80c8bc86 --- /dev/null +++ b/src/entry/content/adapters/vanilla/resolvers/definition.ts @@ -0,0 +1,8 @@ +import {mergeDefinition} from "../../../resolvers/definition"; +import {isValidRenderValue} from "./render"; + +import type {ContentScriptDefinition} from "@typing/content"; + +export const resolveDefinition = (module: object): ContentScriptDefinition => { + return mergeDefinition(module, value => typeof value === "function" || isValidRenderValue(value)); +}; diff --git a/src/entry/content/core/resolvers/render.ts b/src/entry/content/adapters/vanilla/resolvers/render.ts similarity index 65% rename from src/entry/content/core/resolvers/render.ts rename to src/entry/content/adapters/vanilla/resolvers/render.ts index 41e6713c..0713e506 100644 --- a/src/entry/content/core/resolvers/render.ts +++ b/src/entry/content/adapters/vanilla/resolvers/render.ts @@ -1,11 +1,11 @@ -import {ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; +import type {ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; -export const isValidContentScriptRenderValue = (value: unknown): value is string | number | Element => { +export const isValidRenderValue = (value: unknown): value is string | number | Element => { return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; }; // prettier-ignore -export const contentScriptRenderResolver = +export const createRenderResolver = (render?: ContentScriptRenderValue | ContentScriptRenderHandler): ContentScriptRenderHandler => async (props): Promise => { let resolvedRender = typeof render === "function" ? render(props) : render; @@ -14,7 +14,7 @@ export const contentScriptRenderResolver = resolvedRender = await resolvedRender; } - if (resolvedRender !== true && !isValidContentScriptRenderValue(resolvedRender)) { + if (resolvedRender !== true && !isValidRenderValue(resolvedRender)) { return; } diff --git a/src/entry/content/core/resolvers/definition.ts b/src/entry/content/core/resolvers/definition.ts deleted file mode 100644 index 092ff686..00000000 --- a/src/entry/content/core/resolvers/definition.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {isValidContentScriptRenderValue} from "./render"; - -import {ContentScriptDefinition, ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; - -export const isContentScriptDefinition = (definition: any): definition is ContentScriptDefinition => { - return ( - definition && typeof definition === "object" && definition.constructor === Object && !("$$typeof" in definition) - ); -}; - -export const isValidContentScriptDefinitionRenderValue = ( - definition: any -): definition is ContentScriptRenderValue | ContentScriptRenderHandler => { - return ( - isValidContentScriptRenderValue(definition) || - typeof definition === "function" || - (typeof definition === "object" && definition.constructor === Object && "$$typeof" in definition) - ); -}; diff --git a/src/entry/content/core/resolvers/watch.test.ts b/src/entry/content/core/resolvers/watch.test.ts deleted file mode 100644 index 55d564a6..00000000 --- a/src/entry/content/core/resolvers/watch.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {contentScriptMutationObserverResolver} from "./watch"; - -describe("contentScriptMutationObserverResolver", () => { - test("observes the document element before body exists", () => { - const body = document.body; - const observe = jest.spyOn(MutationObserver.prototype, "observe"); - - body.remove(); - - try { - const unwatch = contentScriptMutationObserverResolver()(jest.fn(), {} as never); - - expect(observe).toHaveBeenCalledWith( - document.documentElement, - expect.objectContaining({childList: true, subtree: true}) - ); - - unwatch(); - } finally { - document.documentElement.append(body); - observe.mockRestore(); - } - }); -}); diff --git a/src/entry/content/frame/Builder.ts b/src/entry/content/frame/Builder.ts deleted file mode 100644 index 6baa8871..00000000 --- a/src/entry/content/frame/Builder.ts +++ /dev/null @@ -1,14 +0,0 @@ -import MountBuilder from "../core/MountBuilder"; -import EventNode from "../core/nodes/EventNode"; -import type {ContentScriptDefinition, ContentScriptNode} from "@typing/content"; - -/** Page/src navigation has a document owner of its own and does not use a UI renderer. */ -export default class Builder extends MountBuilder { - public constructor(definition: ContentScriptDefinition) { - super(definition); - } - - protected async createNode(anchor: Element): Promise { - return new EventNode(await super.createNode(anchor), this.emitter); - } -} diff --git a/src/entry/content/frame/index.ts b/src/entry/content/frame/index.ts deleted file mode 100644 index a1fca591..00000000 --- a/src/entry/content/frame/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import Builder from "./Builder"; - -export {Builder}; -export default Builder.resolver(); diff --git a/src/entry/content/index.ts b/src/entry/content/index.ts index 2e4efe5f..5861eced 100644 --- a/src/entry/content/index.ts +++ b/src/entry/content/index.ts @@ -1 +1,6 @@ -export * from "./core/resolvers/definition"; +import MountBuilder from "./lifecycle/MountBuilder"; + +export {MountBuilder as Builder}; +export {resolveDefinition} from "./resolvers/definition"; + +export default MountBuilder.resolver(); diff --git a/src/entry/content/core/Builder.ts b/src/entry/content/lifecycle/Builder.ts similarity index 85% rename from src/entry/content/core/Builder.ts rename to src/entry/content/lifecycle/Builder.ts index f609f918..cb1290b7 100644 --- a/src/entry/content/core/Builder.ts +++ b/src/entry/content/lifecycle/Builder.ts @@ -1,20 +1,20 @@ import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; import AwaitLock from "await-lock"; -import Builder from "@entry/core/Builder"; +import EntrypointBuilder from "@entry/core/Builder"; import { - contentScriptAnchorResolver, - contentScriptAwaitFirstResolver, - contentScriptContainerResolver, - contentScriptLocationResolver, - contentScriptMountAppendResolver, - contentScriptMutationObserverResolver, - contentScriptRenderResolver, -} from "./resolvers"; + createAnchorResolver, + createAwaitFirstStrategy, + createContainerResolver, + withLocationTracking, + createAppendMountHandler, + createMutationObserverStrategy, +} from "../resolvers"; import {ManagedContext, EventEmitter} from "./context"; import {AttributeMarker, WeakMarker} from "./markers"; +import EventNode from "./nodes/EventNode"; import { ContentScriptAnchor, @@ -42,7 +42,7 @@ import { import {Awaiter} from "@typing/helpers"; -export default abstract class extends Builder implements ContentScriptBuilder { +export default abstract class Builder extends EntrypointBuilder implements ContentScriptBuilder { private readonly lock: AwaitLock = new AwaitLock(); protected readonly definition: ContentScriptResolvedDefinition; @@ -102,37 +102,37 @@ export default abstract class extends Builder implements ContentScriptBuilder { } protected resolveAnchor(anchor?: ContentScriptAnchor | ContentScriptAnchorGetter): ContentScriptAnchorGetter { - return contentScriptAnchorResolver(anchor); + return createAnchorResolver(anchor); } protected resolveMount(mount?: ContentScriptMountFunction): ContentScriptMountFunction { - return mount || contentScriptMountAppendResolver(); + return mount || createAppendMountHandler(); } protected resolveContainer( container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory ): ContentScriptContainerCreator { - return contentScriptContainerResolver(container); + return createContainerResolver(container); } protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler ): ContentScriptRenderHandler | undefined { - if (render === undefined) { - return; + if (render !== undefined) { + throw new Error("Content script rendering requires a renderer adapter"); } - return contentScriptRenderResolver(render); + return undefined; } protected resolveWatch(watch?: true | ContentScriptWatchStrategy): ContentScriptWatchStrategy { if (watch === undefined) { - watch = contentScriptAwaitFirstResolver(); + watch = createAwaitFirstStrategy(); } else if (watch === true) { - watch = contentScriptMutationObserverResolver(); + watch = createMutationObserverStrategy(); } - return contentScriptLocationResolver(watch); + return withLocationTracking(watch); } public getContext(): ContentScriptContext { @@ -185,7 +185,7 @@ export default abstract class extends Builder implements ContentScriptBuilder { } protected async processAnchor(anchor: Element): Promise { - const node = await this.createNode(anchor); + const node = new EventNode(await this.createNode(anchor), this.emitter); this.context.add(node); diff --git a/src/entry/content/lifecycle/MountBuilder.test.ts b/src/entry/content/lifecycle/MountBuilder.test.ts new file mode 100644 index 00000000..593c6a6a --- /dev/null +++ b/src/entry/content/lifecycle/MountBuilder.test.ts @@ -0,0 +1,132 @@ +import MountBuilder from "./MountBuilder"; +import {resolveDefinition} from "../resolvers/definition"; +import {defineContentScript} from "@main/content"; +import {ContentScriptEvent, ContentScriptIsolation, type ContentScriptDefinition} from "@typing/content"; + +// Extend the shared random-ID mock for the builders' generated marker attribute. +jest.mock("nanoid", () => ({ + nanoid: jest.fn(() => "mocked-id"), + customAlphabet: () => () => "contentmarker", +})); + +describe("MountBuilder", () => { + afterEach(() => document.body.replaceChildren()); + + test("Common runtime runs main without creating nodes or starting a DOM watcher", async () => { + const main = jest.fn(); + const anchor = jest.fn(() => document.body); + const container = jest.fn(() => document.createElement("section")); + const watch = jest.fn(() => jest.fn()); + const builder = new MountBuilder(resolveDefinition({default: {main, anchor, container, watch}})); + + try { + await builder.build(); + expect(main).toHaveBeenCalledWith( + builder.getContext(), + expect.objectContaining({ + isolation: {type: ContentScriptIsolation.None}, + }) + ); + expect(main).toHaveBeenCalledTimes(1); + expect(anchor).not.toHaveBeenCalled(); + expect(container).not.toHaveBeenCalled(); + expect(watch).not.toHaveBeenCalled(); + expect(builder.getContext().nodes.size).toBe(0); + } finally { + await builder.destroy(); + } + }); + + test.each(["named", "default"])("Common runtime rejects a %s render handler without calling it", exported => { + const render = jest.fn(); + const definition = resolveDefinition(exported === "named" ? {render} : {default: render}); + + expect(() => new MountBuilder(definition)).toThrow("Content script rendering requires a renderer adapter"); + expect(render).not.toHaveBeenCalled(); + }); + + test("Common runtime mounts, remounts and cleans up navigation with lifecycle events", async () => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const events: {event: ContentScriptEvent; connected: boolean; src?: string}[] = []; + const unwatch = jest.fn(); + const cleanupMount = jest.fn(); + const src = "https://example.com/panel"; + const builder = new MountBuilder( + defineContentScript({ + anchor, + isolation: {type: "iframe", src}, + watch: () => unwatch, + mount: (target, container) => { + target.append(container); + return cleanupMount; + }, + main: context => { + context.watch((event, node) => { + events.push({ + event, + connected: !!node.container?.isConnected, + src: node.container?.querySelector("iframe")?.src, + }); + }); + }, + }) + ); + + try { + await builder.build(); + expect(anchor.querySelector("iframe")?.src).toBe(src); + const context = builder.getContext(); + expect(context.nodes.size).toBe(1); + context.mount(); + context.unmount(); + expect(anchor.childElementCount).toBe(0); + context.mount(); + expect(anchor.querySelector("iframe")?.src).toBe(src); + expect(context.nodes.size).toBe(1); + } finally { + await builder.destroy(); + } + + expect(events).toEqual([ + {event: ContentScriptEvent.Add, connected: false, src: undefined}, + {event: ContentScriptEvent.Mount, connected: true, src}, + {event: ContentScriptEvent.Unmount, connected: false, src: undefined}, + {event: ContentScriptEvent.Mount, connected: true, src}, + {event: ContentScriptEvent.Unmount, connected: false, src: undefined}, + {event: ContentScriptEvent.Remove, connected: false, src: undefined}, + ]); + expect(anchor.childElementCount).toBe(0); + expect(builder.getContext().nodes.size).toBe(0); + expect(cleanupMount).toHaveBeenCalledTimes(2); + expect(unwatch).toHaveBeenCalledTimes(1); + }); + + test.each([undefined, () => "forbidden"])("Common runtime rejects an explicit render property: %p", render => { + const definition = resolveDefinition({ + default: {isolation: {type: "iframe", src: "https://example.com"}, render}, + }); + expect(() => new MountBuilder(definition)).toThrow( + "isolation.page/isolation.src cannot be combined with render" + ); + }); + + test("Common runtime rejects a default function without calling it", () => { + const render = jest.fn(); + const definition = resolveDefinition({ + isolation: {type: "iframe", src: "https://example.com"}, + default: render, + }); + expect(() => new MountBuilder(definition)).toThrow( + "isolation.page/isolation.src cannot be combined with render" + ); + expect(render).not.toHaveBeenCalled(); + }); + + test("Common runtime keeps the configuration accepted by the root define helper", () => { + const options: ContentScriptDefinition = defineContentScript({ + isolation: {type: "iframe", src: "https://example.com"}, + }); + expect(() => new MountBuilder(resolveDefinition({default: options}))).not.toThrow(); + }); +}); diff --git a/src/entry/content/core/MountBuilder.ts b/src/entry/content/lifecycle/MountBuilder.ts similarity index 86% rename from src/entry/content/core/MountBuilder.ts rename to src/entry/content/lifecycle/MountBuilder.ts index a7872086..2ae31143 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/lifecycle/MountBuilder.ts @@ -4,11 +4,21 @@ import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; import {isContentScriptFrameNavigation} from "@shared/content"; -import {ContentScriptIsolation, ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; - -export default abstract class extends Builder { +import { + ContentScriptDefinition, + ContentScriptIsolation, + ContentScriptNode, + ContentScriptProps, + ContentScriptRenderValue, +} from "@typing/content"; + +export default class MountBuilder extends Builder { private values = new Map(); + public constructor(definition: ContentScriptDefinition) { + super(definition); + } + protected getProps(anchor: Element): ContentScriptProps { const {anchor: _, mount, watch, render, container, main, isolation, ...options} = this.definition; diff --git a/src/entry/content/core/context/Context.test.ts b/src/entry/content/lifecycle/context/Context.test.ts similarity index 100% rename from src/entry/content/core/context/Context.test.ts rename to src/entry/content/lifecycle/context/Context.test.ts diff --git a/src/entry/content/core/context/Context.ts b/src/entry/content/lifecycle/context/Context.ts similarity index 95% rename from src/entry/content/core/context/Context.ts rename to src/entry/content/lifecycle/context/Context.ts index 9eeb4eb0..0c45e72a 100644 --- a/src/entry/content/core/context/Context.ts +++ b/src/entry/content/lifecycle/context/Context.ts @@ -6,7 +6,7 @@ import { ContentScriptNodeSet, } from "@typing/content"; -export default class implements ContentScriptContext { +export default class Context implements ContentScriptContext { protected readonly collection: ContentScriptNodeSet = new Set(); constructor(protected readonly emitter: ContentScriptEventEmitter) {} diff --git a/src/entry/content/core/context/EventEmitter.ts b/src/entry/content/lifecycle/context/EventEmitter.ts similarity index 94% rename from src/entry/content/core/context/EventEmitter.ts rename to src/entry/content/lifecycle/context/EventEmitter.ts index 361cfa7c..073e1375 100644 --- a/src/entry/content/core/context/EventEmitter.ts +++ b/src/entry/content/lifecycle/context/EventEmitter.ts @@ -5,7 +5,7 @@ import { ContentScriptNode, } from "@typing/content"; -export default class implements ContentScriptEventEmitter { +export default class EventEmitter implements ContentScriptEventEmitter { private readonly listeners: Set = new Set(); public emit(event: ContentScriptEvent, node: ContentScriptNode): void { diff --git a/src/entry/content/core/context/ManagedContext.ts b/src/entry/content/lifecycle/context/ManagedContext.ts similarity index 81% rename from src/entry/content/core/context/ManagedContext.ts rename to src/entry/content/lifecycle/context/ManagedContext.ts index 88274b94..e9b4e25d 100644 --- a/src/entry/content/core/context/ManagedContext.ts +++ b/src/entry/content/lifecycle/context/ManagedContext.ts @@ -2,7 +2,7 @@ import Context from "./Context"; import {ContentScriptNode} from "@typing/content"; -export default class extends Context { +export default class ManagedContext extends Context { public add(node: ContentScriptNode): this { this.collection.add(node); diff --git a/src/entry/content/core/context/index.ts b/src/entry/content/lifecycle/context/index.ts similarity index 100% rename from src/entry/content/core/context/index.ts rename to src/entry/content/lifecycle/context/index.ts diff --git a/src/entry/content/core/markers/AbstractMarker.ts b/src/entry/content/lifecycle/markers/AbstractMarker.ts similarity index 96% rename from src/entry/content/core/markers/AbstractMarker.ts rename to src/entry/content/lifecycle/markers/AbstractMarker.ts index bd9ceacd..84001475 100644 --- a/src/entry/content/core/markers/AbstractMarker.ts +++ b/src/entry/content/lifecycle/markers/AbstractMarker.ts @@ -1,6 +1,6 @@ import {ContentScriptAnchor, ContentScriptMarkerContract, ContentScriptMarkerValue} from "@typing/content"; -export default abstract class implements ContentScriptMarkerContract { +export default abstract class AbstractMarker implements ContentScriptMarkerContract { public abstract mark(element: Element, value: ContentScriptMarkerValue): boolean; public abstract unmark(element: Element): boolean; diff --git a/src/entry/content/core/markers/AttributeMarker.test.ts b/src/entry/content/lifecycle/markers/AttributeMarker.test.ts similarity index 95% rename from src/entry/content/core/markers/AttributeMarker.test.ts rename to src/entry/content/lifecycle/markers/AttributeMarker.test.ts index ce13eaa8..2ab86b39 100644 --- a/src/entry/content/core/markers/AttributeMarker.test.ts +++ b/src/entry/content/lifecycle/markers/AttributeMarker.test.ts @@ -1,4 +1,4 @@ -import AttributeMarker from "@entry/content/core/markers/AttributeMarker"; +import AttributeMarker from "@entry/content/lifecycle/markers/AttributeMarker"; import {ContentScriptMarkerContract, ContentScriptMarkerValue} from "@typing/content"; describe("AttributeMarker — specifics", () => { diff --git a/src/entry/content/core/markers/AttributeMarker.ts b/src/entry/content/lifecycle/markers/AttributeMarker.ts similarity index 95% rename from src/entry/content/core/markers/AttributeMarker.ts rename to src/entry/content/lifecycle/markers/AttributeMarker.ts index d61c1bf7..d7b11788 100644 --- a/src/entry/content/core/markers/AttributeMarker.ts +++ b/src/entry/content/lifecycle/markers/AttributeMarker.ts @@ -4,7 +4,7 @@ import AbstractMarker from "./AbstractMarker"; import {ContentScriptAnchor, ContentScriptMarkerValue} from "@typing/content"; -export default class extends AbstractMarker { +export default class AttributeMarker extends AbstractMarker { protected readonly attr: string; constructor(anchor?: ContentScriptAnchor, attr?: string) { diff --git a/src/entry/content/core/markers/WeakMarker.test.ts b/src/entry/content/lifecycle/markers/WeakMarker.test.ts similarity index 93% rename from src/entry/content/core/markers/WeakMarker.test.ts rename to src/entry/content/lifecycle/markers/WeakMarker.test.ts index 1fa61203..cd1a2f23 100644 --- a/src/entry/content/core/markers/WeakMarker.test.ts +++ b/src/entry/content/lifecycle/markers/WeakMarker.test.ts @@ -1,4 +1,4 @@ -import WeakMarker from "@entry/content/core/markers/WeakMarker"; +import WeakMarker from "@entry/content/lifecycle/markers/WeakMarker"; describe("WeakMarker — specifics", () => { test("does not mutate DOM (no attributes)", () => { diff --git a/src/entry/content/core/markers/WeakMarker.ts b/src/entry/content/lifecycle/markers/WeakMarker.ts similarity index 94% rename from src/entry/content/core/markers/WeakMarker.ts rename to src/entry/content/lifecycle/markers/WeakMarker.ts index f125f556..c1277e90 100644 --- a/src/entry/content/core/markers/WeakMarker.ts +++ b/src/entry/content/lifecycle/markers/WeakMarker.ts @@ -2,7 +2,7 @@ import AbstractMarker from "./AbstractMarker"; import {ContentScriptAnchor, ContentScriptMarkerValue} from "@typing/content"; -export default class extends AbstractMarker { +export default class WeakMarker extends AbstractMarker { private readonly map: WeakMap = new WeakMap(); constructor(anchor?: ContentScriptAnchor) { diff --git a/src/entry/content/core/markers/index.ts b/src/entry/content/lifecycle/markers/index.ts similarity index 100% rename from src/entry/content/core/markers/index.ts rename to src/entry/content/lifecycle/markers/index.ts diff --git a/src/entry/content/core/markers/marker.test.ts b/src/entry/content/lifecycle/markers/marker.test.ts similarity index 100% rename from src/entry/content/core/markers/marker.test.ts rename to src/entry/content/lifecycle/markers/marker.test.ts diff --git a/src/entry/content/core/nodes/EventNode.ts b/src/entry/content/lifecycle/nodes/EventNode.ts similarity index 93% rename from src/entry/content/core/nodes/EventNode.ts rename to src/entry/content/lifecycle/nodes/EventNode.ts index b48cfb4b..b9597a7f 100644 --- a/src/entry/content/core/nodes/EventNode.ts +++ b/src/entry/content/lifecycle/nodes/EventNode.ts @@ -1,6 +1,6 @@ import {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; -export default class implements ContentScriptNode { +export default class EventNode implements ContentScriptNode { constructor( protected readonly node: ContentScriptNode, protected readonly emitter: ContentScriptEventEmitter diff --git a/src/entry/content/core/nodes/FrameNode.test.ts b/src/entry/content/lifecycle/nodes/FrameNode.test.ts similarity index 100% rename from src/entry/content/core/nodes/FrameNode.test.ts rename to src/entry/content/lifecycle/nodes/FrameNode.test.ts diff --git a/src/entry/content/core/nodes/FrameNode.ts b/src/entry/content/lifecycle/nodes/FrameNode.ts similarity index 100% rename from src/entry/content/core/nodes/FrameNode.ts rename to src/entry/content/lifecycle/nodes/FrameNode.ts diff --git a/src/entry/content/core/nodes/MarkerNode.ts b/src/entry/content/lifecycle/nodes/MarkerNode.ts similarity index 92% rename from src/entry/content/core/nodes/MarkerNode.ts rename to src/entry/content/lifecycle/nodes/MarkerNode.ts index c97a17d1..0c8481d9 100644 --- a/src/entry/content/core/nodes/MarkerNode.ts +++ b/src/entry/content/lifecycle/nodes/MarkerNode.ts @@ -1,6 +1,6 @@ import {ContentScriptMarkerContract, ContentScriptNode} from "@typing/content"; -export default class implements ContentScriptNode { +export default class MarkerNode implements ContentScriptNode { constructor( protected readonly node: ContentScriptNode, protected readonly marker: ContentScriptMarkerContract diff --git a/src/entry/content/core/nodes/MountNode.ts b/src/entry/content/lifecycle/nodes/MountNode.ts similarity index 94% rename from src/entry/content/core/nodes/MountNode.ts rename to src/entry/content/lifecycle/nodes/MountNode.ts index db1643dc..2211df5f 100644 --- a/src/entry/content/core/nodes/MountNode.ts +++ b/src/entry/content/lifecycle/nodes/MountNode.ts @@ -1,6 +1,6 @@ import {ContentScriptMountFunction, ContentScriptNode} from "@typing/content"; -export default class implements ContentScriptNode { +export default class MountNode implements ContentScriptNode { private unmounting?: () => void; constructor( diff --git a/src/entry/content/core/nodes/Node.ts b/src/entry/content/lifecycle/nodes/Node.ts similarity index 93% rename from src/entry/content/core/nodes/Node.ts rename to src/entry/content/lifecycle/nodes/Node.ts index d855d443..f21b383c 100644 --- a/src/entry/content/core/nodes/Node.ts +++ b/src/entry/content/lifecycle/nodes/Node.ts @@ -1,6 +1,6 @@ import {ContentScriptNode} from "@typing/content"; -export default class implements ContentScriptNode { +export default class Node implements ContentScriptNode { private readonly _container?: Element; constructor( diff --git a/src/entry/content/core/nodes/ShadowNode.test.ts b/src/entry/content/lifecycle/nodes/ShadowNode.test.ts similarity index 100% rename from src/entry/content/core/nodes/ShadowNode.test.ts rename to src/entry/content/lifecycle/nodes/ShadowNode.test.ts diff --git a/src/entry/content/core/nodes/ShadowNode.ts b/src/entry/content/lifecycle/nodes/ShadowNode.ts similarity index 100% rename from src/entry/content/core/nodes/ShadowNode.ts rename to src/entry/content/lifecycle/nodes/ShadowNode.ts diff --git a/src/entry/content/core/nodes/index.ts b/src/entry/content/lifecycle/nodes/index.ts similarity index 100% rename from src/entry/content/core/nodes/index.ts rename to src/entry/content/lifecycle/nodes/index.ts diff --git a/src/entry/content/core/nodes/isolated-styles.ts b/src/entry/content/lifecycle/nodes/isolated-styles.ts similarity index 100% rename from src/entry/content/core/nodes/isolated-styles.ts rename to src/entry/content/lifecycle/nodes/isolated-styles.ts diff --git a/src/entry/content/core/resolvers/anchor.ts b/src/entry/content/resolvers/anchor.ts similarity index 93% rename from src/entry/content/core/resolvers/anchor.ts rename to src/entry/content/resolvers/anchor.ts index 8572a102..df9dc831 100644 --- a/src/entry/content/core/resolvers/anchor.ts +++ b/src/entry/content/resolvers/anchor.ts @@ -1,7 +1,7 @@ import {ContentScriptAnchor, ContentScriptAnchorGetter} from "@typing/content"; // prettier-ignore -export const contentScriptAnchorResolver = +export const createAnchorResolver = (anchor?: ContentScriptAnchor | ContentScriptAnchorGetter): ContentScriptAnchorGetter => async (): Promise => { let resolved = typeof anchor === "function" ? anchor() : anchor; diff --git a/src/entry/content/core/resolvers/container.ts b/src/entry/content/resolvers/container.ts similarity index 96% rename from src/entry/content/core/resolvers/container.ts rename to src/entry/content/resolvers/container.ts index 92879dbf..f6fbe54f 100644 --- a/src/entry/content/core/resolvers/container.ts +++ b/src/entry/content/resolvers/container.ts @@ -7,7 +7,7 @@ import { } from "@typing/content"; // prettier-ignore -export const contentScriptContainerResolver = ( +export const createContainerResolver = ( container?: ContentScriptContainerTag | ContentScriptContainerOptions | diff --git a/src/entry/content/resolvers/definition.test.ts b/src/entry/content/resolvers/definition.test.ts new file mode 100644 index 00000000..5545739e --- /dev/null +++ b/src/entry/content/resolvers/definition.test.ts @@ -0,0 +1,18 @@ +import {resolveDefinition} from "./definition"; + +describe("Common content definitions", () => { + test("merges default options over named exports without mutating either object", () => { + const main = jest.fn(); + const defaults = Object.freeze({matches: ["https://default.example/*"], main}); + const module = Object.freeze({default: defaults, matches: ["https://named.example/*"], anchor: "article"}); + + expect(resolveDefinition(module)).toEqual({matches: defaults.matches, main, anchor: "article"}); + expect(module.matches).toEqual(["https://named.example/*"]); + expect(main).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, ""])("preserves named options with an empty default %p", value => { + const main = jest.fn(); + expect(resolveDefinition({default: value, main})).toEqual({main}); + }); +}); diff --git a/src/entry/content/resolvers/definition.ts b/src/entry/content/resolvers/definition.ts new file mode 100644 index 00000000..c15726e2 --- /dev/null +++ b/src/entry/content/resolvers/definition.ts @@ -0,0 +1,32 @@ +import type {ContentScriptDefinition} from "@typing/content"; + +/** Adapters must recognize their render objects before falling back to an options object. */ +const isDefinitionObject = (value: unknown): value is ContentScriptDefinition => { + return value !== null && typeof value === "object" && value.constructor === Object; +}; + +/** Merge module exports using the selected adapter's interpretation of the default value. */ +export const mergeDefinition = ( + module: object, + isRenderInput: (value: unknown) => boolean +): ContentScriptDefinition => { + const {default: defaultDefinition, ...definition} = module as Record; + + if (isRenderInput(defaultDefinition)) { + return {...definition, render: defaultDefinition} as ContentScriptDefinition; + } + + if (isDefinitionObject(defaultDefinition)) { + return {...definition, ...defaultDefinition}; + } + + return definition; +}; + +/** Resolve exports without a renderer; the content parser validates default render exports before navigation. */ +export const resolveDefinition = (module: object): ContentScriptDefinition => { + return mergeDefinition( + module, + value => value != null && value !== "" && typeof value !== "boolean" && !isDefinitionObject(value) + ); +}; diff --git a/src/entry/content/core/resolvers/index.ts b/src/entry/content/resolvers/index.ts similarity index 83% rename from src/entry/content/core/resolvers/index.ts rename to src/entry/content/resolvers/index.ts index cb482acd..63cd2a1b 100644 --- a/src/entry/content/core/resolvers/index.ts +++ b/src/entry/content/resolvers/index.ts @@ -2,5 +2,4 @@ export * from "./anchor"; export * from "./container"; export * from "./definition"; export * from "./mount"; -export * from "./render"; export * from "./watch"; diff --git a/src/entry/content/core/resolvers/mount.ts b/src/entry/content/resolvers/mount.ts similarity index 95% rename from src/entry/content/core/resolvers/mount.ts rename to src/entry/content/resolvers/mount.ts index 9bd752bc..36533654 100644 --- a/src/entry/content/core/resolvers/mount.ts +++ b/src/entry/content/resolvers/mount.ts @@ -1,7 +1,7 @@ import {ContentScriptAppend, ContentScriptMountFunction} from "@typing/content"; // prettier-ignore -export const contentScriptMountAppendResolver = +export const createAppendMountHandler = (append?: ContentScriptAppend): ContentScriptMountFunction => (anchor: Element, container: Element): void => { switch (append) { diff --git a/src/entry/content/resolvers/watch.test.ts b/src/entry/content/resolvers/watch.test.ts new file mode 100644 index 00000000..af413e37 --- /dev/null +++ b/src/entry/content/resolvers/watch.test.ts @@ -0,0 +1,90 @@ +import {createAwaitFirstStrategy, createMutationObserverStrategy} from "../../../content"; +import {EventEmitter, ManagedContext} from "../lifecycle/context"; +import Node from "../lifecycle/nodes/Node"; + +describe("content watch strategies", () => { + afterEach(() => { + jest.useRealTimers(); + document.body.replaceChildren(); + }); + + test("observes the document element before body exists", () => { + const body = document.body; + const observe = jest.spyOn(MutationObserver.prototype, "observe"); + + body.remove(); + + try { + const unwatch = createMutationObserverStrategy()(jest.fn(), {} as never); + + expect(observe).toHaveBeenCalledWith( + document.documentElement, + expect.objectContaining({childList: true, subtree: true}) + ); + + unwatch(); + } finally { + document.documentElement.append(body); + observe.mockRestore(); + } + }); + + test("observes configured mutations and cancels pending updates on unsubscribe", async () => { + jest.useFakeTimers(); + const element = document.createElement("span"); + document.body.append(element); + const update = jest.fn(); + const context = new ManagedContext(new EventEmitter()); + const unwatch = createMutationObserverStrategy({ + childList: false, + characterData: false, + attributeFilter: ["data-state"], + })(update, context); + + try { + element.setAttribute("data-ignored", "1"); + await Promise.resolve(); + jest.advanceTimersByTime(201); + expect(update).not.toHaveBeenCalled(); + + element.setAttribute("data-state", "ready"); + await Promise.resolve(); + jest.advanceTimersByTime(201); + expect(update).toHaveBeenCalledTimes(1); + + element.setAttribute("data-state", "pending"); + await Promise.resolve(); + unwatch(); + jest.advanceTimersByTime(201); + element.setAttribute("data-state", "unsubscribed"); + await Promise.resolve(); + jest.advanceTimersByTime(201); + expect(update).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + } + }); + + test("await-first stops observing once an update adds content nodes", async () => { + jest.useFakeTimers(); + const context = new ManagedContext(new EventEmitter()); + const update = jest.fn(() => context.add(new Node(document.body))); + const unwatch = createAwaitFirstStrategy()(update, context); + + try { + document.body.append(document.createElement("article")); + await Promise.resolve(); + jest.advanceTimersByTime(201); + expect(update).toHaveBeenCalledTimes(1); + expect(context.nodes.size).toBe(1); + + document.body.append(document.createElement("article")); + await Promise.resolve(); + jest.advanceTimersByTime(201); + expect(update).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + context.clear(); + } + }); +}); diff --git a/src/entry/content/core/resolvers/watch.ts b/src/entry/content/resolvers/watch.ts similarity index 83% rename from src/entry/content/core/resolvers/watch.ts rename to src/entry/content/resolvers/watch.ts index b05ea45a..61afc6d5 100644 --- a/src/entry/content/core/resolvers/watch.ts +++ b/src/entry/content/resolvers/watch.ts @@ -1,9 +1,10 @@ import debounce from "debounce"; -import {ContentScriptWatchStrategy} from "@typing/content"; +import type {ContentScriptWatchStrategy} from "@typing/content"; +/** Create a debounced DOM-mutation strategy. Calling the returned strategy starts observing. */ // prettier-ignore -export const contentScriptMutationObserverResolver = +export const createMutationObserverStrategy = (options?: MutationObserverInit): ContentScriptWatchStrategy => update => { if (!options) { @@ -28,11 +29,12 @@ export const contentScriptMutationObserverResolver = }; }; +/** Observe DOM mutations while waiting for the first content nodes. */ // prettier-ignore -export const contentScriptAwaitFirstResolver = +export const createAwaitFirstStrategy = (options?: MutationObserverInit): ContentScriptWatchStrategy => (update, context) => { - const resolver = contentScriptMutationObserverResolver({ + const resolver = createMutationObserverStrategy({ attributes: false, characterData: false, ...options, @@ -61,7 +63,7 @@ export const contentScriptAwaitFirstResolver = }; // prettier-ignore -export const contentScriptLocationResolver = +export const withLocationTracking = (strategy: ContentScriptWatchStrategy): ContentScriptWatchStrategy => (update, context) => { let currentUrl = location.href; diff --git a/src/entry/relay/Builder.test.ts b/src/entry/relay/Builder.test.ts new file mode 100644 index 00000000..3169a071 --- /dev/null +++ b/src/entry/relay/Builder.test.ts @@ -0,0 +1,252 @@ +import Builder from "./Builder"; +import VanillaBuilder from "../content/adapters/vanilla/Builder"; +import MountBuilder from "../content/lifecycle/MountBuilder"; +import RelayManager from "@relay/RelayManager"; +import {isRelayContext} from "@relay/utils"; +import {RelayAllFrames, RelayMethod, type RelayOptions} from "@typing/relay"; +import type {ContentScriptDefinition} from "@typing/content"; + +// Extend the shared random-ID mock for content marker attributes. +jest.mock("nanoid", () => ({nanoid: jest.fn(() => "mocked-id"), customAlphabet: () => () => "relaymarker"})); + +describe("Relay Builder", () => { + const manager = RelayManager.getInstance(); + + beforeEach(() => { + // The shared harness mocks context detection; exercise its actual predicate here. + jest.mocked(isRelayContext).mockImplementation( + jest.requireActual("@relay/utils").isRelayContext + ); + manager.clear(); + }); + + afterEach(() => { + manager.clear(); + document.body.replaceChildren(); + }); + + test.each([undefined, false, true, RelayAllFrames.Any, RelayAllFrames.All])( + "separates content options while keeping the Relay allFrames mode %p", + async allFrames => { + const received: ContentScriptDefinition[] = []; + class ContentBuilder extends VanillaBuilder { + constructor(definition: ContentScriptDefinition) { + super(definition); + received.push(definition); + } + } + const instance = {ready: true}; + const init = jest.fn((_options: RelayOptions) => instance); + const main = jest.fn(); + const render = () => true as const; + const watch = () => () => {}; + const builder = new Builder( + { + name: "options", + init, + main, + method: RelayMethod.Scripting, + allFrames, + anchor: document.body, + render, + watch, + isolation: "none", + }, + ContentBuilder + ); + + expect(received).toEqual([ + { + anchor: document.body, + render, + watch, + isolation: "none", + ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), + }, + ]); + expect(init).not.toHaveBeenCalled(); + try { + await builder.build(); + expect(init).toHaveBeenCalledTimes(1); + expect(init.mock.calls[0][0]).toEqual({ + name: "options", + allFrames, + anchor: document.body, + render, + watch, + isolation: "none", + }); + expect(main).toHaveBeenCalledTimes(1); + expect(main).toHaveBeenCalledWith( + instance, + expect.any(Object), + expect.objectContaining({ + name: "options", + init, + main, + method: RelayMethod.Scripting, + allFrames, + isolation: {type: "none"}, + }) + ); + } finally { + await builder.destroy(); + } + } + ); + + test("registers transport before content rendering and waits for content and main", async () => { + const events: string[] = []; + let releaseRender!: () => void; + let releaseMain!: () => void; + let renderStarted!: () => void; + let mainStarted!: () => void; + const renderGate = new Promise(resolve => { + releaseRender = resolve; + }); + const mainGate = new Promise(resolve => { + releaseMain = resolve; + }); + const rendering = new Promise(resolve => { + renderStarted = resolve; + }); + const runningMain = new Promise(resolve => { + mainStarted = resolve; + }); + const instance = {ready: true}; + const main = jest.fn(async (relay, context) => { + events.push("main"); + expect(relay).toBe(instance); + expect(context.nodes.size).toBe(1); + expect(document.body.textContent).toBe("rendered"); + mainStarted(); + await mainGate; + events.push("main-ready"); + }); + const builder = new Builder( + { + name: "ordered", + method: RelayMethod.Scripting, + init: () => { + events.push("init"); + return instance; + }, + render: async () => { + events.push("render"); + expect(manager.get("ordered")).toBe(instance); + renderStarted(); + await renderGate; + return "rendered"; + }, + watch: () => () => {}, + main, + }, + VanillaBuilder + ); + let completed = false; + const building = builder.build().then(() => { + completed = true; + }); + try { + await rendering; + expect(events).toEqual(["init", "render"]); + expect(main).not.toHaveBeenCalled(); + releaseRender(); + await runningMain; + expect(events).toEqual(["init", "render", "main"]); + expect(completed).toBe(false); + releaseMain(); + await building; + expect(events).toEqual(["init", "render", "main", "main-ready"]); + expect(completed).toBe(true); + } finally { + releaseRender(); + releaseMain(); + await building; + await builder.destroy(); + } + }); + + test.each([RelayMethod.Messaging, RelayMethod.Scripting])( + "rebuilds and cleans up the %s transport before its content", + async method => { + const instances: {generation: number}[] = []; + const cleanup = jest.fn(() => { + expect(manager.has("rebuild")).toBe(false); + }); + const unwatch = jest.fn(() => { + expect(manager.has("rebuild")).toBe(false); + }); + const main = jest.fn(); + const builder = new Builder( + { + name: "rebuild", + method, + init: () => { + const instance = {generation: instances.length + 1}; + instances.push(instance); + return instance; + }, + render: () => String(manager.get("rebuild").generation), + mount: (anchor, container) => { + anchor.append(container); + return cleanup; + }, + watch: () => unwatch, + main, + }, + VanillaBuilder + ); + try { + await builder.build(); + expect(document.body.textContent).toBe("1"); + await builder.build(); + expect(document.body.textContent).toBe("2"); + expect(manager.get("rebuild")).toBe(instances[1]); + expect(main).toHaveBeenCalledTimes(2); + expect(main.mock.calls[0][0]).toBe(instances[0]); + expect(main.mock.calls[1][0]).toBe(instances[1]); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(unwatch).toHaveBeenCalledTimes(1); + } finally { + await builder.destroy(); + } + expect(manager.has("rebuild")).toBe(false); + expect(document.body.childElementCount).toBe(0); + expect(cleanup).toHaveBeenCalledTimes(2); + expect(unwatch).toHaveBeenCalledTimes(2); + await builder.destroy(); + expect(cleanup).toHaveBeenCalledTimes(2); + expect(unwatch).toHaveBeenCalledTimes(2); + } + ); + + test("uses the common content builder for iframe navigation", async () => { + const main = jest.fn(); + const builder = new Builder( + { + name: "navigation", + method: RelayMethod.Scripting, + init: () => ({ready: true}), + main, + isolation: {type: "iframe", src: "https://example.com/panel"}, + watch: () => () => {}, + }, + MountBuilder + ); + try { + await builder.build(); + expect(document.querySelector("iframe")?.src).toBe("https://example.com/panel"); + expect(main.mock.calls[0][1].nodes.size).toBe(1); + expect(main.mock.calls[0][2].isolation).toEqual({ + type: "iframe", + src: "https://example.com/panel", + width: "100%", + height: 150, + }); + } finally { + await builder.destroy(); + } + expect(document.querySelector("iframe")).toBeNull(); + }); +}); diff --git a/src/entry/relay/Builder.ts b/src/entry/relay/Builder.ts index f58a3688..f923d809 100644 --- a/src/entry/relay/Builder.ts +++ b/src/entry/relay/Builder.ts @@ -2,41 +2,41 @@ import {resolveContentScriptIsolation} from "@shared/content"; import TransportBuilder from "./TransportBuilder"; -import Builder from "../core/Builder"; +import EntrypointBuilder from "../core/Builder"; import {RelayUnresolvedDefinition} from "@typing/relay"; -import {ContentScriptBuilder} from "@typing/content"; +import {ContentScriptBuilder, ContentScriptDefinition} from "@typing/content"; import {TransportType} from "@typing/transport"; -export default class extends Builder { +export default class Builder extends EntrypointBuilder { protected readonly _transport: TransportBuilder; - protected _content?: ContentScriptBuilder; + protected readonly _content: ContentScriptBuilder; - constructor(protected readonly definition: RelayUnresolvedDefinition) { + constructor( + protected readonly definition: RelayUnresolvedDefinition, + contentBuilder: new (definition: ContentScriptDefinition) => ContentScriptBuilder + ) { super(); this._transport = new TransportBuilder(definition); - } - public content(content: ContentScriptBuilder): this { - this._content = content; + const {init, main, name, method, allFrames, ...contentOptions} = definition; - return this; + this._content = new contentBuilder({ + ...contentOptions, + ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), + } as ContentScriptDefinition); } public async build(): Promise { await this.destroy(); await this._transport.build(); - await this._content?.build(); + await this._content.build(); const {main} = this.definition; if (main) { - if (!this._content) { - throw new Error("Content script builder not set"); - } - await main(this._transport.get(), this._content.getContext(), { ...this.definition, isolation: resolveContentScriptIsolation(this.definition.isolation, "render" in this.definition), @@ -46,6 +46,6 @@ export default class extends Builder { public async destroy(): Promise { await this._transport.destroy(); - await this._content?.destroy(); + await this._content.destroy(); } } diff --git a/src/entry/relay/index.test.ts b/src/entry/relay/index.test.ts new file mode 100644 index 00000000..6ea77c71 --- /dev/null +++ b/src/entry/relay/index.test.ts @@ -0,0 +1,108 @@ +import relay, {resolveDefinition} from "./index"; +import VanillaBuilder from "../content/adapters/vanilla/Builder"; +import RelayManager from "@relay/RelayManager"; +import {isRelayContext} from "@relay/utils"; +import {RelayMethod} from "@typing/relay"; +import type {ContentScriptDefinition} from "@typing/content"; + +jest.mock("nanoid", () => ({nanoid: jest.fn(() => "mocked-id"), customAlphabet: () => () => "relaymarker"})); + +describe("Relay startup", () => { + const manager = RelayManager.getInstance(); + let content: VanillaBuilder | undefined; + + class ContentBuilder extends VanillaBuilder { + constructor(definition: ContentScriptDefinition) { + super(definition); + content = this; + } + } + + beforeEach(() => { + // Use the real predicate through the existing context mock in the shared harness. + jest.mocked(isRelayContext).mockImplementation( + jest.requireActual("@relay/utils").isRelayContext + ); + manager.clear(); + content = undefined; + }); + + afterEach(async () => { + await content?.destroy(); + manager.clear(); + document.body.replaceChildren(); + jest.restoreAllMocks(); + }); + + test("starts a normalized module with the selected content builder", async () => { + const instance = {ready: true}; + const init = jest.fn(() => instance); + let started!: () => void; + const running = new Promise(resolve => { + started = resolve; + }); + const main = jest.fn(() => { + started(); + }); + const definition = resolveDefinition( + { + default: init, + name: "source-name", + method: RelayMethod.Scripting, + render: "started", + watch: () => () => {}, + main, + }, + "build-name" + ); + + expect(relay(definition, ContentBuilder)).toBeUndefined(); + await running; + expect(init).toHaveBeenCalledTimes(1); + expect(manager.get("build-name")).toBe(instance); + expect(manager.has("source-name")).toBe(false); + expect(document.body.textContent).toBe("started"); + expect(main).toHaveBeenCalledWith( + instance, + content!.getContext(), + expect.objectContaining({name: "build-name"}) + ); + }); + + test.each(["init", "main"])("reports asynchronous build failures from %s", async phase => { + const error = new Error(`${phase} failed`); + const reported = new Promise(resolve => { + jest.spyOn(console, "error").mockImplementation((...args) => { + resolve(args); + }); + }); + const definition = resolveDefinition( + { + method: RelayMethod.Scripting, + init: () => { + if (phase === "init") throw error; + return {ready: true}; + }, + main: async () => { + throw error; + }, + }, + "failed" + ); + + expect(() => relay(definition, ContentBuilder)).not.toThrow(); + expect(await reported).toEqual(["Failed to build relay: ", error]); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + test.each([ + [{name: "missing-init"}, "The transport entrypoint must export a init function"], + [{name: "", init: () => ({})}, "The transport entrypoint must export a name string"], + ] as const)("leaves synchronous construction errors to the caller: %s", (definition, message) => { + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => relay(definition, ContentBuilder)).toThrow(message); + expect(content).toBeUndefined(); + expect(report).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entry/relay/index.ts b/src/entry/relay/index.ts index 962da2d8..e42eebfa 100644 --- a/src/entry/relay/index.ts +++ b/src/entry/relay/index.ts @@ -1,6 +1,17 @@ import Builder from "./Builder"; +import type {RelayUnresolvedDefinition} from "@typing/relay"; +import type {TransportType} from "@typing/transport"; + export {Builder}; +export {resolveDefinition} from "./resolvers/definition"; export type {RelayUnresolvedDefinition} from "@typing/relay"; -export default Builder.resolver(); +export default function relay( + definition: RelayUnresolvedDefinition, + contentBuilder: ConstructorParameters[1] +): void { + new Builder(definition, contentBuilder).build().catch(error => { + console.error("Failed to build relay: ", error); + }); +} diff --git a/src/entry/relay/resolvers/definition.test.ts b/src/entry/relay/resolvers/definition.test.ts new file mode 100644 index 00000000..ec1978d3 --- /dev/null +++ b/src/entry/relay/resolvers/definition.test.ts @@ -0,0 +1,69 @@ +import {resolveDefinition} from "./definition"; + +describe("Relay definitions", () => { + test("merges default options over named exports and uses the build name without executing callbacks", () => { + const init = jest.fn(() => ({ready: true})); + const main = jest.fn(); + const defaults = Object.freeze({name: "default-name", init, matches: ["https://default.example/*"]}); + const module = Object.freeze({ + default: defaults, + name: "named-name", + main, + matches: ["https://named.example/*"], + anchor: "article", + }); + + expect(resolveDefinition(module, "build-name")).toEqual({ + name: "build-name", + init, + main, + matches: defaults.matches, + anchor: "article", + }); + expect(module.name).toBe("named-name"); + expect(module.matches).toEqual(["https://named.example/*"]); + expect(defaults.name).toBe("default-name"); + expect(init).not.toHaveBeenCalled(); + expect(main).not.toHaveBeenCalled(); + }); + + test("treats a default function as init while preserving named render and main", () => { + const namedInit = jest.fn(() => ({named: true})); + const init = jest.fn(() => ({ready: true})); + const main = jest.fn(); + const render = jest.fn(() => "content"); + + expect(resolveDefinition({default: init, init: namedInit, main, render}, "panel")).toEqual({ + name: "panel", + init, + main, + render, + }); + expect(namedInit).not.toHaveBeenCalled(); + expect(init).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + expect(main).not.toHaveBeenCalled(); + }); + + test("does not infer render from a default init function", () => { + const init = jest.fn(() => ({ready: true})); + const definition = resolveDefinition({default: init, isolation: {type: "iframe", page: "panel"}}, "panel"); + + expect(definition).toEqual({name: "panel", init, main: undefined, isolation: {type: "iframe", page: "panel"}}); + expect(definition).not.toHaveProperty("render"); + expect(init).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, "", "ignored", 0, []])( + "preserves named exports when default is not a definition or init: %p", + value => { + const init = jest.fn(() => ({ready: true})); + expect(resolveDefinition({default: value, init, name: "ignored", allFrames: false}, "panel")).toEqual({ + name: "panel", + init, + main: undefined, + allFrames: false, + }); + } + ); +}); diff --git a/src/entry/relay/resolvers/definition.ts b/src/entry/relay/resolvers/definition.ts new file mode 100644 index 00000000..3489f551 --- /dev/null +++ b/src/entry/relay/resolvers/definition.ts @@ -0,0 +1,24 @@ +import {isValidTransportDefinition, isValidTransportInitFunction} from "@entry/transport/resolvers/definition"; + +import type {RelayOptions, RelayUnresolvedDefinition} from "@typing/relay"; +import type {TransportType} from "@typing/transport"; + +/** Resolve Relay exports without executing init; the build supplies the authoritative name. */ +export const resolveDefinition = ( + module: object, + name: string +): RelayUnresolvedDefinition => { + const {default: defaultDefinition, ...otherDefinition} = module as Record; + + let definition = otherDefinition as RelayUnresolvedDefinition; + + if (isValidTransportDefinition(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidTransportInitFunction(defaultDefinition)) { + definition = {...definition, init: defaultDefinition}; + } + + const {init, main, name: _name, ...options} = definition; + + return {name, init, main, ...options}; +}; diff --git a/src/main/content.ts b/src/main/content.ts index 5f2f5f4f..178316d5 100644 --- a/src/main/content.ts +++ b/src/main/content.ts @@ -1,23 +1,7 @@ -import { - contentScriptMountAppendResolver, - contentScriptAnchorResolver, - contentScriptAwaitFirstResolver, - contentScriptMutationObserverResolver, - contentScriptRenderResolver, - contentScriptContainerResolver, -} from "@entry/content/core/resolvers"; +import {createAppendMountHandler} from "@entry/content/resolvers/mount"; import {ContentScriptAppendDefinition, ContentScriptDefinition} from "@typing/content"; -export { - contentScriptMountAppendResolver, - contentScriptAnchorResolver, - contentScriptAwaitFirstResolver, - contentScriptMutationObserverResolver, - contentScriptRenderResolver, - contentScriptContainerResolver, -}; - export * from "@typing/content"; export const defineContentScript = (options: ContentScriptDefinition): ContentScriptDefinition => { @@ -29,6 +13,6 @@ export const defineContentScriptAppend = (options: ContentScriptAppendDefinition return { ...definition, - mount: contentScriptMountAppendResolver(append), + mount: createAppendMountHandler(append), }; }; diff --git a/src/relay/README.md b/src/relay/README.md index 8ef5fe9c..c1e1a221 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -80,7 +80,7 @@ The local accessor is not a way to address another frame. The remote accessor re - [`adnbn`](../main/relay.ts) exports `defineRelay`, the remote `getRelay`, and the public definition, call-target, proxy, result, and error contracts. Import `RelayAllFrames`, `RelayMethod`, `RelayFrameErrorKind`, and `RelayDiscoveryError` here. - [`adnbn/relay`](./index.ts) exports only the local `getRelay` and `RelayRegistry`, `RelayName`, and `RelayTarget` types. The generated `.adnbn/relay.d.ts` augments this registry; both accessors derive their types from it. It does not redeclare accessor overloads. -- [`adnbn/entry/relay`](../entry/relay/index.ts) is the internal bootstrap interface: `Builder`, its default resolver, and `RelayUnresolvedDefinition`. The unresolved type represents a definition assembled by the virtual module, not the public `defineRelay` contract. +- [`adnbn/entry/relay`](../entry/relay/index.ts) is the internal bootstrap interface: `Builder`, `resolveDefinition`, the default `relay(definition, contentBuilder)` startup function, and `RelayUnresolvedDefinition`. The unresolved type represents merged runtime input, not the public `defineRelay` contract. `ProxyRelay`, `RegisterRelay`, and `ProxyRelayParams` are internal implementation details, not exports of `adnbn/relay`. Remote call/result types are available from `adnbn`, not the local-access module. There are no legacy export aliases. @@ -146,7 +146,7 @@ Neither mode freezes the page: frames can navigate, disappear, or appear after d `defineRelay({allFrames: ...})` controls content-script coverage and build-time permission requirements. `getRelay(name, {allFrames: ...})` controls delivery and return type for that call. One does not replace or implicitly set the other. -The Relay driver converts entrypoint `true`, `Any`, and `All` to the content-script boolean `allFrames: true`. The virtual Relay module applies the same conversion when constructing the runtime content builder, without passing Relay's `method` option to it. `false` remains false, and an omitted value remains omitted. The regular `ContentScriptEntrypointOptions.allFrames` type stays boolean; only Relay replaces it with its own type. Relay's own builder retains the original mode. +The Relay driver converts entrypoint `true`, `Any`, and `All` to the content-script boolean `allFrames: true`. The runtime Relay builder applies the same conversion when constructing its content builder, without passing Relay's `init`, `main`, `name`, or `method` options to it. `false` remains false, and an omitted value remains omitted. The regular `ContentScriptEntrypointOptions.allFrames` type stays boolean; only Relay replaces it with its own type. Relay's own builder retains the original mode. An all-frame call cannot make a top-frame-only entrypoint appear inside an iframe. Each frame must still satisfy registration and access requirements. @@ -212,13 +212,19 @@ If the frame-local manager already exists, Scripting starts the method without a ### Entrypoint lifecycle -The generated [`virtual Relay module`](../cli/virtual/relay.ts) combines the definition with its resolved name and attaches a content builder to [`Builder`](../entry/relay/Builder.ts). Each build: +The generated [`virtual Relay module`](../cli/virtual/relay.ts) imports the selected content-builder constructor and the user module, then calls `relay(resolveDefinition(module, name), contentBuilder)`. + +The runtime [`definition resolver`](../entry/relay/resolvers/definition.ts) merges named exports with the default export without executing callbacks. A default options object overrides named options; a default function overrides `init` and is never interpreted as `render`. The name supplied by the build overrides any runtime `name` export. + +[`Builder`](../entry/relay/Builder.ts) receives the selected content-builder constructor as its second argument and creates both builders. It owns the separation of Relay and content options; it does not import React or Vanilla implementations. Each build: 1. Destroys the previous transport/content state. 2. Creates the instance through `init(options)` and registers it through [`TransportBuilder`](../entry/relay/TransportBuilder.ts). 3. Builds the content context. 4. Calls optional `main(instance, context, options)`. +The startup function reports rejected builds as `Failed to build relay: `. Synchronous construction or normalization errors reach the virtual module's `The relay crashed on startup:` handler. The same builder can be rebuilt or destroyed through its lifecycle methods. + Virtual templates are checked against the actual package source exports. The [`virtual module declarations`](../cli/virtual/virtual.d.ts) describe placeholders and derive framework constructors from the real adapters; do not add handwritten ambient declarations that shadow `adnbn` or its real subpaths. Registration precedes `main`; do not assume an asynchronous `main` finishes before the first remote call. Keep synchronously required state in `init` or explicitly coordinate readiness in the exposed API. diff --git a/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts index b43d48c5..ebe91acc 100644 --- a/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts @@ -1,5 +1,6 @@ import {observeMounts} from "../shared/events"; import {ContentScriptIsolation, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; +import {createAwaitFirstStrategy} from "adnbn/content"; import sharedStyles from "../shared/styles.module.css?isolation"; import styles from "./styles.module.css?isolation"; @@ -11,6 +12,7 @@ export default defineContentScriptAppend({ anchor: "[data-shadow-secondary]", append: ContentScriptAppend.Last, isolation: ContentScriptIsolation.Iframe, + watch: createAwaitFirstStrategy(), container: () => { const host = document.createElement("section"); host.dataset.shadowProbe = "secondary"; diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts index c4253b04..99645037 100644 --- a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts @@ -1,4 +1,5 @@ import {ContentScriptIsolation, ContentScriptShadowMode, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; +import {createMutationObserverStrategy} from "adnbn/content"; import sharedStyles from "../shared/styles.module.css?isolation"; import styles from "./styles.module.css?isolation"; @@ -11,7 +12,7 @@ export default defineContentScriptAppend({ anchor: "[data-shadow-secondary]", append: ContentScriptAppend.Last, isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, - watch: true, + watch: createMutationObserverStrategy(), container: () => { const host = document.createElement("section"); host.dataset.shadowProbe = "secondary"; diff --git a/tests/integration/browser/content/isolation-spike/src/probe.tsx b/tests/integration/browser/content/isolation-spike/src/probe.tsx index 15b6670e..3a97ced8 100644 --- a/tests/integration/browser/content/isolation-spike/src/probe.tsx +++ b/tests/integration/browser/content/isolation-spike/src/probe.tsx @@ -1,8 +1,8 @@ import {createElement} from "react"; import {createRoot, type Root} from "react-dom/client"; -import Context from "../../../../../../src/entry/content/core/context/ManagedContext"; -import EventEmitter from "../../../../../../src/entry/content/core/context/EventEmitter"; +import Context from "../../../../../../src/entry/content/lifecycle/context/ManagedContext"; +import EventEmitter from "../../../../../../src/entry/content/lifecycle/context/EventEmitter"; // Browser feasibility probe only. No production iframe implementation is imported. const context = new Context(new EventEmitter()); diff --git a/tests/integration/build/content/content-api.integration.test.ts b/tests/integration/build/content/content-api.integration.test.ts new file mode 100644 index 00000000..44445910 --- /dev/null +++ b/tests/integration/build/content/content-api.integration.test.ts @@ -0,0 +1,47 @@ +import path from "path"; +import {build} from "esbuild"; + +describe("published content API", () => { + const projectDir = path.resolve(__dirname, "../../../.."); + + test.each([ + { + entry: "adnbn/content", + exports: ["ContentScriptEvent", "createAwaitFirstStrategy", "createMutationObserverStrategy"], + functionsOnly: false, + }, + { + entry: "./dist/main/content.js", + exports: ["defineContentScript", "defineContentScriptAppend"], + functionsOnly: true, + }, + { + entry: "adnbn/entry/content", + exports: ["Builder", "default", "resolveDefinition"], + functionsOnly: false, + }, + ])("$entry exposes its own API without rendering adapters", async ({entry, exports, functionsOnly}) => { + const result = await build({ + absWorkingDir: projectDir, + entryPoints: [entry], + bundle: true, + platform: "browser", + format: "esm", + tsconfigRaw: {}, + write: false, + metafile: true, + logLevel: "silent", + }); + const output = Object.values(result.metafile!.outputs)[0]; + const exportedNames = functionsOnly ? output.exports.filter(name => /^[a-z]/.test(name)) : output.exports; + expect(exportedNames.sort()).toEqual(exports); + + const inputs = Object.keys(result.metafile!.inputs).map(filename => filename.replaceAll("\\", "/")); + expect(inputs.some(filename => filename.includes("entry/content/adapters/"))).toBe(false); + expect(inputs.some(filename => /node_modules\/(react|react-dom)\//.test(filename))).toBe(false); + if (entry === "adnbn/content") { + expect(inputs).toContain("dist/content/index.js"); + expect(inputs.some(filename => filename.includes("entry/content/lifecycle/"))).toBe(false); + } + }); +}); diff --git a/tests/integration/types/content.integration.test.ts b/tests/integration/types/content.integration.test.ts index f0106f91..bcaf8ec2 100644 --- a/tests/integration/types/content.integration.test.ts +++ b/tests/integration/types/content.integration.test.ts @@ -4,9 +4,10 @@ import ts from "typescript"; describe("content contracts", () => { const projectDir = path.resolve(__dirname, "../../.."); const fixture = path.join(__dirname, "fixtures/content/definition.tsx"); + const watchFixture = path.join(__dirname, "fixtures/content/watch.ts"); test.each(["source", "package"])( - "checks shared and adapter render types through the %s API", + "checks content definitions and watch strategies through the %s API", mode => { const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); @@ -25,7 +26,7 @@ describe("content contracts", () => { types: ["node", "chrome"], }; const host = ts.createCompilerHost(options); - const program = ts.createProgram([fixture], options, host); + const program = ts.createProgram([fixture, watchFixture], options, host); const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); @@ -37,6 +38,13 @@ describe("content contracts", () => { expect(apiFile).toBe( path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") ); + const contentFile = ts.resolveModuleName("adnbn/content", watchFixture, options, host).resolvedModule + ?.resolvedFileName; + expect(contentFile).toBe( + path + .join(projectDir, mode === "source" ? "src/content/index.ts" : "dist/content/index.d.ts") + .replace(/\\/g, "/") + ); }, 30_000 ); diff --git a/tests/integration/types/fixtures/content/watch.ts b/tests/integration/types/fixtures/content/watch.ts new file mode 100644 index 00000000..9c985926 --- /dev/null +++ b/tests/integration/types/fixtures/content/watch.ts @@ -0,0 +1,49 @@ +import {defineContentScript, defineContentScriptAppend} from "adnbn"; +import { + createMutationObserverStrategy, + createAwaitFirstStrategy, + ContentScriptEvent, + type ContentScriptWatchStrategy, + type ContentScriptContext, + type ContentScriptEventCallback, + type ContentScriptNode, +} from "adnbn/content"; + +const watch: ContentScriptWatchStrategy = createMutationObserverStrategy({ + attributes: true, + attributeFilter: ["class"], +}); + +const callback: ContentScriptEventCallback = (event, node) => { + const contentNode: ContentScriptNode = node; + const rootEvent: import("adnbn").ContentScriptEvent = event; + if (rootEvent === ContentScriptEvent.Mount) { + contentNode.container?.setAttribute("data-mounted", "true"); + } +}; + +defineContentScript({ + watch, + render: () => "Content", + main: (context: ContentScriptContext) => { + const unsubscribe: () => void = context.watch(callback); + unsubscribe(); + context.unwatch(); + }, +}); + +defineContentScriptAppend({watch: createAwaitFirstStrategy({attributes: true}), render: () => "Content"}); + +const custom: ContentScriptWatchStrategy = (update, context) => { + const observer = new MutationObserver(update); + observer.observe(document.body, {childList: true}); + const unsubscribe = context.watch(callback); + return () => { + observer.disconnect(); + unsubscribe(); + }; +}; +defineContentScript({watch: custom}); + +// @ts-expect-error: Mutation observation uses the platform's typed options. +createMutationObserverStrategy({attributeFilter: [1]}); diff --git a/tsconfig.json b/tsconfig.json index 03f085d9..9511d008 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,8 +23,8 @@ "baseUrl": "./src", "paths": { "adnbn": ["index.ts"], - "adnbn/entry/content/frame": [ - "entry/content/frame/index.ts" + "adnbn/content": [ + "content/index.ts" ], "adnbn/entry/content/*": [ "entry/content/adapters/*/index.ts" From 55c65184a055dfbe0f68f21d601bdf67680e4dd0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:01:20 +0300 Subject: [PATCH 5/9] docs: define code layout conventions --- AGENTS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index db2b074e..9e20d5b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,31 @@ - Imports of actual JavaScript files, package subpaths, and loader-specific resources such as `.ts?raw` keep the syntax required by their owner. This rule does not rewrite raw template contents or generated JavaScript imports. - Apply this convention to new and changed imports; do not mass-rewrite unrelated files. +## Code layout + +- Always write `if` and `else` bodies as multiline blocks with braces, even for a single `return`, `throw`, or expression. Never use inline guards such as `if (!current()) return;`. +- Put a blank line before `return` when another statement precedes it in the same block. A `return` that is the first or only statement in a block follows the opening brace directly, as in the example below. +- Separate a multiline statement or code block from adjacent code with a blank line above and below. This includes multiline declarations, calls, callbacks, and control-flow blocks. Do not add empty padding immediately inside opening or closing braces, and keep `} else {`, `} catch (...) {`, and `} finally {` together. +- Apply these rules to implementation code, tests, fixtures, and examples. Keep formatting changes within the files being worked on unless a broader cleanup is explicitly requested. + +```ts +if (!current()) { + return; +} + +const node = createNode(anchor); + +if (!node.target) { + node.unmount(); + + return; +} + +register(node); + +return node; +``` + ## Symbol naming - Apply these conventions throughout the project: shared types, runtime APIs, entrypoints, CLI, bundler plugins, and tests. From 57f3018219eb343c0a9bf050f1f9efad4767c849 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:01:45 +0300 Subject: [PATCH 6/9] feat!: prepare content data before synchronous rendering Prepare data per anchor before creating UI and pass the prepared data, container, and render target to Content and Relay renderers. Keep mount and unmount synchronous, invalidate stale preparation, and retain watching after anchor errors. Cover lifecycle behavior, React portals, headless Relay, and published declarations. BREAKING CHANGE: Render handlers are synchronous; asynchronous work and render decisions belong in prepare. React render functions are components invoked by React. ContentScriptProps now includes data, container, and target; container factories receive ContentScriptContainerProps before those DOM elements exist. ContentScriptWatchStrategy update callbacks may be asynchronous. Literal render: true tracks anchors without UI, while prepare returning false skips UI creation. Consumers checking published declarations require TypeScript 5.6.3 or newer. --- README.md | 3 + .../tests/fixtures/relay-types/consumer.ts | 20 + src/cli/virtual/virtual.d.ts | 1 + src/entry/content/README.md | 77 ++- .../content/adapters/react/Builder.test.ts | 93 ++++ src/entry/content/adapters/react/Builder.ts | 41 +- src/entry/content/adapters/react/Node.test.ts | 23 +- src/entry/content/adapters/react/Node.ts | 53 +- .../adapters/react/resolvers/render.ts | 22 +- .../content/adapters/vanilla/Builder.test.ts | 469 +++++++++++++++++- src/entry/content/adapters/vanilla/Builder.ts | 33 +- .../content/adapters/vanilla/Node.test.ts | 20 +- src/entry/content/adapters/vanilla/Node.ts | 60 +-- .../adapters/vanilla/resolvers/render.ts | 12 +- src/entry/content/lifecycle/Builder.ts | 142 ++++-- .../content/lifecycle/MountBuilder.test.ts | 77 ++- src/entry/content/lifecycle/MountBuilder.ts | 76 ++- .../content/lifecycle/context/Context.ts | 9 + .../content/lifecycle/nodes/EventNode.ts | 9 + .../content/lifecycle/nodes/RenderNode.ts | 101 ++++ src/entry/content/lifecycle/nodes/index.ts | 1 + src/entry/content/resolvers/container.ts | 10 +- src/entry/content/resolvers/watch.test.ts | 10 +- src/entry/content/resolvers/watch.ts | 10 +- src/entry/relay/Builder.test.ts | 169 ++++++- src/entry/relay/Builder.ts | 39 +- src/entry/relay/TransportBuilder.ts | 6 +- src/entry/relay/index.ts | 6 +- src/main/content.ts | 8 +- src/main/relay.ts | 6 +- src/relay/README.md | 11 +- src/types/content/adapters/react.ts | 10 +- src/types/content/common.ts | 23 +- src/types/content/definition.ts | 29 +- src/types/content/index.ts | 1 + src/types/content/prepare.ts | 14 + src/types/content/render.ts | 11 +- src/types/relay.ts | 14 +- .../browser/content/render-props-utils.ts | 132 +++++ .../render-props.firefox.integration.test.ts | 5 + .../content/render-props.integration.test.ts | 3 + .../browser/content/render-props/README.md | 9 + .../content/render-props/adnbn.config.ts | 8 + .../browser/content/render-props/package.json | 12 + .../content/render-props/site/top.html | 15 + .../content/render-props/src/background.ts | 57 +++ .../render-props/src/frame.content.tsx | 13 + .../render-props/src/headless.relay.tsx | 20 + .../content/render-props/src/probe.relay.tsx | 25 + .../render-props/src/shadow.content.tsx | 13 + .../content/render-props/src/shared/Panel.tsx | 43 ++ .../render-props/src/shared/lifecycle.ts | 47 ++ .../render-props/src/shared/prepare.ts | 16 + .../render-props/src/vanilla.content.ts | 22 + .../content/render-props/tsconfig.json | 11 + .../types/content.integration.test.ts | 16 +- .../types/fixtures/content/definition.tsx | 22 +- .../types/fixtures/content/prepare.tsx | 100 ++++ 58 files changed, 1976 insertions(+), 332 deletions(-) create mode 100644 src/entry/content/lifecycle/nodes/RenderNode.ts create mode 100644 src/types/content/prepare.ts create mode 100644 tests/integration/browser/content/render-props-utils.ts create mode 100644 tests/integration/browser/content/render-props.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/render-props.integration.test.ts create mode 100644 tests/integration/browser/content/render-props/README.md create mode 100644 tests/integration/browser/content/render-props/adnbn.config.ts create mode 100644 tests/integration/browser/content/render-props/package.json create mode 100644 tests/integration/browser/content/render-props/site/top.html create mode 100644 tests/integration/browser/content/render-props/src/background.ts create mode 100644 tests/integration/browser/content/render-props/src/frame.content.tsx create mode 100644 tests/integration/browser/content/render-props/src/headless.relay.tsx create mode 100644 tests/integration/browser/content/render-props/src/probe.relay.tsx create mode 100644 tests/integration/browser/content/render-props/src/shadow.content.tsx create mode 100644 tests/integration/browser/content/render-props/src/shared/Panel.tsx create mode 100644 tests/integration/browser/content/render-props/src/shared/lifecycle.ts create mode 100644 tests/integration/browser/content/render-props/src/shared/prepare.ts create mode 100644 tests/integration/browser/content/render-props/src/vanilla.content.ts create mode 100644 tests/integration/browser/content/render-props/tsconfig.json create mode 100644 tests/integration/types/fixtures/content/prepare.tsx diff --git a/README.md b/README.md index 6bf827d5..804b24e6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ the browser-specific build and manifest. npm i -D adnbn ``` +TypeScript projects require TypeScript **5.6.3 or newer** to check the published declarations and +their dependencies. Addon Bone's own TypeScript dependency does not replace your project's compiler. + ## Get started Create a project, then build it for production: diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts index b3f1c4c2..a07580e3 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts @@ -14,6 +14,24 @@ const definition = api.defineRelay({ }); new entry.Builder(definition, ContentBuilder); + +const preparedDefinition = api.defineRelay({ + name: "scanner", + init: () => ({scan: (text: string) => text.length}), + prepare: async () => ({title: "Prepared"}), + + render: ({data}) => { + const title: string = data.title; + // @ts-expect-error: Runtime constructors preserve the inferred prepare data. + data.missing; + + return title; + }, +}); + +new entry.Builder(preparedDefinition, ContentBuilder); +entry.default(preparedDefinition, ContentBuilder); + const unresolved: entry.RelayUnresolvedDefinition> = {}; new entry.Builder(unresolved, ContentBuilder); entry.default(entry.resolveDefinition({default: definition}, "scanner"), ContentBuilder); @@ -46,9 +64,11 @@ type AsyncBatchResult = Expect, Promise, Promise>>; type NestedBatchResult = Expect, Promise>>>; type PropertyResult = Expect, Promise>>>; + type NestedObjectResult = Expect< Equal, Promise>> >; + type ScalarAlias = Expect>>; type BatchAlias = Expect>>; type LocalAlias = Expect>>; diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index da1fe72b..f8455439 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -82,6 +82,7 @@ declare module "virtual:relay-entrypoint" { marker, anchor, mount, + prepare, render, container, watch, diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 2049a15b..157c9365 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -28,7 +28,9 @@ starts it when processing content and invokes its returned unsubscribe function `ContentScriptContext`, `ContentScriptEventCallback`, and `ContentScriptNode` types. A custom strategy receives `(update, context)` and returns an unsubscribe function. Use `context.watch(callback)` for lifecycle events; its returned function removes that subscription, and `context.unwatch()` removes all -context subscriptions. Without a local renderer, anchor processing and watching run only for page/src navigation. +context subscriptions. `context.mount()` and `context.unmount()` run synchronously, including their +lifecycle events. Await asynchronous discovery and preparation through `builder.build()` or the +watch strategy's `update()` callback. The public `src/content/index.ts` entrypoint explicitly exports these tools from their runtime owners. Definition helpers remain in `src/main/content.ts`; normalization and mounting helpers remain internal. @@ -66,9 +68,8 @@ override named options; a recognized default render value overrides a named `ren refactor preserves the existing render forms; it does not expand rendering to every value in `ReactNode`. `mergeDefinition` combines exports using the selected resolver's interpretation of default values. The common `resolveDefinition` accepts configuration without recognizing framework -components; adapters provide their own definition resolvers. The common builder accepts an absent render -handler and reports an error if rendering is requested without an adapter. Vanilla keeps its value check and -async handler normalization together in `adapters/vanilla/resolvers/render.ts`, used internally by the +components; adapters provide their own definition resolvers. The common builder accepts absent rendering and literal `render: true`; UI rendering requires an adapter. Vanilla keeps its value check and +synchronous handler normalization together in `adapters/vanilla/resolvers/render.ts`, used internally by the Vanilla builder. The CLI `ContentParser.ts` and its test live beside the other parsers in `src/cli/entrypoint/parser`. @@ -80,8 +81,7 @@ The common runtime receives this build-validated configuration and retains the r The common lifecycle wraps each complete node in `EventNode`, including any adapter renderer, before adding it to the context. Mount/unmount events follow the underlying node operations. Without render, -`main` still runs and context cleanup remains available; anchor processing and watching start only for -page/src navigation. `FrameNode` continues to own iframe creation, navigation, and child-document recovery. +`main` still runs and context cleanup remains available. Anchor processing starts for rendering, `prepare`, headless tracking, or page/src navigation. `FrameNode` continues to own iframe creation, navigation, and child-document recovery. When adding a runtime adapter, implement its definition and render resolvers and expose the definition resolver from that adapter's `index.ts`. Extend filename/build support and parser metadata interpretation @@ -91,6 +91,7 @@ implementations through a common barrel. The content define helpers stay at the Content contracts live in `src/types/content`: - `common.ts` owns shared options, isolation, props, containers, markers, nodes, and lifecycle contracts. +- `prepare.ts` owns per-anchor preparation inputs, results, and handlers. - `definition.ts` composes entrypoint definitions and preserves the frame-navigation restrictions. - `adapters/vanilla.ts` and `adapters/react.ts` describe each adapter's render values. - `adapters/index.ts` exports the types of all adapters for the shared render contract and public exports. @@ -103,6 +104,66 @@ render values under `adapters`, export them from `adapters/index.ts`, and includ `adnbn`; runtime adapter selection remains based on the entrypoint filename. Adding types does not implement the adapter or its build support. +## Preparation and render props + +`prepare` runs once for each discovered anchor, before creating its UI. It receives the anchor and +entrypoint options and may return a Promise. Returning `false` keeps a tracked node in the context +without invoking the container factory or renderer. Other results become `props.data`; without +`prepare`, `data` is `undefined`. DOM mutations and ordinary remounts do not repeat preparation for +a tracked anchor. + +For newly discovered anchors, failures in preparation, container creation, or synchronous +mounting/rendering are collected and logged as an `AggregateError` after each processing pass. Successful anchors +remain available, and a failure on the first pass does not prevent the watcher from starting. The same +policy applies to later passes. Use `watch: true` to keep processing new anchors; the default strategy +stops watching once it has tracked nodes. A failed anchor is not added as a headless result. +Initialization errors, such as a rejected marker factory or `main`, still reject the build. React +component errors during React's scheduled rendering follow React's error handling. + +```tsx title="src/product.content.tsx" +import {defineContentScript} from "adnbn"; +import {getProduct} from "./product-service"; +import {ProductCard} from "./ProductCard"; + +export default defineContentScript({ + anchor: ".product", + isolation: {type: "shadow", mode: "closed"}, + + prepare: async ({anchor}) => { + const product = await getProduct(anchor.getAttribute("data-id")); + + return product.available ? {product} : false; + }, + + render: ({data, container, target}) => ( + + ), +}); +``` + +`ContentScriptProps` contains `anchor`, `data`, `container`, `target`, and entrypoint options. +`container` is the outer mounted element; `target` is the actual UI destination. They are equal +without isolation. Shadow rendering uses an inner element inside its root; iframe rendering uses +an element inside the child document. Use `target.getRootNode()` and `target.ownerDocument` when +working with portals, including closed Shadow DOM. A custom detached mount can return an element +as the root, so do not assume `getRootNode()` always returns a Document or ShadowRoot. + +Container factories receive `ContentScriptContainerProps`: anchor, options, and prepared data, +without a container or target. React components are invoked by React with the completed render props. +Render handlers are synchronous; asynchronous requests and decisions belong in `prepare`. An empty Vanilla +result releases that UI and leaves the anchor tracked; use `prepare` for decisions that must precede DOM creation. + +Literal `render: true` tracks anchors without creating containers, Shadow roots, iframes, or renderer +instances. It works with both adapters and Relay. Use it for parsing existing nodes through the context. + +Destroying the builder or removing the anchor invalidates pending results. A new target on remount +or iframe document recovery receives fresh props and reruns rendering with the same prepared data. +Mounting an unchanged target preserves its UI and React state. `RenderNode` mounts DOM and invokes the +renderer synchronously; adapter nodes own UI insertion and disposal. `EventNode` emits Mount before +`mount()` returns, after the adapter accepts the render value. React schedules its component rendering +and effects independently; Mount does not wait for a React commit. A render callback that unmounts or +replaces its own target cannot subsequently insert UI into the discarded target or emit a stale Mount. + ## Isolation `isolation` accepts `ContentScriptIsolation.None`, `Shadow`, or `Iframe`, or their string values @@ -177,6 +238,10 @@ export default defineContentScriptAppend({ }); ``` +`prepare` can also control document embedding. Returning `false` for an anchor skips the container +factory and iframe creation for both `isolation.page` and `isolation.src`. The anchor stays tracked +without UI; remounting it does not rerun preparation or create the iframe. + `page` is a generated, typed page alias. Its HTML must already be accessible through the final manifest's web accessible resources. In MV3 those rules must cover every content-script match origin; paths, exclusions and globs do not reduce this check. In MV2 the resource must be in the flat WAR list. diff --git a/src/entry/content/adapters/react/Builder.test.ts b/src/entry/content/adapters/react/Builder.test.ts index 50a3bc4d..6b004335 100644 --- a/src/entry/content/adapters/react/Builder.test.ts +++ b/src/entry/content/adapters/react/Builder.test.ts @@ -18,21 +18,27 @@ describe("React Builder", () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); const events: (ContentScriptEvent | "effect-cleanup")[] = []; + const cleanup = jest.fn(() => { events.push("effect-cleanup"); }); + const render = jest.fn(({anchor: target}: ContentScriptProps) => { const [label] = useState(target.tagName); useEffect(() => cleanup, []); + return createElement("span", null, label); }); + const definition = resolveDefinition({ anchor, default: render, + main: (context: ContentScriptContext) => { context.watch(event => events.push(event)); }, }); + expect(definition.render).toBe(render); expect(render).not.toHaveBeenCalled(); const builder = new Builder(definition); @@ -44,8 +50,10 @@ describe("React Builder", () => { } finally { await act(() => builder.destroy()); } + expect(cleanup).toHaveBeenCalledTimes(1); expect(anchor.childElementCount).toBe(0); + expect(events).toEqual([ ContentScriptEvent.Add, ContentScriptEvent.Mount, @@ -60,6 +68,7 @@ describe("React Builder", () => { const definition = resolveDefinition({default: element}); expect(definition).toEqual({render: element}); const builder = new Builder(definition); + try { await act(() => builder.build()); expect(document.querySelector("strong")?.textContent).toBe("React element"); @@ -68,3 +77,87 @@ describe("React Builder", () => { } }); }); + +test("React receives prepared data and fresh DOM props while retaining state on the same target", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const calls: ContentScriptProps<{title: string}>[] = []; + const events: ContentScriptEvent[] = []; + + const builder = new Builder({ + anchor, + prepare: async () => ({title: "Product"}), + + main: context => { + context.watch(event => events.push(event)); + }, + + render: props => { + const [count, setCount] = useState(0); + calls.push(props); + + return createElement( + "button", + {onClick: () => setCount(value => value + 1)}, + `${props.data.title}:${count}` + ); + }, + }); + + try { + await act(() => builder.build()); + const first = calls[0]; + expect(first.container).toBe(anchor.firstElementChild); + expect(first.target).toBe(first.container); + + await act(() => { + (anchor.querySelector("button") as HTMLButtonElement).click(); + }); + + await act(() => { + expect(builder.getContext().mount()).toBeUndefined(); + }); + + expect(anchor.textContent).toBe("Product:1"); + events.length = 0; + + await act(() => { + expect(builder.getContext().unmount()).toBeUndefined(); + expect(anchor.childElementCount).toBe(0); + expect(events).toEqual([ContentScriptEvent.Unmount]); + expect(builder.getContext().mount()).toBeUndefined(); + expect(events).toEqual([ContentScriptEvent.Unmount, ContentScriptEvent.Mount]); + }); + + expect(anchor.textContent).toBe("Product:0"); + expect(calls.at(-1)!.target).not.toBe(first.target); + expect(calls.at(-1)!.data).toBe(first.data); + } finally { + await act(() => builder.destroy()); + anchor.remove(); + } +}); + +test.each([true, false])( + "React handles headless mode and preparation rejection before invoking a component: %p", + async headless => { + const render = jest.fn(() => createElement("span")); + const container = jest.fn(() => document.createElement("section")); + + const builder = new Builder({ + anchor: document.body, + isolation: "shadow", + container, + ...(headless ? {render: true} : {prepare: async () => false, render}), + }); + + try { + await act(() => builder.build()); + expect(builder.getContext().nodes.size).toBe(1); + expect(render).not.toHaveBeenCalled(); + expect(container).not.toHaveBeenCalled(); + } finally { + await act(() => builder.destroy()); + } + } +); diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index dfb75465..ef1db493 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,39 +1,26 @@ -import {isValidElement} from "react"; - import MountBuilder from "../../lifecycle/MountBuilder"; -import ReactNode from "./Node"; - +import RenderNode from "./Node"; import {createRenderResolver} from "./resolvers/render"; -import { - ContentScriptDefinition, +import type { ContentScriptNode, + ContentScriptProps, ContentScriptRenderHandler, ContentScriptRenderValue, } from "@typing/content"; -export default class Builder extends MountBuilder { - constructor(definition: ContentScriptDefinition) { - super(definition); - } - - protected resolveRender(render?: ContentScriptRenderValue): ContentScriptRenderHandler | undefined { - if (render === undefined) { - return; - } - - return createRenderResolver(render); +export default class Builder extends MountBuilder { + protected resolveRender( + render?: ContentScriptRenderValue | ContentScriptRenderHandler + ): ContentScriptRenderHandler | undefined { + return render === undefined ? undefined : createRenderResolver(render); } - protected async createNode(anchor: Element): Promise { - let value = await this.getValue(anchor); - - if (!isValidElement(value)) { - value = undefined; - - console.warn("Content script react value is not a valid React element"); - } - - return new ReactNode(await super.createNode(anchor), value); + protected createRenderer( + node: ContentScriptNode, + render: ContentScriptRenderHandler, + props: () => ContentScriptProps + ): ContentScriptNode { + return new RenderNode(node, render, props); } } diff --git a/src/entry/content/adapters/react/Node.test.ts b/src/entry/content/adapters/react/Node.test.ts index d2794f2f..e38ca296 100644 --- a/src/entry/content/adapters/react/Node.test.ts +++ b/src/entry/content/adapters/react/Node.test.ts @@ -1,8 +1,9 @@ +import {createElement} from "react"; import {createRoot} from "react-dom/client"; import ReactNode from "./Node"; -import {ContentScriptShadowMode, type ContentScriptNode} from "@typing/content"; +import {ContentScriptShadowMode} from "@typing/content"; const render = jest.fn(); const unmount = jest.fn(); @@ -21,18 +22,29 @@ describe("ReactNode", () => { "renders into the target while preserving the outer host in %s mode", mode => { const anchor = document.createElement("div"); + document.body.append(anchor); const container = document.createElement("section"); const target = document.createElement("div"); - if (mode) container.attachShadow({mode}).appendChild(target); - const node: ContentScriptNode = { + + if (mode) { + container.attachShadow({mode}).appendChild(target); + } + + const node = { anchor, container, target, mount: jest.fn(() => true), unmount: jest.fn(() => true), }; - const component = "content"; - const reactNode = new ReactNode(node, component); + + const component = createElement("span", null, "content"); + + const reactNode = new ReactNode( + node, + () => component, + () => ({anchor, container, target, data: undefined}) + ); expect(reactNode.mount()).toBe(true); expect(node.mount).toHaveBeenCalledTimes(1); @@ -43,6 +55,7 @@ describe("ReactNode", () => { expect(reactNode.unmount()).toBe(true); expect(unmount).toHaveBeenCalledTimes(1); expect(node.unmount).toHaveBeenCalledTimes(1); + anchor.remove(); } ); }); diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 82aec867..2f5c76f6 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -1,57 +1,26 @@ -import {ReactNode as ReactComponent} from "react"; -import {createRoot, Root} from "react-dom/client"; +import {isValidElement} from "react"; +import {createRoot, type Root} from "react-dom/client"; -import {ContentScriptNode} from "@typing/content"; +import {RenderNode} from "../../lifecycle/nodes"; -export default class implements ContentScriptNode { - protected root?: Root; +import type {ContentScriptRenderValue} from "@typing/content"; - private renderedTarget?: Element; +export default class Node extends RenderNode { + private root?: Root; - constructor( - protected readonly node: ContentScriptNode, - protected readonly component?: ReactComponent - ) {} - - public get anchor(): Element { - return this.node.anchor; - } - - public get container(): Element | undefined { - return this.node.container; - } - - public get target(): Element | undefined { - return this.node.target; - } - - public mount(): boolean { - this.node.mount(); - - if (!this.target || (this.root && this.renderedTarget === this.target)) { + protected render(value: ContentScriptRenderValue | undefined, target: Element): boolean { + if (!isValidElement(value)) { return false; } - if (!this.component) { - console.warn("Content script react component is empty"); - - return false; - } - - this.root?.unmount(); - this.renderedTarget = this.target; - this.root = createRoot(this.target); - - this.root.render(this.component); + this.root = createRoot(target); + this.root.render(value); return true; } - public unmount(): boolean { + protected clear(): void { this.root?.unmount(); this.root = undefined; - this.renderedTarget = undefined; - - return !!this.node.unmount(); } } diff --git a/src/entry/content/adapters/react/resolvers/render.ts b/src/entry/content/adapters/react/resolvers/render.ts index 357f9232..4113f7bc 100644 --- a/src/entry/content/adapters/react/resolvers/render.ts +++ b/src/entry/content/adapters/react/resolvers/render.ts @@ -1,13 +1,21 @@ import {createElement, isValidElement} from "react"; -import {ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; +import type { + ContentScriptRenderHandler, + ContentScriptRenderValue, + ContentScriptRenderReactComponent, +} from "@typing/content"; export const createRenderResolver = - (render?: ContentScriptRenderValue): ContentScriptRenderHandler => - async (props): Promise => { - let resolvedRender = typeof render === "function" ? createElement(render, props) : render; + ( + render?: ContentScriptRenderValue | ContentScriptRenderHandler + ): ContentScriptRenderHandler => + props => { + // Functions in React entrypoints are components; React owns their invocation and hooks. + const value = + typeof render === "function" + ? createElement(render as ContentScriptRenderReactComponent, props) + : render; - if (isValidElement(resolvedRender)) { - return resolvedRender; - } + return isValidElement(value) ? value : undefined; }; diff --git a/src/entry/content/adapters/vanilla/Builder.test.ts b/src/entry/content/adapters/vanilla/Builder.test.ts index 0aab36fd..a0f960b2 100644 --- a/src/entry/content/adapters/vanilla/Builder.test.ts +++ b/src/entry/content/adapters/vanilla/Builder.test.ts @@ -1,7 +1,7 @@ import Builder from "./Builder"; import {resolveDefinition} from "./resolvers/definition"; import {defineContentScript, defineContentScriptAppend} from "@main/content"; -import {ContentScriptEvent, type ContentScriptProps} from "@typing/content"; +import {ContentScriptAppend, ContentScriptEvent, type ContentScriptProps} from "@typing/content"; // Extend the shared random-ID mock for the builders' generated marker attribute. jest.mock("nanoid", () => ({ @@ -12,24 +12,29 @@ jest.mock("nanoid", () => ({ describe("Vanilla Builder", () => { afterEach(() => document.body.replaceChildren()); - test("Vanilla awaits a default handler and preserves props, placement and cleanup", async () => { + test("Vanilla invokes a default handler and preserves props, placement and cleanup", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); - const render = jest.fn(async ({anchor: target}: ContentScriptProps) => { - await Promise.resolve(); + + const render = jest.fn(({anchor: target}: ContentScriptProps) => { const element = document.createElement("span"); element.textContent = target.tagName; + return element; }); + const unwatch = jest.fn(); const events: {event: ContentScriptEvent; text: string | null | undefined}[] = []; + const options = defineContentScriptAppend({ anchor, watch: () => unwatch, + main: context => { context.watch((event, node) => events.push({event, text: node.target?.textContent})); }, }); + const definition = resolveDefinition({...options, default: render}); expect(definition.render).toBe(render); expect(render).not.toHaveBeenCalled(); @@ -44,8 +49,10 @@ describe("Vanilla Builder", () => { } finally { await builder.destroy(); } + expect(anchor.childElementCount).toBe(0); expect(unwatch).toHaveBeenCalledTimes(1); + expect(events).toEqual([ {event: ContentScriptEvent.Add, text: ""}, {event: ContentScriptEvent.Mount, text: "ARTICLE"}, @@ -58,6 +65,7 @@ describe("Vanilla Builder", () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); const builder = new Builder(resolveDefinition({anchor, default: value})); + try { await builder.build(); expect(anchor.textContent).toBe(String(value)); @@ -66,11 +74,12 @@ describe("Vanilla Builder", () => { } }); - test("Vanilla preserves the true result used to mark an anchor without a container", async () => { + test("Vanilla preserves the literal true used to track an anchor without a container", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); const container = jest.fn(() => document.createElement("section")); - const builder = new Builder(defineContentScript({anchor, render: () => true, container})); + const builder = new Builder(defineContentScript({anchor, render: true, container})); + try { await builder.build(); expect(container).not.toHaveBeenCalled(); @@ -80,4 +89,452 @@ describe("Vanilla Builder", () => { await builder.destroy(); } }); + + test.each([false, null, undefined, ""])( + "an empty render result %p releases UI without repeating rendering", + async value => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const container = jest.fn(() => document.createElement("section")); + const render = jest.fn(() => value); + const builder = new Builder(defineContentScript({anchor, container, render})); + + try { + await builder.build(); + expect(container).toHaveBeenCalledTimes(1); + expect(anchor.childElementCount).toBe(0); + const [node] = builder.getContext().nodes; + expect(node.anchor).toBe(anchor); + expect(node.target).toBeUndefined(); + expect(node.mount()).toBe(false); + expect(render).toHaveBeenCalledTimes(1); + expect(anchor.childElementCount).toBe(0); + } finally { + await builder.destroy(); + } + } + ); +}); + +describe("Vanilla preparation and render props", () => { + afterEach(() => document.body.replaceChildren()); + + const createAnchor = () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + + return anchor; + }; + + test("awaits per-anchor data before creating a container and refreshes props on remount", async () => { + const anchor = createAnchor(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers<{title: string}>(); + + const prepare = jest.fn(() => { + started.resolve(); + + return response.promise; + }); + + const calls: ContentScriptProps<{title: string}>[] = []; + const events: ContentScriptEvent[] = []; + + const container = jest.fn(({data}) => { + const element = document.createElement("section"); + element.title = data.title; + + return element; + }); + + const builder = new Builder( + defineContentScript({ + anchor, + prepare, + + main: context => { + context.watch(event => events.push(event)); + }, + + container, + + render: props => { + calls.push(props); + + return props.data.title; + }, + }) + ); + + const building = builder.build(); + + try { + await started.promise; + expect(container).not.toHaveBeenCalled(); + expect(anchor.childElementCount).toBe(0); + expect(calls).toHaveLength(0); + response.resolve({title: "Prepared"}); + await building; + const first = calls[0]; + expect(first.container).toBe(anchor.firstElementChild); + expect(first.target).toBe(first.container); + expect(first.isolation).toEqual({type: "none"}); + expect(first.data).toEqual({title: "Prepared"}); + expect(first).not.toHaveProperty("prepare"); + expect(builder.getContext().mount()).toBeUndefined(); + expect(calls).toHaveLength(1); + events.length = 0; + expect(builder.getContext().unmount()).toBeUndefined(); + expect(anchor.childElementCount).toBe(0); + expect(events).toEqual([ContentScriptEvent.Unmount]); + expect(builder.getContext().mount()).toBeUndefined(); + expect(events).toEqual([ContentScriptEvent.Unmount, ContentScriptEvent.Mount]); + expect(calls).toHaveLength(2); + expect(calls[1].container).not.toBe(first.container); + expect(calls[1].target).toBe(calls[1].container); + expect(calls[1].data).toBe(first.data); + expect(prepare).toHaveBeenCalledTimes(1); + expect(anchor.textContent).toBe("Prepared"); + } finally { + response.resolve({title: "Prepared"}); + await building; + await builder.destroy(); + } + }); + + test("false tracks anchors without UI or retries while other anchors receive their own data", async () => { + const allowed = createAnchor(); + const denied = createAnchor(); + allowed.className = denied.className = "product"; + const prepare = jest.fn(async ({anchor}) => (anchor === allowed ? {title: "Allowed"} : false)); + const container = jest.fn(() => document.createElement("section")); + const render = jest.fn(({data}) => data.title); + const update = Promise.withResolvers<() => void>(); + + const builder = new Builder( + defineContentScript({ + anchor: ".product", + prepare, + render, + container, + + watch: callback => { + update.resolve(callback); + + return () => {}; + }, + }) + ); + + try { + await builder.build(); + (await update.promise)(); + expect(builder.getContext().mount()).toBeUndefined(); + expect(builder.getContext().nodes.size).toBe(2); + expect(prepare).toHaveBeenCalledTimes(2); + expect(container).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(1); + expect(allowed.textContent).toBe("Allowed"); + const node = [...builder.getContext().nodes].find(node => node.anchor === denied)!; + expect(node.container).toBeUndefined(); + expect(node.target).toBeUndefined(); + } finally { + await builder.destroy(); + } + }); + + test.each(["remove", "destroy", "rebuild"])("ignores a pending preparation after %s", async action => { + const anchor = createAnchor(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + let calls = 0; + const container = jest.fn(() => document.createElement("section")); + const render = jest.fn(({data}) => data); + const watch = jest.fn(() => () => {}); + + const builder = new Builder( + defineContentScript({ + anchor, + container, + render, + watch, + + prepare: () => { + calls++; + started.resolve(); + + return calls === 1 ? response.promise : "new"; + }, + }) + ); + + const building = builder.build(); + + try { + await started.promise; + + if (action === "remove") { + anchor.remove(); + } else if (action === "destroy") { + await builder.destroy(); + } else { + await builder.build(); + } + + response.resolve("stale"); + await building; + expect(render).toHaveBeenCalledTimes(action === "rebuild" ? 1 : 0); + expect(container).toHaveBeenCalledTimes(action === "rebuild" ? 1 : 0); + expect(anchor.textContent).toBe(action === "rebuild" ? "new" : ""); + expect(watch).toHaveBeenCalledTimes(action === "destroy" ? 0 : 1); + } finally { + response.resolve("stale"); + await building; + await builder.destroy(); + } + }); + + test.each(["prepare", "container", "render"])( + "reports per-anchor %s errors and continues processing initial and watched anchors", + async stage => { + jest.useFakeTimers(); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + const error = new Error(`${stage} failed`); + const failed = [createAnchor(), createAnchor()]; + const initial = createAnchor(); + const failures = new Set(failed); + + const builder = new Builder( + defineContentScript({ + anchor: "article", + watch: true, + + prepare: async ({anchor}) => { + if (stage === "prepare" && failures.has(anchor)) { + throw error; + } + + return "content"; + }, + + container: ({anchor}) => { + if (stage === "container" && failures.has(anchor)) { + throw error; + } + + return document.createElement("section"); + }, + + render: ({anchor, data}) => { + if (stage === "render" && failures.has(anchor)) { + throw error; + } + + return data; + }, + }) + ); + + try { + await expect(builder.build()).resolves.toBeUndefined(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenLastCalledWith(expect.any(AggregateError)); + expect(report.mock.calls[0][0].errors).toEqual([error, error]); + expect(builder.getContext().nodes.size).toBe(1); + expect(initial.textContent).toBe("content"); + + for (const anchor of failed) { + expect(anchor.childElementCount).toBe(0); + anchor.remove(); + } + + const rejected = createAnchor(); + failures.add(rejected); + const next = createAnchor(); + await jest.advanceTimersByTimeAsync(201); + expect(next.textContent).toBe("content"); + expect(rejected.childElementCount).toBe(0); + expect(builder.getContext().nodes.size).toBe(2); + expect(report).toHaveBeenCalledTimes(2); + expect(report).toHaveBeenLastCalledWith(expect.any(AggregateError)); + expect(report.mock.calls[1][0].errors).toEqual([error]); + + rejected.remove(); + const last = createAnchor(); + await jest.advanceTimersByTimeAsync(201); + expect(last.textContent).toBe("content"); + expect(builder.getContext().nodes.size).toBe(3); + } finally { + await builder.destroy(); + report.mockRestore(); + jest.useRealTimers(); + } + } + ); + + test.each(["remove", "unmount", "remount"])( + "discards UI when a render callback synchronously triggers %s", + async action => { + const anchor = createAnchor(); + let calls = 0; + const events: ContentScriptEvent[] = []; + + const builder = new Builder( + defineContentScript({ + anchor, + + main: context => { + context.watch(event => events.push(event)); + }, + + render: () => { + if (calls++ > 0) { + return "current"; + } + + if (action === "remove") { + anchor.remove(); + } else { + builder.getContext().unmount(); + + if (action === "remount") { + builder.getContext().mount(); + } + } + + return "stale"; + }, + }) + ); + + try { + await builder.build(); + expect(anchor.textContent).toBe(action === "remount" ? "current" : ""); + + expect(events.filter(event => event === ContentScriptEvent.Mount)).toHaveLength( + action === "remount" ? 1 : 0 + ); + } finally { + await builder.destroy(); + } + } + ); +}); + +test("append replacement renders with prepared props even though mounting replaces the anchor", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + + const builder = new Builder( + defineContentScriptAppend({ + anchor, + append: ContentScriptAppend.Replace, + prepare: async () => ({title: "replacement"}), + render: ({data}) => data.title, + }) + ); + + try { + await builder.build(); + expect(anchor.isConnected).toBe(false); + expect(document.body.textContent).toBe("replacement"); + } finally { + await builder.destroy(); + document.body.replaceChildren(); + } +}); + +test("remount propagates render errors synchronously and releases the failed UI", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const error = new Error("Cannot render"); + let calls = 0; + const events: ContentScriptEvent[] = []; + + const builder = new Builder( + defineContentScript({ + anchor, + + main: context => { + context.watch(event => events.push(event)); + }, + + render: () => { + if (calls++ > 0) { + throw error; + } + + return "content"; + }, + }) + ); + + try { + await builder.build(); + builder.getContext().unmount(); + events.length = 0; + expect(() => builder.getContext().mount()).toThrow(error); + expect(anchor.childElementCount).toBe(0); + expect([...builder.getContext().nodes][0].target).toBeUndefined(); + expect(events).not.toContain(ContentScriptEvent.Mount); + } finally { + await builder.destroy(); + anchor.remove(); + } +}); + +test("does not mount a container whose async factory finishes after its anchor was removed", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + const render = jest.fn(() => "UI"); + const mount = jest.fn(); + + const builder = new Builder( + defineContentScript({ + anchor, + mount, + render, + + container: () => { + started.resolve(); + + return response.promise; + }, + }) + ); + + const building = builder.build(); + + try { + await started.promise; + anchor.remove(); + response.resolve(document.createElement("section")); + await building; + expect(mount).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + expect(builder.getContext().nodes.size).toBe(0); + } finally { + response.resolve(document.createElement("section")); + await building; + await builder.destroy(); + } +}); + +test("simultaneous builds prepare each anchor only in the latest lifecycle", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const prepare = jest.fn(async () => ({title: "latest"})); + const builder = new Builder(defineContentScript({anchor, prepare, render: ({data}) => data.title})); + + try { + await Promise.all([builder.build(), builder.build()]); + expect(prepare).toHaveBeenCalledTimes(1); + expect(builder.getContext().nodes.size).toBe(1); + expect(anchor.textContent).toBe("latest"); + } finally { + await builder.destroy(); + anchor.remove(); + } }); diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index a04fad89..ef1db493 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -1,35 +1,26 @@ -import {createRenderResolver, isValidRenderValue} from "./resolvers/render"; - import MountBuilder from "../../lifecycle/MountBuilder"; -import VanillaNode from "./Node"; +import RenderNode from "./Node"; +import {createRenderResolver} from "./resolvers/render"; import type { - ContentScriptDefinition, ContentScriptNode, + ContentScriptProps, ContentScriptRenderHandler, ContentScriptRenderValue, } from "@typing/content"; -export default class Builder extends MountBuilder { - public constructor(definition: ContentScriptDefinition) { - super(definition); - } - +export default class Builder extends MountBuilder { protected resolveRender( - render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler | undefined { + render?: ContentScriptRenderValue | ContentScriptRenderHandler + ): ContentScriptRenderHandler | undefined { return render === undefined ? undefined : createRenderResolver(render); } - protected async createNode(anchor: Element): Promise { - let value = await this.getValue(anchor); - - if (value !== true && !isValidRenderValue(value)) { - value = undefined; - - console.warn("Content script vanilla value is not a valid render value"); - } - - return new VanillaNode(await super.createNode(anchor), value); + protected createRenderer( + node: ContentScriptNode, + render: ContentScriptRenderHandler, + props: () => ContentScriptProps + ): ContentScriptNode { + return new RenderNode(node, render, props); } } diff --git a/src/entry/content/adapters/vanilla/Node.test.ts b/src/entry/content/adapters/vanilla/Node.test.ts index 74eee873..7436514a 100644 --- a/src/entry/content/adapters/vanilla/Node.test.ts +++ b/src/entry/content/adapters/vanilla/Node.test.ts @@ -1,24 +1,35 @@ import VanillaNode from "./Node"; -import {ContentScriptShadowMode, type ContentScriptNode} from "@typing/content"; +import {ContentScriptShadowMode} from "@typing/content"; describe("VanillaNode", () => { test.each([undefined, ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( "renders into the target while preserving the outer host in %s mode", mode => { const anchor = document.createElement("div"); + document.body.append(anchor); const container = document.createElement("section"); const target = document.createElement("div"); - if (mode) container.attachShadow({mode}).appendChild(target); + + if (mode) { + container.attachShadow({mode}).appendChild(target); + } + const value = document.createElement("span"); - const node: ContentScriptNode = { + + const node = { anchor, container, target, mount: jest.fn(() => true), unmount: jest.fn(() => true), }; - const vanillaNode = new VanillaNode(node, value); + + const vanillaNode = new VanillaNode( + node, + () => value, + () => ({anchor, container, target, data: undefined}) + ); expect(vanillaNode.mount()).toBe(true); expect(node.mount).toHaveBeenCalledTimes(1); @@ -27,6 +38,7 @@ describe("VanillaNode", () => { expect(vanillaNode.unmount()).toBe(true); expect(node.unmount).toHaveBeenCalledTimes(1); + anchor.remove(); } ); }); diff --git a/src/entry/content/adapters/vanilla/Node.ts b/src/entry/content/adapters/vanilla/Node.ts index 82b05491..3838bdc4 100644 --- a/src/entry/content/adapters/vanilla/Node.ts +++ b/src/entry/content/adapters/vanilla/Node.ts @@ -1,54 +1,18 @@ -import {ContentScriptNode} from "@typing/content"; - -export default class implements ContentScriptNode { - private renderedTarget?: Element; - - constructor( - protected readonly node: ContentScriptNode, - protected readonly value?: null | boolean | string | number | Element - ) {} - - public get anchor(): Element { - return this.node.anchor; - } - - public get container(): Element | undefined { - return this.node.container; - } - - public get target(): Element | undefined { - return this.node.target; - } - - public mount(): boolean { - this.node.mount(); - - if (!this.target || this.renderedTarget === this.target) { +import {RenderNode} from "../../lifecycle/nodes"; +import type {ContentScriptRenderValue} from "@typing/content"; + +export default class Node extends RenderNode { + protected render(value: ContentScriptRenderValue | undefined, target: Element): boolean { + if (value && typeof value === "object" && "nodeType" in value && value.nodeType === 1) { + target.appendChild(value as Element); + } else if (typeof value === "string" || typeof value === "number") { + target.textContent = String(value); + } else { return false; } - let result: boolean = true; - - if (this.value && typeof this.value === "object" && this.value.nodeType === 1) { - this.target.appendChild(this.value); - } else if (typeof this.value === "string" || typeof this.value === "number") { - this.target.textContent = String(this.value); - } else if (this.value === null || this.value === undefined || this.value === false) { - result = false; - - console.warn("Content script vanilla value is empty"); - } else if (this.value === true) { - result = false; - } - - this.renderedTarget = this.target; - - return result; + return true; } - public unmount(): boolean { - this.renderedTarget = undefined; - - return !!this.node.unmount(); - } + protected clear(): void {} } diff --git a/src/entry/content/adapters/vanilla/resolvers/render.ts b/src/entry/content/adapters/vanilla/resolvers/render.ts index 0713e506..1976f486 100644 --- a/src/entry/content/adapters/vanilla/resolvers/render.ts +++ b/src/entry/content/adapters/vanilla/resolvers/render.ts @@ -6,15 +6,11 @@ export const isValidRenderValue = (value: unknown): value is string | number | E // prettier-ignore export const createRenderResolver = - (render?: ContentScriptRenderValue | ContentScriptRenderHandler): ContentScriptRenderHandler => - async (props): Promise => { - let resolvedRender = typeof render === "function" ? render(props) : render; + (render?: ContentScriptRenderValue | ContentScriptRenderHandler): ContentScriptRenderHandler => + props => { + const resolvedRender = typeof render === "function" ? render(props) : render; - if (resolvedRender instanceof Promise) { - resolvedRender = await resolvedRender; - } - - if (resolvedRender !== true && !isValidRenderValue(resolvedRender)) { + if (!isValidRenderValue(resolvedRender)) { return; } diff --git a/src/entry/content/lifecycle/Builder.ts b/src/entry/content/lifecycle/Builder.ts index cb1290b7..c249f4f6 100644 --- a/src/entry/content/lifecycle/Builder.ts +++ b/src/entry/content/lifecycle/Builder.ts @@ -33,6 +33,7 @@ import { ContentScriptMarkerType, ContentScriptMountFunction, ContentScriptNode, + ContentScriptPrepareProps, ContentScriptOptions, ContentScriptRenderHandler, ContentScriptRenderValue, @@ -40,12 +41,12 @@ import { ContentScriptWatchStrategy, } from "@typing/content"; -import {Awaiter} from "@typing/helpers"; +export default abstract class Builder extends EntrypointBuilder implements ContentScriptBuilder { + private lock = new AwaitLock(); -export default abstract class Builder extends EntrypointBuilder implements ContentScriptBuilder { - private readonly lock: AwaitLock = new AwaitLock(); + protected generation = 0; - protected readonly definition: ContentScriptResolvedDefinition; + protected readonly definition: ContentScriptResolvedDefinition; protected readonly emitter = new EventEmitter(); @@ -55,11 +56,9 @@ export default abstract class Builder extends EntrypointBuilder implements Conte protected unwatch?: () => void; - protected abstract createNode(anchor: Element): Promise; + protected abstract createNode(anchor: Element, data: Data, enabled: boolean): Promise; - protected abstract cleanupNode(anchor: Element): Awaiter; - - protected constructor(definition: ContentScriptDefinition) { + protected constructor(definition: ContentScriptDefinition) { super(); const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); @@ -70,7 +69,7 @@ export default abstract class Builder extends EntrypointBuilder implements Conte anchor: this.resolveAnchor(definition.anchor), mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), - render: this.resolveRender(definition.render), + render: definition.render === true ? true : this.resolveRender(definition.render), isolation, watch: this.resolveWatch(definition.watch), }; @@ -110,14 +109,14 @@ export default abstract class Builder extends EntrypointBuilder implements Conte } protected resolveContainer( - container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory - ): ContentScriptContainerCreator { + container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory + ): ContentScriptContainerCreator { return createContainerResolver(container); } protected resolveRender( - render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler | undefined { + render?: ContentScriptRenderValue | ContentScriptRenderHandler + ): ContentScriptRenderHandler | undefined { if (render !== undefined) { throw new Error("Content script rendering requires a renderer adapter"); } @@ -140,28 +139,60 @@ export default abstract class Builder extends EntrypointBuilder implements Conte } public async build(): Promise { - await this.destroy(); + const destroying = this.destroy(); + const generation = this.generation; + await destroying; + + if (generation !== this.generation) { + return; + } - const {render, main, anchor, marker, container, watch, mount, ...options} = this.definition; + const {render, prepare, main, anchor, marker, container, watch, mount, ...options} = this.definition; - this.marker = await marker(options); + const resolvedMarker = await marker(options); + + if (generation !== this.generation) { + return; + } + + this.marker = resolvedMarker; await main?.(this.context, options); - if (render !== undefined || isContentScriptFrameNavigation(this.definition.isolation)) { - await this.processing(); + if (generation !== this.generation) { + return; + } + + if ( + render !== undefined || + prepare !== undefined || + isContentScriptFrameNavigation(this.definition.isolation) + ) { + await this.processing(generation); - this.unwatch = watch(() => { - this.context.mount(); + if (generation !== this.generation) { + return; + } - this.processing().catch(e => { - console.error("Content script processing on watch error", e); - }); + this.unwatch = watch(async () => { + if (generation !== this.generation) { + return; + } + + try { + this.context.mount(); + await this.processing(generation); + } catch (error) { + console.error("Content script processing on watch error", error); + } }, this.context); } } public async destroy(): Promise { + this.generation++; + this.lock = new AwaitLock(); + this.unwatch?.(); this.unwatch = undefined; @@ -170,27 +201,74 @@ export default abstract class Builder extends EntrypointBuilder implements Conte this.marker.reset(); } - protected async processing(): Promise { - await this.lock.acquireAsync(); + protected getPrepareProps(anchor: Element): ContentScriptPrepareProps { + const {anchor: _, marker, mount, watch, prepare, render, container, main, ...options} = this.definition; + + return {...options, anchor}; + } + + protected async processing(generation: number): Promise { + const lock = this.lock; + await lock.acquireAsync(); try { + if (generation !== this.generation) { + return; + } + const anchor = await this.definition.anchor(); - const anchors = this.marker.for(anchor).unmarked(); + if (generation !== this.generation) { + return; + } + + const anchors = this.marker + .for(anchor) + .unmarked() + .filter(anchor => anchor.isConnected); + + const results = await Promise.allSettled(anchors.map(anchor => this.processAnchor(anchor, generation))); + const errors = results.flatMap(result => (result.status === "rejected" ? [result.reason] : [])); - await Promise.allSettled(anchors.map(this.processAnchor.bind(this))); + if (errors.length && generation === this.generation) { + console.error(new AggregateError(errors, "Content script anchor processing failed")); + } } finally { - this.lock.release(); + lock.release(); } } - protected async processAnchor(anchor: Element): Promise { - const node = new EventNode(await this.createNode(anchor), this.emitter); + protected async processAnchor(anchor: Element, generation: number): Promise { + const current = () => generation === this.generation && anchor.isConnected; + const data = await this.definition.prepare?.(this.getPrepareProps(anchor)); + + if (!current()) { + return; + } + + const node = new EventNode(await this.createNode(anchor, data as Data, data !== false), this.emitter); + + if (!current()) { + node.unmount(); + + return; + } this.context.add(node); - await this.cleanupNode(anchor); + if (generation !== this.generation) { + return; + } + + try { + node.mount(); - node.mount(); + if (!current() && !node.container?.isConnected) { + this.context.remove(node); + } + } catch (error) { + this.context.remove(node); + throw error; + } } } diff --git a/src/entry/content/lifecycle/MountBuilder.test.ts b/src/entry/content/lifecycle/MountBuilder.test.ts index 593c6a6a..42845353 100644 --- a/src/entry/content/lifecycle/MountBuilder.test.ts +++ b/src/entry/content/lifecycle/MountBuilder.test.ts @@ -21,12 +21,14 @@ describe("MountBuilder", () => { try { await builder.build(); + expect(main).toHaveBeenCalledWith( builder.getContext(), expect.objectContaining({ isolation: {type: ContentScriptIsolation.None}, }) ); + expect(main).toHaveBeenCalledTimes(1); expect(anchor).not.toHaveBeenCalled(); expect(container).not.toHaveBeenCalled(); @@ -52,15 +54,19 @@ describe("MountBuilder", () => { const unwatch = jest.fn(); const cleanupMount = jest.fn(); const src = "https://example.com/panel"; + const builder = new MountBuilder( defineContentScript({ anchor, isolation: {type: "iframe", src}, watch: () => unwatch, + mount: (target, container) => { target.append(container); + return cleanupMount; }, + main: context => { context.watch((event, node) => { events.push({ @@ -78,10 +84,10 @@ describe("MountBuilder", () => { expect(anchor.querySelector("iframe")?.src).toBe(src); const context = builder.getContext(); expect(context.nodes.size).toBe(1); - context.mount(); + expect(context.mount()).toBeUndefined(); context.unmount(); expect(anchor.childElementCount).toBe(0); - context.mount(); + expect(context.mount()).toBeUndefined(); expect(anchor.querySelector("iframe")?.src).toBe(src); expect(context.nodes.size).toBe(1); } finally { @@ -96,6 +102,7 @@ describe("MountBuilder", () => { {event: ContentScriptEvent.Unmount, connected: false, src: undefined}, {event: ContentScriptEvent.Remove, connected: false, src: undefined}, ]); + expect(anchor.childElementCount).toBe(0); expect(builder.getContext().nodes.size).toBe(0); expect(cleanupMount).toHaveBeenCalledTimes(2); @@ -106,20 +113,63 @@ describe("MountBuilder", () => { const definition = resolveDefinition({ default: {isolation: {type: "iframe", src: "https://example.com"}, render}, }); + expect(() => new MountBuilder(definition)).toThrow( "isolation.page/isolation.src cannot be combined with render" ); }); + test.each([{page: "panel"}, {src: "https://example.com/panel"}])( + "prepare false tracks the anchor without embedding %p", + async navigation => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const prepare = jest.fn(async () => false as const); + const container = jest.fn(() => document.createElement("section")); + const mount = jest.fn(); + + const builder = new MountBuilder( + defineContentScript({ + anchor, + isolation: {type: "iframe", ...navigation}, + prepare, + container, + mount, + }) + ); + + try { + await builder.build(); + const context = builder.getContext(); + expect(context.nodes.size).toBe(1); + const [node] = context.nodes; + expect(node.anchor).toBe(anchor); + expect(node.container).toBeUndefined(); + expect(node.target).toBeUndefined(); + context.unmount(); + context.mount(); + expect(prepare).toHaveBeenCalledTimes(1); + expect(container).not.toHaveBeenCalled(); + expect(mount).not.toHaveBeenCalled(); + expect(anchor.childElementCount).toBe(0); + } finally { + await builder.destroy(); + } + } + ); + test("Common runtime rejects a default function without calling it", () => { const render = jest.fn(); + const definition = resolveDefinition({ isolation: {type: "iframe", src: "https://example.com"}, default: render, }); + expect(() => new MountBuilder(definition)).toThrow( "isolation.page/isolation.src cannot be combined with render" ); + expect(render).not.toHaveBeenCalled(); }); @@ -127,6 +177,29 @@ describe("MountBuilder", () => { const options: ContentScriptDefinition = defineContentScript({ isolation: {type: "iframe", src: "https://example.com"}, }); + expect(() => new MountBuilder(resolveDefinition({default: options}))).not.toThrow(); }); }); + +test.each(["none", "shadow", "iframe"] as const)( + "headless true never creates %s UI in the common runtime", + async isolation => { + const container = jest.fn(() => document.createElement("section")); + + const builder = new MountBuilder( + defineContentScript({anchor: document.body, isolation, render: true, container}) + ); + + try { + await builder.build(); + const [node] = builder.getContext().nodes; + expect(node.anchor).toBe(document.body); + expect(node.container).toBeUndefined(); + expect(node.target).toBeUndefined(); + expect(container).not.toHaveBeenCalled(); + } finally { + await builder.destroy(); + } + } +); diff --git a/src/entry/content/lifecycle/MountBuilder.ts b/src/entry/content/lifecycle/MountBuilder.ts index 2ae31143..d70499e7 100644 --- a/src/entry/content/lifecycle/MountBuilder.ts +++ b/src/entry/content/lifecycle/MountBuilder.ts @@ -1,7 +1,5 @@ import Builder from "./Builder"; - import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; - import {isContentScriptFrameNavigation} from "@shared/content"; import { @@ -9,67 +7,57 @@ import { ContentScriptIsolation, ContentScriptNode, ContentScriptProps, - ContentScriptRenderValue, + ContentScriptRenderHandler, } from "@typing/content"; -export default class MountBuilder extends Builder { - private values = new Map(); - - public constructor(definition: ContentScriptDefinition) { +export default class MountBuilder extends Builder { + public constructor(definition: ContentScriptDefinition) { super(definition); } - protected getProps(anchor: Element): ContentScriptProps { - const {anchor: _, mount, watch, render, container, main, isolation, ...options} = this.definition; - - return {...options, anchor}; - } - - protected async getValue(anchor: Element): Promise { - if (!this.values.has(anchor)) { - const value = await this.renderValue(anchor); - - this.values.set(anchor, value === undefined ? undefined : value); + protected getProps(node: ContentScriptNode, data: Data): ContentScriptProps { + if (!node.container || !node.target) { + throw new Error("Content script render requires a container and target"); } - return this.values.get(anchor); + return {...this.getPrepareProps(node.anchor), data, container: node.container, target: node.target}; } - protected async renderValue(anchor: Element): Promise { - const {render} = this.definition; + protected async createNode(anchor: Element, data: Data, enabled: boolean): Promise { + const navigation = isContentScriptFrameNavigation(this.definition.isolation); + const render = this.definition.render; - if (render === undefined) { - return; + if (!enabled || (!navigation && (render === undefined || render === true))) { + return new MarkerNode(new Node(anchor), this.marker); } - return render(this.getProps(anchor)); - } - - protected async createNode(anchor: Element): Promise { - let container: Element | undefined; - - const value = await this.getValue(anchor); + const marker = this.marker; + const container = await this.definition.container({...this.getPrepareProps(anchor), data}); - if ( - isContentScriptFrameNavigation(this.definition.isolation) || - (typeof value !== "boolean" && value !== undefined) - ) { - container = (await this.definition.container(this.getProps(anchor))) as Element | undefined; - } - - const node = new MountNode(new MarkerNode(new Node(anchor, container), this.marker), this.definition.mount); + let node: ContentScriptNode = new MountNode( + new MarkerNode(new Node(anchor, container), marker), + this.definition.mount + ); switch (this.definition.isolation.type) { case ContentScriptIsolation.Shadow: - return new ShadowNode(node, this.definition.isolation); + node = new ShadowNode(node, this.definition.isolation); + break; case ContentScriptIsolation.Iframe: - return new FrameNode(node, this.definition.isolation, () => this.context.mount()); - default: - return node; + node = new FrameNode(node, this.definition.isolation, () => this.context.mount()); + break; } + + return navigation || typeof render !== "function" + ? node + : this.createRenderer(node, render, () => this.getProps(node, data)); } - protected cleanupNode(anchor: Element): void { - this.values.delete(anchor); + protected createRenderer( + node: ContentScriptNode, + render: ContentScriptRenderHandler, + props: () => ContentScriptProps + ): ContentScriptNode { + throw new Error("Content script rendering requires a renderer adapter"); } } diff --git a/src/entry/content/lifecycle/context/Context.ts b/src/entry/content/lifecycle/context/Context.ts index 0c45e72a..0da89b9a 100644 --- a/src/entry/content/lifecycle/context/Context.ts +++ b/src/entry/content/lifecycle/context/Context.ts @@ -43,6 +43,15 @@ export default class Context implements ContentScriptContext { } } + public remove(node: ContentScriptNode): void { + if (!this.collection.delete(node)) { + return; + } + + node.unmount(); + this.emitter.emitRemove(node); + } + public clear(): void { for (const node of this.collection) { node.unmount(); diff --git a/src/entry/content/lifecycle/nodes/EventNode.ts b/src/entry/content/lifecycle/nodes/EventNode.ts index b9597a7f..2b8d4e19 100644 --- a/src/entry/content/lifecycle/nodes/EventNode.ts +++ b/src/entry/content/lifecycle/nodes/EventNode.ts @@ -1,6 +1,8 @@ import {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; export default class EventNode implements ContentScriptNode { + private generation = 0; + constructor( protected readonly node: ContentScriptNode, protected readonly emitter: ContentScriptEventEmitter @@ -19,8 +21,13 @@ export default class EventNode implements ContentScriptNode { } public mount(): boolean { + const generation = this.generation; const result = this.node.mount(); + if (generation !== this.generation) { + return false; + } + if (result === true) { this.emitter.emitMount(this.node); } @@ -29,6 +36,8 @@ export default class EventNode implements ContentScriptNode { } public unmount(): boolean { + this.generation++; + const result = this.node.unmount(); if (result === true) { diff --git a/src/entry/content/lifecycle/nodes/RenderNode.ts b/src/entry/content/lifecycle/nodes/RenderNode.ts new file mode 100644 index 00000000..fa343491 --- /dev/null +++ b/src/entry/content/lifecycle/nodes/RenderNode.ts @@ -0,0 +1,101 @@ +import type { + ContentScriptNode, + ContentScriptProps, + ContentScriptRenderHandler, + ContentScriptRenderValue, +} from "@typing/content"; + +/** Runs the adapter only after mounting DOM, and invalidates work when its target is replaced. */ +export default abstract class RenderNode implements ContentScriptNode { + private generation = 0; + private renderedTarget?: Element; + private empty = false; + + constructor( + protected readonly node: ContentScriptNode, + private readonly resolve: ContentScriptRenderHandler, + private readonly props: () => ContentScriptProps + ) {} + + public get anchor(): Element { + return this.node.anchor; + } + + public get container(): Element | undefined { + return this.node.container; + } + + public get target(): Element | undefined { + return this.node.target; + } + + protected abstract render(value: ContentScriptRenderValue | undefined, target: Element): boolean; + + protected abstract clear(): void; + + public mount(): boolean { + if (this.empty || !this.anchor.isConnected) { + return false; + } + + this.node.mount(); + + const target = this.target; + + if (!target) { + return false; + } + + if (target === this.renderedTarget) { + return false; + } + + this.clear(); + + const generation = ++this.generation; + + this.renderedTarget = target; + const replacedAnchor = !this.anchor.isConnected; + + try { + const value = this.resolve(this.props()); + + if (generation !== this.generation || target !== this.target) { + return false; + } + + // A synchronous custom mount (including append: Replace) can deliberately replace its anchor. + if (!this.anchor.isConnected && !(replacedAnchor && this.container?.isConnected)) { + this.unmount(); + + return false; + } + + if (!this.render(value, target)) { + this.empty = true; + this.node.unmount(); + + return false; + } + + return true; + } catch (error) { + if (generation !== this.generation) { + return false; + } + + this.unmount(); + + throw error; + } + } + + public unmount(): boolean { + this.generation++; + this.renderedTarget = undefined; + + this.clear(); + + return !!this.node.unmount(); + } +} diff --git a/src/entry/content/lifecycle/nodes/index.ts b/src/entry/content/lifecycle/nodes/index.ts index d9ed48cc..3a9b957d 100644 --- a/src/entry/content/lifecycle/nodes/index.ts +++ b/src/entry/content/lifecycle/nodes/index.ts @@ -1,4 +1,5 @@ export {default as Node} from "./Node"; +export {default as RenderNode} from "./RenderNode"; export {default as MountNode} from "./MountNode"; export {default as MarkerNode} from "./MarkerNode"; export {default as EventNode} from "./EventNode"; diff --git a/src/entry/content/resolvers/container.ts b/src/entry/content/resolvers/container.ts index f6fbe54f..52494d96 100644 --- a/src/entry/content/resolvers/container.ts +++ b/src/entry/content/resolvers/container.ts @@ -3,17 +3,17 @@ import { ContentScriptContainerFactory, ContentScriptContainerOptions, ContentScriptContainerTag, - ContentScriptProps, + ContentScriptContainerProps, } from "@typing/content"; // prettier-ignore -export const createContainerResolver = ( +export const createContainerResolver = ( container?: ContentScriptContainerTag | ContentScriptContainerOptions | - ContentScriptContainerFactory -): ContentScriptContainerCreator => - async (props: ContentScriptProps): Promise => { + ContentScriptContainerFactory +): ContentScriptContainerCreator => + async (props: ContentScriptContainerProps): Promise => { let resolvedContainer = typeof container === "function" ? container(props) : container; if (resolvedContainer instanceof Promise) { diff --git a/src/entry/content/resolvers/watch.test.ts b/src/entry/content/resolvers/watch.test.ts index af413e37..2dcfee0d 100644 --- a/src/entry/content/resolvers/watch.test.ts +++ b/src/entry/content/resolvers/watch.test.ts @@ -35,6 +35,7 @@ describe("content watch strategies", () => { document.body.append(element); const update = jest.fn(); const context = new ManagedContext(new EventEmitter()); + const unwatch = createMutationObserverStrategy({ childList: false, characterData: false, @@ -68,13 +69,18 @@ describe("content watch strategies", () => { test("await-first stops observing once an update adds content nodes", async () => { jest.useFakeTimers(); const context = new ManagedContext(new EventEmitter()); - const update = jest.fn(() => context.add(new Node(document.body))); + + const update = jest.fn(async () => { + await Promise.resolve(); + context.add(new Node(document.body)); + }); + const unwatch = createAwaitFirstStrategy()(update, context); try { document.body.append(document.createElement("article")); await Promise.resolve(); - jest.advanceTimersByTime(201); + await jest.advanceTimersByTimeAsync(201); expect(update).toHaveBeenCalledTimes(1); expect(context.nodes.size).toBe(1); diff --git a/src/entry/content/resolvers/watch.ts b/src/entry/content/resolvers/watch.ts index 61afc6d5..12866d8f 100644 --- a/src/entry/content/resolvers/watch.ts +++ b/src/entry/content/resolvers/watch.ts @@ -48,8 +48,8 @@ export const createAwaitFirstStrategy = }; if (context.nodes.size === 0) { - unwatch = resolver(() => { - update(); + unwatch = resolver(async () => { + await update(); if (context.nodes.size > 0) { clear(); @@ -72,7 +72,11 @@ export const withLocationTracking = if (currentUrl !== location.href) { currentUrl = location.href; - context.mount(); + try { + context.mount(); + } catch (error) { + console.error("Content script mount on location change failed", error); + } } }, 300); diff --git a/src/entry/relay/Builder.test.ts b/src/entry/relay/Builder.test.ts index 3169a071..a458bae0 100644 --- a/src/entry/relay/Builder.test.ts +++ b/src/entry/relay/Builder.test.ts @@ -17,6 +17,7 @@ describe("Relay Builder", () => { jest.mocked(isRelayContext).mockImplementation( jest.requireActual("@relay/utils").isRelayContext ); + manager.clear(); }); @@ -29,17 +30,20 @@ describe("Relay Builder", () => { "separates content options while keeping the Relay allFrames mode %p", async allFrames => { const received: ContentScriptDefinition[] = []; + class ContentBuilder extends VanillaBuilder { constructor(definition: ContentScriptDefinition) { super(definition); received.push(definition); } } + const instance = {ready: true}; const init = jest.fn((_options: RelayOptions) => instance); const main = jest.fn(); - const render = () => true as const; + const render = true as const; const watch = () => () => {}; + const builder = new Builder( { name: "options", @@ -64,10 +68,13 @@ describe("Relay Builder", () => { ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), }, ]); + expect(init).not.toHaveBeenCalled(); + try { await builder.build(); expect(init).toHaveBeenCalledTimes(1); + expect(init.mock.calls[0][0]).toEqual({ name: "options", allFrames, @@ -76,7 +83,9 @@ describe("Relay Builder", () => { watch, isolation: "none", }); + expect(main).toHaveBeenCalledTimes(1); + expect(main).toHaveBeenCalledWith( instance, expect.any(Object), @@ -95,25 +104,31 @@ describe("Relay Builder", () => { } ); - test("registers transport before content rendering and waits for content and main", async () => { + test("registers transport before preparation, then mounts content before main", async () => { const events: string[] = []; - let releaseRender!: () => void; + let releasePrepare!: () => void; let releaseMain!: () => void; - let renderStarted!: () => void; + let prepareStarted!: () => void; let mainStarted!: () => void; - const renderGate = new Promise(resolve => { - releaseRender = resolve; + + const prepareGate = new Promise(resolve => { + releasePrepare = resolve; }); + const mainGate = new Promise(resolve => { releaseMain = resolve; }); - const rendering = new Promise(resolve => { - renderStarted = resolve; + + const preparing = new Promise(resolve => { + prepareStarted = resolve; }); + const runningMain = new Promise(resolve => { mainStarted = resolve; }); + const instance = {ready: true}; + const main = jest.fn(async (relay, context) => { events.push("main"); expect(relay).toBe(instance); @@ -123,80 +138,150 @@ describe("Relay Builder", () => { await mainGate; events.push("main-ready"); }); + const builder = new Builder( { name: "ordered", method: RelayMethod.Scripting, + init: () => { events.push("init"); + return instance; }, - render: async () => { - events.push("render"); + + prepare: async () => { + events.push("prepare"); expect(manager.get("ordered")).toBe(instance); - renderStarted(); - await renderGate; - return "rendered"; + prepareStarted(); + await prepareGate; + + return {text: "rendered"}; + }, + + render: ({data}) => { + events.push("render"); + + return data.text; }, + watch: () => () => {}, main, }, VanillaBuilder ); + let completed = false; + const building = builder.build().then(() => { completed = true; }); + try { - await rendering; - expect(events).toEqual(["init", "render"]); + await preparing; + expect(events).toEqual(["init", "prepare"]); + expect(document.body.childElementCount).toBe(0); expect(main).not.toHaveBeenCalled(); - releaseRender(); + releasePrepare(); await runningMain; - expect(events).toEqual(["init", "render", "main"]); + expect(events).toEqual(["init", "prepare", "render", "main"]); expect(completed).toBe(false); releaseMain(); await building; - expect(events).toEqual(["init", "render", "main", "main-ready"]); + expect(events).toEqual(["init", "prepare", "render", "main", "main-ready"]); expect(completed).toBe(true); } finally { - releaseRender(); + releasePrepare(); releaseMain(); await building; await builder.destroy(); } }); + test("runs main with the registered transport after an anchor preparation fails", async () => { + const error = new Error("Preparation failed"); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + const instance = {ready: true}; + const main = jest.fn(); + const watch = jest.fn(() => () => {}); + + const builder = new Builder( + { + name: "prepare-failure", + method: RelayMethod.Scripting, + init: () => instance, + + prepare: async () => { + expect(manager.get("prepare-failure")).toBe(instance); + + throw error; + }, + + render: true, + watch, + main, + }, + VanillaBuilder + ); + + try { + await expect(builder.build()).resolves.toBeUndefined(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith(expect.any(AggregateError)); + expect(report.mock.calls[0][0].errors).toEqual([error]); + expect(watch).toHaveBeenCalledTimes(1); + expect(main).toHaveBeenCalledTimes(1); + const [relay, context] = main.mock.calls[0]; + expect(relay).toBe(instance); + expect(context.nodes.size).toBe(0); + expect(manager.get("prepare-failure")).toBe(instance); + } finally { + await builder.destroy(); + report.mockRestore(); + } + }); + test.each([RelayMethod.Messaging, RelayMethod.Scripting])( "rebuilds and cleans up the %s transport before its content", async method => { const instances: {generation: number}[] = []; + const cleanup = jest.fn(() => { expect(manager.has("rebuild")).toBe(false); }); + const unwatch = jest.fn(() => { expect(manager.has("rebuild")).toBe(false); }); + const main = jest.fn(); + const builder = new Builder( { name: "rebuild", method, + init: () => { const instance = {generation: instances.length + 1}; instances.push(instance); + return instance; }, + render: () => String(manager.get("rebuild").generation), + mount: (anchor, container) => { anchor.append(container); + return cleanup; }, + watch: () => unwatch, main, }, VanillaBuilder ); + try { await builder.build(); expect(document.body.textContent).toBe("1"); @@ -211,6 +296,7 @@ describe("Relay Builder", () => { } finally { await builder.destroy(); } + expect(manager.has("rebuild")).toBe(false); expect(document.body.childElementCount).toBe(0); expect(cleanup).toHaveBeenCalledTimes(2); @@ -223,6 +309,7 @@ describe("Relay Builder", () => { test("uses the common content builder for iframe navigation", async () => { const main = jest.fn(); + const builder = new Builder( { name: "navigation", @@ -234,10 +321,12 @@ describe("Relay Builder", () => { }, MountBuilder ); + try { await builder.build(); expect(document.querySelector("iframe")?.src).toBe("https://example.com/panel"); expect(main.mock.calls[0][1].nodes.size).toBe(1); + expect(main.mock.calls[0][2].isolation).toEqual({ type: "iframe", src: "https://example.com/panel", @@ -247,6 +336,48 @@ describe("Relay Builder", () => { } finally { await builder.destroy(); } + expect(document.querySelector("iframe")).toBeNull(); }); }); + +test("Relay destruction invalidates pending preparation and prevents late main", async () => { + const prepareStarted = Promise.withResolvers(); + const response = Promise.withResolvers(); + const main = jest.fn(); + const render = jest.fn(() => "UI"); + const init = jest.fn((_options: RelayOptions) => ({ready: true})); + + const builder = new Builder( + { + name: "pending", + method: RelayMethod.Scripting, + init, + main, + render, + + prepare: () => { + prepareStarted.resolve(); + + return response.promise; + }, + }, + VanillaBuilder + ); + + const building = builder.build(); + + try { + await prepareStarted.promise; + expect(init.mock.calls[0]?.[0]).not.toHaveProperty("prepare"); + await builder.destroy(); + response.resolve("late"); + await building; + expect(main).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + } finally { + response.resolve("late"); + await building; + await builder.destroy(); + } +}); diff --git a/src/entry/relay/Builder.ts b/src/entry/relay/Builder.ts index f923d809..7b83d3a9 100644 --- a/src/entry/relay/Builder.ts +++ b/src/entry/relay/Builder.ts @@ -8,13 +8,15 @@ import {RelayUnresolvedDefinition} from "@typing/relay"; import {ContentScriptBuilder, ContentScriptDefinition} from "@typing/content"; import {TransportType} from "@typing/transport"; -export default class Builder extends EntrypointBuilder { - protected readonly _transport: TransportBuilder; +export default class Builder extends EntrypointBuilder { + private generation = 0; + + protected readonly _transport: TransportBuilder; protected readonly _content: ContentScriptBuilder; constructor( - protected readonly definition: RelayUnresolvedDefinition, - contentBuilder: new (definition: ContentScriptDefinition) => ContentScriptBuilder + protected readonly definition: RelayUnresolvedDefinition, + contentBuilder: new (definition: ContentScriptDefinition) => ContentScriptBuilder ) { super(); @@ -25,27 +27,44 @@ export default class Builder extends EntrypointBuilder this._content = new contentBuilder({ ...contentOptions, ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), - } as ContentScriptDefinition); + } as ContentScriptDefinition); } public async build(): Promise { - await this.destroy(); + const destroying = this.destroy(); + const generation = this.generation; + await destroying; + + if (generation !== this.generation) { + return; + } await this._transport.build(); + + if (generation !== this.generation) { + return; + } + await this._content.build(); - const {main} = this.definition; + if (generation !== this.generation) { + return; + } + + const {prepare, ...options} = this.definition; + const {main} = options; if (main) { await main(this._transport.get(), this._content.getContext(), { - ...this.definition, + ...options, isolation: resolveContentScriptIsolation(this.definition.isolation, "render" in this.definition), }); } } public async destroy(): Promise { - await this._transport.destroy(); - await this._content.destroy(); + this.generation++; + // Invalidate pending content work immediately; transport cleanup starts first. + await Promise.all([this._transport.destroy(), this._content.destroy()]); } } diff --git a/src/entry/relay/TransportBuilder.ts b/src/entry/relay/TransportBuilder.ts index b96333d7..cbcc456d 100644 --- a/src/entry/relay/TransportBuilder.ts +++ b/src/entry/relay/TransportBuilder.ts @@ -5,11 +5,11 @@ import {RegisterRelay} from "@relay/providers"; import {RelayOptions, RelayUnresolvedDefinition, RelayMethod} from "@typing/relay"; import {TransportName, TransportType} from "@typing/transport"; -export default class extends AbstractBuilder { +export default class extends AbstractBuilder { protected readonly method: RelayMethod; - constructor(definition: RelayUnresolvedDefinition) { - const {main, method, ...options} = definition; + constructor(definition: RelayUnresolvedDefinition) { + const {main, method, prepare, ...options} = definition; super(options); diff --git a/src/entry/relay/index.ts b/src/entry/relay/index.ts index e42eebfa..8dcf5574 100644 --- a/src/entry/relay/index.ts +++ b/src/entry/relay/index.ts @@ -7,9 +7,9 @@ export {Builder}; export {resolveDefinition} from "./resolvers/definition"; export type {RelayUnresolvedDefinition} from "@typing/relay"; -export default function relay( - definition: RelayUnresolvedDefinition, - contentBuilder: ConstructorParameters[1] +export default function relay( + definition: RelayUnresolvedDefinition, + contentBuilder: ConstructorParameters>[1] ): void { new Builder(definition, contentBuilder).build().catch(error => { console.error("Failed to build relay: ", error); diff --git a/src/main/content.ts b/src/main/content.ts index 178316d5..bc1c3c59 100644 --- a/src/main/content.ts +++ b/src/main/content.ts @@ -4,11 +4,15 @@ import {ContentScriptAppendDefinition, ContentScriptDefinition} from "@typing/co export * from "@typing/content"; -export const defineContentScript = (options: ContentScriptDefinition): ContentScriptDefinition => { +export const defineContentScript = ( + options: ContentScriptDefinition +): ContentScriptDefinition => { return options; }; -export const defineContentScriptAppend = (options: ContentScriptAppendDefinition): ContentScriptDefinition => { +export const defineContentScriptAppend = ( + options: ContentScriptAppendDefinition +): ContentScriptDefinition => { const {append, ...definition} = options; return { diff --git a/src/main/relay.ts b/src/main/relay.ts index bd1c8a2d..21df2abd 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -3,6 +3,7 @@ import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; import {options as relayOptions} from "#adnbn/relay"; import type {TransportType} from "@typing/transport"; + import { RelayDefinition, RelayAllFrames, @@ -18,6 +19,7 @@ import { export {RelayAllFrames, RelayFrameErrorKind, RelayMethod}; export {RelayDiscoveryError} from "@relay/discovery/RelayDiscovery"; + export type { RelayAllFramesOptions, RelayAnyFramesOptions, @@ -42,7 +44,9 @@ export type { RelayDefinition, } from "@typing/relay"; -export const defineRelay = (options: RelayDefinition): RelayDefinition => { +export const defineRelay = ( + options: RelayDefinition +): RelayDefinition => { return options; }; diff --git a/src/relay/README.md b/src/relay/README.md index c1e1a221..bc155ee3 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -220,13 +220,22 @@ The runtime [`definition resolver`](../entry/relay/resolvers/definition.ts) merg 1. Destroys the previous transport/content state. 2. Creates the instance through `init(options)` and registers it through [`TransportBuilder`](../entry/relay/TransportBuilder.ts). -3. Builds the content context. +3. Builds the content context, awaiting per-anchor `prepare` before synchronously mounting UI. 4. Calls optional `main(instance, context, options)`. The startup function reports rejected builds as `Failed to build relay: `. Synchronous construction or normalization errors reach the virtual module's `The relay crashed on startup:` handler. The same builder can be rebuilt or destroyed through its lifecycle methods. Virtual templates are checked against the actual package source exports. The [`virtual module declarations`](../cli/virtual/virtual.d.ts) describe placeholders and derive framework constructors from the real adapters; do not add handwritten ambient declarations that shadow `adnbn` or its real subpaths. +`prepare` follows the shared Content contract, including typed `data`, `container`/`target` props, and `false` for tracked anchors without UI. Literal `render: true` collects anchors without invoking a renderer. Preparation is a content hook and is excluded from transport initialization and Relay main options. Destroying a pending build prevents its `main` from running. + +When processing newly discovered anchors, failures in preparation, container creation, or synchronous +mounting/rendering are logged as an `AggregateError` after each pass. These failures do not prevent content watching or +Relay `main` from starting; the registered transport remains available. Use `watch: true` for continued +observation of new anchors. Initialization failures still reject startup. Returning `false` from +`prepare` also skips iframe embedding for `isolation.page` and `isolation.src`, leaving a tracked +anchor without UI. + Registration precedes `main`; do not assume an asynchronous `main` finishes before the first remote call. Keep synchronously required state in `init` or explicitly coordinate readiness in the exposed API. [`RegisterRelay`](./providers/RegisterRelay.ts) registers Messaging listeners through the shared `RegisterTransport`. Scripting registers only the local instance in [`RelayManager`](./RelayManager.ts), exposed under `RelayGlobalKey`. Destroying the builder unregisters the transport and destroys the content context. diff --git a/src/types/content/adapters/react.ts b/src/types/content/adapters/react.ts index ceb4c2f1..a3c0633c 100644 --- a/src/types/content/adapters/react.ts +++ b/src/types/content/adapters/react.ts @@ -1,7 +1,11 @@ -import type {FC, ReactNode} from "react"; +import type {ReactNode} from "react"; import type {ContentScriptProps} from "../common"; -export type ContentScriptRenderReactComponent = FC; +export type ContentScriptRenderReactComponent = ( + props: ContentScriptProps +) => Exclude>; -export type ContentScriptReactRenderValue = ReactNode | ContentScriptRenderReactComponent; +export type ContentScriptReactRenderValue = + | Exclude> + | ContentScriptRenderReactComponent; diff --git a/src/types/content/common.ts b/src/types/content/common.ts index ce4ff224..39d841db 100644 --- a/src/types/content/common.ts +++ b/src/types/content/common.ts @@ -1,3 +1,5 @@ +import type {ContentScriptPrepareProps} from "./prepare"; + import type {PageAlias} from "@typing/page"; import type {Optional} from "utility-types"; @@ -221,8 +223,15 @@ export interface ContentScriptMount { } // Props -export interface ContentScriptProps extends ContentScriptEntrypointOptions { - anchor: Element; +export interface ContentScriptContainerProps extends ContentScriptPrepareProps { + data: Data; +} + +export interface ContentScriptProps extends ContentScriptContainerProps { + /** Outer mounted element; the Shadow host or iframe wrapper when isolated. */ + container: Element; + /** Actual render destination. Use its root/document for portals and DOM operations. */ + target: Element; } // Anchor @@ -275,14 +284,16 @@ export type ContentScriptContainerOptions = { } & Exclude>, "id">; }[ContentScriptContainerTag]; -export type ContentScriptContainerFactory = ( - props: ContentScriptProps +export type ContentScriptContainerFactory = ( + props: ContentScriptContainerProps ) => Awaiter; -export type ContentScriptContainerCreator = (props: ContentScriptProps) => Awaiter; +export type ContentScriptContainerCreator = ( + props: ContentScriptContainerProps +) => Awaiter; // Watch -export type ContentScriptWatchStrategy = (update: () => void, context: ContentScriptContext) => () => void; +export type ContentScriptWatchStrategy = (update: () => Awaiter, context: ContentScriptContext) => () => void; export type ContentScriptEventCallback = (event: ContentScriptEvent, node: ContentScriptNode) => void; diff --git a/src/types/content/definition.ts b/src/types/content/definition.ts index b555dfb9..fd98f905 100644 --- a/src/types/content/definition.ts +++ b/src/types/content/definition.ts @@ -1,3 +1,5 @@ +import type {ContentScriptPrepareHandler} from "./prepare"; + import type { ContentScriptAnchor, ContentScriptAnchorGetter, @@ -22,18 +24,25 @@ import type { ContentScriptOptions, ContentScriptWatchStrategy, } from "./common"; + import type {ContentScriptRenderHandler, ContentScriptRenderValue} from "./render"; -export interface ContentScriptDefinitionBase extends Partial { +export interface ContentScriptDefinitionBase extends Partial { marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; anchor?: ContentScriptAnchor | ContentScriptAnchorGetter; mount?: ContentScriptMountFunction; - container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory; + + container?: + | ContentScriptContainerTag + | ContentScriptContainerOptions + | ContentScriptContainerFactory>; + + prepare?: ContentScriptPrepareHandler; watch?: true | ContentScriptWatchStrategy; main?: ContentScriptMainFunction; } -export type ContentScriptDefinition = ContentScriptDefinitionBase & +export type ContentScriptDefinition = ContentScriptDefinitionBase & ( | { isolation?: @@ -42,28 +51,30 @@ export type ContentScriptDefinition = ContentScriptDefinitionBase & | ContentScriptIsolationNoneOptions | ContentScriptIsolationShadowOptions | (ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions); - render?: ContentScriptRenderValue | ContentScriptRenderHandler; + + render?: ContentScriptRenderValue> | ContentScriptRenderHandler>; } | { isolation: ContentScriptIsolationFrameOptions & (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions); + render?: never; } ); -export interface ContentScriptResolvedDefinition extends Omit< - ContentScriptDefinitionBase, +export interface ContentScriptResolvedDefinition extends Omit< + ContentScriptDefinitionBase, "anchor" | "marker" | "mount" | "container" | "watch" > { isolation: ContentScriptIsolationOptions; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; - render?: ContentScriptRenderHandler; - container: ContentScriptContainerCreator; + render?: true | ContentScriptRenderHandler; + container: ContentScriptContainerCreator; watch: ContentScriptWatchStrategy; } type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; -export type ContentScriptAppendDefinition = ContentScriptAppendVariant; +export type ContentScriptAppendDefinition = ContentScriptAppendVariant>; diff --git a/src/types/content/index.ts b/src/types/content/index.ts index 9abda0b8..5a0b9864 100644 --- a/src/types/content/index.ts +++ b/src/types/content/index.ts @@ -4,3 +4,4 @@ export type * from "./definition"; export type * from "./render"; export type * from "./adapters"; +export type * from "./prepare"; diff --git a/src/types/content/prepare.ts b/src/types/content/prepare.ts new file mode 100644 index 00000000..205f3ae4 --- /dev/null +++ b/src/types/content/prepare.ts @@ -0,0 +1,14 @@ +import type {Awaiter} from "@typing/helpers"; +import type {ContentScriptEntrypointOptions} from "./common"; + +/** Per-anchor input available before a container or render target exists. */ +export interface ContentScriptPrepareProps extends ContentScriptEntrypointOptions { + anchor: Element; +} + +/** False tracks the anchor without creating UI. Other results become render props.data. */ +export type ContentScriptPrepareResult = Data | false; + +export type ContentScriptPrepareHandler = ( + props: ContentScriptPrepareProps +) => Awaiter>; diff --git a/src/types/content/render.ts b/src/types/content/render.ts index 8690b8d3..134d2f12 100644 --- a/src/types/content/render.ts +++ b/src/types/content/render.ts @@ -1,9 +1,12 @@ -import type {Awaiter} from "@typing/helpers"; - import type {ContentScriptProps} from "./common"; import type {ContentScriptVanillaRenderValue, ContentScriptReactRenderValue} from "./adapters"; /** All supported render values; the entrypoint's filename selects the runtime adapter. */ -export type ContentScriptRenderValue = ContentScriptVanillaRenderValue | ContentScriptReactRenderValue; +export type ContentScriptRenderValue = + | ContentScriptVanillaRenderValue + | ContentScriptReactRenderValue; -export type ContentScriptRenderHandler = (props: ContentScriptProps) => Awaiter; +/** Synchronous rendering into prepared DOM. Await data and decide whether to render in prepare. */ +export type ContentScriptRenderHandler = ( + props: ContentScriptProps +) => ContentScriptRenderValue; diff --git a/src/types/relay.ts b/src/types/relay.ts index 0cf4c51b..a1750dac 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,10 +1,12 @@ import {EntrypointOptions} from "@typing/entrypoint"; + import { ContentScriptConfig, ContentScriptContext, ContentScriptDefinition, ContentScriptEntrypointOptions, } from "@typing/content"; + import { TransportConfig, TransportDefinition, @@ -12,6 +14,7 @@ import { type TransportProxyTarget, type TransportTarget, } from "@typing/transport"; + import {Awaiter} from "@typing/helpers"; import type {MessageError} from "@typing/message"; @@ -209,11 +212,16 @@ export type RelayMainHandler = ( type RelayContentDefinition = T extends unknown ? Omit : never; -export type RelayDefinition = Omit, "main"> & - RelayContentDefinition & +export type RelayDefinition = Omit< + TransportDefinition, + "main" +> & + RelayContentDefinition> & Partial & { main?: RelayMainHandler; }; /** Internal, merged runtime input. The public definition retains its discriminated union. */ -export type RelayUnresolvedDefinition = Partial, never>>; +export type RelayUnresolvedDefinition = Partial< + Omit, never> +>; diff --git a/tests/integration/browser/content/render-props-utils.ts b/tests/integration/browser/content/render-props-utils.ts new file mode 100644 index 00000000..d7a23f85 --- /dev/null +++ b/tests/integration/browser/content/render-props-utils.ts @@ -0,0 +1,132 @@ +import path from "path"; +import {run} from "../../utils/process"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; +import {waitFor} from "../utils/browser"; + +export async function expectRenderProps(browser: "chrome" | "firefox") { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture(root, path.join(__dirname, "render-props")); + const site = await startIntegrationSite(path.join(fixture.directory, "site"), null); + let session: Awaited> | undefined; + + const until = (check: () => Promise, description: string) => + waitFor(async () => ((await check()) ? true : undefined), 15_000, description); + + try { + const extension = await fixture.build({browser, manifestVersion: browser === "chrome" ? 3 : 2}); + + await run( + process.execPath, + [path.join(root, "node_modules/typescript/bin/tsc"), "--noEmit"], + fixture.directory + ); + + session = await startBrowserSession(browser, root, extension); + await session.navigate(site.origin + "/top.html"); + + await until( + async () => await session!.evaluate("document.querySelectorAll('[data-preparing]').length === 6"), + "all anchors to enter preparation" + ); + + await until( + async () => await session!.evaluate("document.body.dataset.relayRpc === JSON.stringify({ready: false})"), + "Relay RPC before preparation finishes" + ); + + expect(await session.evaluate("document.querySelectorAll('article > *').length")).toBe(0); + expect(await session.evaluate("document.querySelector('#headless').dataset.headless")).toBe("true"); + + await session.evaluate(`(() => { + const anchors = [...document.querySelectorAll('[data-preparing]')]; + document.querySelector('#removed').remove(); + for (const anchor of anchors) anchor.dispatchEvent(new Event('release')); + })()`); + + await until( + async () => + await session!.evaluate( + "['shadow','frame','vanilla','relay'].every(id => document.getElementById(id).dataset.snapshot)" + ), + "all render props snapshots" + ); + + await until( + async () => await session!.evaluate("document.body.dataset.relayRpc === JSON.stringify({ready: true})"), + "Relay main after preparation" + ); + + const snapshot = (id: string) => + session!.evaluate(`JSON.parse(document.getElementById(${JSON.stringify(id)}).dataset.snapshot)`); + + const command = (id: string, command: string) => + session!.evaluate( + `document.dispatchEvent(new CustomEvent('probe-command', {detail: ${JSON.stringify(JSON.stringify({id, command}))}}))` + ); + + const shadow = await snapshot("shadow"); + + expect(shadow).toMatchObject({ + label: "shadow", + count: 0, + containerConnected: true, + targetConnected: true, + same: false, + shadow: true, + closed: true, + frame: false, + portal: "shadow", + }); + + const frame = await snapshot("frame"); + expect(frame).toMatchObject({label: "frame", frame: true, portal: "frame", same: false}); + expect(await snapshot("vanilla")).toEqual({label: "vanilla", same: true, connected: true}); + expect(await snapshot("relay")).toMatchObject({label: "relay", shadow: true, portal: "relay"}); + await command("vanilla", "remount"); + + expect(await session.evaluate("JSON.parse(document.getElementById('vanilla').dataset.lifecycle)")).toEqual({ + events: ["unmount", "mount"], + mounted: true, + connected: true, + text: "vanilla", + }); + + await command("skip", "mount"); + + await until( + async () => await session!.evaluate("document.querySelector('#skip').dataset.tracked === '2'"), + "headless anchor tracking" + ); + + expect(await session.evaluate("document.querySelector('#skip').childElementCount")).toBe(0); + expect(await session.evaluate("document.querySelector('[data-host=removed]')")).toBeNull(); + await command("shadow", "click"); + await until(async () => (await snapshot("shadow")).count === 1, "React counter update"); + await command("shadow", "mount"); + expect((await snapshot("shadow")).count).toBe(1); + expect((await snapshot("shadow")).targetId).toBe(shadow.targetId); + await command("shadow", "remount"); + + expect(await session.evaluate("JSON.parse(document.getElementById('shadow').dataset.lifecycle)")).toMatchObject( + {events: ["unmount", "mount"], mounted: true, connected: true} + ); + + await until(async () => (await snapshot("shadow")).targetId !== shadow.targetId, "new Shadow DOM target"); + expect(await snapshot("shadow")).toMatchObject({count: 0, closed: true, portal: "shadow"}); + await command("frame", "frame-reload"); + await until(async () => (await snapshot("frame")).targetId !== frame.targetId, "iframe document recovery"); + expect(await snapshot("frame")).toMatchObject({frame: true, portal: "frame", targetConnected: true}); + expect(await session.evaluate("document.body.dataset.error ?? null")).toBeNull(); + expect(session.errors).toEqual([]); + } catch (error) { + throw new Error( + `${String(error)}; errors: ${JSON.stringify(session?.errors)}; state: ${session ? await session.evaluate("document.body.innerHTML") : "no session"}` + ); + } finally { + await session?.close(); + await site.close(); + await fixture.dispose(); + } +} diff --git a/tests/integration/browser/content/render-props.firefox.integration.test.ts b/tests/integration/browser/content/render-props.firefox.integration.test.ts new file mode 100644 index 00000000..648b913c --- /dev/null +++ b/tests/integration/browser/content/render-props.firefox.integration.test.ts @@ -0,0 +1,5 @@ +import {expectRenderProps} from "./render-props-utils"; +jest.setTimeout(90_000); + +test("firefox prepares content and Relay before mounting UI with current DOM props", () => + expectRenderProps("firefox")); diff --git a/tests/integration/browser/content/render-props.integration.test.ts b/tests/integration/browser/content/render-props.integration.test.ts new file mode 100644 index 00000000..c66d3a77 --- /dev/null +++ b/tests/integration/browser/content/render-props.integration.test.ts @@ -0,0 +1,3 @@ +import {expectRenderProps} from "./render-props-utils"; +jest.setTimeout(90_000); +test("chrome prepares content and Relay before mounting UI with current DOM props", () => expectRenderProps("chrome")); diff --git a/tests/integration/browser/content/render-props/README.md b/tests/integration/browser/content/render-props/README.md new file mode 100644 index 00000000..0e541a90 --- /dev/null +++ b/tests/integration/browser/content/render-props/README.md @@ -0,0 +1,9 @@ +# Prepared Content render props + +Exercises the published package in Chrome MV3 and Firefox MV2. Preparation waits for a page-controlled +release, then reads a real asynchronous response from the background context. Before release there +must be no UI containers; Relay RPC must already be callable while Relay main is still pending. + +The fixture checks per-anchor data, denied and removed anchors, literal headless rendering, Vanilla +DOM props, React portals in closed Shadow DOM and an iframe, preserved state on an unchanged target, +and fresh props after remount or actual iframe document navigation. diff --git a/tests/integration/browser/content/render-props/adnbn.config.ts b/tests/integration/browser/content/render-props/adnbn.config.ts new file mode 100644 index 00000000..2d6ae4c0 --- /dev/null +++ b/tests/integration/browser/content/render-props/adnbn.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Content render props", + version: "1.0.0", + manifest: {permissions: ["tabs"]}, + specific: {gecko: {id: "render-props@adnbn.test"}}, +}); diff --git a/tests/integration/browser/content/render-props/package.json b/tests/integration/browser/content/render-props/package.json new file mode 100644 index 00000000..20436b0c --- /dev/null +++ b/tests/integration/browser/content/render-props/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-render-props", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} diff --git a/tests/integration/browser/content/render-props/site/top.html b/tests/integration/browser/content/render-props/site/top.html new file mode 100644 index 00000000..93ca342a --- /dev/null +++ b/tests/integration/browser/content/render-props/site/top.html @@ -0,0 +1,15 @@ + + + + Content render props + + +
+
+
+
+
+
+
+ + diff --git a/tests/integration/browser/content/render-props/src/background.ts b/tests/integration/browser/content/render-props/src/background.ts new file mode 100644 index 00000000..95e047a1 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/background.ts @@ -0,0 +1,57 @@ +import {defineBackground, getRelay} from "adnbn"; + +export default defineBackground({ + main() { + chrome.runtime.onMessage.addListener((message, _sender, respond) => { + if (message.type === "prepare-data") { + respond({label: message.label, allowed: message.allowed}); + } + }); + + const started = new Set(); + + function run(tabId: number, url?: string) { + if (!url?.startsWith("http://127.0.0.1:") || started.has(tabId)) { + return; + } + + started.add(tabId); + + void (async () => { + const relay = getRelay("propsProbe", tabId); + const start = Date.now(); + + while (Date.now() - start < 15000) { + try { + const result = await relay.status(); + await relay.report(result); + + if (result.ready) { + return; + } + } catch { + /* Wait until the declarative Relay is registered. */ + } + + await new Promise(resolve => setTimeout(resolve, 20)); + } + + throw new Error("Prepared Relay did not become ready"); + })().catch(error => console.error(error)); + } + + chrome.tabs.onUpdated.addListener((id, change, tab) => { + if (change.status === "complete") { + run(id, tab.url); + } + }); + + void chrome.tabs.query({}).then(tabs => + tabs.forEach(tab => { + if (tab.id !== undefined) { + run(tab.id, tab.url); + } + }) + ); + }, +}); diff --git a/tests/integration/browser/content/render-props/src/frame.content.tsx b/tests/integration/browser/content/render-props/src/frame.content.tsx new file mode 100644 index 00000000..7d638b43 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/frame.content.tsx @@ -0,0 +1,13 @@ +import {defineContentScriptAppend} from "adnbn"; +import {Panel} from "./shared/Panel"; +import {prepare} from "./shared/prepare"; +import {main} from "./shared/lifecycle"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + anchor: "#frame", + isolation: "iframe", + prepare, + main, + render: Panel, +}); diff --git a/tests/integration/browser/content/render-props/src/headless.relay.tsx b/tests/integration/browser/content/render-props/src/headless.relay.tsx new file mode 100644 index 00000000..d231a60f --- /dev/null +++ b/tests/integration/browser/content/render-props/src/headless.relay.tsx @@ -0,0 +1,20 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({ + name: "headlessProbe", + matches: ["http://127.0.0.1/*"], + anchor: "#headless", + isolation: "shadow", + render: true, + + container: () => { + throw new Error("Headless mode must not create a container"); + }, + + init: () => ({}), + + main(_instance, context) { + const [node] = context.nodes; + node.anchor.setAttribute("data-headless", String(!node.container && !node.target)); + }, +}); diff --git a/tests/integration/browser/content/render-props/src/probe.relay.tsx b/tests/integration/browser/content/render-props/src/probe.relay.tsx new file mode 100644 index 00000000..4e583926 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/probe.relay.tsx @@ -0,0 +1,25 @@ +import {defineRelay} from "adnbn"; +import {Panel} from "./shared/Panel"; +import {prepare} from "./shared/prepare"; +let ready = false; + +export default defineRelay({ + name: "propsProbe", + matches: ["http://127.0.0.1/*"], + anchor: "#relay", + isolation: "shadow", + prepare, + render: Panel, + + init: () => ({ + status: () => ({ready}), + + report(value: object) { + document.body.dataset.relayRpc = JSON.stringify(value); + }, + }), + + main(_instance, context) { + ready = context.nodes.size === 1; + }, +}); diff --git a/tests/integration/browser/content/render-props/src/shadow.content.tsx b/tests/integration/browser/content/render-props/src/shadow.content.tsx new file mode 100644 index 00000000..f0b6bb1f --- /dev/null +++ b/tests/integration/browser/content/render-props/src/shadow.content.tsx @@ -0,0 +1,13 @@ +import {defineContentScript} from "adnbn"; +import {Panel} from "./shared/Panel"; +import {prepare} from "./shared/prepare"; +import {main} from "./shared/lifecycle"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + anchor: ".react-anchor", + isolation: {type: "shadow", mode: "closed"}, + prepare, + main, + render: Panel, +}); diff --git a/tests/integration/browser/content/render-props/src/shared/Panel.tsx b/tests/integration/browser/content/render-props/src/shared/Panel.tsx new file mode 100644 index 00000000..b1256a23 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/shared/Panel.tsx @@ -0,0 +1,43 @@ +import React, {useLayoutEffect, useState} from "react"; +import {createPortal} from "react-dom"; +import type {ContentScriptProps} from "adnbn"; + +const targets = new WeakMap(); +let nextTarget = 0; + +export function Panel({anchor, container, target, data}: ContentScriptProps<{label: string; allowed: boolean}>) { + const [count, setCount] = useState(0); + + useLayoutEffect(() => { + if (!targets.has(target)) { + targets.set(target, ++nextTarget); + } + + container.setAttribute("data-host", data.label); + const root = target.getRootNode(); + const shadow = root instanceof ShadowRoot; + + anchor.setAttribute( + "data-snapshot", + JSON.stringify({ + label: data.label, + count, + containerConnected: container.isConnected, + targetConnected: target.isConnected, + same: container === target, + shadow, + closed: shadow && container.shadowRoot === null, + frame: target.ownerDocument !== anchor.ownerDocument, + portal: target.querySelector("[data-portal]")?.textContent, + targetId: targets.get(target), + }) + ); + }, [anchor, container, target, data, count]); + + return ( + <> + + {createPortal({data.label}, target)} + + ); +} diff --git a/tests/integration/browser/content/render-props/src/shared/lifecycle.ts b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts new file mode 100644 index 00000000..7128b6d1 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts @@ -0,0 +1,47 @@ +import type {ContentScriptContext} from "adnbn"; + +export function main(context: ContentScriptContext) { + const events: string[] = []; + context.watch(event => events.push(event)); + + document.addEventListener("probe-command", event => { + const {id, command} = JSON.parse((event as CustomEvent).detail) as {id: string; command: string}; + const node = [...context.nodes].find(node => node.anchor.id === id); + + if (!node) { + return; + } + + try { + events.length = 0; + let mounted: ReturnType; + + if (command === "remount") { + node.unmount(); + mounted = node.mount(); + } + + if (command === "mount") { + mounted = node.mount(); + } + + if (command === "click") { + (node.target?.querySelector("button") as HTMLButtonElement)?.click(); + } + + if (command === "frame-reload") { + node.container!.querySelector("iframe")!.contentWindow!.location.reload(); + } + + node.anchor.setAttribute("data-tracked", String(context.nodes.size)); + node.anchor.setAttribute("data-command", command); + + node.anchor.setAttribute( + "data-lifecycle", + JSON.stringify({events, mounted, connected: node.target?.isConnected, text: node.target?.textContent}) + ); + } catch (error) { + document.body.dataset.error = String(error); + } + }); +} diff --git a/tests/integration/browser/content/render-props/src/shared/prepare.ts b/tests/integration/browser/content/render-props/src/shared/prepare.ts new file mode 100644 index 00000000..768cd8d1 --- /dev/null +++ b/tests/integration/browser/content/render-props/src/shared/prepare.ts @@ -0,0 +1,16 @@ +import type {ContentScriptPrepareProps} from "adnbn"; + +export async function prepare({anchor}: ContentScriptPrepareProps) { + await new Promise(resolve => { + anchor.addEventListener("release", () => resolve(), {once: true}); + anchor.setAttribute("data-preparing", "true"); + }); + + const data: {label: string; allowed: boolean} = await chrome.runtime.sendMessage({ + type: "prepare-data", + label: anchor.id, + allowed: anchor.id !== "skip", + }); + + return data.allowed ? data : false; +} diff --git a/tests/integration/browser/content/render-props/src/vanilla.content.ts b/tests/integration/browser/content/render-props/src/vanilla.content.ts new file mode 100644 index 00000000..3b490efd --- /dev/null +++ b/tests/integration/browser/content/render-props/src/vanilla.content.ts @@ -0,0 +1,22 @@ +import {defineContentScript} from "adnbn"; +import {prepare} from "./shared/prepare"; +import {main} from "./shared/lifecycle"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + anchor: "#vanilla", + prepare, + main, + + render: ({anchor, data, container, target}) => { + anchor.setAttribute( + "data-snapshot", + JSON.stringify({label: data.label, same: container === target, connected: target.isConnected}) + ); + + const span = target.ownerDocument.createElement("span"); + span.textContent = data.label; + + return span; + }, +}); diff --git a/tests/integration/browser/content/render-props/tsconfig.json b/tests/integration/browser/content/render-props/tsconfig.json new file mode 100644 index 00000000..7dbc1bfc --- /dev/null +++ b/tests/integration/browser/content/render-props/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "include": [ + "src", + ".adnbn/**/*.d.ts", + "adnbn.config.ts" + ], + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/tests/integration/types/content.integration.test.ts b/tests/integration/types/content.integration.test.ts index bcaf8ec2..93837719 100644 --- a/tests/integration/types/content.integration.test.ts +++ b/tests/integration/types/content.integration.test.ts @@ -22,24 +22,36 @@ describe("content contracts", () => { jsx: ts.JsxEmit.ReactJSX, strict: true, noEmit: true, - skipLibCheck: true, + skipLibCheck: mode === "source", types: ["node", "chrome"], }; + const host = ts.createCompilerHost(options); - const program = ts.createProgram([fixture, watchFixture], options, host); + + const program = ts.createProgram( + [fixture, watchFixture, path.join(__dirname, "fixtures/content/prepare.tsx")], + options, + host + ); + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; }); + expect(diagnostics).toEqual([]); const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + expect(apiFile).toBe( path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") ); + const contentFile = ts.resolveModuleName("adnbn/content", watchFixture, options, host).resolvedModule ?.resolvedFileName; + expect(contentFile).toBe( path .join(projectDir, mode === "source" ? "src/content/index.ts" : "dist/content/index.d.ts") diff --git a/tests/integration/types/fixtures/content/definition.tsx b/tests/integration/types/fixtures/content/definition.tsx index d6258755..dd00a8c1 100644 --- a/tests/integration/types/fixtures/content/definition.tsx +++ b/tests/integration/types/fixtures/content/definition.tsx @@ -1,4 +1,5 @@ import {ContentScriptAppend, ContentScriptIsolation, defineContentScript, defineContentScriptAppend} from "adnbn"; + import type { ContentScriptDefinition, ContentScriptProps, @@ -10,6 +11,7 @@ import type { } from "adnbn"; const Panel: ContentScriptRenderReactComponent = props => {props.anchor.tagName}; + const vanillaValues: ContentScriptVanillaRenderValue[] = [ document.createElement("div"), "text", @@ -19,7 +21,13 @@ const vanillaValues: ContentScriptVanillaRenderValue[] = [ null, undefined, ]; -const reactValues: ContentScriptReactRenderValue[] = [Panel, , [, "text"]]; + +const reactValues: ContentScriptReactRenderValue[] = [ + Panel, + , + [, "text"], +]; + const values: ContentScriptRenderValue[] = [...vanillaValues, ...reactValues]; for (const render of values) { @@ -30,18 +38,22 @@ for (const render of values) { defineContentScript({ anchor: ".product", isolation: ContentScriptIsolation.Shadow, + container: props => { const anchor: Element = props.anchor; // @ts-expect-error: Container factories receive the shared Content props. props.missing; + return {tagName: "section", title: anchor.tagName}; }, - render: async props => { + + render: props => { const anchor: Element = props.anchor; - // @ts-expect-error: Async Vanilla handlers retain contextual props. + // @ts-expect-error: Vanilla handlers retain contextual props. props.missing; const element = document.createElement("div"); element.textContent = anchor.tagName; + return element; }, }); @@ -50,15 +62,17 @@ defineContentScriptAppend({ anchor: ".product", append: ContentScriptAppend.After, isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + render: props => { const sharedProps: ContentScriptProps = props; // @ts-expect-error: React handlers receive the same typed anchor. const anchor: number = props.anchor; + return ; }, }); -const render: ContentScriptRenderHandler = async props => ; +const render: ContentScriptRenderHandler = props => ; defineContentScript({render}); defineContentScriptAppend({render, container: "article"}); diff --git a/tests/integration/types/fixtures/content/prepare.tsx b/tests/integration/types/fixtures/content/prepare.tsx new file mode 100644 index 00000000..f4cb20b0 --- /dev/null +++ b/tests/integration/types/fixtures/content/prepare.tsx @@ -0,0 +1,100 @@ +import {ContentScriptAppend, defineContentScript, defineContentScriptAppend, defineRelay} from "adnbn"; +import type {ContentScriptMount, ContentScriptProps, ContentScriptRenderReactComponent} from "adnbn"; + +const service = {get: async () => ({title: "Product", visible: true})}; + +defineContentScript({ + prepare: async ({anchor}) => { + const product = await service.get(); + + if (!anchor.isConnected || !product.visible) { + return false; + } + + return {product}; + }, + + container: ({data, anchor}) => { + const title: string = data.product.title; + + return {tagName: "section", title: title + anchor.tagName}; + }, + + render: ({data, container, target, anchor}) => { + const title: string = data.product.title; + // @ts-expect-error: data retains the prepared shape. + data.missing; + // @ts-expect-error: title is not a number. + const invalid: number = data.product.title; + container.setAttribute("title", title); + + return {target.ownerDocument === anchor.ownerDocument ? title : "iframe"}; + }, +}); + +defineContentScriptAppend({ + append: ContentScriptAppend.Last, + prepare: () => ({count: 2}), + + render: ({data}) => { + const count: number = data.count; + // @ts-expect-error: append retains data inference. + data.missing; + + return count; + }, +}); + +defineRelay({ + name: "prepared", + init: () => service, + prepare: async () => ({product: await service.get()}), + + render: ({data}) => { + const title: string = data.product.title; + // @ts-expect-error: Relay shares the typed Content contract. + data.missing; + + return title; + }, +}); + +defineContentScript({ + prepare: props => { + // @ts-expect-error: preparation has no UI container. + props.container; + // @ts-expect-error: preparation has no target. + props.target; + + return {value: 1}; + }, + + render: ({data}) => data.value, +}); + +defineContentScript({ + render: ({data}) => { + const absent: undefined = data; + + return null; + }, +}); + +const Panel = ({data}: ContentScriptProps<{title: string}>) => {data.title}; +defineContentScript({prepare: () => ({title: "Typed"}), render: Panel}); + +// @ts-expect-error: Asynchronous work belongs in prepare, not the render handler. +defineContentScript({render: async () => document.createElement("div")}); +// @ts-expect-error: The append variant also requires synchronous rendering. +defineContentScriptAppend({render: async () => "content"}); +// @ts-expect-error: Relay uses the same synchronous render contract. +defineRelay({name: "async-render", init: () => service, render: async () => }); +// @ts-expect-error: Promise render values are not part of the Content contract. +defineContentScript({render: Promise.resolve()}); +// @ts-expect-error: Prepare data before passing it to a synchronous React component. +const AsyncPanel: ContentScriptRenderReactComponent = async () => ; + +function useMount(node: ContentScriptMount): void { + const mounted: boolean | undefined | void = node.mount(); + const unmounted: boolean | undefined | void = node.unmount(); +} From 0e6698cf72e114d983462e95b7cc245e28eaba39 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:12:10 +0300 Subject: [PATCH 7/9] refactor: centralize runtime access and organize bundler modules Read entrypoint assets and isolated styles through the generated #adnbn/runtime facade. Keep Rspack runtime access in build code and preserve per-entrypoint asset-map selection and isolated CSS delivery. Separate asset collection, filename handling, compilation state and layer classification. Keep isolated-style helpers inside their owning plugin. --- src/cli/bundler/index.ts | 1 + src/cli/bundler/{utils => }/layers.ts | 4 ++ .../BuildAssetsMapPlugin.test.ts | 26 +++++-- .../build-assets-map/BuildAssetsMapPlugin.ts | 3 +- .../plugins/build-assets-map/README.md | 14 +++- .../build-assets-map/entrypoint-module.ts | 20 ------ .../bundler/plugins/build-assets-map/index.ts | 3 +- .../tests/fixtures/usage/styles.entry.js | 3 + .../bundler/plugins/build-assets-map/usage.ts | 21 +++--- src/cli/bundler/plugins/index.ts | 5 +- .../IsolatedStylesPlugin.test.ts | 2 +- .../isolated-styles/IsolatedStylesPlugin.ts | 2 +- .../isolated-styles/chunks.ts} | 17 +++-- .../plugins/manifest/ManifestPlugin.test.ts | 12 ++-- .../plugins/manifest/ManifestPlugin.ts | 3 +- .../ResourceAccessPlugin.test.ts | 8 ++- src/cli/bundler/plugins/types.ts | 5 ++ .../plugins/utils/compilation-assets.ts | 13 ++++ src/cli/bundler/plugins/utils/index.ts | 2 + .../bundler/plugins/utils/runtime-module.ts | 13 ++++ .../{output.test.ts => app-filename.test.ts} | 2 +- src/cli/bundler/utils/app-filename.ts | 21 ++++++ .../bundler/utils/{output.ts => assets.ts} | 72 +++++++++---------- src/cli/bundler/utils/index.ts | 5 +- src/cli/plugins/content/index.ts | 2 +- src/cli/plugins/locale/index.ts | 2 +- .../plugins/{output.ts => output/index.ts} | 15 ++-- src/cli/plugins/output/runtime.ts | 18 +++++ src/cli/plugins/style/index.ts | 2 +- src/cli/resolvers/config.ts | 2 +- .../lifecycle/nodes/isolated-styles.test.ts | 34 +++++++++ .../lifecycle/nodes/isolated-styles.ts | 13 +--- src/main/entrypoint.ts | 2 +- src/virtual/modules.d.ts | 7 +- src/virtual/{entrypoint.ts => runtime.ts} | 4 ++ .../build/style-routing/src/runtime.entry.js | 4 ++ .../style-routing.integration.test.ts | 20 +++--- 37 files changed, 263 insertions(+), 139 deletions(-) rename src/cli/bundler/{utils => }/layers.ts (66%) delete mode 100644 src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/styles.entry.js rename src/cli/bundler/{utils/styles.ts => plugins/isolated-styles/chunks.ts} (73%) create mode 100644 src/cli/bundler/plugins/utils/compilation-assets.ts create mode 100644 src/cli/bundler/plugins/utils/runtime-module.ts rename src/cli/bundler/utils/{output.test.ts => app-filename.test.ts} (95%) create mode 100644 src/cli/bundler/utils/app-filename.ts rename src/cli/bundler/utils/{output.ts => assets.ts} (84%) rename src/cli/plugins/{output.ts => output/index.ts} (83%) create mode 100644 src/cli/plugins/output/runtime.ts create mode 100644 src/entry/content/lifecycle/nodes/isolated-styles.test.ts rename src/virtual/{entrypoint.ts => runtime.ts} (63%) create mode 100644 tests/integration/build/style-routing/src/runtime.entry.js diff --git a/src/cli/bundler/index.ts b/src/cli/bundler/index.ts index e7c43ab8..38ea5e36 100644 --- a/src/cli/bundler/index.ts +++ b/src/cli/bundler/index.ts @@ -1,2 +1,3 @@ export * from "./plugins"; export * from "./utils"; +export * from "./layers"; diff --git a/src/cli/bundler/utils/layers.ts b/src/cli/bundler/layers.ts similarity index 66% rename from src/cli/bundler/utils/layers.ts rename to src/cli/bundler/layers.ts index 7b38db89..246b9952 100644 --- a/src/cli/bundler/utils/layers.ts +++ b/src/cli/bundler/layers.ts @@ -2,6 +2,10 @@ import type {ContentScriptWorld} from "@typing/content"; const ContentLayerPrefix = "adnbn:content:"; +// This is a bundler layer, not a CSS @layer. A distinct css-loader identity is +// also required: CssExtract deduplicates dependencies by their loader request. +export const IsolatedStylesLayer = "adnbn:css:isolation"; + /** Shared build identity for content and Relay modules, independent of their UI isolation. */ export const getContentLayer = (world: ContentScriptWorld): string => { return `${ContentLayerPrefix}${world.toLowerCase()}`; diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts index 5cf5388c..736a0a92 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -20,13 +20,14 @@ import { } from "@rspack/core"; import type {Filename} from "@rspack/core"; -import {appFilenameResolver, getCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {appFilenameResolver} from "@cli/bundler/utils/app-filename"; +import {createRuntimeModule, getCompilationBuildAssets} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; import type {EntrypointAssetsMap, EntrypointAssetsMapEntry, EntrypointAssets} from "@typing/entrypoint"; import BuildAssetsMapPlugin from "./BuildAssetsMapPlugin"; import {GenerateModulePlugin} from "../generate-module"; -import {createEntrypointModule, EntrypointAssetsModule} from "./entrypoint-module"; const AppName = "Build Assets Fixture"; const AppToken = "build-assets-fixture"; @@ -292,6 +293,8 @@ const compile = async ( isolated: {import: "./usage/local.entry.js", layer: "fixture:isolated"}, unused: "./usage/unused.entry.js", "unused-isolated": {import: "./usage/unused.entry.js", layer: "fixture:isolated"}, + styles: "./usage/styles.entry.js", + "styles-isolated": {import: "./usage/styles.entry.js", layer: "fixture:isolated"}, namespace: "./usage/namespace.entry.js", lazy: "./usage/lazy.entry.js", "shared-a": "./usage/shared.entry.js", @@ -420,7 +423,9 @@ const compile = async ( ...(options.renameBetaCss ? [new RenameBetaCssPlugin()] : []), ...(options.removeBetaCss ? [new RemoveBetaCssPlugin()] : []), ...(options.betaRuntimeMarker ? [new AddBetaRuntimeModulePlugin(options.betaRuntimeMarker)] : []), - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, buildHashSalt: options.buildHashSalt, @@ -708,11 +713,20 @@ describe("BuildAssetsMapPlugin", () => { "selects consumers across layers, reexports and lazy/shared modules (shared=%s)", async common => { const build = await compile(common, {background: "unused", usage: true}); - for (const entry of ["background", "unused", "unused-isolated"]) { + + for (const entry of ["background", "unused", "unused-isolated", "styles", "styles-isolated"]) { const source = build.assets[entry].initial.js.map(file => build.sources[file]).join("\n"); + expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); } + + for (const entry of ["styles", "styles-isolated"]) { + const {sandbox} = executeEntrypoint(build, entry); + + expect((sandbox.readStyles as () => unknown)()).toBeUndefined(); + } + for (const entry of ["local", "isolated", "namespace", "lazy", "shared-a", "shared-b"]) { const {sandbox} = executeEntrypoint(build, entry); expect(await (sandbox.readCurrent as () => unknown)()).toEqual(runtimeAssets(build.assets[entry])); @@ -991,7 +1005,9 @@ describe("BuildAssetsMapPlugin", () => { module: {rules: [{test: /\.css$/, use: [CssExtractRspackPlugin.loader, "css-loader"]}]}, plugins: [ new CssExtractRspackPlugin({filename: "[name].[contenthash:8].css"}), - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, fullMapEntrypoint: "background", diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts index e1054275..78c79219 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -2,7 +2,8 @@ import {Chunk, Compilation, Compiler, Filename, RuntimeGlobals, RuntimeModule, s import stringify from "json-stringify-deterministic"; import ts from "typescript"; -import {collectBuildAssets, setCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {collectBuildAssets} from "@cli/bundler/utils/assets"; +import {setCompilationBuildAssets} from "../utils"; import { CssContentHashType, JavaScriptContentHashType, diff --git a/src/cli/bundler/plugins/build-assets-map/README.md b/src/cli/bundler/plugins/build-assets-map/README.md index 3c4122f4..c7921512 100644 --- a/src/cli/bundler/plugins/build-assets-map/README.md +++ b/src/cli/bundler/plugins/build-assets-map/README.md @@ -2,7 +2,7 @@ `BuildAssetsMapPlugin` collects the complete entrypoint inventory for manifest generation on every compilation. Runtime delivery is selected separately through the configured module's used exports. -The output feature composes it with `GenerateModulePlugin`, which provides `#adnbn/entrypoint`. +The output feature composes it with `GenerateModulePlugin`, which provides `#adnbn/runtime`. The virtual module exports readers, not snapshots. The current-entry reader is supplied in consuming non-background runtimes; the full-map reader is supplied only in a consuming background runtime. @@ -20,3 +20,15 @@ including watch rebuilds that add or remove consumers. Isolated CSS delivery belongs to `IsolatedStylesPlugin` and does not require these public maps. The build inventory remains complete for manifest dependencies even when no runtime requests a map. + +The same facade exposes `readContentStyles` for isolated content rendering. Its usage does not +select either asset-map reader or embed an asset map. Extension code accesses runtime properties +through the generated module; the asset-map and isolated-style plugins retain ownership of their data. + +The output feature owns the facade request, reader definitions and asset-map reader selection in +`cli/plugins/output/runtime.ts`. Shared plugin infrastructure generates the readers from those +definitions; it has no knowledge of content or entrypoint asset properties. + +Asset collection and classification live in `bundler/utils/assets.ts`. The per-compilation result is +shared with manifest generation through `bundler/plugins/utils/compilation-assets.ts`. Filename +template handling is independent in `bundler/utils/app-filename.ts` and `chunk-filename.ts`. diff --git a/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts b/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts deleted file mode 100644 index 6986cfb1..00000000 --- a/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {RuntimeGlobals} from "@rspack/core"; - -import {EntrypointAssetsMapRuntimeProperty, EntrypointAssetsRuntimeProperty} from "@typing/entrypoint"; - -import type {BuildAssetsMapPluginModuleOptions} from "./usage"; - -export const EntrypointAssetsModule = { - request: "#adnbn/entrypoint", - current: {export: "readAssets", property: EntrypointAssetsRuntimeProperty}, - full: {export: "readAssetsMap", property: EntrypointAssetsMapRuntimeProperty}, -} satisfies BuildAssetsMapPluginModuleOptions; - -/** Readers keep runtime data live without capturing a snapshot during module evaluation. */ -export const createEntrypointModule = (): string => - [EntrypointAssetsModule.current, EntrypointAssetsModule.full] - .map( - ({export: name, property}) => - `export const ${name} = () => ${RuntimeGlobals.require}[${JSON.stringify(property)}];\n` - ) - .join(""); diff --git a/src/cli/bundler/plugins/build-assets-map/index.ts b/src/cli/bundler/plugins/build-assets-map/index.ts index ef939139..98934c60 100644 --- a/src/cli/bundler/plugins/build-assets-map/index.ts +++ b/src/cli/bundler/plugins/build-assets-map/index.ts @@ -1,3 +1,2 @@ export {default, type BuildAssetsMapPluginOptions} from "./BuildAssetsMapPlugin"; -export {createEntrypointModule, EntrypointAssetsModule} from "./entrypoint-module"; -export type {BuildAssetsMapPluginModuleOptions, BuildAssetsMapPluginExportOptions} from "./usage"; +export type {BuildAssetsMapPluginModuleOptions} from "./usage"; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/styles.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/styles.entry.js new file mode 100644 index 00000000..f8434288 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/styles.entry.js @@ -0,0 +1,3 @@ +import {readContentStyles} from "#adnbn/runtime"; + +globalThis.readStyles = readContentStyles; diff --git a/src/cli/bundler/plugins/build-assets-map/usage.ts b/src/cli/bundler/plugins/build-assets-map/usage.ts index 77f1866e..0dd7e8c3 100644 --- a/src/cli/bundler/plugins/build-assets-map/usage.ts +++ b/src/cli/bundler/plugins/build-assets-map/usage.ts @@ -1,15 +1,11 @@ import type {Chunk, Compilation, Compiler, NormalModule} from "@rspack/core"; -import type {RuntimePropertyOptions} from "../types"; - -export interface BuildAssetsMapPluginExportOptions extends RuntimePropertyOptions { - readonly export: string; -} +import type {RuntimeModuleReaderOptions} from "../types"; export interface BuildAssetsMapPluginModuleOptions { readonly request: string; - readonly current: BuildAssetsMapPluginExportOptions; - readonly full: BuildAssetsMapPluginExportOptions; + readonly current: RuntimeModuleReaderOptions; + readonly full: RuntimeModuleReaderOptions; } /** Retain every layer's module before concatenation, then inspect export use per runtime. */ @@ -23,12 +19,16 @@ export const trackModuleUsage = ( compilation.hooks.finishModules.tapPromise("BuildAssetsMapPlugin:resolve", async () => { const resolver = compiler.resolverFactory.get("normal", {dependencyType: "esm"}); + resource = await new Promise((resolve, reject) => { resolver.resolve({}, compiler.context, options.request, {}, (error, result) => { - if (error) reject(error); - else if (typeof result !== "string") + if (error) { + reject(error); + } else if (typeof result !== "string") { reject(new Error(`Unable to resolve assets module "${options.request}"`)); - else resolve(result); + } else { + resolve(result); + } }); }); }); @@ -41,6 +41,7 @@ export const trackModuleUsage = ( return (chunk, name) => { const runtime = new Set(chunk.runtime); + return modules.some(module => compilation.moduleGraph.getExportsInfo(module).getUsed(name, runtime) !== 0); }; }; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index aa6757bb..b1248170 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -21,9 +21,6 @@ export { default as BuildAssetsMapPlugin, type BuildAssetsMapPluginOptions, type BuildAssetsMapPluginModuleOptions, - type BuildAssetsMapPluginExportOptions, - createEntrypointModule, - EntrypointAssetsModule, } from "./build-assets-map"; export { @@ -52,4 +49,4 @@ export {default as ReplacePlugin} from "./replace"; export {default as WatchPlugin} from "./watch"; -export type {RuntimePropertyOptions} from "./types"; +export type {RuntimePropertyOptions, RuntimeModuleReaderOptions} from "./types"; diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts index 8f6059da..ff3c845b 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts @@ -7,7 +7,7 @@ import vm from "vm"; import {type Compiler, CssExtractRspackPlugin, type Filename, rspack, type Stats} from "@rspack/core"; -import {IsolatedStylesLayer} from "@cli/bundler/utils/styles"; +import {IsolatedStylesLayer} from "@cli/bundler/layers"; import IsolatedStylesPlugin, { type IsolatedStylesPluginOptions, diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts index fbbbd5dc..50500f33 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts @@ -1,11 +1,11 @@ import {type Chunk, type Compilation, type Compiler, type Filename, RuntimeGlobals, RuntimeModule} from "@rspack/core"; -import {getIsolatedStylesFiles, isIsolatedStylesChunk} from "@cli/bundler/utils/styles"; import {CssContentHashType, filenameRequiresFullHash, resolveChunkFilename} from "@cli/bundler/utils/chunk-filename"; import type {RuntimePropertyOptions} from "../types"; import {getManifestHooks} from "../utils/manifest-hooks"; import {renderIsolatedStylesCssLoader, renderIsolatedStylesRuntime} from "./templates"; +import {getIsolatedStylesFiles, isIsolatedStylesChunk} from "./chunks"; const PluginName = "IsolatedStylesPlugin"; const CssLoadingRuntimeIdentifier = "webpack/runtime/css loading"; diff --git a/src/cli/bundler/utils/styles.ts b/src/cli/bundler/plugins/isolated-styles/chunks.ts similarity index 73% rename from src/cli/bundler/utils/styles.ts rename to src/cli/bundler/plugins/isolated-styles/chunks.ts index 2a07c1c1..220c6c28 100644 --- a/src/cli/bundler/utils/styles.ts +++ b/src/cli/bundler/plugins/isolated-styles/chunks.ts @@ -1,31 +1,38 @@ import type {Chunk, Compilation, Module} from "@rspack/core"; -// This is a bundler layer, not a CSS @layer. A distinct css-loader identity is -// also required: CssExtract deduplicates dependencies by their loader request. -export const IsolatedStylesLayer = "adnbn:css:isolation"; +import {IsolatedStylesLayer} from "@cli/bundler/layers"; -export const isIsolatedStylesModule = (module: Module): boolean => +const isIsolatedStylesModule = (module: Module): boolean => module.type === "css/mini-extract" && module.layer === IsolatedStylesLayer; export const isIsolatedStylesChunk = (compilation: Compilation, chunk: Chunk): boolean => { const modules = [...compilation.chunkGraph.getChunkModulesIterable(chunk)].filter( module => module.type === "css/mini-extract" ); + const isolated = modules.some(isIsolatedStylesModule); + if (isolated && !modules.every(isIsolatedStylesModule)) { throw new Error( `CSS chunk "${chunk.name ?? chunk.id}" mixes document and ?isolation styles. Preserve the adnbnIsolatedStyles CSS cache group in your bundler configuration.` ); } + return isolated; }; export const getIsolatedStylesFiles = (compilation: Compilation): ReadonlySet => { const files = new Set(); + for (const chunk of compilation.chunks) { if (isIsolatedStylesChunk(compilation, chunk)) { - for (const file of chunk.files) if (file.endsWith(".css")) files.add(file); + for (const file of chunk.files) { + if (file.endsWith(".css")) { + files.add(file); + } + } } } + return files; }; diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts index 763a4a18..cda73f72 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts @@ -5,12 +5,10 @@ import path from "path"; import {rspack, type Compiler, type RspackPluginInstance, type Stats} from "@rspack/core"; import ManifestV3 from "@cli/builders/manifest/ManifestV3"; -import BuildAssetsMapPlugin, { - createEntrypointModule, - EntrypointAssetsModule, -} from "@cli/bundler/plugins/build-assets-map"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import {createRuntimeModule, getCompilationBuildAssets} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; import {GenerateModulePlugin} from "../generate-module"; -import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; import {Browser} from "@typing/browser"; import type {EntrypointAssetsMap} from "@typing/entrypoint"; @@ -128,7 +126,9 @@ describe("ManifestPlugin compilation hooks", () => { devtool: false, output: {path: directory, filename: "[name].[contenthash:8].js"}, plugins: [ - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, fullMapEntrypoint: "background", diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts index 3417d5b7..52016063 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts @@ -1,7 +1,6 @@ import rspack, {Compilation, Compiler} from "@rspack/core"; -import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; -import {getManifestHooks} from "../utils/manifest-hooks"; +import {getCompilationBuildAssets, getManifestHooks} from "../utils"; import {ManifestBuilder, ManifestDependencies, ManifestDependency} from "@typing/manifest"; import {EntrypointAssetsMap} from "@typing/entrypoint"; diff --git a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts index c9d3b44f..438872cb 100644 --- a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts +++ b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts @@ -9,7 +9,9 @@ import ManifestV3 from "@cli/builders/manifest/ManifestV3"; import {Browser} from "@typing/browser"; import type {ManifestVersion} from "@typing/manifest"; -import BuildAssetsMapPlugin, {createEntrypointModule, EntrypointAssetsModule} from "../build-assets-map"; +import BuildAssetsMapPlugin from "../build-assets-map"; +import {createRuntimeModule} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; import {GenerateModulePlugin} from "../generate-module"; import ManifestPlugin from "../manifest"; import ResourceAccessPlugin, {type ResourceAccessPluginOptions} from "./ResourceAccessPlugin"; @@ -43,7 +45,9 @@ describe("ResourceAccessPlugin", () => { devtool: false, output: {path: directory, filename: "[name].js"}, plugins: [ - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, fullMapEntrypoint: "background", diff --git a/src/cli/bundler/plugins/types.ts b/src/cli/bundler/plugins/types.ts index 3f06c78f..f02b88d9 100644 --- a/src/cli/bundler/plugins/types.ts +++ b/src/cli/bundler/plugins/types.ts @@ -2,3 +2,8 @@ export interface RuntimePropertyOptions { readonly property: string; } + +/** Names a module export that reads one property of the calling bundler runtime. */ +export interface RuntimeModuleReaderOptions extends RuntimePropertyOptions { + readonly export: string; +} diff --git a/src/cli/bundler/plugins/utils/compilation-assets.ts b/src/cli/bundler/plugins/utils/compilation-assets.ts new file mode 100644 index 00000000..e5d9657f --- /dev/null +++ b/src/cli/bundler/plugins/utils/compilation-assets.ts @@ -0,0 +1,13 @@ +import type {Compilation} from "@rspack/core"; + +import type {EntrypointAssetsMap} from "@typing/entrypoint"; + +const compilationBuildAssets = new WeakMap(); + +export const setCompilationBuildAssets = (compilation: Compilation, assets: EntrypointAssetsMap): void => { + compilationBuildAssets.set(compilation, assets); +}; + +export const getCompilationBuildAssets = (compilation: Compilation): EntrypointAssetsMap | undefined => { + return compilationBuildAssets.get(compilation); +}; diff --git a/src/cli/bundler/plugins/utils/index.ts b/src/cli/bundler/plugins/utils/index.ts index 36d91164..dd43643f 100644 --- a/src/cli/bundler/plugins/utils/index.ts +++ b/src/cli/bundler/plugins/utils/index.ts @@ -1,3 +1,5 @@ export {getManifestHooks, type ManifestHooks} from "./manifest-hooks"; export {renderRuntimeTemplate} from "./runtime-template"; export {watchCompilation} from "./watch-compilation"; +export {createRuntimeModule} from "./runtime-module"; +export {getCompilationBuildAssets, setCompilationBuildAssets} from "./compilation-assets"; diff --git a/src/cli/bundler/plugins/utils/runtime-module.ts b/src/cli/bundler/plugins/utils/runtime-module.ts new file mode 100644 index 00000000..7b649bf8 --- /dev/null +++ b/src/cli/bundler/plugins/utils/runtime-module.ts @@ -0,0 +1,13 @@ +import {RuntimeGlobals} from "@rspack/core"; + +import type {RuntimeModuleReaderOptions} from "../types"; + +/** Read the calling entrypoint's live runtime without initializing other features. */ +export const createRuntimeModule = (readers: readonly RuntimeModuleReaderOptions[]): string => { + return readers + .map( + ({export: name, property}) => + `export const ${name} = () => ${RuntimeGlobals.require}[${JSON.stringify(property)}];\n` + ) + .join(""); +}; diff --git a/src/cli/bundler/utils/output.test.ts b/src/cli/bundler/utils/app-filename.test.ts similarity index 95% rename from src/cli/bundler/utils/output.test.ts rename to src/cli/bundler/utils/app-filename.test.ts index 100e3651..5aa639a7 100644 --- a/src/cli/bundler/utils/output.test.ts +++ b/src/cli/bundler/utils/app-filename.test.ts @@ -1,6 +1,6 @@ import type {AssetInfo, PathData} from "@rspack/core"; -import {appFilenameResolver} from "./output"; +import {appFilenameResolver} from "./app-filename"; describe("appFilenameResolver", () => { test("preserves string templates while resolving the app and directory", () => { diff --git a/src/cli/bundler/utils/app-filename.ts b/src/cli/bundler/utils/app-filename.ts new file mode 100644 index 00000000..3f766b2d --- /dev/null +++ b/src/cli/bundler/utils/app-filename.ts @@ -0,0 +1,21 @@ +import type {Filename} from "@rspack/core"; +import _ from "lodash"; +import path from "path"; + +export const appFilenameResolver = (app: string, filename: Filename, dirname?: string): Filename => { + app = _.kebabCase(app); + + const resolve = (name: string): string => { + name = name.replaceAll("[app]", app); + + return dirname ? path.posix.join(dirname, name) : name; + }; + + if (!_.isFunction(filename)) { + return resolve(filename); + } + + return (pathData, assetInfo): string => { + return resolve(filename(pathData, assetInfo)); + }; +}; diff --git a/src/cli/bundler/utils/output.ts b/src/cli/bundler/utils/assets.ts similarity index 84% rename from src/cli/bundler/utils/output.ts rename to src/cli/bundler/utils/assets.ts index 2fce1c65..6a742f7d 100644 --- a/src/cli/bundler/utils/output.ts +++ b/src/cli/bundler/utils/assets.ts @@ -1,5 +1,4 @@ -import type {AssetInfo, Chunk, Compilation, Filename, Module} from "@rspack/core"; -import _ from "lodash"; +import type {AssetInfo, Chunk, Compilation, Module} from "@rspack/core"; import path from "path"; import postcss from "postcss"; import valueParser from "postcss-value-parser"; @@ -10,28 +9,6 @@ import type {EntrypointAssetsMap, EntrypointAssetsMapEntry, EntrypointAssetsFile type AssetKind = "asset" | "css" | "js"; -const compilationBuildAssets = new WeakMap(); - -// prettier-ignore -export const appFilenameResolver = - (app: string, filename: Filename, dirname?: string): Filename => { - app = _.kebabCase(app); - - const resolve = (name: string): string => { - name = name.replaceAll("[app]", app); - - return dirname ? path.posix.join(dirname, name) : name; - }; - - if (!_.isFunction(filename)) { - return resolve(filename); - } - - return (pathData, assetInfo): string => { - return resolve(filename(pathData, assetInfo)); - }; - }; - const addRelatedSourceMaps = (sourceMaps: Set, related: AssetInfo["related"]): void => { const sourceMap = related?.sourceMap; @@ -113,16 +90,21 @@ const collectResources = ( const normalized = toPosix(file); candidates.set(normalized, candidates.get(normalized) ?? false); - if (getAssetKind(compilation, normalized, sourceMaps) === "css") cssFiles.add(normalized); + + if (getAssetKind(compilation, normalized, sourceMaps) === "css") { + cssFiles.add(normalized); + } } for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) { for (const file of getModuleAssetNames(module)) { candidates.set(toPosix(file), true); } + if (module.type === "css/mini-extract") { - for (const dependency of module.buildInfo?.fileDependencies ?? []) + for (const dependency of module.buildInfo?.fileDependencies ?? []) { sourceFiles.add(path.resolve(dependency)); + } } } } @@ -142,27 +124,47 @@ const collectResources = ( ) .map(asset => asset.name) .sort((left, right) => right.length - left.length); + for (const file of cssFiles) { const css = compilation.getAsset(file)?.source.source().toString(); - if (!css) continue; + + if (!css) { + continue; + } + postcss.parse(css, {from: file}).walkDecls(declaration => { valueParser(declaration.value).walk(node => { - if (node.type !== "function" || node.value.toLowerCase() !== "url") return; + if (node.type !== "function" || node.value.toLowerCase() !== "url") { + return; + } + const argument = node.nodes[0]; - if (!argument || (argument.type !== "string" && argument.type !== "word")) return false; + + if (!argument || (argument.type !== "string" && argument.type !== "word")) { + return false; + } + const url = argument.value.replace(/\\([\da-f]{1,6}\s?|.)/gi, (_match, escape: string) => /^[\da-f]{1,6}\s?$/i.test(escape) ? String.fromCodePoint(parseInt(escape.trim(), 16) || 0xfffd) : escape ); - if (/^(?:data|blob):/i.test(url)) return false; + + if (/^(?:data|blob):/i.test(url)) { + return false; + } + try { const pathname = decodeURIComponent(new URL(url, `https://adnbn.invalid/${file}`).pathname); const resource = resources.find(candidate => pathname.endsWith(`/${candidate}`)); - if (resource) candidates.set(resource, true); + + if (resource) { + candidates.set(resource, true); + } } catch { // External or malformed URLs do not name an emitted build resource. } + return false; }); }); @@ -239,11 +241,3 @@ export const collectBuildAssets = (compilation: Compilation): EntrypointAssetsMa return assets; }; - -export const setCompilationBuildAssets = (compilation: Compilation, assets: EntrypointAssetsMap): void => { - compilationBuildAssets.set(compilation, assets); -}; - -export const getCompilationBuildAssets = (compilation: Compilation): EntrypointAssetsMap | undefined => { - return compilationBuildAssets.get(compilation); -}; diff --git a/src/cli/bundler/utils/index.ts b/src/cli/bundler/utils/index.ts index d42c7064..bae2efef 100644 --- a/src/cli/bundler/utils/index.ts +++ b/src/cli/bundler/utils/index.ts @@ -1,4 +1,3 @@ export * from "./optimization"; -export * from "./layers"; -export * from "./output"; -export * from "./styles"; +export * from "./app-filename"; +export * from "./assets"; diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index d7ca7c85..6f0cca6d 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -10,7 +10,7 @@ import {createRelayModule, RelayModuleName} from "./relay-module"; import {definePlugin} from "@main/plugin"; import {PageFinder} from "@cli/entrypoint"; -import {getContentLayer} from "@cli/bundler/utils/layers"; +import {getContentLayer} from "@cli/bundler/layers"; import { appFilenameResolver, diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 33609529..7b6d29af 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -3,7 +3,7 @@ import type {Configuration as RspackConfig, RspackPluginInstance} from "@rspack/ import {definePlugin} from "@main/plugin"; import {GenerateJsonPlugin, GenerateModulePlugin, WatchPlugin} from "@cli/bundler"; -import {getContentLayer} from "@cli/bundler/utils/layers"; +import {getContentLayer} from "@cli/bundler/layers"; import {extractLocaleKey, modifyLocaleMessageKey} from "@shared/locale"; import Locale from "./Locale"; diff --git a/src/cli/plugins/output.ts b/src/cli/plugins/output/index.ts similarity index 83% rename from src/cli/plugins/output.ts rename to src/cli/plugins/output/index.ts index b1ce3dc7..1fdbf4be 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output/index.ts @@ -3,15 +3,12 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import { - appFilenameResolver, - BuildAssetsMapPlugin, - GenerateModulePlugin, - createEntrypointModule, - EntrypointAssetsModule, -} from "@cli/bundler"; +import {appFilenameResolver, BuildAssetsMapPlugin, GenerateModulePlugin} from "@cli/bundler"; +import {createRuntimeModule} from "@cli/bundler/plugins/utils"; import {getOutputPath, getResolvePath} from "@cli/resolvers/path"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "./runtime"; + import {Command} from "@typing/app"; import {BackgroundEntryName} from "@typing/background"; @@ -52,7 +49,9 @@ export default definePlugin(() => { clean: config.command === Command.Build, }, plugins: [ - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, buildHashSalt, diff --git a/src/cli/plugins/output/runtime.ts b/src/cli/plugins/output/runtime.ts new file mode 100644 index 00000000..33740750 --- /dev/null +++ b/src/cli/plugins/output/runtime.ts @@ -0,0 +1,18 @@ +import type {BuildAssetsMapPluginModuleOptions, RuntimeModuleReaderOptions} from "@cli/bundler/plugins"; + +import {ContentScriptStylesRuntimeProperty} from "@typing/content"; +import {EntrypointAssetsMapRuntimeProperty, EntrypointAssetsRuntimeProperty} from "@typing/entrypoint"; + +export const RuntimeModuleRequest = "#adnbn/runtime"; + +export const RuntimeModuleReaders = { + assets: {export: "readAssets", property: EntrypointAssetsRuntimeProperty}, + assetsMap: {export: "readAssetsMap", property: EntrypointAssetsMapRuntimeProperty}, + contentStyles: {export: "readContentStyles", property: ContentScriptStylesRuntimeProperty}, +} as const satisfies Record; + +export const EntrypointAssetsModule = { + request: RuntimeModuleRequest, + current: RuntimeModuleReaders.assets, + full: RuntimeModuleReaders.assetsMap, +} satisfies BuildAssetsMapPluginModuleOptions; diff --git a/src/cli/plugins/style/index.ts b/src/cli/plugins/style/index.ts index 8f681584..a47a4886 100644 --- a/src/cli/plugins/style/index.ts +++ b/src/cli/plugins/style/index.ts @@ -5,7 +5,7 @@ import {Configuration as RspackConfig, CssExtractRspackPlugin, RuleSetUse, RuleS import {mergeStyleSources} from "./utils"; import type {StylePluginOptions} from "./types"; -import {IsolatedStylesLayer} from "@cli/bundler/utils/styles"; +import {IsolatedStylesLayer} from "@cli/bundler/layers"; import {definePlugin} from "@main/plugin"; diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 4e3397bb..9732b89f 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -31,7 +31,7 @@ import { } from "../plugins"; import {fromRootPath, getAppPath, getAppSourcePath, getConfigFile} from "../resolvers/path"; -import {isContentLayer} from "@cli/bundler/utils/layers"; +import {isContentLayer} from "@cli/bundler/layers"; import type {Config, OptionalConfig, ReadonlyConfig, UserConfig} from "@typing/config"; import {Command, Mode, Workspace} from "@typing/app"; diff --git a/src/entry/content/lifecycle/nodes/isolated-styles.test.ts b/src/entry/content/lifecycle/nodes/isolated-styles.test.ts new file mode 100644 index 00000000..a4be9475 --- /dev/null +++ b/src/entry/content/lifecycle/nodes/isolated-styles.test.ts @@ -0,0 +1,34 @@ +import {getUrl} from "@addon-core/browser"; +import {readContentStyles} from "#adnbn/runtime"; + +import type {ContentScriptStylesRuntime} from "@typing/content"; + +import {getContentScriptStylesRuntime} from "./isolated-styles"; + +jest.mock("#adnbn/runtime", () => ({readContentStyles: jest.fn()})); +jest.mock("@addon-core/browser", () => ({getUrl: jest.fn()})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +test("reports an unavailable styles runtime", () => { + expect(() => getContentScriptStylesRuntime()).toThrow( + "Isolated styles runtime is unavailable in this content entrypoint" + ); +}); + +test("initializes and returns the styles runtime supplied by the facade", () => { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + load: jest.fn(), + }; + + jest.mocked(readContentStyles).mockReturnValue(runtime); + + expect(getContentScriptStylesRuntime()).toBe(runtime); + expect(runtime.initialize).toHaveBeenCalledWith(getUrl); + expect(runtime.add).not.toHaveBeenCalled(); +}); diff --git a/src/entry/content/lifecycle/nodes/isolated-styles.ts b/src/entry/content/lifecycle/nodes/isolated-styles.ts index 72c9daaa..e1c7657c 100644 --- a/src/entry/content/lifecycle/nodes/isolated-styles.ts +++ b/src/entry/content/lifecycle/nodes/isolated-styles.ts @@ -1,17 +1,10 @@ import {getUrl} from "@addon-core/browser"; +import {readContentStyles} from "#adnbn/runtime"; -import {ContentScriptStylesRuntimeProperty, type ContentScriptStylesRuntime} from "@typing/content"; - -interface WebpackRuntime { - (moduleId: string | number): unknown; - [ContentScriptStylesRuntimeProperty]?: ContentScriptStylesRuntime; -} - -declare const __webpack_require__: WebpackRuntime; +import type {ContentScriptStylesRuntime} from "@typing/content"; export const getContentScriptStylesRuntime = (): ContentScriptStylesRuntime => { - const runtime = - typeof __webpack_require__ === "function" ? __webpack_require__[ContentScriptStylesRuntimeProperty] : undefined; + const runtime = readContentStyles(); if (!runtime) { throw new Error("Isolated styles runtime is unavailable in this content entrypoint"); diff --git a/src/main/entrypoint.ts b/src/main/entrypoint.ts index 8bde63cf..210d6f87 100644 --- a/src/main/entrypoint.ts +++ b/src/main/entrypoint.ts @@ -1,4 +1,4 @@ -import {readAssets, readAssetsMap} from "#adnbn/entrypoint"; +import {readAssets, readAssetsMap} from "#adnbn/runtime"; import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; diff --git a/src/virtual/modules.d.ts b/src/virtual/modules.d.ts index ad38eea8..5a22634d 100644 --- a/src/virtual/modules.d.ts +++ b/src/virtual/modules.d.ts @@ -10,9 +10,10 @@ declare module "#adnbn/page" { export const aliases: typeof import("./page").aliases; } -declare module "#adnbn/entrypoint" { - export const readAssets: typeof import("./entrypoint").readAssets; - export const readAssetsMap: typeof import("./entrypoint").readAssetsMap; +declare module "#adnbn/runtime" { + export const readAssets: typeof import("./runtime").readAssets; + export const readAssetsMap: typeof import("./runtime").readAssetsMap; + export const readContentStyles: typeof import("./runtime").readContentStyles; } declare module "#adnbn/relay" { diff --git a/src/virtual/entrypoint.ts b/src/virtual/runtime.ts similarity index 63% rename from src/virtual/entrypoint.ts rename to src/virtual/runtime.ts index f02c9235..8a8f2e4e 100644 --- a/src/virtual/entrypoint.ts +++ b/src/virtual/runtime.ts @@ -1,7 +1,11 @@ import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; +import type {ContentScriptStylesRuntime} from "@typing/content"; /** Package fallback; extension builds provide access to their own bundler runtime. */ export const readAssets = (): EntrypointAssets | undefined => undefined; /** Package fallback; the complete map is supplied only by a consuming background runtime. */ export const readAssetsMap = (): EntrypointAssetsMap | undefined => undefined; + +/** Package fallback; isolated content builds supply their own styles runtime. */ +export const readContentStyles = (): ContentScriptStylesRuntime | undefined => undefined; diff --git a/tests/integration/build/style-routing/src/runtime.entry.js b/tests/integration/build/style-routing/src/runtime.entry.js new file mode 100644 index 00000000..b5109443 --- /dev/null +++ b/tests/integration/build/style-routing/src/runtime.entry.js @@ -0,0 +1,4 @@ +import "./entry.js"; +import {readContentStyles} from "#adnbn/runtime"; + +globalThis.readStyles = readContentStyles; diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index f09a2124..86dc6e20 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -7,13 +7,11 @@ import assetPlugin from "@cli/plugins/asset"; import optimizationPlugin from "@cli/plugins/optimization"; import {merge} from "webpack-merge"; import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; -import BuildAssetsMapPlugin, { - createEntrypointModule, - EntrypointAssetsModule, -} from "@cli/bundler/plugins/build-assets-map"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import {createRuntimeModule, getCompilationBuildAssets} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; -import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; -import {getContentLayer, isContentLayer} from "@cli/bundler/utils/layers"; +import {getContentLayer, isContentLayer} from "@cli/bundler/layers"; import ManifestPlugin from "@cli/bundler/plugins/manifest"; import ManifestV3 from "@cli/builders/manifest/ManifestV3"; import type {ReadonlyConfig} from "@typing/config"; @@ -100,11 +98,11 @@ test.each<{ devtool: false, entry: { background: withoutMap ? "./background-unused.js" : "./background.js", - popup: "./entry.js", + popup: "./runtime.entry.js", assets: "./assets.js", other: "./other-assets.js", - content: {import: "./entry.js", layer}, - relay: {import: "./entry.js", layer}, + content: {import: "./runtime.entry.js", layer}, + relay: {import: "./runtime.entry.js", layer}, }, output: {...assets.output, path: output, filename: "js/[name].[chunkhash:8].js", publicPath: ""}, resolveLoader: {modules: [path.join(root, "node_modules")]}, @@ -126,7 +124,9 @@ test.each<{ property: ContentScriptStylesRuntimeProperty, test: entry => entry === "content", }), - new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, fullMapEntrypoint: "background", From 822cca363bb787dceee052a91955ba564f8a421c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:15:01 +0300 Subject: [PATCH 8/9] feat!: configure content isolation boundaries and render targets Expose typed boundaries in content and Relay render props. Accept a tag, DOM properties or a synchronous factory for isolated render targets, and run boundary setup with an optional cleanup function for subscriptions. Share setup policy through IsolationSetup while the nodes retain DOM, style and cleanup ownership. Preserve synchronous mounting and protect cleanup, remounts and iframe document recovery from reentrant callbacks. Cover the contract with unit, declaration and browser integration tests, and document the current API and lifecycle. BREAKING CHANGE: Remove ContentScriptFrameOptions and isolation.width / isolation.height. Set iframe dimensions and styles through boundary; iframes receive no framework width, height, border or display defaults. ContentScriptProps now includes a required boundary property, and content and Relay definitions carry an isolation type parameter. When specifying data generics explicitly for an isolated entrypoint, specify its mode too. --- .../entrypoint/parser/ContentParser.test.ts | 16 +- src/cli/entrypoint/parser/ContentParser.ts | 2 - src/cli/entrypoint/parser/RelayParser.test.ts | 2 +- .../content/invalid/frame-auto.content.ts | 2 - .../content/invalid/shadow/frame.content.ts | 2 - .../content/invalid/shadow/iframe.content.ts | 2 +- .../isolation/frame-constant.content.ts | 2 +- .../options/isolation/iframe.content.ts | 5 +- .../content/options/isolation/page.content.ts | 2 +- .../options/isolation/source.content.ts | 2 - .../plugins/content/ContentManager.test.ts | 2 +- src/cli/plugins/content/utils.test.ts | 13 +- src/cli/virtual/virtual.d.ts | 2 + src/entry/content/README.md | 215 +++++++++++--- src/entry/content/adapters/react/Builder.ts | 6 +- src/entry/content/adapters/react/Node.test.ts | 2 +- .../content/adapters/vanilla/Builder.test.ts | 207 ++++++++++++- src/entry/content/adapters/vanilla/Builder.ts | 6 +- .../content/adapters/vanilla/Node.test.ts | 2 +- src/entry/content/lifecycle/Builder.ts | 51 +++- .../content/lifecycle/IsolationSetup.test.ts | 135 +++++++++ src/entry/content/lifecycle/IsolationSetup.ts | 54 ++++ .../content/lifecycle/MountBuilder.test.ts | 81 ++++- src/entry/content/lifecycle/MountBuilder.ts | 34 ++- .../content/lifecycle/context/Context.ts | 37 ++- .../content/lifecycle/nodes/EventNode.ts | 6 +- .../content/lifecycle/nodes/FrameNode.test.ts | 278 +++++++++++++++++- .../content/lifecycle/nodes/FrameNode.ts | 155 +++++++--- .../content/lifecycle/nodes/MarkerNode.ts | 6 +- .../content/lifecycle/nodes/MountNode.ts | 6 +- .../content/lifecycle/nodes/RenderNode.ts | 5 + .../lifecycle/nodes/ShadowNode.test.ts | 210 ++++++++++++- .../content/lifecycle/nodes/ShadowNode.ts | 102 ++++++- src/entry/content/resolvers/index.ts | 1 + src/entry/content/resolvers/target.test.ts | 87 ++++++ src/entry/content/resolvers/target.ts | 41 +++ src/entry/relay/Builder.test.ts | 27 +- src/entry/relay/Builder.ts | 18 +- src/entry/relay/TransportBuilder.ts | 11 +- src/entry/relay/index.ts | 11 +- src/main/content.ts | 19 +- src/main/relay.ts | 11 +- src/shared/content/isolation.test.ts | 21 +- src/shared/content/isolation.ts | 51 ++-- src/types/content/adapters/react.ts | 16 +- src/types/content/boundary.ts | 29 ++ src/types/content/common.ts | 27 +- src/types/content/definition.ts | 77 +++-- src/types/content/index.ts | 2 + src/types/content/render.ts | 16 +- src/types/content/target.ts | 27 ++ src/types/relay.ts | 26 +- .../content/frame-pages/src/contract.ts | 4 +- .../content/frame-pages/src/page.content.ts | 21 +- .../content/frame-pages/src/source.content.ts | 21 +- .../browser/content/relay-styles-utils.ts | 2 +- .../browser/content/render-props-utils.ts | 63 +++- .../browser/content/render-props/README.md | 8 + .../render-props/src/frame.content.tsx | 15 + .../render-props/src/headless.relay.tsx | 6 +- .../content/render-props/src/probe.relay.tsx | 3 + .../render-props/src/shadow.content.tsx | 3 + .../content/render-props/src/shared/Panel.tsx | 17 +- .../render-props/src/shared/boundary.ts | 22 ++ .../render-props/src/shared/lifecycle.ts | 10 +- .../build/isolation/states/iframe.ts | 5 +- .../types/content.integration.test.ts | 8 +- .../types/fixtures/content/boundary.tsx | 80 +++++ .../types/fixtures/content/definition.tsx | 10 +- .../types/fixtures/content/prepare.tsx | 21 +- .../types/fixtures/content/target.tsx | 133 +++++++++ 71 files changed, 2283 insertions(+), 339 deletions(-) delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts create mode 100644 src/entry/content/lifecycle/IsolationSetup.test.ts create mode 100644 src/entry/content/lifecycle/IsolationSetup.ts create mode 100644 src/entry/content/resolvers/target.test.ts create mode 100644 src/entry/content/resolvers/target.ts create mode 100644 src/types/content/boundary.ts create mode 100644 src/types/content/target.ts create mode 100644 tests/integration/browser/content/render-props/src/shared/boundary.ts create mode 100644 tests/integration/types/fixtures/content/boundary.tsx create mode 100644 tests/integration/types/fixtures/content/target.tsx diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index 9d3de3a0..a22755f7 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -44,7 +44,6 @@ describe("ContentParser", () => { }); test.each([ ["iframe", /isolation.mode is not supported/], - ["frame", /isolation.height is not supported/], ["mode", /isolation.mode must be "open" or "closed"/], ["dynamic", /isolation.mode must be statically known/], ["identifier", /isolation.mode must be statically known/], @@ -82,12 +81,12 @@ describe("ContentParser", () => { }); test("resolves a local frame object without treating identifiers as aliases", () => { expect(parser.options(file("options", "isolation", "frame-constant.content.ts"))).toMatchObject({ - isolation: {type: "iframe", page: "panel", height: 320}, + isolation: {type: "iframe", page: "panel"}, }); }); - test("parses the isolation enum and frame dimensions", () => { + test("parses the isolation enum without executing boundary setup", () => { expect(parser.options(file("options", "isolation", "iframe.content.ts"))).toMatchObject({ - isolation: {type: "iframe", height: 320}, + isolation: {type: "iframe"}, }); }); test("projects the Shadow isolation mode without runtime properties", () => { @@ -99,23 +98,21 @@ describe("ContentParser", () => { }); test("resolves a local page alias", () => { expect(parser.options(file("options", "isolation", "page.content.ts"))).toMatchObject({ - isolation: {type: "iframe", page: "panel", width: "80%"}, + isolation: {type: "iframe", page: "panel"}, }); }); - test("preserves frame source and numeric/CSS dimensions in the normalized options", () => { + test("preserves the frame source in normalized options", () => { expect(parser.options(file("options", "isolation", "source.content.ts"))).toEqual({ matches: ["http://*/*", "https://*/*"], runAt: "document_idle", isolation: { type: "iframe", src: "https://example.com/panel", - width: 0, - height: "calc(100vh - 24px)", }, }); }); test.each([ - ["iframe-short", {type: "iframe", width: "100%", height: 150}], + ["iframe-short", {type: "iframe"}], ["none-object", {type: "none"}], ])("normalizes shorthand and explicit options from %s", (name, isolation) => { expect(parser.options(file("options", "isolation", name + ".content.ts"))).toMatchObject({isolation}); @@ -128,7 +125,6 @@ describe("ContentParser", () => { ["frame-identifier-page", /statically known/], ["frame-conflict", /mutually exclusive/], ["frame-render", /cannot be combined/], - ["frame-auto", /not supported yet/], ["frame-spread", /statically known/], ["isolation-dynamic", /statically known/], ["frame-dynamic-page", /statically known/], diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index d35036b0..73a596de 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -27,8 +27,6 @@ export default class ContentParser< const frameSchema = z .object({ type: z.literal(ContentScriptIsolation.Iframe), - width: z.union([z.number(), z.string()]), - height: z.union([z.number(), z.string()]), }) .strict(); diff --git a/src/cli/entrypoint/parser/RelayParser.test.ts b/src/cli/entrypoint/parser/RelayParser.test.ts index bc42bb9f..fcf9b22b 100644 --- a/src/cli/entrypoint/parser/RelayParser.test.ts +++ b/src/cli/entrypoint/parser/RelayParser.test.ts @@ -51,7 +51,7 @@ describe("RelayParser", () => { }); test("preserves Relay isolation and frame build options", () => { const options = parser.options(file("options", "isolation", "relay.ts")); - expect(options.isolation).toEqual({type: "iframe", page: "panel", width: "100%", height: 150}); + expect(options.isolation).toEqual({type: "iframe", page: "panel"}); }); test("parses the all-frame response capability from a real entrypoint file", () => { expect(parser.options(file("options", "all-frames", "relay.ts"))).toEqual( diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts deleted file mode 100644 index 22e5b174..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: {type: "iframe", height: "auto"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts deleted file mode 100644 index b415342e..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {defineContentScript} from "adnbn"; -export default defineContentScript({isolation: {type: "shadow", height: 200, mode: "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts index 369d85d5..77452afa 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts @@ -1,2 +1,2 @@ import {defineContentScript} from "adnbn"; -export default defineContentScript({isolation: {type: "iframe", height: 200, mode: "closed"}}); +export default defineContentScript({isolation: {type: "iframe", mode: "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts index d45decc4..21e673c7 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts @@ -1,4 +1,4 @@ import {defineContentScriptAppend} from "adnbn"; const page = "panel"; -const isolation = {type: "iframe", page, height: 320} as const; +const isolation = {type: "iframe", page} as const; export default defineContentScriptAppend({isolation}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts index bf7572b5..c44e81aa 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts @@ -1,5 +1,8 @@ import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ - isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + boundary: ({boundary}) => { + boundary.style.height = "320px"; + }, + isolation: {type: ContentScriptIsolation.Iframe}, }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts index 80b11a99..1b44c905 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts @@ -1,3 +1,3 @@ import {defineContentScriptAppend} from "adnbn"; const alias = "panel"; -export default defineContentScriptAppend({isolation: {type: "iframe", page: alias, width: "80%"}}); +export default defineContentScriptAppend({isolation: {type: "iframe", page: alias}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts index 6742a086..0ecfa3b1 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts @@ -4,7 +4,5 @@ export default defineContentScriptAppend({ isolation: { type: ContentScriptIsolation.Iframe, src: "https://example.com/panel", - width: 0, - height: "calc(100vh - 24px)", }, }); diff --git a/src/cli/plugins/content/ContentManager.test.ts b/src/cli/plugins/content/ContentManager.test.ts index 35da8723..034c8c9c 100644 --- a/src/cli/plugins/content/ContentManager.test.ts +++ b/src/cli/plugins/content/ContentManager.test.ts @@ -278,7 +278,7 @@ describe("ContentManager execution worlds", () => { describe("ContentManager Shadow DOM entries", () => { test("keeps every iframe variant separate even with concatenation enabled", async () => { - const frames = [undefined, {height: 200}, {page: "panel"}, {src: "https://example.com/panel"}]; + const frames = [undefined, {}, {page: "panel"}, {src: "https://example.com/panel"}]; const items: ContentItems = new Map( frames.map((frame, index) => [ `frame-${index}`, diff --git a/src/cli/plugins/content/utils.test.ts b/src/cli/plugins/content/utils.test.ts index 0c312e28..cd8f131a 100644 --- a/src/cli/plugins/content/utils.test.ts +++ b/src/cli/plugins/content/utils.test.ts @@ -86,13 +86,12 @@ describe("content utils - getContentScriptConfigFromOptions", () => { }); describe("content render targets", () => { - test.each([ - {isolation: {type: "shadow"}}, - {isolation: {type: "iframe"}}, - {isolation: {type: "iframe", height: 300}}, - ])("provides an isolated local target for %j", options => { - expect(hasIsolatedTarget(options)).toBe(true); - }); + test.each([{isolation: {type: "shadow"}}, {isolation: {type: "iframe"}}])( + "provides an isolated local target for %j", + options => { + expect(hasIsolatedTarget(options)).toBe(true); + } + ); test.each([ {isolation: {type: "iframe", page: "panel"}}, diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index f8455439..67bef56f 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -85,6 +85,8 @@ declare module "virtual:relay-entrypoint" { prepare, render, container, + boundary, + target, watch, includeBrowser, excludeBrowser, diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 157c9365..dc72b8d7 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -33,7 +33,7 @@ lifecycle events. Await asynchronous discovery and preparation through `builder. watch strategy's `update()` callback. The public `src/content/index.ts` entrypoint explicitly exports these tools from their runtime owners. -Definition helpers remain in `src/main/content.ts`; normalization and mounting helpers remain internal. +Definition helpers live in `src/main/content.ts`; normalization and mounting helpers are internal. Runtime code imports implementations directly, without depending on the public `adnbn/content` facade. The separate `adnbn/entry/content` entrypoint supplies the builder, startup function, and definition resolver required by generated modules. @@ -44,6 +44,7 @@ resolver required by generated modules. `lifecycle/MountBuilder.ts` is a concrete builder that composes mounting and isolation without a UI renderer. React and Vanilla extend it with rendering. Related implementations and their tests live together: +- `lifecycle/IsolationSetup.ts`: prepares current props, invokes boundary/target handlers, and protects user cleanup. - `lifecycle/nodes`: host mounting, node decorators, ShadowRoot/iframe targets, and the styles-runtime helper. - `lifecycle/markers`: anchor marking and lookup strategies. - `lifecycle/context`: the node collection, lifecycle operations, and event subscriptions. @@ -54,18 +55,19 @@ renderer. React and Vanilla extend it with rendering. Related implementations an Pure structural validation and the frame-navigation predicate live in `src/shared/content/isolation.ts`. CLI and runtime import this shared module independently; the parser does not import runtime resolvers. -A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. The runtime +A blank iframe uses its React/Vanilla adapter with the shared `FrameNode`. The runtime `adnbn/entry/content` import resolves to the common builder without importing renderer adapters. Internally, `virtual:content-builder` selects either that builder or a renderer adapter. The selected entrypoint exports both its builder and `resolveDefinition`; the generated content module imports them together. `.tsx`/`.jsx` selects React, while the other currently supported script extensions select Vanilla. `isolation.page`/`isolation.src` selects the common builder regardless of the filename, for both -Content and Relay. Scripts with only `main` retain the filename-based selection. +Content and Relay. Scripts with only `main` use the filename-based selection. Each adapter interprets its own default export before shared code merges options. Default options override named options; a recognized default render value overrides a named `render`. React uses -`isValidElement` to recognize elements without exposing that dependency to Vanilla or the common runtime. This -refactor preserves the existing render forms; it does not expand rendering to every value in `ReactNode`. +`isValidElement` to recognize elements without exposing that dependency to Vanilla or the common runtime. +For React UI, pass a React element or component function. Component functions are invoked by React +and may use hooks. Vanilla renders DOM elements, nonempty strings, and numbers. `mergeDefinition` combines exports using the selected resolver's interpretation of default values. The common `resolveDefinition` accepts configuration without recognizing framework components; adapters provide their own definition resolvers. The common builder accepts absent rendering and literal `render: true`; UI rendering requires an adapter. Vanilla keeps its value check and @@ -76,12 +78,14 @@ The CLI `ContentParser.ts` and its test live beside the other parsers in `src/cl Content fixtures live in `parser/tests/fixtures/content`. The helper in `parser/utils/content/default-render.ts` interprets source metadata from `entrypoint/file` without loading UI runtimes. In particular, it rejects known default render exports with frame navigation, including explicit element objects. -The common runtime receives this build-validated configuration and retains the runtime rejection of an explicit -`render` property. Relay keeps its separate default-export interpretation as transport initialization. +The common runtime also rejects an explicit `render` property with frame navigation. +Relay interprets its default export as transport initialization. The common lifecycle wraps each complete node in `EventNode`, including any adapter renderer, before adding it to the context. Mount/unmount events follow the underlying node operations. Without render, -`main` still runs and context cleanup remains available. Anchor processing starts for rendering, `prepare`, headless tracking, or page/src navigation. `FrameNode` continues to own iframe creation, navigation, and child-document recovery. +`main` runs and context cleanup is available. Anchor processing starts for rendering, `prepare`, +headless tracking, or page/src navigation. `FrameNode` owns iframe creation, navigation, and +child-document recovery. When adding a runtime adapter, implement its definition and render resolvers and expose the definition resolver from that adapter's `index.ts`. Extend filename/build support and parser metadata interpretation @@ -92,6 +96,8 @@ Content contracts live in `src/types/content`: - `common.ts` owns shared options, isolation, props, containers, markers, nodes, and lifecycle contracts. - `prepare.ts` owns per-anchor preparation inputs, results, and handlers. +- `boundary.ts` maps isolation modes to their DOM boundaries and owns setup props, handlers, and cleanup. +- `target.ts` owns target descriptions and synchronous target factory contracts. - `definition.ts` composes entrypoint definitions and preserves the frame-navigation restrictions. - `adapters/vanilla.ts` and `adapters/react.ts` describe each adapter's render values. - `adapters/index.ts` exports the types of all adapters for the shared render contract and public exports. @@ -100,8 +106,8 @@ Content contracts live in `src/types/content`: Keep adapter-internal imports pointed at the owning file. To add an adapter's types, define its render values under `adapters`, export them from `adapters/index.ts`, and include them in `render.ts`. -`defineContentScript` and `defineContentScriptAppend` continue to accept the combined contract from -`adnbn`; runtime adapter selection remains based on the entrypoint filename. Adding types does not +`defineContentScript` and `defineContentScriptAppend` accept the combined contract from +`adnbn`; runtime adapter selection is based on the entrypoint filename. Adding types does not implement the adapter or its build support. ## Preparation and render props @@ -117,10 +123,11 @@ mounting/rendering are collected and logged as an `AggregateError` after each pr remain available, and a failure on the first pass does not prevent the watcher from starting. The same policy applies to later passes. Use `watch: true` to keep processing new anchors; the default strategy stops watching once it has tracked nodes. A failed anchor is not added as a headless result. -Initialization errors, such as a rejected marker factory or `main`, still reject the build. React +Initialization errors, such as a rejected marker factory or `main`, reject the build. React component errors during React's scheduled rendering follow React's error handling. ```tsx title="src/product.content.tsx" +import React from "react"; import {defineContentScript} from "adnbn"; import {getProduct} from "./product-service"; import {ProductCard} from "./ProductCard"; @@ -141,15 +148,15 @@ export default defineContentScript({ }); ``` -`ContentScriptProps` contains `anchor`, `data`, `container`, `target`, and entrypoint options. +`ContentScriptProps` contains `anchor`, `data`, `container`, `boundary`, `target`, and entrypoint options. `container` is the outer mounted element; `target` is the actual UI destination. They are equal without isolation. Shadow rendering uses an inner element inside its root; iframe rendering uses -an element inside the child document. Use `target.getRootNode()` and `target.ownerDocument` when -working with portals, including closed Shadow DOM. A custom detached mount can return an element -as the root, so do not assume `getRootNode()` always returns a Document or ShadowRoot. +an element inside the child document. `boundary` is the `ShadowRoot` (including closed roots), the +`HTMLIFrameElement`, or `undefined` without isolation. Use `target` for portals and +`target.ownerDocument` for its document. The outer `container` remains available for placement and styling. Container factories receive `ContentScriptContainerProps`: anchor, options, and prepared data, -without a container or target. React components are invoked by React with the completed render props. +without a container, boundary, or target. React components are invoked by React with the completed render props. Render handlers are synchronous; asynchronous requests and decisions belong in `prepare`. An empty Vanilla result releases that UI and leaves the anchor tracked; use `prepare` for decisions that must precede DOM creation. @@ -166,6 +173,125 @@ replaces its own target cannot subsequently insert UI into the discarded target ## Isolation +### Boundary setup and cleanup + +`boundary` is a synchronous setup handler for an existing isolation node. It is available with +Shadow DOM and all iframe variants, including `isolation.page` and `isolation.src`, and is forbidden +without isolation. The handler receives `ContentScriptBoundaryProps`: `anchor`, +prepared `data`, the outer `container`, the typed `boundary`, and entrypoint options. It does not +receive an inner `document` or `target`, which are not ready at this stage. `prepare` may compute +layout values asynchronously; the boundary handler applies them after isolation exists. + +The lifecycle calls it once for each newly created boundary: after `attachShadow()` for Shadow DOM, +or after creating the iframe but before assigning `src` and inserting it. +Iframe geometry and its child document are not ready during setup. Use `target`/render or lifecycle +events for work that needs the mounted UI. Page and URL navigation remains owned by `isolation`. + +Return nothing, or a synchronous cleanup function to unsubscribe listeners and observers: + +```ts title="src/panel.content.ts" +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + isolation: "shadow", + + boundary: ({boundary}) => { + const onProbe = () => console.info("Boundary event"); + boundary.addEventListener("panel-probe", onProbe); + + return () => boundary.removeEventListener("panel-probe", onProbe); + }, + + render: () => "Panel", +}); +``` + +Cleanup runs once before removal, including rollback after a synchronous target/render error. A cleanup error +is logged without preventing the remaining nodes from being released. Mount requests made while +unmounting the context do not recreate UI. The handler reruns for a new boundary on full remount; +intact mounts and replacement of the document/target inside the same iframe keep the existing setup +and subscriptions. `prepare: () => false` and headless `render: true` never create a boundary or invoke +its handler. Setup errors follow the same per-anchor error handling as target creation. + +`mount` controls container placement; `boundary` configures isolation; `target` creates the inner +render destination. These handlers are runtime behavior, not serialized entrypoint configuration. +Content, append content, and Relay share the same contract and inferred boundary types. + +Internally, `MountBuilder` creates one `IsolationSetup` instance per isolated anchor and passes it to +`ShadowNode` or `FrameNode`. It reads current props and the container through callbacks, so remounts +use the replacement container. Its `setup()` and `createTarget()` operations stay independent: +iframe document recovery creates a new target without repeating boundary setup. The isolation nodes +retain cleanup ownership, DOM insertion, style registration, and checks for synchronous unmounts. + +### Target configuration and boundary access + +The `target` option controls the inner render element without changing the outer container. It accepts +a tag name, an object with `tagName` and DOM properties, or a synchronous factory. The default is `div`. +Use it with Shadow DOM or an iframe rendering local UI. It is unavailable without isolation or with +`isolation.page`/`isolation.src`, where there is no local render target. + +```tsx title="src/panel.content.tsx" +import React from "react"; +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + anchor: "article", + isolation: {type: "iframe"}, + prepare: ({anchor}) => ({title: anchor.getAttribute("aria-label") ?? "Panel"}), + + boundary: ({boundary, container, data}) => { + boundary.title = data.title; + boundary.style.height = "240px"; + container.setAttribute("data-panel", "ready"); + }, + + target: ({document}) => { + const element = document.createElement("section"); + element.className = "panel-root"; + + return element; + }, + + render: ({data, boundary}) => ( + + {data.title} · {boundary.title} + + ), +}); +``` + +For a simple Shadow target, use `isolation: "shadow", target: "span"` or +`target: {tagName: "section", className: "panel-root"}`. Object options are DOM properties; +set styles through a factory's element or `boundary`. In Shadow mode, style the host through +`container` or `boundary.host`; the root itself is not a styled element. + +The factory receives `ContentScriptTargetProps`: `anchor`, prepared `data`, +entrypoint options, the mounted `container`, `boundary`, and `document`. The document is supplied +in both modes: the container's document for Shadow, and the child document for iframe. Return a new, +detached element from this document, a tag name, or a properties object. The lifecycle inserts it and +manages its removal. Async work belongs in `prepare`; a Promise or an invalid factory result is rejected. + +The factory runs after the isolation boundary exists and before rendering. It runs again for a new +target after remount or iframe document recovery, retaining the prepared data. Mounting an intact target +does not rerun it. `render: true` and `prepare: () => false` do not invoke it or create isolation DOM. +Factory failures release the partially mounted UI and follow the per-anchor error policy above. + +Inline factories and render functions infer the boundary type from string, enum, or object isolation. +For a reusable Shadow component, annotate its props as `ContentScriptProps`; +use `"iframe"` for an iframe component. Omitting the second parameter allows all boundary types and +requires narrowing before using mode-specific properties. The same parameters apply to +`ContentScriptDefinition` and `ContentScriptTargetFactory`. +When supplying generic arguments to a define helper explicitly, provide both data and mode, for example +`defineContentScript({...})`; otherwise let the helper infer them from `prepare` and `isolation`. + +Context nodes and lifecycle callbacks also expose `node.boundary`, including navigation iframes. +It is available at Mount and cleared at Unmount, so read it from the current node after remount instead +of retaining an old reference. These shared context APIs can contain different modes and expose the union +`ShadowRoot | HTMLIFrameElement | undefined`; narrow it before using mode-specific APIs. A navigation +iframe's boundary does not grant access to a cross-origin child document. + +### Isolation options + `isolation` accepts `ContentScriptIsolation.None`, `Shadow`, or `Iframe`, or their string values `"none"`, `"shadow"`, and `"iframe"`. For additional options, use an object with a required `type` containing the same enum or string value. The default is `None`. Isolation changes the render target; @@ -179,22 +305,28 @@ import Panel from "./Panel"; export default defineContentScriptAppend({ matches: ["https://example.com/*"], anchor: ".product", - isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + isolation: {type: ContentScriptIsolation.Iframe}, + + boundary: ({boundary}) => { + boundary.style.height = "320px"; + }, + render: Panel, }); ``` -| Type | Target | Additional isolation options | -| ----------------------- | ---------------------------------------------------- | ------------------------------------ | -| None | Host container | None | -| Shadow | Inner element in a ShadowRoot | Optional `mode`, default `open` | -| Iframe without page/src | Inner element in a blank iframe document | Optional `width` and `height` | -| Iframe with page/src | Embedded document owns its UI; `render` is forbidden | `page` or `src`, optional dimensions | +| Type | Target | Additional isolation options | +| ----------------------- | ---------------------------------------------------- | ------------------------------- | +| None | Host container | None | +| Shadow | Inner element in a ShadowRoot | Optional `mode`, default `open` | +| Iframe without page/src | Inner element in a blank iframe document | None | +| Iframe with page/src | Embedded document owns its UI; `render` is forbidden | `page` or `src` | -`isolation.width` and `isolation.height` accept pixels as numbers or CSS strings. Defaults are `100%` and -`150px`, with no border and `display: block`. Automatic height is not implemented; `height: "auto"` -produces an error. The blank iframe has no `src` attribute. The framework creates the host and target, -so a custom `container` factory is optional. +The framework does not assign iframe dimensions or inline styles. Configure dimensions, borders, +display, and other DOM properties through the `boundary` handler before insertion/navigation. +Browser defaults and page styles apply unless you override them. CSS sizes use their normal string +values; automatic content-based iframe sizing is not provided. The blank iframe has no `src` +attribute. The framework creates the host and target, so a custom `container` factory is optional. Import UI styles with `?isolation` and declare fonts in CSS as described below. Relay supports the same isolation and frame variants; its RPC transport and all-frame addressing remain independent @@ -217,7 +349,7 @@ export default defineContentScriptAppend({ `mode` accepts `ContentScriptShadowMode.Open` / `Closed` and the string literals `"open"` / `"closed"`. The shorthand `isolation: "shadow"` and `{type: "shadow"}` both keep the default `open` behavior. -`mode` is only valid for Shadow; iframe dimensions and page/src are only valid for Iframe. +`mode` is only valid for Shadow; `page` and `src` are only valid for Iframe. In closed mode, `host.shadowRoot` returns `null`. The framework retains its own reference, so `node.target`, initial and lazy CSS, renderer cleanup, and remount still work. The mode is selected @@ -234,7 +366,11 @@ import {defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ matches: ["https://example.com/*"], - isolation: {type: "iframe", page: "panel", height: 320}, + isolation: {type: "iframe", page: "panel"}, + + boundary: ({boundary}) => { + boundary.style.height = "320px"; + }, }); ``` @@ -265,7 +401,8 @@ keys are supported. Unresolved identifiers, function calls, methods and circular options produce an error naming the field and source file; the CLI never executes entrypoint code to compute these values. Only build properties are evaluated, so runtime functions such as `render` and `main` remain untouched. CLI and runtime both normalize shorthand values to an object -with `type` and the mode/dimension defaults; runtime rendering values are not evaluated by the CLI. +with `type`; Shadow DOM also receives the default `mode: "open"` when omitted. Runtime handlers, +including `prepare`, `boundary`, and `target`, are not evaluated by the CLI. ## Execution worlds and lifecycle @@ -273,7 +410,7 @@ Shadow, blank iframe, extension URLs and `isolation.page` require effective `ISO `isolation.src` also supports `MAIN`. MV2 normalizes requested `MAIN` to `ISOLATED` with a build warning before grouping and bundling. Unsupported MV3 combinations fail the build. -In a blank iframe, React/Vanilla JavaScript still executes in the content-script runtime and renders +In a blank iframe, React/Vanilla JavaScript executes in the content-script runtime and renders into the child document. This is visual/document isolation, not a separate JavaScript security boundary. Code using `document`, portals or document-level listeners must deliberately use `node.target.ownerDocument` when it intends to address the child document. @@ -303,13 +440,15 @@ if it must survive. For page/src, reload remains ordinary browser navigation beh ## Styles and fonts -Shadow and all iframe entries bypass `concatContentScripts`. `commonChunks` is still supported. +Shadow and all iframe entries bypass `concatContentScripts`. They support `commonChunks`. Plain CSS/SCSS imports keep document delivery: initial CSS goes into `content_scripts.css`; lazy CSS loads into the page only when its `import()` runs. UI isolation does not change plain imports. ```ts title="src/panel.content/index.ts" import "./host.css?asis"; import styles from "./panel.module.css?isolation"; + +export {}; ``` `?isolation` sends CSS to ShadowRoot or the blank iframe document. Combine it with `?asis` as @@ -319,7 +458,7 @@ its own destination. Initial isolated CSS is excluded from `content_scripts.css` and exposed through WAR. Each root or iframe head receives its own links in asset-map order. A shared CSS file can remain in an ordinary -consumer's manifest and also be linked by an isolated consumer. `getEntrypointAssets()` retains every +consumer's manifest and also be linked by an isolated consumer. `getEntrypointAssets()` includes every CSS file in `initial.css` / `async.css`. The isolated styles runtime owns CSS routing; the asset map does not expose delivery-specific subsets. With `isolation: None`, marked CSS loads normally into the page. Outside content and Relay, including @@ -356,8 +495,8 @@ Rendering starts immediately, so briefly unstyled UI is possible. Lazy CSS is re `import()`; mixed imports wait for both document CSS and targets active at that request's start. Late targets receive initial and already requested lazy CSS. Imports before the first target do not wait for future UI. Failed or timed-out lazy links reject the import and permit retry; timeout follows `output.chunkLoadTimeout`. -Initial CSS errors identify the entrypoint and URL but do not remove UI. There is no inline-CSS or -constructed-stylesheet fallback. Registries belong to each entry runtime, not `window`; no carrier, +Initial CSS errors identify the entrypoint and URL but do not remove UI. Styles are delivered through +external stylesheet links. Registries belong to each entry runtime, not `window`; no carrier, JSON map or background bundle is injected into other entries. For Shadow, declare `@font-face` in ordinary document CSS, then use that family inside `?isolation` @@ -392,9 +531,9 @@ depend on network, privacy choices and page CSP and are not a supported replacem ## Verification -Chrome 155 MV3 and Firefox 155 MV2/MV3 integration fixtures exercise strict CSP, initial and lazy CSS, +Chrome MV3 and Firefox MV2/MV3 integration fixtures exercise strict CSP, initial and lazy CSS, React and Vanilla, multiple entries/targets, real font use measured by text width, iframe movement, cleanup/remount, extension pages, and allowed/blocked external embedding. Reports record exact browser versions under `.cache/integration`. The build watch test covers isolation transitions, fonts, page -alias changes and removal of outdated WAR rules. The local `addon` playground has native Shadow and -iframe panels with separate resource statuses. +alias changes and removal of outdated WAR rules. The `addon` playground exercises content and Relay +on YouTube and Instagram, including Shadow DOM, iframe rendering, headless tracking, and remount controls. diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index ef1db493..dcdd4c7f 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -4,12 +4,16 @@ import {createRenderResolver} from "./resolvers/render"; import type { ContentScriptNode, + ContentScriptIsolation, ContentScriptProps, ContentScriptRenderHandler, ContentScriptRenderValue, } from "@typing/content"; -export default class Builder extends MountBuilder { +export default class Builder< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends MountBuilder { protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler ): ContentScriptRenderHandler | undefined { diff --git a/src/entry/content/adapters/react/Node.test.ts b/src/entry/content/adapters/react/Node.test.ts index e38ca296..3488133a 100644 --- a/src/entry/content/adapters/react/Node.test.ts +++ b/src/entry/content/adapters/react/Node.test.ts @@ -43,7 +43,7 @@ describe("ReactNode", () => { const reactNode = new ReactNode( node, () => component, - () => ({anchor, container, target, data: undefined}) + () => ({anchor, container, target, data: undefined, boundary: undefined}) ); expect(reactNode.mount()).toBe(true); diff --git a/src/entry/content/adapters/vanilla/Builder.test.ts b/src/entry/content/adapters/vanilla/Builder.test.ts index a0f960b2..05310cc8 100644 --- a/src/entry/content/adapters/vanilla/Builder.test.ts +++ b/src/entry/content/adapters/vanilla/Builder.test.ts @@ -1,7 +1,14 @@ import Builder from "./Builder"; import {resolveDefinition} from "./resolvers/definition"; import {defineContentScript, defineContentScriptAppend} from "@main/content"; -import {ContentScriptAppend, ContentScriptEvent, type ContentScriptProps} from "@typing/content"; +import { + ContentScriptAppend, + ContentScriptEvent, + type ContentScriptProps, + type ContentScriptBoundaryProps, + type ContentScriptTargetProps, + type ContentScriptMainFunction, +} from "@typing/content"; // Extend the shared random-ID mock for the builders' generated marker attribute. jest.mock("nanoid", () => ({ @@ -9,6 +16,10 @@ jest.mock("nanoid", () => ({ customAlphabet: () => () => "contentmarker", })); +jest.mock("../../lifecycle/nodes/isolated-styles", () => ({ + getContentScriptStylesRuntime: () => ({add: jest.fn(), delete: jest.fn()}), +})); + describe("Vanilla Builder", () => { afterEach(() => document.body.replaceChildren()); @@ -202,6 +213,102 @@ describe("Vanilla preparation and render props", () => { } }); + test("creates a custom target with prepared data and exposes the same closed boundary throughout the lifecycle", async () => { + const anchor = createAnchor(); + const data = {title: "Prepared"}; + const prepare = jest.fn(() => data); + const calls: ContentScriptTargetProps[] = []; + const rendered: ContentScriptProps[] = []; + const boundaries: (ShadowRoot | HTMLIFrameElement | undefined)[] = []; + + const main = jest.fn, Parameters>(context => { + context.watch((_event, node) => boundaries.push(node.boundary)); + }); + + const builder = new Builder( + defineContentScript({ + anchor, + isolation: {type: "shadow", mode: "closed"}, + prepare, + main, + + target: props => { + calls.push(props); + props.container.setAttribute("data-title", props.data.title); + + return {tagName: "span", className: "custom-target"}; + }, + + render: props => { + rendered.push(props); + + return props.data.title; + }, + }) + ); + + try { + await builder.build(); + const context = builder.getContext(); + const [node] = context.nodes; + const first = calls[0]; + + expect(first).toMatchObject({anchor, data, document, container: node.container, boundary: node.boundary}); + expect(first.boundary.host).toBe(first.container); + expect(first.container.shadowRoot).toBeNull(); + expect(first.container.getAttribute("data-title")).toBe("Prepared"); + expect(first).not.toHaveProperty("target"); + expect(first).not.toHaveProperty("prepare"); + expect(main.mock.calls[0][1]).not.toHaveProperty("target"); + expect(rendered[0]).toMatchObject({boundary: first.boundary, target: node.target}); + expect(node.target?.outerHTML).toBe('Prepared'); + expect(boundaries).toEqual([undefined, first.boundary]); + context.mount(); + expect(calls).toHaveLength(1); + context.unmount(); + expect(node.boundary).toBeUndefined(); + expect(boundaries.at(-1)).toBeUndefined(); + context.mount(); + expect(calls).toHaveLength(2); + expect(calls[1].boundary).not.toBe(first.boundary); + expect(calls[1].container).not.toBe(first.container); + expect(calls[1].data).toBe(data); + expect(rendered[1].boundary).toBe(node.boundary); + expect(prepare).toHaveBeenCalledTimes(1); + } finally { + await builder.destroy(); + } + }); + + test.each(["headless", "disabled"])("%s never calls the target factory", async mode => { + const anchor = createAnchor(); + const target = jest.fn(() => "span" as const); + const builder = new Builder( + defineContentScript({ + anchor, + isolation: "shadow", + target, + prepare: () => (mode === "disabled" ? false : undefined), + render: mode === "headless" ? true : () => "UI", + }) + ); + + try { + await builder.build(); + const context = builder.getContext(); + const [node] = context.nodes; + context.unmount(); + context.mount(); + expect(target).not.toHaveBeenCalled(); + expect(node.boundary).toBeUndefined(); + expect(node.target).toBeUndefined(); + expect(node.container).toBeUndefined(); + expect(anchor.childElementCount).toBe(0); + } finally { + await builder.destroy(); + } + }); + test("false tracks anchors without UI or retries while other anchors receive their own data", async () => { const allowed = createAnchor(); const denied = createAnchor(); @@ -294,7 +401,7 @@ describe("Vanilla preparation and render props", () => { } }); - test.each(["prepare", "container", "render"])( + test.each(["prepare", "container", "target", "render"])( "reports per-anchor %s errors and continues processing initial and watched anchors", async stage => { jest.useFakeTimers(); @@ -307,6 +414,7 @@ describe("Vanilla preparation and render props", () => { const builder = new Builder( defineContentScript({ anchor: "article", + isolation: "shadow", watch: true, prepare: async ({anchor}) => { @@ -325,6 +433,14 @@ describe("Vanilla preparation and render props", () => { return document.createElement("section"); }, + target: ({anchor, document}) => { + if (stage === "target" && failures.has(anchor)) { + throw error; + } + + return document.createElement("span"); + }, + render: ({anchor, data}) => { if (stage === "render" && failures.has(anchor)) { throw error; @@ -341,7 +457,7 @@ describe("Vanilla preparation and render props", () => { expect(report).toHaveBeenLastCalledWith(expect.any(AggregateError)); expect(report.mock.calls[0][0].errors).toEqual([error, error]); expect(builder.getContext().nodes.size).toBe(1); - expect(initial.textContent).toBe("content"); + expect(initial.firstElementChild?.shadowRoot?.textContent).toBe("content"); for (const anchor of failed) { expect(anchor.childElementCount).toBe(0); @@ -352,7 +468,7 @@ describe("Vanilla preparation and render props", () => { failures.add(rejected); const next = createAnchor(); await jest.advanceTimersByTimeAsync(201); - expect(next.textContent).toBe("content"); + expect(next.firstElementChild?.shadowRoot?.textContent).toBe("content"); expect(rejected.childElementCount).toBe(0); expect(builder.getContext().nodes.size).toBe(2); expect(report).toHaveBeenCalledTimes(2); @@ -362,7 +478,7 @@ describe("Vanilla preparation and render props", () => { rejected.remove(); const last = createAnchor(); await jest.advanceTimersByTimeAsync(201); - expect(last.textContent).toBe("content"); + expect(last.firstElementChild?.shadowRoot?.textContent).toBe("content"); expect(builder.getContext().nodes.size).toBe(3); } finally { await builder.destroy(); @@ -538,3 +654,84 @@ test("simultaneous builds prepare each anchor only in the latest lifecycle", asy anchor.remove(); } }); + +test.each(["shadow", "iframe"] as const)( + "boundary cleanup cannot remount an old %s renderer or stop other cleanups", + async isolation => { + const anchors = [document.createElement("article"), document.createElement("article")]; + anchors.forEach(anchor => anchor.classList.add("boundary-test")); + document.body.append(...anchors); + const cleanup = jest.fn(); + const render = jest.fn(() => "Panel"); + const error = new Error("Cleanup failed"); + const logged = jest.spyOn(console, "error").mockImplementation(() => {}); + const options = { + anchor: ".boundary-test", + prepare: (props: {anchor: Element}) => ({label: props.anchor.tagName}), + boundary: (props: ContentScriptBoundaryProps<{label: string}>) => { + expect(props.data.label).toBe("ARTICLE"); + expect(props.container.isConnected).toBe(true); + expect(props).not.toHaveProperty("target"); + expect(props).not.toHaveProperty("document"); + expect(props).not.toHaveProperty("prepare"); + + return () => { + cleanup(); + builder.getContext().mount(); + throw error; + }; + }, + render, + }; + + const builder = + isolation === "shadow" + ? new Builder(defineContentScript({...options, isolation: "shadow"})) + : new Builder(defineContentScript({...options, isolation: "iframe"})); + + try { + await builder.build(); + expect(render).toHaveBeenCalledTimes(2); + builder.getContext().unmount(); + expect(cleanup).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(2); + expect(logged).toHaveBeenCalledWith("Content script boundary cleanup failed", error); + expect(anchors.every(anchor => anchor.childElementCount === 0)).toBe(true); + } finally { + await builder.destroy(); + logged.mockRestore(); + anchors.forEach(anchor => anchor.remove()); + } + + expect(cleanup).toHaveBeenCalledTimes(2); + } +); + +test("boundary cleanup does not hide the original render failure", async () => { + const original = new Error("Render failed"); + const cleanup = new Error("Cleanup failed"); + const logged = jest.spyOn(console, "error").mockImplementation(() => {}); + const builder = new Builder( + defineContentScript({ + anchor: document.body, + isolation: "shadow", + boundary: () => () => { + throw cleanup; + }, + render: () => { + throw original; + }, + }) + ); + + try { + await builder.build(); + expect(logged).toHaveBeenCalledWith("Content script boundary cleanup failed", cleanup); + const aggregate = logged.mock.calls.find(([error]) => error instanceof AggregateError)?.[0] as AggregateError; + expect(aggregate.errors).toEqual([original]); + expect(builder.getContext().nodes.size).toBe(0); + } finally { + await builder.destroy(); + logged.mockRestore(); + } +}); diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index ef1db493..dcdd4c7f 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -4,12 +4,16 @@ import {createRenderResolver} from "./resolvers/render"; import type { ContentScriptNode, + ContentScriptIsolation, ContentScriptProps, ContentScriptRenderHandler, ContentScriptRenderValue, } from "@typing/content"; -export default class Builder extends MountBuilder { +export default class Builder< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends MountBuilder { protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler ): ContentScriptRenderHandler | undefined { diff --git a/src/entry/content/adapters/vanilla/Node.test.ts b/src/entry/content/adapters/vanilla/Node.test.ts index 7436514a..7d531c62 100644 --- a/src/entry/content/adapters/vanilla/Node.test.ts +++ b/src/entry/content/adapters/vanilla/Node.test.ts @@ -28,7 +28,7 @@ describe("VanillaNode", () => { const vanillaNode = new VanillaNode( node, () => value, - () => ({anchor, container, target, data: undefined}) + () => ({anchor, container, target, data: undefined, boundary: undefined}) ); expect(vanillaNode.mount()).toBe(true); diff --git a/src/entry/content/lifecycle/Builder.ts b/src/entry/content/lifecycle/Builder.ts index c249f4f6..5e59accd 100644 --- a/src/entry/content/lifecycle/Builder.ts +++ b/src/entry/content/lifecycle/Builder.ts @@ -7,6 +7,7 @@ import { createAnchorResolver, createAwaitFirstStrategy, createContainerResolver, + createTargetResolver, withLocationTracking, createAppendMountHandler, createMutationObserverStrategy, @@ -14,7 +15,7 @@ import { import {ManagedContext, EventEmitter} from "./context"; import {AttributeMarker, WeakMarker} from "./markers"; -import EventNode from "./nodes/EventNode"; +import {EventNode} from "./nodes"; import { ContentScriptAnchor, @@ -26,6 +27,7 @@ import { ContentScriptContainerTag, ContentScriptContext, ContentScriptDefinition, + ContentScriptIsolation, ContentScriptMarker, ContentScriptMarkerContract, ContentScriptMarkerGetter, @@ -41,7 +43,13 @@ import { ContentScriptWatchStrategy, } from "@typing/content"; -export default abstract class Builder extends EntrypointBuilder implements ContentScriptBuilder { +export default abstract class Builder< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> + extends EntrypointBuilder + implements ContentScriptBuilder +{ private lock = new AwaitLock(); protected generation = 0; @@ -58,17 +66,37 @@ export default abstract class Builder extends EntrypointBuilder protected abstract createNode(anchor: Element, data: Data, enabled: boolean): Promise; - protected constructor(definition: ContentScriptDefinition) { + protected constructor(input: ContentScriptDefinition) { super(); + // Normalization selects the isolation at runtime; its node supplies the matching callback props. + const definition = input as ContentScriptDefinition; const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); + if (definition.boundary !== undefined) { + if (typeof definition.boundary !== "function") { + throw new Error("Content script boundary must be a synchronous setup handler"); + } + + if (isolation.type === "none") { + throw new Error("Content script boundary requires Shadow DOM or an iframe"); + } + } + + if ( + definition.target !== undefined && + (isolation.type === "none" || isContentScriptFrameNavigation(isolation)) + ) { + throw new Error("Content script target requires Shadow DOM or an iframe with local rendering"); + } + this.definition = { ...definition, marker: this.resolveMarker(definition.marker), anchor: this.resolveAnchor(definition.anchor), mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), + target: createTargetResolver(definition.target), render: definition.render === true ? true : this.resolveRender(definition.render), isolation, watch: this.resolveWatch(definition.watch), @@ -147,7 +175,8 @@ export default abstract class Builder extends EntrypointBuilder return; } - const {render, prepare, main, anchor, marker, container, watch, mount, ...options} = this.definition; + const {render, prepare, main, anchor, marker, container, boundary, target, watch, mount, ...options} = + this.definition; const resolvedMarker = await marker(options); @@ -202,7 +231,19 @@ export default abstract class Builder extends EntrypointBuilder } protected getPrepareProps(anchor: Element): ContentScriptPrepareProps { - const {anchor: _, marker, mount, watch, prepare, render, container, main, ...options} = this.definition; + const { + anchor: _, + marker, + mount, + watch, + prepare, + render, + container, + boundary, + target, + main, + ...options + } = this.definition; return {...options, anchor}; } diff --git a/src/entry/content/lifecycle/IsolationSetup.test.ts b/src/entry/content/lifecycle/IsolationSetup.test.ts new file mode 100644 index 00000000..4a7a2e02 --- /dev/null +++ b/src/entry/content/lifecycle/IsolationSetup.test.ts @@ -0,0 +1,135 @@ +import type {ContentScriptBoundaryHandler, ContentScriptTargetProps} from "@typing/content"; + +import IsolationSetup from "./IsolationSetup"; +import {Node} from "./nodes"; +import {createTargetResolver} from "../resolvers/target"; + +test("IsolationSetup reads current props and the replacement container after remount", () => { + const anchor = document.createElement("article"); + const node = new Node(anchor, document.createElement("section")); + let data = {title: "First"}; + const boundary = jest.fn(); + + const target = jest.fn(({document, data}: ContentScriptTargetProps<{title: string}>) => { + const element = document.createElement("span"); + element.textContent = data.title; + + return element; + }); + + const setup = new IsolationSetup({ + props: () => ({anchor, data}), + container: () => node.container, + boundary, + target: createTargetResolver(target), + }); + + const firstContainer = node.container!; + const firstRoot = firstContainer.attachShadow({mode: "closed"}); + setup.setup(firstRoot); + expect(setup.createTarget(firstRoot, document).textContent).toBe("First"); + node.unmount(); + node.mount(); + data = {title: "Second"}; + + const container = node.container!; + const root = container.attachShadow({mode: "closed"}); + setup.setup(root); + const element = setup.createTarget(root, document); + + expect(container).not.toBe(firstContainer); + expect(boundary).toHaveBeenLastCalledWith({anchor, data, container, boundary: root}); + expect(target).toHaveBeenLastCalledWith({anchor, data, container, boundary: root, document}); + expect(element.textContent).toBe("Second"); + expect(element.parentNode).toBeNull(); + node.unmount(); +}); + +test("IsolationSetup uses each supplied document without repeating boundary setup or cleanup", () => { + const container = document.createElement("section"); + const frame = document.createElement("iframe"); + const cleanup = jest.fn(); + const boundary = jest.fn(() => cleanup); + + const setup = new IsolationSetup({ + props: () => ({anchor: document.body, data: undefined}), + container: () => container, + boundary, + target: createTargetResolver("section"), + }); + + const dispose = setup.setup(frame); + const firstDocument = document.implementation.createHTMLDocument("first"); + const nextDocument = document.implementation.createHTMLDocument("next"); + const first = setup.createTarget(frame, firstDocument); + const next = setup.createTarget(frame, nextDocument); + + expect(first.ownerDocument).toBe(firstDocument); + expect(next.ownerDocument).toBe(nextDocument); + expect(next).not.toBe(first); + expect(boundary).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + dispose?.(); + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +test.each(["boundary", "target"] as const)("IsolationSetup rejects a missing container during %s", stage => { + const boundary = jest.fn(); + const target = jest.fn(({document}: ContentScriptTargetProps) => document.createElement("div")); + + const setup = new IsolationSetup({ + props: () => ({anchor: document.body, data: undefined}), + container: () => undefined, + boundary, + target, + }); + + const frame = document.createElement("iframe"); + const invoke = () => (stage === "boundary" ? setup.setup(frame) : setup.createTarget(frame, document)); + + expect(invoke).toThrow(`Content script ${stage} requires a mounted container`); + expect(boundary).not.toHaveBeenCalled(); + expect(target).not.toHaveBeenCalled(); +}); + +test.each([ + {name: "a Promise", result: () => Promise.resolve()}, + {name: "an element", result: () => document.createElement("div")}, +])("IsolationSetup rejects $name returned instead of a cleanup function", ({result}) => { + const container = document.createElement("section"); + + const setup = new IsolationSetup({ + props: () => ({anchor: document.body, data: undefined}), + container: () => container, + boundary: result as unknown as ContentScriptBoundaryHandler, + target: createTargetResolver(), + }); + + expect(() => setup.setup(document.createElement("iframe"))).toThrow( + "Content script boundary must return synchronously with a cleanup function or undefined" + ); +}); + +test("IsolationSetup logs cleanup errors when the owning node invokes the returned function", () => { + const error = new Error("Cleanup failed"); + const logged = jest.spyOn(console, "error").mockImplementation(() => {}); + const container = document.createElement("section"); + + const setup = new IsolationSetup({ + props: () => ({anchor: document.body, data: undefined}), + container: () => container, + boundary: () => () => { + throw error; + }, + target: createTargetResolver(), + }); + + try { + const cleanup = setup.setup(document.createElement("iframe")); + expect(logged).not.toHaveBeenCalled(); + expect(() => cleanup?.()).not.toThrow(); + expect(logged).toHaveBeenCalledWith("Content script boundary cleanup failed", error); + } finally { + logged.mockRestore(); + } +}); diff --git a/src/entry/content/lifecycle/IsolationSetup.ts b/src/entry/content/lifecycle/IsolationSetup.ts new file mode 100644 index 00000000..78364081 --- /dev/null +++ b/src/entry/content/lifecycle/IsolationSetup.ts @@ -0,0 +1,54 @@ +import type { + ContentScriptBoundary, + ContentScriptBoundaryCleanup, + ContentScriptBoundaryHandler, + ContentScriptContainerProps, + ContentScriptTargetProps, +} from "@typing/content"; + +interface IsolationSetupOptions { + props: () => ContentScriptContainerProps; + container: () => Element | undefined; + boundary?: ContentScriptBoundaryHandler; + target: (props: ContentScriptTargetProps) => Element; +} + +/** Applies user configuration; isolation nodes own DOM, cleanup timing, and recovery. */ +export default class IsolationSetup { + public constructor(private readonly options: IsolationSetupOptions) {} + + public setup( + boundary: Exclude, undefined> + ): ContentScriptBoundaryCleanup | undefined { + const props = this.getProps("boundary"); + const cleanup = this.options.boundary?.({...props, boundary}); + + if (cleanup !== undefined && typeof cleanup !== "function") { + throw new Error("Content script boundary must return synchronously with a cleanup function or undefined"); + } + + if (cleanup) { + return () => { + try { + cleanup(); + } catch (error) { + console.error("Content script boundary cleanup failed", error); + } + }; + } + } + + public createTarget(boundary: Exclude, undefined>, document: Document): Element { + return this.options.target({...this.getProps("target"), boundary, document}); + } + + private getProps(stage: "boundary" | "target"): ContentScriptContainerProps & {container: Element} { + const container = this.options.container(); + + if (!container) { + throw new Error(`Content script ${stage} requires a mounted container`); + } + + return {...this.options.props(), container}; + } +} diff --git a/src/entry/content/lifecycle/MountBuilder.test.ts b/src/entry/content/lifecycle/MountBuilder.test.ts index 42845353..c2983e6e 100644 --- a/src/entry/content/lifecycle/MountBuilder.test.ts +++ b/src/entry/content/lifecycle/MountBuilder.test.ts @@ -72,7 +72,7 @@ describe("MountBuilder", () => { events.push({ event, connected: !!node.container?.isConnected, - src: node.container?.querySelector("iframe")?.src, + src: node.boundary && "src" in node.boundary ? node.boundary.src : undefined, }); }); }, @@ -127,6 +127,7 @@ describe("MountBuilder", () => { const prepare = jest.fn(async () => false as const); const container = jest.fn(() => document.createElement("section")); const mount = jest.fn(); + const boundary = jest.fn(); const builder = new MountBuilder( defineContentScript({ @@ -135,6 +136,7 @@ describe("MountBuilder", () => { prepare, container, mount, + boundary, }) ); @@ -151,6 +153,7 @@ describe("MountBuilder", () => { expect(prepare).toHaveBeenCalledTimes(1); expect(container).not.toHaveBeenCalled(); expect(mount).not.toHaveBeenCalled(); + expect(boundary).not.toHaveBeenCalled(); expect(anchor.childElementCount).toBe(0); } finally { await builder.destroy(); @@ -173,8 +176,21 @@ describe("MountBuilder", () => { expect(render).not.toHaveBeenCalled(); }); + test.each([undefined, "none", {type: "iframe", src: "https://example.com"}])( + "rejects JavaScript target configuration without local isolation: %p", + isolation => { + const target = jest.fn(); + const definition = resolveDefinition({default: {isolation, target}}); + + expect(() => new MountBuilder(definition)).toThrow( + "target requires Shadow DOM or an iframe with local rendering" + ); + expect(target).not.toHaveBeenCalled(); + } + ); + test("Common runtime keeps the configuration accepted by the root define helper", () => { - const options: ContentScriptDefinition = defineContentScript({ + const options: ContentScriptDefinition = defineContentScript({ isolation: {type: "iframe", src: "https://example.com"}, }); @@ -203,3 +219,64 @@ test.each(["none", "shadow", "iframe"] as const)( } } ); + +test.each([undefined, "none", {type: "none"}])("rejects boundary setup without isolation %p", isolation => { + const boundary = jest.fn(); + + expect(() => new MountBuilder(resolveDefinition({default: {isolation, boundary}}))).toThrow( + "boundary requires Shadow DOM or an iframe" + ); + expect(boundary).not.toHaveBeenCalled(); +}); + +test("rejects an invalid JavaScript boundary option without invoking it", () => { + expect(() => new MountBuilder(resolveDefinition({default: {isolation: "iframe", boundary: {height: 20}}}))).toThrow( + "boundary must be a synchronous setup handler" + ); +}); + +test("rejects asynchronous boundary setup and removes a partially created iframe", async () => { + const logged = jest.spyOn(console, "error").mockImplementation(() => {}); + const builder = new MountBuilder( + resolveDefinition({ + default: { + anchor: document.body, + isolation: {type: "iframe", src: "https://example.com"}, + boundary: async () => {}, + }, + }) + ); + + try { + await builder.build(); + expect(document.querySelector("iframe")).toBeNull(); + expect(builder.getContext().nodes.size).toBe(0); + const error = logged.mock.calls[0][0] as AggregateError; + expect(error.errors[0].message).toMatch(/boundary must return synchronously/); + } finally { + await builder.destroy(); + logged.mockRestore(); + } +}); + +test.each(["shadow", "iframe"] as const)("headless %s never invokes boundary setup", async isolation => { + const boundary = jest.fn(); + const builder = + isolation === "shadow" + ? new MountBuilder( + defineContentScript({anchor: document.body, isolation: "shadow", render: true, boundary}) + ) + : new MountBuilder( + defineContentScript({anchor: document.body, isolation: "iframe", render: true, boundary}) + ); + + try { + await builder.build(); + builder.getContext().unmount(); + builder.getContext().mount(); + expect(boundary).not.toHaveBeenCalled(); + expect([...builder.getContext().nodes][0].boundary).toBeUndefined(); + } finally { + await builder.destroy(); + } +}); diff --git a/src/entry/content/lifecycle/MountBuilder.ts b/src/entry/content/lifecycle/MountBuilder.ts index d70499e7..452a6e72 100644 --- a/src/entry/content/lifecycle/MountBuilder.ts +++ b/src/entry/content/lifecycle/MountBuilder.ts @@ -1,4 +1,5 @@ import Builder from "./Builder"; +import IsolationSetup from "./IsolationSetup"; import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; import {isContentScriptFrameNavigation} from "@shared/content"; @@ -10,8 +11,11 @@ import { ContentScriptRenderHandler, } from "@typing/content"; -export default class MountBuilder extends Builder { - public constructor(definition: ContentScriptDefinition) { +export default class MountBuilder< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends Builder { + public constructor(definition: ContentScriptDefinition) { super(definition); } @@ -20,7 +24,13 @@ export default class MountBuilder extends Builder { throw new Error("Content script render requires a container and target"); } - return {...this.getPrepareProps(node.anchor), data, container: node.container, target: node.target}; + return { + ...this.getPrepareProps(node.anchor), + data, + container: node.container, + target: node.target, + boundary: node.boundary, + }; } protected async createNode(anchor: Element, data: Data, enabled: boolean): Promise { @@ -34,17 +44,23 @@ export default class MountBuilder extends Builder { const marker = this.marker; const container = await this.definition.container({...this.getPrepareProps(anchor), data}); - let node: ContentScriptNode = new MountNode( - new MarkerNode(new Node(anchor, container), marker), - this.definition.mount - ); + const mountedNode = new MountNode(new MarkerNode(new Node(anchor, container), marker), this.definition.mount); + + const setup = new IsolationSetup({ + props: () => ({...this.getPrepareProps(anchor), data}), + container: () => mountedNode.container, + boundary: this.definition.boundary, + target: this.definition.target, + }); + + let node: ContentScriptNode = mountedNode; switch (this.definition.isolation.type) { case ContentScriptIsolation.Shadow: - node = new ShadowNode(node, this.definition.isolation); + node = new ShadowNode(mountedNode, this.definition.isolation, setup); break; case ContentScriptIsolation.Iframe: - node = new FrameNode(node, this.definition.isolation, () => this.context.mount()); + node = new FrameNode(mountedNode, this.definition.isolation, () => this.context.mount(), setup); break; } diff --git a/src/entry/content/lifecycle/context/Context.ts b/src/entry/content/lifecycle/context/Context.ts index 0da89b9a..233d5aba 100644 --- a/src/entry/content/lifecycle/context/Context.ts +++ b/src/entry/content/lifecycle/context/Context.ts @@ -8,6 +8,7 @@ import { export default class Context implements ContentScriptContext { protected readonly collection: ContentScriptNodeSet = new Set(); + private unmounting = false; constructor(protected readonly emitter: ContentScriptEventEmitter) {} @@ -16,6 +17,10 @@ export default class Context implements ContentScriptContext { } public mount(): void { + if (this.unmounting) { + return; + } + for (const node of this.collection) { if (!node.anchor.isConnected) { node.unmount(); @@ -32,14 +37,21 @@ export default class Context implements ContentScriptContext { } public unmount(): void { - for (const node of this.collection) { - node.unmount(); + const unmounting = this.unmounting; + this.unmounting = true; - if (!node.anchor.isConnected) { - this.collection.delete(node); + try { + for (const node of this.collection) { + node.unmount(); - this.emitter.emitRemove(node); + if (!node.anchor.isConnected) { + this.collection.delete(node); + + this.emitter.emitRemove(node); + } } + } finally { + this.unmounting = unmounting; } } @@ -53,10 +65,17 @@ export default class Context implements ContentScriptContext { } public clear(): void { - for (const node of this.collection) { - node.unmount(); - this.collection.delete(node); - this.emitter.emitRemove(node); + const unmounting = this.unmounting; + this.unmounting = true; + + try { + for (const node of this.collection) { + node.unmount(); + this.collection.delete(node); + this.emitter.emitRemove(node); + } + } finally { + this.unmounting = unmounting; } } diff --git a/src/entry/content/lifecycle/nodes/EventNode.ts b/src/entry/content/lifecycle/nodes/EventNode.ts index 2b8d4e19..0dac8088 100644 --- a/src/entry/content/lifecycle/nodes/EventNode.ts +++ b/src/entry/content/lifecycle/nodes/EventNode.ts @@ -1,4 +1,4 @@ -import {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; +import {ContentScriptBoundary, ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; export default class EventNode implements ContentScriptNode { private generation = 0; @@ -20,6 +20,10 @@ export default class EventNode implements ContentScriptNode { return this.node.target; } + public get boundary(): ContentScriptBoundary { + return this.node.boundary; + } + public mount(): boolean { const generation = this.generation; const result = this.node.mount(); diff --git a/src/entry/content/lifecycle/nodes/FrameNode.test.ts b/src/entry/content/lifecycle/nodes/FrameNode.test.ts index cce6cbb6..a0d33cf4 100644 --- a/src/entry/content/lifecycle/nodes/FrameNode.test.ts +++ b/src/entry/content/lifecycle/nodes/FrameNode.test.ts @@ -1,7 +1,16 @@ +import type { + ContentScriptBoundaryHandler, + ContentScriptBoundaryProps, + ContentScriptTargetProps, + ContentScriptIsolationFrameOptions, +} from "@typing/content"; + import {FrameNode, Node, MountNode} from "./index"; +import IsolationSetup from "../IsolationSetup"; import {getContentScriptStylesRuntime} from "./isolated-styles"; jest.mock("@addon-core/browser", () => ({getUrl: (file: string) => `chrome-extension://fixture/${file}`})); +jest.mock("#adnbn/page", () => ({aliases: {panel: "panel.html"}})); jest.mock("./isolated-styles", () => ({getContentScriptStylesRuntime: jest.fn()})); test("FrameNode explains a disconnected host and allows mounting again once the mounter connects it", () => { @@ -11,13 +20,24 @@ test("FrameNode explains a disconnected host and allows mounting again once the const container = document.createElement("div"); document.body.append(anchor); let connect = false; + + const mountedNode = new MountNode(new Node(anchor, container), (anchor, container) => { + if (connect) { + anchor.append(container); + } + }); + const node = new FrameNode( - new MountNode(new Node(anchor, container), (anchor, container) => { - if (connect) anchor.append(container); - }), + mountedNode, {type: "iframe"}, - () => {} + () => {}, + new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => mountedNode.container, + target: ({document}) => document.createElement("section"), + }) ); + try { expect(() => node.mount()).toThrow(/container is not connected.*mount must attach/); expect(container.querySelector("iframe")).toBeNull(); @@ -25,7 +45,9 @@ test("FrameNode explains a disconnected host and allows mounting again once the connect = true; expect(node.mount()).toBe(true); expect(node.target?.isConnected).toBe(true); + expect(node.target?.tagName).toBe("SECTION"); const frame = container.querySelector("iframe")!; + expect(frame.getAttribute("style")).toBeNull(); frame.contentDocument!.head.remove(); expect(() => node.mount()).toThrow(/no accessible document/); } finally { @@ -36,15 +58,30 @@ test("FrameNode explains a disconnected host and allows mounting again once the test("FrameNode does not require a connected host for document navigation", () => { const container = document.createElement("div"); + + const createTarget = jest.fn(({document}: ContentScriptTargetProps) => + document.createElement("span") + ); + + const mountedNode = new Node(document.createElement("section"), container); + const node = new FrameNode( - new Node(document.createElement("section"), container), + mountedNode, {type: "iframe", src: "https://example.com"}, - () => {} + () => {}, + new IsolationSetup({ + props: () => ({anchor: mountedNode.anchor, data: undefined}), + container: () => mountedNode.container, + target: createTarget, + }) ); + try { expect(node.mount()).toBe(true); expect(container.querySelector("iframe")?.src).toBe("https://example.com/"); + expect(node.boundary).toBe(container.querySelector("iframe")); expect(node.target).toBeUndefined(); + expect(createTarget).not.toHaveBeenCalled(); } finally { node.unmount(); } @@ -56,33 +93,68 @@ test("FrameNode replaces lost targets, unregisters old styles and releases its l const anchor = document.createElement("section"); document.body.append(anchor); const recover = jest.fn(); + + const factory = jest.fn(({boundary, document}: ContentScriptTargetProps) => { + boundary.title = "Prepared frame"; + + return document.createElement("section"); + }); + + const mountedNode = new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => { + anchor.append(container); + }); + const node = new FrameNode( - new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => { - anchor.append(container); - }), - {type: "iframe", height: 320}, - recover + mountedNode, + {type: "iframe"}, + recover, + new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => mountedNode.container, + target: factory, + boundary: ({boundary}) => { + boundary.style.height = "320px"; + }, + }) ); + node.mount(); const frame = node.container!.querySelector("iframe")!; const target = node.target!; expect(frame.hasAttribute("src")).toBe(false); expect(frame.style.height).toBe("320px"); + expect(node.boundary).toBe(frame); + expect(target.tagName).toBe("SECTION"); + expect(frame.title).toBe("Prepared frame"); + + expect(factory).toHaveBeenCalledWith({ + anchor, + data: undefined, + container: node.container, + boundary: frame, + document: frame.contentDocument, + }); + expect(target.ownerDocument).toBe(frame.contentDocument); expect(runtime.add).toHaveBeenLastCalledWith(frame.contentDocument!.head, null, false); // An initial load on an intact document does not start recovery or enable retries. frame.dispatchEvent(new Event("load")); await Promise.resolve(); expect(recover).not.toHaveBeenCalled(); + node.mount(); + expect(factory).toHaveBeenCalledTimes(1); target.remove(); frame.dispatchEvent(new Event("load")); await Promise.resolve(); expect(recover).toHaveBeenCalledTimes(1); node.mount(); expect(node.target).not.toBe(target); + expect(node.boundary).toBe(frame); + expect(factory).toHaveBeenCalledTimes(2); expect(runtime.delete).toHaveBeenCalledTimes(1); expect(runtime.add).toHaveBeenLastCalledWith(frame.contentDocument!.head, null, true); node.unmount(); + expect(node.boundary).toBeUndefined(); frame.dispatchEvent(new Event("load")); await Promise.resolve(); expect(recover).toHaveBeenCalledTimes(1); @@ -92,3 +164,187 @@ test("FrameNode replaces lost targets, unregisters old styles and releases its l node.unmount(); anchor.remove(); }); + +test.each(["throw", "unmount"])("FrameNode cleans up when target creation triggers %s", action => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + const anchor = document.createElement("section"); + document.body.append(anchor); + + const mountedNode = new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => + anchor.append(container) + ); + + const node = new FrameNode( + mountedNode, + {type: "iframe"}, + () => {}, + new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => mountedNode.container, + target: ({document}) => { + if (action === "throw") { + throw new Error("Target failed"); + } + + node.unmount(); + + return document.createElement("span"); + }, + }) + ); + + try { + if (action === "throw") { + expect(() => node.mount()).toThrow("Target failed"); + } else { + expect(node.mount()).toBe(false); + } + + expect(node.boundary).toBeUndefined(); + expect(node.target).toBeUndefined(); + expect(anchor.childElementCount).toBe(0); + expect(runtime.add).not.toHaveBeenCalled(); + } finally { + node.unmount(); + anchor.remove(); + } +}); + +const createBoundaryNode = ( + boundary: ContentScriptBoundaryHandler, + options: ContentScriptIsolationFrameOptions = {type: "iframe"}, + target: (props: ContentScriptTargetProps) => Element = jest.fn( + ({document}: ContentScriptTargetProps) => document.createElement("section") + ) +) => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + + const mountedNode = new MountNode( + new Node(document.body, document.createElement("section")), + (anchor, container) => { + anchor.append(container); + } + ); + + const node = new FrameNode( + mountedNode, + options, + () => node.mount(), + new IsolationSetup({ + props: () => ({anchor: mountedNode.anchor, data: undefined}), + container: () => mountedNode.container, + boundary, + target, + }) + ); + + return {node, runtime, target}; +}; + +test.each([ + {type: "iframe"}, + {type: "iframe", page: "panel"}, + {type: "iframe", src: "https://example.com/panel"}, +])("FrameNode configures %j before insertion and navigation", options => { + const load = jest.fn(); + const cleanup = jest.fn(); + + const setup = jest.fn(({boundary: frame}: ContentScriptBoundaryProps) => { + expect(frame.getAttribute("style")).toBeNull(); + expect(frame.hasAttribute("src")).toBe(false); + expect(frame.isConnected).toBe(false); + frame.style.height = "234px"; + frame.style.border = "2px solid red"; + frame.addEventListener("load", load); + expect(node.mount()).toBe(false); + + return () => { + expect(frame.isConnected).toBe(true); + expect(node.target).toBeUndefined(); + frame.removeEventListener("load", load); + cleanup(); + }; + }); + + const {node, target} = createBoundaryNode(setup, options); + + try { + expect(node.mount()).toBe(true); + const frame = node.boundary!; + expect(frame.style.height).toBe("234px"); + expect(frame.style.borderTopWidth).toBe("2px"); + + expect(frame.getAttribute("src")).toBe( + options.page ? "chrome-extension://fixture/panel.html" : (options.src ?? null) + ); + + frame.dispatchEvent(new Event("load")); + expect(load).toHaveBeenCalledTimes(1); + node.mount(); + expect(setup).toHaveBeenCalledTimes(1); + + if (!options.page && !options.src) { + node.target!.remove(); + node.mount(); + expect(setup).toHaveBeenCalledTimes(1); + expect(target).toHaveBeenCalledTimes(2); + expect(cleanup).not.toHaveBeenCalled(); + } else { + expect(target).not.toHaveBeenCalled(); + } + + node.unmount(); + node.unmount(); + frame.dispatchEvent(new Event("load")); + expect(load).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + node.mount(); + expect(setup).toHaveBeenCalledTimes(2); + expect(node.boundary).not.toBe(frame); + } finally { + node.unmount(); + } + + expect(cleanup).toHaveBeenCalledTimes(2); +}); + +test.each(["setup", "target", "unmount"])("FrameNode releases partial setup after %s", action => { + const cleanup = jest.fn(); + let frame: HTMLIFrameElement | undefined; + + const {node, runtime} = createBoundaryNode( + ({boundary}) => { + frame = boundary; + + if (action === "setup") { + throw new Error("Setup failed"); + } + + if (action === "unmount") { + node.unmount(); + } + + return cleanup; + }, + action === "unmount" ? {type: "iframe", src: "https://example.com"} : {type: "iframe"}, + jest.fn(() => { + throw new Error("Target failed"); + }) + ); + + if (action === "unmount") { + expect(node.mount()).toBe(false); + expect(frame?.hasAttribute("src")).toBe(false); + } else { + expect(() => node.mount()).toThrow(action === "setup" ? "Setup failed" : "Target failed"); + } + + expect(frame?.isConnected).toBe(false); + expect(node.container).toBeUndefined(); + expect(node.boundary).toBeUndefined(); + expect(runtime.add).not.toHaveBeenCalled(); + node.unmount(); + expect(cleanup).toHaveBeenCalledTimes(action === "setup" ? 0 : 1); +}); diff --git a/src/entry/content/lifecycle/nodes/FrameNode.ts b/src/entry/content/lifecycle/nodes/FrameNode.ts index b3060149..d62776f1 100644 --- a/src/entry/content/lifecycle/nodes/FrameNode.ts +++ b/src/entry/content/lifecycle/nodes/FrameNode.ts @@ -1,24 +1,40 @@ -import type {ContentScriptIsolationFrameOptions, ContentScriptNode, ContentScriptStylesRuntime} from "@typing/content"; -import {ContentScriptIsolation} from "@typing/content"; import {getPageUrl} from "@main/page"; +import type { + ContentScriptBoundaryCleanup, + ContentScriptIsolationFrameOptions, + ContentScriptNode, + ContentScriptStylesRuntime, +} from "@typing/content"; +import {ContentScriptIsolation} from "@typing/content"; + +import type IsolationSetup from "../IsolationSetup"; + import {getContentScriptStylesRuntime} from "./isolated-styles"; import {isContentScriptFrameNavigation} from "@shared/content"; -/** Owns the child document, but never moves the content script's JavaScript into it. */ -export default class FrameNode implements ContentScriptNode { +/** Owns the child document but never moves the content script's JavaScript into it. */ +export default class FrameNode implements ContentScriptNode { private frame?: HTMLIFrameElement; private head?: HTMLHeadElement; private _target?: Element; + private runtime?: ContentScriptStylesRuntime; + private generation = 0; private queued = false; + private cleanupBoundary?: ContentScriptBoundaryCleanup; + + private mounting = false; + private unmounting = false; + public constructor( private readonly node: ContentScriptNode, private readonly options: ContentScriptIsolationFrameOptions = {type: ContentScriptIsolation.Iframe}, - private readonly recover: () => void + private readonly recover: () => void, + private readonly isolation: IsolationSetup ) {} public get anchor(): Element { @@ -33,6 +49,10 @@ export default class FrameNode implements ContentScriptNode { return this._target; } + public get boundary(): HTMLIFrameElement | undefined { + return this.frame; + } + private readonly onLoad = (): void => { if (!this.frame || this.queued || this.intact()) { return; @@ -66,6 +86,20 @@ export default class FrameNode implements ContentScriptNode { } public mount(): boolean { + if (this.mounting || this.unmounting) { + return false; + } + + this.mounting = true; + + try { + return this.mountNode(); + } finally { + this.mounting = false; + } + } + + private mountNode(): boolean { const mounted = !!this.node.mount(); if (!this.container) { @@ -83,22 +117,40 @@ export default class FrameNode implements ContentScriptNode { if (!this.frame) { const frame = this.container.ownerDocument.createElement("iframe"); - frame.style.width = this.size(this.options.width ?? "100%"); - frame.style.height = this.size(this.options.height ?? 150); - frame.style.border = "0"; - frame.style.display = "block"; - this.frame = frame; + const generation = this.generation; - if (isContentScriptFrameNavigation(this.options)) { - frame.src = this.options.page !== undefined ? getPageUrl(this.options.page) : this.options.src!; - } else { - frame.addEventListener("load", this.onLoad); - } + try { + const cleanup = this.isolation.setup(frame); + + if (generation !== this.generation || this.frame !== frame) { + cleanup?.(); - this.container.append(frame); + return false; + } + + this.cleanupBoundary = cleanup || undefined; + + if (isContentScriptFrameNavigation(this.options)) { + frame.src = this.options.page !== undefined ? getPageUrl(this.options.page) : this.options.src!; + } else { + frame.addEventListener("load", this.onLoad); + } + + this.container.append(frame); - created = true; + if (generation !== this.generation || this.frame !== frame) { + return false; + } + + created = true; + } catch (error) { + if (generation === this.generation && this.frame === frame) { + this.unmount(); + } + + throw error; + } } if (isContentScriptFrameNavigation(this.options) || this.intact()) { @@ -117,41 +169,78 @@ export default class FrameNode implements ContentScriptNode { this.releaseStyles(); - const target = doc.createElement("div"); + const frame = this.frame; + const generation = this.generation; + + try { + const target = this.isolation.createTarget(frame, doc); - doc.body.append(target); + if (generation !== this.generation || this.frame !== frame || frame.contentDocument !== doc) { + return false; + } + + doc.body.append(target); + + this._target = target; + this.head = doc.head; + this.runtime = getContentScriptStylesRuntime(); + this.runtime.add(doc.head, null, recovering); + } catch (error) { + if (generation === this.generation && this.frame === frame) { + this.unmount(); + } - this._target = target; - this.head = doc.head; - this.runtime = getContentScriptStylesRuntime(); - this.runtime.add(doc.head, null, recovering); + throw error; + } return true; } public unmount(): boolean { + if (this.unmounting) { + return false; + } + + this.unmounting = true; this.generation++; this.queued = false; - this.frame?.removeEventListener("load", this.onLoad); + this._target = undefined; - this.releaseStyles(); + const cleanup = this.cleanupBoundary; - this.frame = undefined; + this.cleanupBoundary = undefined; + + let removed = false; + + try { + try { + cleanup?.(); + } finally { + try { + this.frame?.removeEventListener("load", this.onLoad); + this.releaseStyles(); + } finally { + this.frame = undefined; + removed = !!this.node.unmount(); + } + } - return !!this.node.unmount(); + return removed; + } finally { + this.unmounting = false; + } } private releaseStyles(): void { - if (this.head) { - this.runtime?.delete(this.head); - } + const head = this.head; + const runtime = this.runtime; this.head = undefined; this.runtime = undefined; this._target = undefined; - } - private size(value: number | string): string { - return typeof value === "number" ? `${value}px` : value; + if (head) { + runtime?.delete(head); + } } } diff --git a/src/entry/content/lifecycle/nodes/MarkerNode.ts b/src/entry/content/lifecycle/nodes/MarkerNode.ts index 0c8481d9..443fdfe5 100644 --- a/src/entry/content/lifecycle/nodes/MarkerNode.ts +++ b/src/entry/content/lifecycle/nodes/MarkerNode.ts @@ -1,4 +1,4 @@ -import {ContentScriptMarkerContract, ContentScriptNode} from "@typing/content"; +import {ContentScriptBoundary, ContentScriptMarkerContract, ContentScriptNode} from "@typing/content"; export default class MarkerNode implements ContentScriptNode { constructor( @@ -18,6 +18,10 @@ export default class MarkerNode implements ContentScriptNode { return this.node.target; } + public get boundary(): ContentScriptBoundary { + return this.node.boundary; + } + public mount(): boolean | undefined | void { this.marker.mount(this.anchor); diff --git a/src/entry/content/lifecycle/nodes/MountNode.ts b/src/entry/content/lifecycle/nodes/MountNode.ts index 2211df5f..7d596ea6 100644 --- a/src/entry/content/lifecycle/nodes/MountNode.ts +++ b/src/entry/content/lifecycle/nodes/MountNode.ts @@ -1,4 +1,4 @@ -import {ContentScriptMountFunction, ContentScriptNode} from "@typing/content"; +import {ContentScriptBoundary, ContentScriptMountFunction, ContentScriptNode} from "@typing/content"; export default class MountNode implements ContentScriptNode { private unmounting?: () => void; @@ -20,6 +20,10 @@ export default class MountNode implements ContentScriptNode { return this.node.target; } + public get boundary(): ContentScriptBoundary { + return this.node.boundary; + } + public mount(): boolean { this.node.mount(); diff --git a/src/entry/content/lifecycle/nodes/RenderNode.ts b/src/entry/content/lifecycle/nodes/RenderNode.ts index fa343491..3bff9de3 100644 --- a/src/entry/content/lifecycle/nodes/RenderNode.ts +++ b/src/entry/content/lifecycle/nodes/RenderNode.ts @@ -1,4 +1,5 @@ import type { + ContentScriptBoundary, ContentScriptNode, ContentScriptProps, ContentScriptRenderHandler, @@ -29,6 +30,10 @@ export default abstract class RenderNode implements ContentScrip return this.node.target; } + public get boundary(): ContentScriptBoundary { + return this.node.boundary; + } + protected abstract render(value: ContentScriptRenderValue | undefined, target: Element): boolean; protected abstract clear(): void; diff --git a/src/entry/content/lifecycle/nodes/ShadowNode.test.ts b/src/entry/content/lifecycle/nodes/ShadowNode.test.ts index e4fbf01c..0f45cd3f 100644 --- a/src/entry/content/lifecycle/nodes/ShadowNode.test.ts +++ b/src/entry/content/lifecycle/nodes/ShadowNode.test.ts @@ -1,11 +1,14 @@ -import MountNode from "./MountNode"; -import Node from "./Node"; -import ShadowNode from "./ShadowNode"; +import {MountNode, Node, ShadowNode} from "./index"; +import IsolationSetup from "../IsolationSetup"; import {getContentScriptStylesRuntime} from "./isolated-styles"; + import { ContentScriptShadowMode, type ContentScriptShadowOptions, type ContentScriptStylesRuntime, + type ContentScriptBoundaryHandler, + type ContentScriptBoundaryProps, + type ContentScriptTargetProps, } from "@typing/content"; jest.mock("./isolated-styles", () => ({ @@ -15,21 +18,30 @@ jest.mock("./isolated-styles", () => ({ const createNode = ( runtime: ContentScriptStylesRuntime, host = document.createElement("section"), - options?: ContentScriptShadowOptions + options?: ContentScriptShadowOptions, + target: (props: ContentScriptTargetProps) => Element = ({document}) => + document.createElement("span"), + boundary?: ContentScriptBoundaryHandler ) => { const anchor = document.createElement("div"); document.body.appendChild(anchor); jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + const mountedNode = new MountNode(new Node(anchor, host), (_anchor, container) => { + anchor.appendChild(container); + }); + + const setup = new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => mountedNode.container, + boundary, + target, + }); + return { anchor, host, - node: new ShadowNode( - new MountNode(new Node(anchor, host), (_anchor, container) => { - anchor.appendChild(container); - }), - options - ), + node: new ShadowNode(mountedNode, options, setup), }; }; @@ -48,6 +60,7 @@ describe("ShadowNode", () => { delete: jest.fn(), load: jest.fn(async () => undefined), }; + const {host, node} = createNode(runtime, undefined, options); expect(node.mount()).toBe(true); @@ -56,8 +69,9 @@ describe("ShadowNode", () => { expect(host.shadowRoot).toBe(options?.mode === ContentScriptShadowMode.Closed ? null : root); expect(node.target).toBe(root.lastElementChild); expect(node.target).not.toBe(host); + expect(node.boundary).toBe(root); expect(runtime.add).toHaveBeenCalledWith(root, node.target); - expect(Array.from(root.children).map(element => element.tagName)).toEqual(["DIV"]); + expect(Array.from(root.children).map(element => element.tagName)).toEqual(["SPAN"]); node.mount(); expect(runtime.add).toHaveBeenCalledTimes(1); @@ -68,12 +82,14 @@ describe("ShadowNode", () => { "unregisters %s before removing the host and creates a new root on remount", mode => { const states: boolean[] = []; + const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), delete: jest.fn(root => states.push((root as ShadowRoot).host.isConnected)), load: jest.fn(async () => undefined), }; + const {anchor, node} = createNode(runtime, undefined, {mode}); node.mount(); @@ -84,6 +100,7 @@ describe("ShadowNode", () => { expect(states).toEqual([true]); expect(runtime.delete).toHaveBeenCalledWith(firstRoot); expect(node.target).toBeUndefined(); + expect(node.boundary).toBeUndefined(); expect(firstHost?.isConnected).toBe(false); expect(node.mount()).toBe(true); @@ -97,6 +114,86 @@ describe("ShadowNode", () => { } ); + test("creates a custom target per mount with access to a closed root", () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + + const factory = jest.fn(({boundary, document}: ContentScriptTargetProps) => { + expect(boundary.mode).toBe("closed"); + + return document.createElement("span"); + }); + + const {node} = createNode(runtime, undefined, {mode: "closed"}, factory); + + try { + node.mount(); + const target = node.target; + const boundary = node.boundary; + expect(target?.tagName).toBe("SPAN"); + expect(target?.parentNode).toBe(boundary); + + expect(factory).toHaveBeenCalledWith({ + anchor: node.anchor, + data: undefined, + container: node.container, + boundary, + document, + }); + + node.mount(); + expect(factory).toHaveBeenCalledTimes(1); + node.unmount(); + node.mount(); + expect(factory).toHaveBeenCalledTimes(2); + expect(node.target).not.toBe(target); + expect(node.boundary).not.toBe(boundary); + } finally { + node.unmount(); + } + }); + + test("releases the host when target creation fails and can mount a fresh root", () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + + const factory = jest.fn(({document}: ContentScriptTargetProps) => + document.createElement("span") + ); + + factory.mockImplementationOnce(() => { + throw new Error("Target failed"); + }); + + const {node, host} = createNode(runtime, undefined, undefined, factory); + + expect(() => node.mount()).toThrow("Target failed"); + expect(host.isConnected).toBe(false); + expect(node.boundary).toBeUndefined(); + expect(node.target).toBeUndefined(); + expect(runtime.add).not.toHaveBeenCalled(); + + try { + expect(node.mount()).toBe(true); + expect(node.target?.isConnected).toBe(true); + } finally { + node.unmount(); + } + }); + + test("does not register a root unmounted by the target factory", () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + + const {node} = createNode(runtime, undefined, undefined, ({document}) => { + node.unmount(); + + return document.createElement("span"); + }); + + expect(node.mount()).toBe(false); + expect(node.boundary).toBeUndefined(); + expect(node.target).toBeUndefined(); + expect(runtime.add).not.toHaveBeenCalled(); + }); + test.each([ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( "preserves event propagation while encapsulating the %s event path", mode => { @@ -106,14 +203,17 @@ describe("ShadowNode", () => { delete: jest.fn(), load: jest.fn(async () => undefined), }; + const {host, node} = createNode(runtime, undefined, {mode}); node.mount(); const button = document.createElement("button"); node.target!.appendChild(button); let eventPath: EventTarget[] = []; + host.addEventListener("click", event => { eventPath = event.composedPath(); }); + button.click(); expect(eventPath).toContain(host); expect(eventPath.includes(button)).toBe(mode === ContentScriptShadowMode.Open); @@ -127,6 +227,7 @@ describe("ShadowNode", () => { delete: jest.fn(), load: jest.fn(async () => undefined), }; + const host = document.createElement("section"); host.attachShadow({mode: "open"}); const {node} = createNode(runtime, host); @@ -142,6 +243,7 @@ describe("ShadowNode", () => { delete: jest.fn(), load: jest.fn(async () => undefined), }; + const host = document.createElement("section"); host.attachShadow({mode: ContentScriptShadowMode.Closed}); const {node} = createNode(runtime, host, {mode: ContentScriptShadowMode.Closed}); @@ -150,3 +252,89 @@ describe("ShadowNode", () => { expect(node.target).toBeUndefined(); }); }); + +test("ShadowNode sets up each closed boundary once and cleans subscriptions before removing it", () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const calls: string[] = []; + const cleanup = jest.fn(); + + const setup = jest.fn(({boundary: root}: ContentScriptBoundaryProps) => { + calls.push("boundary"); + expect(root.mode).toBe("closed"); + expect(root.childNodes.length).toBe(0); + + return () => { + expect(root.host.isConnected).toBe(true); + cleanup(); + }; + }); + + const target = jest.fn(({document}: ContentScriptTargetProps) => { + calls.push("target"); + + return document.createElement("section"); + }); + + const {node} = createNode(runtime, undefined, {mode: "closed"}, target, setup); + + try { + node.mount(); + const root = node.boundary; + node.mount(); + expect(calls).toEqual(["boundary", "target"]); + node.unmount(); + node.unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + node.mount(); + expect(node.boundary).not.toBe(root); + expect(setup).toHaveBeenCalledTimes(2); + } finally { + node.unmount(); + } + + expect(cleanup).toHaveBeenCalledTimes(2); +}); + +test.each(["setup", "target", "unmount"])("ShadowNode releases boundary resources after %s", action => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const cleanup = jest.fn(); + + const {node} = createNode( + runtime, + undefined, + undefined, + ({document}) => { + if (action === "target") { + throw new Error("Target failed"); + } + + return document.createElement("section"); + }, + () => { + expect(node.mount()).toBe(false); + + if (action === "setup") { + throw new Error("Setup failed"); + } + + if (action === "unmount") { + node.unmount(); + expect(node.mount()).toBe(false); + } + + return cleanup; + } + ); + + if (action === "unmount") { + expect(node.mount()).toBe(false); + } else { + expect(() => node.mount()).toThrow(action === "setup" ? "Setup failed" : "Target failed"); + } + + expect(node.boundary).toBeUndefined(); + expect(node.container).toBeUndefined(); + expect(runtime.add).not.toHaveBeenCalled(); + node.unmount(); + expect(cleanup).toHaveBeenCalledTimes(action === "setup" ? 0 : 1); +}); diff --git a/src/entry/content/lifecycle/nodes/ShadowNode.ts b/src/entry/content/lifecycle/nodes/ShadowNode.ts index 20551d95..c2e2f638 100644 --- a/src/entry/content/lifecycle/nodes/ShadowNode.ts +++ b/src/entry/content/lifecycle/nodes/ShadowNode.ts @@ -1,21 +1,30 @@ +import type IsolationSetup from "../IsolationSetup"; + import {getContentScriptStylesRuntime} from "./isolated-styles"; + import { ContentScriptShadowMode, type ContentScriptShadowOptions, type ContentScriptStylesRuntime, type ContentScriptNode, + type ContentScriptBoundaryCleanup, } from "@typing/content"; -export default class ShadowNode implements ContentScriptNode { +export default class ShadowNode implements ContentScriptNode { private root?: ShadowRoot; private _target?: Element; private runtime?: ContentScriptStylesRuntime; + private cleanupBoundary?: ContentScriptBoundaryCleanup; + + private mounting = false; + private unmounting = false; public constructor( protected readonly node: ContentScriptNode, - private readonly options: ContentScriptShadowOptions = {} + private readonly options: ContentScriptShadowOptions = {}, + private readonly isolation: IsolationSetup ) {} public get anchor(): Element { @@ -30,7 +39,25 @@ export default class ShadowNode implements ContentScriptNode { return this._target; } + public get boundary(): ShadowRoot | undefined { + return this.root; + } + public mount(): boolean { + if (this.mounting || this.unmounting) { + return false; + } + + this.mounting = true; + + try { + return this.mountNode(); + } finally { + this.mounting = false; + } + } + + private mountNode(): boolean { const mounted = !!this.node.mount(); if (!this.container || this.root) { @@ -46,6 +73,7 @@ export default class ShadowNode implements ContentScriptNode { } let root: ShadowRoot; + try { root = this.container.attachShadow({mode: this.options.mode ?? ContentScriptShadowMode.Open}); } catch (cause) { @@ -55,28 +83,74 @@ export default class ShadowNode implements ContentScriptNode { {cause} ); } - const target = this.container.ownerDocument.createElement("div"); - root.appendChild(target); - - const runtime = getContentScriptStylesRuntime(); - runtime.add(root, target); this.root = root; - this._target = target; - this.runtime = runtime; + + try { + const cleanup = this.isolation.setup(root); + + if (this.root !== root) { + cleanup?.(); + + return false; + } + + this.cleanupBoundary = cleanup || undefined; + const target = this.isolation.createTarget(root, this.container.ownerDocument); + + if (this.root !== root) { + return false; + } + + root.appendChild(target); + + this._target = target; + this.runtime = getContentScriptStylesRuntime(); + this.runtime.add(root, target); + } catch (error) { + if (this.root === root) { + this.unmount(); + } + + throw error; + } return mounted; } public unmount(): boolean { - if (this.root) { - this.runtime?.delete(this.root); + if (this.unmounting) { + return false; } - this.root = undefined; + this.unmounting = true; this._target = undefined; - this.runtime = undefined; - return !!this.node.unmount(); + const cleanup = this.cleanupBoundary; + + this.cleanupBoundary = undefined; + + let removed = false; + + try { + try { + cleanup?.(); + } finally { + try { + if (this.root) { + this.runtime?.delete(this.root); + } + } finally { + this.root = undefined; + this._target = undefined; + this.runtime = undefined; + removed = !!this.node.unmount(); + } + } + + return removed; + } finally { + this.unmounting = false; + } } } diff --git a/src/entry/content/resolvers/index.ts b/src/entry/content/resolvers/index.ts index 63cd2a1b..346cded0 100644 --- a/src/entry/content/resolvers/index.ts +++ b/src/entry/content/resolvers/index.ts @@ -2,4 +2,5 @@ export * from "./anchor"; export * from "./container"; export * from "./definition"; export * from "./mount"; +export * from "./target"; export * from "./watch"; diff --git a/src/entry/content/resolvers/target.test.ts b/src/entry/content/resolvers/target.test.ts new file mode 100644 index 00000000..05997974 --- /dev/null +++ b/src/entry/content/resolvers/target.test.ts @@ -0,0 +1,87 @@ +import {createTargetResolver} from "./target"; +import type {ContentScriptTarget, ContentScriptTargetProps} from "@typing/content"; + +const props = (): ContentScriptTargetProps<{title: string}> => { + const container = document.createElement("div"); + + return { + anchor: document.body, + container, + boundary: container.attachShadow({mode: "closed"}), + document, + data: {title: "Prepared"}, + }; +}; + +describe("content target resolver", () => { + test.each([ + [undefined, "DIV", ""], + ["span", "SPAN", ""], + [{tagName: "section", className: "panel"}, "SECTION", "panel"], + ] as const)("creates an owned element from %j", (definition, tagName, className) => { + const context = props(); + const target = createTargetResolver(definition)(context); + + expect(target.tagName).toBe(tagName); + expect(target.className).toBe(className); + expect(target.ownerDocument).toBe(document); + expect(target.parentNode).toBeNull(); + }); + + test("passes prepared data and isolation to the factory and resolves its returned description", () => { + const context = props(); + const factory = jest.fn((input: typeof context) => ({tagName: "span" as const, title: input.data.title})); + const target = createTargetResolver(factory)(context); + + expect(factory).toHaveBeenCalledWith(context); + expect(target.getAttribute("title")).toBe("Prepared"); + }); + + test("uses an iframe document for both descriptions and factory elements", () => { + const frame = document.createElement("iframe"); + document.body.append(frame); + + try { + const context = {...props(), boundary: frame, document: frame.contentDocument!}; + const factory = createTargetResolver(({document}) => document.createElement("span")); + + expect(createTargetResolver("section")(context).ownerDocument).toBe(frame.contentDocument); + expect(factory(context).ownerDocument).toBe(frame.contentDocument); + + expect(() => createTargetResolver(() => document.createElement("span"))(context)).toThrow( + /supplied document|valid element/ + ); + } finally { + frame.remove(); + } + }); + + test("rejects asynchronous factories at runtime", () => { + const factory = (() => Promise.resolve(document.createElement("span"))) as unknown as ContentScriptTarget; + + expect(() => createTargetResolver(factory)(props())).toThrow(/must return synchronously/); + }); + + test.each([undefined, null, 1, false, {}, {nodeType: 1}])( + "rejects invalid JavaScript factory results %j", + result => { + const factory = (() => result) as unknown as ContentScriptTarget; + + expect(() => createTargetResolver(factory)(props())).toThrow(/valid element/); + } + ); + + test("does not move page-owned elements or reuse the container as its own target", () => { + const context = props(); + const element = document.createElement("section"); + document.body.append(element); + + try { + expect(() => createTargetResolver(() => element)(context)).toThrow(/detached element/); + expect(() => createTargetResolver(() => context.container)(context)).toThrow(/distinct/); + expect(element.parentNode).toBe(document.body); + } finally { + element.remove(); + } + }); +}); diff --git a/src/entry/content/resolvers/target.ts b/src/entry/content/resolvers/target.ts new file mode 100644 index 00000000..64b57de1 --- /dev/null +++ b/src/entry/content/resolvers/target.ts @@ -0,0 +1,41 @@ +import type {ContentScriptTarget, ContentScriptTargetCreator} from "@typing/content"; + +/** Creates the renderer-owned element in the document supplied by the isolation node. */ +export const createTargetResolver = + (target?: ContentScriptTarget): ContentScriptTargetCreator => + props => { + let element = typeof target === "function" ? target(props) : (target ?? "div"); + const {document, container, boundary} = props; + + if (element && typeof element === "object" && "then" in element && typeof element.then === "function") { + throw new Error( + "Content script target factory must return synchronously; use prepare for asynchronous work" + ); + } + + if (typeof element === "string") { + element = document.createElement(element); + } else if (element && typeof element === "object" && "tagName" in element && !("nodeType" in element)) { + const {tagName, ...options} = element; + element = document.createElement(tagName); + Object.assign(element, options); + } + + const ElementConstructor = document.defaultView?.Element ?? Element; + + if (!(element instanceof ElementConstructor)) { + throw new Error("The content script target must be a valid element"); + } + + if (element.ownerDocument !== document) { + throw new Error("The content script target must belong to the supplied document"); + } + + if (element.parentNode || element === container || element === boundary) { + throw new Error( + "The content script target must be a detached element distinct from its container and boundary" + ); + } + + return element; + }; diff --git a/src/entry/relay/Builder.test.ts b/src/entry/relay/Builder.test.ts index a458bae0..ae36088d 100644 --- a/src/entry/relay/Builder.test.ts +++ b/src/entry/relay/Builder.test.ts @@ -29,10 +29,10 @@ describe("Relay Builder", () => { test.each([undefined, false, true, RelayAllFrames.Any, RelayAllFrames.All])( "separates content options while keeping the Relay allFrames mode %p", async allFrames => { - const received: ContentScriptDefinition[] = []; + const received: ContentScriptDefinition[] = []; - class ContentBuilder extends VanillaBuilder { - constructor(definition: ContentScriptDefinition) { + class ContentBuilder extends VanillaBuilder { + constructor(definition: ContentScriptDefinition) { super(definition); received.push(definition); } @@ -43,6 +43,8 @@ describe("Relay Builder", () => { const main = jest.fn(); const render = true as const; const watch = () => () => {}; + const target = jest.fn(() => "span" as const); + const boundary = jest.fn(); const builder = new Builder( { @@ -54,7 +56,9 @@ describe("Relay Builder", () => { anchor: document.body, render, watch, - isolation: "none", + isolation: "shadow", + target, + boundary, }, ContentBuilder ); @@ -64,7 +68,9 @@ describe("Relay Builder", () => { anchor: document.body, render, watch, - isolation: "none", + isolation: "shadow", + target, + boundary, ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), }, ]); @@ -81,10 +87,15 @@ describe("Relay Builder", () => { anchor: document.body, render, watch, - isolation: "none", + isolation: "shadow", }); expect(main).toHaveBeenCalledTimes(1); + expect(main.mock.calls[0][2]).not.toHaveProperty("target"); + expect(main.mock.calls[0][2]).not.toHaveProperty("boundary"); + expect(init.mock.calls[0][0]).not.toHaveProperty("boundary"); + expect(boundary).not.toHaveBeenCalled(); + expect(target).not.toHaveBeenCalled(); expect(main).toHaveBeenCalledWith( instance, @@ -95,7 +106,7 @@ describe("Relay Builder", () => { main, method: RelayMethod.Scripting, allFrames, - isolation: {type: "none"}, + isolation: {type: "shadow", mode: "open"}, }) ); } finally { @@ -330,8 +341,6 @@ describe("Relay Builder", () => { expect(main.mock.calls[0][2].isolation).toEqual({ type: "iframe", src: "https://example.com/panel", - width: "100%", - height: 150, }); } finally { await builder.destroy(); diff --git a/src/entry/relay/Builder.ts b/src/entry/relay/Builder.ts index 7b83d3a9..bb038896 100644 --- a/src/entry/relay/Builder.ts +++ b/src/entry/relay/Builder.ts @@ -5,18 +5,22 @@ import TransportBuilder from "./TransportBuilder"; import EntrypointBuilder from "../core/Builder"; import {RelayUnresolvedDefinition} from "@typing/relay"; -import {ContentScriptBuilder, ContentScriptDefinition} from "@typing/content"; +import {ContentScriptBuilder, ContentScriptDefinition, ContentScriptIsolation} from "@typing/content"; import {TransportType} from "@typing/transport"; -export default class Builder extends EntrypointBuilder { +export default class Builder< + T extends TransportType, + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends EntrypointBuilder { private generation = 0; - protected readonly _transport: TransportBuilder; + protected readonly _transport: TransportBuilder; protected readonly _content: ContentScriptBuilder; constructor( - protected readonly definition: RelayUnresolvedDefinition, - contentBuilder: new (definition: ContentScriptDefinition) => ContentScriptBuilder + protected readonly definition: RelayUnresolvedDefinition, + contentBuilder: new (definition: ContentScriptDefinition>) => ContentScriptBuilder ) { super(); @@ -27,7 +31,7 @@ export default class Builder extends En this._content = new contentBuilder({ ...contentOptions, ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), - } as ContentScriptDefinition); + } as ContentScriptDefinition); } public async build(): Promise { @@ -51,7 +55,7 @@ export default class Builder extends En return; } - const {prepare, ...options} = this.definition; + const {prepare, boundary, target, ...options} = this.definition; const {main} = options; if (main) { diff --git a/src/entry/relay/TransportBuilder.ts b/src/entry/relay/TransportBuilder.ts index cbcc456d..1ab8fff8 100644 --- a/src/entry/relay/TransportBuilder.ts +++ b/src/entry/relay/TransportBuilder.ts @@ -4,12 +4,17 @@ import {RegisterRelay} from "@relay/providers"; import {RelayOptions, RelayUnresolvedDefinition, RelayMethod} from "@typing/relay"; import {TransportName, TransportType} from "@typing/transport"; +import type {ContentScriptIsolation} from "@typing/content"; -export default class extends AbstractBuilder { +export default class TransportBuilder< + T extends TransportType = TransportType, + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends AbstractBuilder { protected readonly method: RelayMethod; - constructor(definition: RelayUnresolvedDefinition) { - const {main, method, prepare, ...options} = definition; + constructor(definition: RelayUnresolvedDefinition) { + const {main, method, prepare, boundary, target, ...options} = definition; super(options); diff --git a/src/entry/relay/index.ts b/src/entry/relay/index.ts index 8dcf5574..979d9b5c 100644 --- a/src/entry/relay/index.ts +++ b/src/entry/relay/index.ts @@ -2,14 +2,19 @@ import Builder from "./Builder"; import type {RelayUnresolvedDefinition} from "@typing/relay"; import type {TransportType} from "@typing/transport"; +import type {ContentScriptIsolation} from "@typing/content"; export {Builder}; export {resolveDefinition} from "./resolvers/definition"; export type {RelayUnresolvedDefinition} from "@typing/relay"; -export default function relay( - definition: RelayUnresolvedDefinition, - contentBuilder: ConstructorParameters>[1] +export default function relay< + T extends TransportType, + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +>( + definition: RelayUnresolvedDefinition, + contentBuilder: ConstructorParameters>>[1] ): void { new Builder(definition, contentBuilder).build().catch(error => { console.error("Failed to build relay: ", error); diff --git a/src/main/content.ts b/src/main/content.ts index bc1c3c59..be9181fa 100644 --- a/src/main/content.ts +++ b/src/main/content.ts @@ -1,22 +1,25 @@ import {createAppendMountHandler} from "@entry/content/resolvers/mount"; -import {ContentScriptAppendDefinition, ContentScriptDefinition} from "@typing/content"; +import {ContentScriptAppendDefinition, ContentScriptDefinition, ContentScriptIsolation} from "@typing/content"; export * from "@typing/content"; -export const defineContentScript = ( - options: ContentScriptDefinition -): ContentScriptDefinition => { +export const defineContentScript = ( + options: ContentScriptDefinition +): ContentScriptDefinition => { return options; }; -export const defineContentScriptAppend = ( - options: ContentScriptAppendDefinition -): ContentScriptDefinition => { +export const defineContentScriptAppend = < + Data = undefined, + const Isolation extends `${ContentScriptIsolation}` = "none", +>( + options: ContentScriptAppendDefinition +): ContentScriptDefinition => { const {append, ...definition} = options; return { ...definition, mount: createAppendMountHandler(append), - }; + } as ContentScriptDefinition; }; diff --git a/src/main/relay.ts b/src/main/relay.ts index 21df2abd..a986dff1 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -3,6 +3,7 @@ import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; import {options as relayOptions} from "#adnbn/relay"; import type {TransportType} from "@typing/transport"; +import type {ContentScriptIsolation} from "@typing/content"; import { RelayDefinition, @@ -44,9 +45,13 @@ export type { RelayDefinition, } from "@typing/relay"; -export const defineRelay = ( - options: RelayDefinition -): RelayDefinition => { +export const defineRelay = < + T extends TransportType, + Data = undefined, + const Isolation extends `${ContentScriptIsolation}` = "none", +>( + options: RelayDefinition +): RelayDefinition => { return options; }; diff --git a/src/shared/content/isolation.test.ts b/src/shared/content/isolation.test.ts index a0692cd5..36cbded7 100644 --- a/src/shared/content/isolation.test.ts +++ b/src/shared/content/isolation.test.ts @@ -12,31 +12,30 @@ test.each([ {type: "shadow", mode: ContentScriptShadowMode.Closed}, {type: "shadow", mode: "closed"}, ], - ["iframe", {type: "iframe", width: "100%", height: 150}], - [ - {type: "iframe", height: 320}, - {type: "iframe", width: "100%", height: 320}, - ], + ["iframe", {type: "iframe"}], [ {type: "iframe", page: "panel"}, - {type: "iframe", page: "panel", width: "100%", height: 150}, + {type: "iframe", page: "panel"}, ], ])("normalizes isolation %j without changing the supplied object", (input, expected) => { - if (input && typeof input === "object") Object.freeze(input); + if (input && typeof input === "object") { + Object.freeze(input); + } expect(resolveContentScriptIsolation(input)).toEqual(expected); }); test.each([ [undefined, false], ["iframe", false], - [{type: "iframe", height: 320}, false], + [{type: "iframe"}, false], [{type: "iframe", page: "panel"}, true], [{type: "iframe", src: "https://example.com/panel"}, true], [{type: "shadow", page: "panel"}, false], ])("recognizes navigation independently from shorthand normalization: %j", (isolation, expected) => { expect(isContentScriptFrameNavigation(isolation)).toBe(expected); - if (expected) + if (expected) { expect(() => resolveContentScriptIsolation(isolation, true)).toThrow(/cannot be combined with render/); + } }); test.each([ @@ -47,7 +46,6 @@ test.each([ ["unknown", /isolation.type must be/], [{type: "none", mode: "open"}, /isolation.mode is not supported/], [{type: "iframe", mode: "closed"}, /isolation.mode is not supported/], - [{type: "shadow", height: 100}, /isolation.height is not supported/], [{type: "shadow", mode: "close"}, /isolation.mode must be "open" or "closed"/], [{type: "shadow", mode: false}, /isolation.mode must be/], [{type: "shadow", delegatesFocus: true}, /isolation.delegatesFocus is not supported/], @@ -56,9 +54,6 @@ test.each([ [{type: "iframe", src: "/relative"}, /absolute HTTP/], [{type: "iframe", src: "javascript:alert(1)"}, /absolute HTTP/], [{type: "iframe", src: "https://"}, /valid absolute URL/], - [{type: "iframe", width: -1}, /pixels or a CSS size/], - [{type: "iframe", height: Infinity}, /pixels or a CSS size/], - [{type: "iframe", height: " auto "}, /not supported yet/], ])("rejects invalid isolation %j", (isolation, error) => { expect(() => resolveContentScriptIsolation(isolation)).toThrow(error as RegExp); }); diff --git a/src/shared/content/isolation.ts b/src/shared/content/isolation.ts index aacd67c0..821e6917 100644 --- a/src/shared/content/isolation.ts +++ b/src/shared/content/isolation.ts @@ -21,71 +21,72 @@ export const isContentScriptFrameNavigation = ( export const resolveContentScriptIsolation = (value: unknown, hasRender = false): ContentScriptIsolationOptions => { const options = value === undefined ? {type: ContentScriptIsolation.None} : typeof value === "string" ? {type: value} : value; + if (!options || typeof options !== "object" || Array.isArray(options)) { throw new Error("isolation must be none, shadow, iframe, or an options object with type"); } - const {type, mode, page, src, width, height} = options as Record; + + const {type, mode, page, src} = options as Record; + if (!Object.values(ContentScriptIsolation).includes(type as ContentScriptIsolation)) { throw new Error( "isolation.type must be a statically known ContentScriptIsolation value: none, shadow or iframe" ); } + const keys = type === ContentScriptIsolation.Shadow ? ["type", "mode"] : type === ContentScriptIsolation.Iframe - ? ["type", "page", "src", "width", "height"] + ? ["type", "page", "src"] : ["type"]; + for (const key of Object.keys(options)) { - if (!keys.includes(key)) throw new Error(`isolation.${key} is not supported for isolation type "${type}"`); + if (!keys.includes(key)) { + throw new Error(`isolation.${key} is not supported for isolation type "${type}"`); + } } - if (type === ContentScriptIsolation.None) return {type}; + + if (type === ContentScriptIsolation.None) { + return {type}; + } + if (type === ContentScriptIsolation.Shadow) { if (mode !== undefined && !Object.values(ContentScriptShadowMode).includes(mode as ContentScriptShadowMode)) { throw new Error('isolation.mode must be "open" or "closed"'); } + return {type, mode: (mode as ContentScriptShadowMode | undefined) ?? ContentScriptShadowMode.Open}; } - if (page !== undefined && src !== undefined) + + if (page !== undefined && src !== undefined) { throw new Error("isolation.page and isolation.src are mutually exclusive"); - if (page !== undefined && (typeof page !== "string" || !page.trim())) + } + + if (page !== undefined && (typeof page !== "string" || !page.trim())) { throw new Error("isolation.page must be a statically known page alias"); + } + if (src !== undefined) { if (typeof src !== "string" || !/^(https?|chrome-extension|moz-extension):\/\//.test(src)) { throw new Error("isolation.src must be an absolute HTTP(S) or extension URL"); } + try { new URL(src); } catch { throw new Error("isolation.src must be a valid absolute URL"); } } + if ((page !== undefined || src !== undefined) && hasRender) { throw new Error( "isolation.page/isolation.src cannot be combined with render; the embedded page owns its UI and resources" ); } - for (const [name, size] of [ - ["width", width], - ["height", height], - ] as const) { - if ( - size !== undefined && - !( - (typeof size === "number" && Number.isFinite(size) && size >= 0) || - (typeof size === "string" && size.trim()) - ) - ) { - throw new Error(`isolation.${name} must be pixels or a CSS size`); - } - } - if (typeof height === "string" && height.trim().toLowerCase() === "auto") { - throw new Error('isolation.height: "auto" is not supported yet; specify an explicit height'); - } + return { type, - width: width ?? "100%", - height: height ?? 150, ...(page === undefined ? {} : {page}), ...(src === undefined ? {} : {src}), } as ContentScriptIsolationFrameOptions; diff --git a/src/types/content/adapters/react.ts b/src/types/content/adapters/react.ts index a3c0633c..ba8f2cc6 100644 --- a/src/types/content/adapters/react.ts +++ b/src/types/content/adapters/react.ts @@ -1,11 +1,13 @@ import type {ReactNode} from "react"; -import type {ContentScriptProps} from "../common"; +import type {ContentScriptIsolation, ContentScriptProps} from "../common"; -export type ContentScriptRenderReactComponent = ( - props: ContentScriptProps -) => Exclude>; +export type ContentScriptRenderReactComponent< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = (props: ContentScriptProps) => Exclude>; -export type ContentScriptReactRenderValue = - | Exclude> - | ContentScriptRenderReactComponent; +export type ContentScriptReactRenderValue< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = Exclude> | ContentScriptRenderReactComponent; diff --git a/src/types/content/boundary.ts b/src/types/content/boundary.ts new file mode 100644 index 00000000..1d4b759e --- /dev/null +++ b/src/types/content/boundary.ts @@ -0,0 +1,29 @@ +import type {ContentScriptContainerProps, ContentScriptIsolation} from "./common"; + +export interface ContentScriptBoundaryMap { + none: undefined; + shadow: ShadowRoot; + iframe: HTMLIFrameElement; +} + +/** The browser node implementing isolation, distinct from the container and render target. */ +export type ContentScriptBoundary = + ContentScriptBoundaryMap[Isolation]; + +/** Available when isolation is created, before its target or child document is ready. */ +export interface ContentScriptBoundaryProps< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends ContentScriptContainerProps { + container: Element; + boundary: Exclude, undefined>; +} + +/** Releases user subscriptions before the boundary is removed. */ +export type ContentScriptBoundaryCleanup = () => void; + +/** Synchronous setup, once per boundary; return cleanup for subscriptions or observers. */ +export type ContentScriptBoundaryHandler< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = (props: ContentScriptBoundaryProps) => void | ContentScriptBoundaryCleanup; diff --git a/src/types/content/common.ts b/src/types/content/common.ts index 39d841db..1427bbb5 100644 --- a/src/types/content/common.ts +++ b/src/types/content/common.ts @@ -1,4 +1,5 @@ import type {ContentScriptPrepareProps} from "./prepare"; +import type {ContentScriptBoundary} from "./boundary"; import type {PageAlias} from "@typing/page"; import type {Optional} from "utility-types"; @@ -149,23 +150,17 @@ export interface ContentScriptShadowOptions { mode?: ContentScriptShadowMode | `${ContentScriptShadowMode}`; } -export interface ContentScriptFrameOptions { - width?: number | string; - /** Defaults to 150px. Automatic height is not supported yet. */ - height?: number | string; -} - -export interface ContentScriptFrameRenderOptions extends ContentScriptFrameOptions { +export interface ContentScriptFrameRenderOptions { page?: never; src?: never; } -export interface ContentScriptFramePageOptions extends ContentScriptFrameOptions { +export interface ContentScriptFramePageOptions { page: PageAlias; src?: never; } -export interface ContentScriptFrameSourceOptions extends ContentScriptFrameOptions { +export interface ContentScriptFrameSourceOptions { src: string; page?: never; } @@ -180,16 +175,12 @@ export interface ContentScriptIsolationNoneOptions { mode?: never; page?: never; src?: never; - width?: never; - height?: never; } export interface ContentScriptIsolationShadowOptions extends ContentScriptShadowOptions { type: ContentScriptIsolation.Shadow | "shadow"; page?: never; src?: never; - width?: never; - height?: never; } export type ContentScriptIsolationFrameOptions = ContentScriptFrame & { @@ -227,11 +218,16 @@ export interface ContentScriptContainerProps extends ContentScri data: Data; } -export interface ContentScriptProps extends ContentScriptContainerProps { +export interface ContentScriptProps< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends ContentScriptContainerProps { /** Outer mounted element; the Shadow host or iframe wrapper when isolated. */ container: Element; /** Actual render destination. Use its root/document for portals and DOM operations. */ target: Element; + /** ShadowRoot or iframe for the active isolation; undefined without isolation. */ + boundary: ContentScriptBoundary; } // Anchor @@ -350,6 +346,9 @@ export interface ContentScriptNode extends ContentScriptMount { /** Element used by the active adapter as the destination for rendered UI. */ target?: Element; + + /** Present while isolation is mounted, including navigation-only iframes. */ + boundary?: ContentScriptBoundary; } export type ContentScriptNodeSet = Set; diff --git a/src/types/content/definition.ts b/src/types/content/definition.ts index fd98f905..69701631 100644 --- a/src/types/content/definition.ts +++ b/src/types/content/definition.ts @@ -1,4 +1,5 @@ import type {ContentScriptPrepareHandler} from "./prepare"; +import type {ContentScriptBoundaryHandler} from "./boundary"; import type { ContentScriptAnchor, @@ -13,7 +14,6 @@ import type { ContentScriptFrameSourceOptions, ContentScriptIsolation, ContentScriptIsolationFrameOptions, - ContentScriptIsolationNoneOptions, ContentScriptIsolationOptions, ContentScriptIsolationShadowOptions, ContentScriptMainFunction, @@ -26,6 +26,7 @@ import type { } from "./common"; import type {ContentScriptRenderHandler, ContentScriptRenderValue} from "./render"; +import type {ContentScriptTarget, ContentScriptTargetCreator} from "./target"; export interface ContentScriptDefinitionBase extends Partial { marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; @@ -42,39 +43,71 @@ export interface ContentScriptDefinitionBase extends Partial = ContentScriptDefinitionBase & - ( - | { - isolation?: - | ContentScriptIsolation - | `${ContentScriptIsolation}` - | ContentScriptIsolationNoneOptions - | ContentScriptIsolationShadowOptions - | (ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions); +export type ContentScriptRenderDefinition< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = { + // Infer the mode only from configuration; callbacks consume that mode without widening it. + isolation?: Isolation | {type: Isolation}; + boundary?: ContentScriptBoundaryHandler, NoInfer>; + target?: ContentScriptTarget, NoInfer>; + render?: + | ContentScriptRenderValue, NoInfer> + | ContentScriptRenderHandler, NoInfer>; +} & ( + | { + isolation?: + | NoInfer + | Extract}`; page?: never; src?: never}>; + boundary?: never; + target?: never; + } + | { + isolation: + | Extract, "shadow"> + | Extract}`}>; + } + | { + isolation: + | Extract, "iframe"> + | Extract< + ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions, + {type: `${NoInfer}`} + >; + } + | { + isolation: Extract< + ContentScriptIsolationFrameOptions & (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions), + {type: `${NoInfer}`} + >; + render?: never; + target?: never; + } +) & + ("none" extends `${NoInfer}` ? unknown : {isolation: unknown}); - render?: ContentScriptRenderValue> | ContentScriptRenderHandler>; - } - | { - isolation: ContentScriptIsolationFrameOptions & - (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions); - - render?: never; - } - ); +export type ContentScriptDefinition< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = ContentScriptDefinitionBase & ContentScriptRenderDefinition; export interface ContentScriptResolvedDefinition extends Omit< ContentScriptDefinitionBase, "anchor" | "marker" | "mount" | "container" | "watch" > { isolation: ContentScriptIsolationOptions; + boundary?: ContentScriptBoundaryHandler; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; render?: true | ContentScriptRenderHandler; container: ContentScriptContainerCreator; + target: ContentScriptTargetCreator; watch: ContentScriptWatchStrategy; } -type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; - -export type ContentScriptAppendDefinition = ContentScriptAppendVariant>; +export type ContentScriptAppendDefinition< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = Omit, "mount"> & + ContentScriptRenderDefinition & {append?: ContentScriptAppend}; diff --git a/src/types/content/index.ts b/src/types/content/index.ts index 5a0b9864..fa5aa486 100644 --- a/src/types/content/index.ts +++ b/src/types/content/index.ts @@ -5,3 +5,5 @@ export type * from "./render"; export type * from "./adapters"; export type * from "./prepare"; +export type * from "./boundary"; +export type * from "./target"; diff --git a/src/types/content/render.ts b/src/types/content/render.ts index 134d2f12..d1d96318 100644 --- a/src/types/content/render.ts +++ b/src/types/content/render.ts @@ -1,12 +1,14 @@ -import type {ContentScriptProps} from "./common"; +import type {ContentScriptIsolation, ContentScriptProps} from "./common"; import type {ContentScriptVanillaRenderValue, ContentScriptReactRenderValue} from "./adapters"; /** All supported render values; the entrypoint's filename selects the runtime adapter. */ -export type ContentScriptRenderValue = - | ContentScriptVanillaRenderValue - | ContentScriptReactRenderValue; +export type ContentScriptRenderValue< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = ContentScriptVanillaRenderValue | ContentScriptReactRenderValue; /** Synchronous rendering into prepared DOM. Await data and decide whether to render in prepare. */ -export type ContentScriptRenderHandler = ( - props: ContentScriptProps -) => ContentScriptRenderValue; +export type ContentScriptRenderHandler< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = (props: ContentScriptProps) => ContentScriptRenderValue; diff --git a/src/types/content/target.ts b/src/types/content/target.ts new file mode 100644 index 00000000..60becc38 --- /dev/null +++ b/src/types/content/target.ts @@ -0,0 +1,27 @@ +import type {ContentScriptContainerOptions, ContentScriptContainerTag, ContentScriptIsolation} from "./common"; +import type {ContentScriptBoundaryProps} from "./boundary"; + +export type ContentScriptTargetTag = ContentScriptContainerTag; +export type ContentScriptTargetOptions = ContentScriptContainerOptions; + +/** Available after isolation exists, before the target is created or rendering starts. */ +export interface ContentScriptTargetProps< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> extends ContentScriptBoundaryProps { + /** Create elements in this document, including for an iframe's child document. */ + document: Document; +} + +export type ContentScriptTargetFactory< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = (props: ContentScriptTargetProps) => Element | ContentScriptTargetTag | ContentScriptTargetOptions; + +export type ContentScriptTarget< + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = ContentScriptTargetTag | ContentScriptTargetOptions | ContentScriptTargetFactory; + +/** Synchronous DOM creation; asynchronous work belongs in prepare. */ +export type ContentScriptTargetCreator = (props: ContentScriptTargetProps) => Element; diff --git a/src/types/relay.ts b/src/types/relay.ts index a1750dac..9ab9ef68 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -3,8 +3,10 @@ import {EntrypointOptions} from "@typing/entrypoint"; import { ContentScriptConfig, ContentScriptContext, - ContentScriptDefinition, + ContentScriptDefinitionBase, + ContentScriptRenderDefinition, ContentScriptEntrypointOptions, + ContentScriptIsolation, } from "@typing/content"; import { @@ -210,18 +212,20 @@ export type RelayMainHandler = ( options: RelayEntrypointOptions ) => Awaiter; -type RelayContentDefinition = T extends unknown ? Omit : never; - -export type RelayDefinition = Omit< - TransportDefinition, - "main" -> & - RelayContentDefinition> & +export type RelayDefinition< + T extends TransportType, + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = Omit, "main"> & + Omit, "main" | "allFrames"> & + ContentScriptRenderDefinition & Partial & { main?: RelayMainHandler; }; /** Internal, merged runtime input. The public definition retains its discriminated union. */ -export type RelayUnresolvedDefinition = Partial< - Omit, never> ->; +export type RelayUnresolvedDefinition< + T extends TransportType, + Data = unknown, + Isolation extends `${ContentScriptIsolation}` = `${ContentScriptIsolation}`, +> = Partial, never>>; diff --git a/tests/integration/browser/content/frame-pages/src/contract.ts b/tests/integration/browser/content/frame-pages/src/contract.ts index 83969dfb..55106d2f 100644 --- a/tests/integration/browser/content/frame-pages/src/contract.ts +++ b/tests/integration/browser/content/frame-pages/src/contract.ts @@ -10,7 +10,7 @@ import { const page: ContentScriptDefinition = {isolation: {type: "iframe", page: "panel"}}; const frame: ContentScriptDefinition = { - isolation: {type: ContentScriptIsolation.Iframe, height: 200}, + isolation: {type: ContentScriptIsolation.Iframe}, render: "UI", }; const shadow: ContentScriptIsolationOptions = {type: "shadow", mode: ContentScriptShadowMode.Closed}; @@ -34,8 +34,6 @@ const unknown: ContentScriptDefinition = {isolation: {type: "iframe", page: "mis getPageUrl("missing"); // @ts-expect-error browser mode is closed, not close defineContentScriptAppend({isolation: {type: "shadow", mode: "close"}}); -// @ts-expect-error dimensions belong to iframe -defineContentScriptAppend({isolation: {type: "shadow", height: 200}}); // @ts-expect-error Shadow mode is not an iframe option defineContentScriptAppend({isolation: {type: "iframe", mode: "closed"}}); const misplaced = {type: ContentScriptIsolation.None, page: "panel"} as const; diff --git a/tests/integration/browser/content/frame-pages/src/page.content.ts b/tests/integration/browser/content/frame-pages/src/page.content.ts index 77c109d4..4360fd51 100644 --- a/tests/integration/browser/content/frame-pages/src/page.content.ts +++ b/tests/integration/browser/content/frame-pages/src/page.content.ts @@ -1,13 +1,20 @@ import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/top.html"], - isolation: {type: ContentScriptIsolation.Iframe, page: "panel", height: 250}, + isolation: {type: ContentScriptIsolation.Iframe, page: "panel"}, container: {tagName: "section", className: "page-host"}, - main() { - window.addEventListener("message", event => { - const frame = document.querySelector(".page-host iframe"); - if (event.source === frame?.contentWindow && event.data === "adnbn-page-ready") - frame!.setAttribute("data-ready", "true"); - }); + + boundary: ({boundary: frame}) => { + frame.style.height = "250px"; + const onMessage = (event: MessageEvent) => { + if (event.source === frame.contentWindow && event.data === "adnbn-page-ready") { + frame.setAttribute("data-ready", "true"); + } + }; + + window.addEventListener("message", onMessage); + + return () => window.removeEventListener("message", onMessage); }, }); diff --git a/tests/integration/browser/content/frame-pages/src/source.content.ts b/tests/integration/browser/content/frame-pages/src/source.content.ts index a4e0fdd5..4988e68d 100644 --- a/tests/integration/browser/content/frame-pages/src/source.content.ts +++ b/tests/integration/browser/content/frame-pages/src/source.content.ts @@ -1,14 +1,21 @@ import {ContentScriptIsolation, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/top.html"], world: ContentScriptWorld.Main, - isolation: {type: ContentScriptIsolation.Iframe, src: "http://127.0.0.1:1/page.html", height: 200}, + isolation: {type: ContentScriptIsolation.Iframe, src: "http://127.0.0.1:1/page.html"}, container: {tagName: "section", className: "source-host"}, - main() { - window.addEventListener("message", event => { - const frame = document.querySelector(".source-host iframe"); - if (event.source === frame?.contentWindow && event.data === "adnbn-source-ready") - frame!.setAttribute("data-ready", "true"); - }); + + boundary: ({boundary: frame}) => { + frame.style.height = "200px"; + const onMessage = (event: MessageEvent) => { + if (event.source === frame.contentWindow && event.data === "adnbn-source-ready") { + frame.setAttribute("data-ready", "true"); + } + }; + + window.addEventListener("message", onMessage); + + return () => window.removeEventListener("message", onMessage); }, }); diff --git a/tests/integration/browser/content/relay-styles-utils.ts b/tests/integration/browser/content/relay-styles-utils.ts index 39ad349e..d24860e2 100644 --- a/tests/integration/browser/content/relay-styles-utils.ts +++ b/tests/integration/browser/content/relay-styles-utils.ts @@ -39,7 +39,7 @@ export const expectRelayStyles = async (browser: "chrome" | "firefox", manifestV background: "rgb(34, 102, 68)", host: "3px", isolated: true, - isolation: {type: "iframe", width: "100%", height: 150}, + isolation: {type: "iframe"}, }, page: "rgb(17, 85, 153)", }); diff --git a/tests/integration/browser/content/render-props-utils.ts b/tests/integration/browser/content/render-props-utils.ts index d7a23f85..e518575c 100644 --- a/tests/integration/browser/content/render-props-utils.ts +++ b/tests/integration/browser/content/render-props-utils.ts @@ -78,12 +78,44 @@ export async function expectRenderProps(browser: "chrome" | "firefox") { closed: true, frame: false, portal: "shadow", + boundaryMatches: true, + targetTag: "SPAN", + targetClass: "custom-target", }); + const boundaryState = (id: string) => + session!.evaluate(`(() => { + const node = document.getElementById(${JSON.stringify(id)}); + return { + calls: Number(node.dataset.boundaryCalls ?? 0), + cleanups: Number(node.dataset.boundaryCleanups ?? 0), + events: Number(node.dataset.boundaryEvents ?? 0), + }; + })()`); + + expect(await boundaryState("skip")).toEqual({calls: 0, cleanups: 0, events: 0}); + expect(await boundaryState("frame")).toEqual({calls: 1, cleanups: 0, events: 0}); + expect(await boundaryState("relay")).toEqual({calls: 1, cleanups: 0, events: 0}); const frame = await snapshot("frame"); - expect(frame).toMatchObject({label: "frame", frame: true, portal: "frame", same: false}); + expect(frame).toMatchObject({ + label: "frame", + frame: true, + portal: "frame", + same: false, + boundaryMatches: true, + targetTag: "SECTION", + frameTitle: "frame", + frameHeight: "260px", + }); + expect(await session.evaluate("document.getElementById('frame').dataset.targetCalls")).toBe("1"); expect(await snapshot("vanilla")).toEqual({label: "vanilla", same: true, connected: true}); - expect(await snapshot("relay")).toMatchObject({label: "relay", shadow: true, portal: "relay"}); + expect(await snapshot("relay")).toMatchObject({ + label: "relay", + shadow: true, + portal: "relay", + boundaryMatches: true, + targetTag: "ASIDE", + }); await command("vanilla", "remount"); expect(await session.evaluate("JSON.parse(document.getElementById('vanilla').dataset.lifecycle)")).toEqual({ @@ -114,10 +146,33 @@ export async function expectRenderProps(browser: "chrome" | "firefox") { ); await until(async () => (await snapshot("shadow")).targetId !== shadow.targetId, "new Shadow DOM target"); - expect(await snapshot("shadow")).toMatchObject({count: 0, closed: true, portal: "shadow"}); + expect(await snapshot("shadow")).toMatchObject({ + count: 0, + closed: true, + portal: "shadow", + boundaryMatches: true, + targetTag: "SPAN", + }); + expect(await boundaryState("shadow")).toEqual({calls: 2, cleanups: 1, events: 0}); await command("frame", "frame-reload"); await until(async () => (await snapshot("frame")).targetId !== frame.targetId, "iframe document recovery"); - expect(await snapshot("frame")).toMatchObject({frame: true, portal: "frame", targetConnected: true}); + expect(await snapshot("frame")).toMatchObject({ + frame: true, + portal: "frame", + targetConnected: true, + boundaryMatches: true, + targetTag: "SECTION", + frameTitle: "frame", + frameHeight: "260px", + }); + expect(await session.evaluate("document.getElementById('frame').dataset.targetCalls")).toBe("2"); + expect(await boundaryState("frame")).toEqual({calls: 1, cleanups: 0, events: 0}); + const recovered = await snapshot("frame"); + await command("frame", "remount"); + await until(async () => (await snapshot("frame")).targetId !== recovered.targetId, "fresh iframe boundary"); + expect(await boundaryState("frame")).toEqual({calls: 2, cleanups: 1, events: 0}); + await command("frame", "unmount"); + expect(await boundaryState("frame")).toEqual({calls: 2, cleanups: 2, events: 0}); expect(await session.evaluate("document.body.dataset.error ?? null")).toBeNull(); expect(session.errors).toEqual([]); } catch (error) { diff --git a/tests/integration/browser/content/render-props/README.md b/tests/integration/browser/content/render-props/README.md index 0e541a90..a991c09d 100644 --- a/tests/integration/browser/content/render-props/README.md +++ b/tests/integration/browser/content/render-props/README.md @@ -7,3 +7,11 @@ must be no UI containers; Relay RPC must already be callable while Relay main is The fixture checks per-anchor data, denied and removed anchors, literal headless rendering, Vanilla DOM props, React portals in closed Shadow DOM and an iframe, preserved state on an unchanged target, and fresh props after remount or actual iframe document navigation. + +Custom targets exercise a tag string, a DOM-properties object, and a factory using prepared data. +The iframe factory creates its element in the child document and configures the frame through +`boundary`. Snapshots check the actual boundary, custom tag, and React portal destination. An intact +mount preserves the target; iframe document recovery calls the factory again. Headless Relay rejects +any attempt to call its target factory. + +Boundary checks cover typed setup before rendering, cleanup on remount/unmount, and iframe document recovery without repeating setup. Setup and cleanup counters are exposed on each anchor. diff --git a/tests/integration/browser/content/render-props/src/frame.content.tsx b/tests/integration/browser/content/render-props/src/frame.content.tsx index 7d638b43..9cc089f2 100644 --- a/tests/integration/browser/content/render-props/src/frame.content.tsx +++ b/tests/integration/browser/content/render-props/src/frame.content.tsx @@ -1,3 +1,4 @@ +import {setupBoundary} from "./shared/boundary"; import {defineContentScriptAppend} from "adnbn"; import {Panel} from "./shared/Panel"; import {prepare} from "./shared/prepare"; @@ -9,5 +10,19 @@ export default defineContentScriptAppend({ isolation: "iframe", prepare, main, + boundary: props => { + props.boundary.title = props.data.label; + props.boundary.style.height = "260px"; + + return setupBoundary(props); + }, + + target: ({document, anchor}) => { + anchor.setAttribute("data-target-calls", String(Number(anchor.getAttribute("data-target-calls") ?? 0) + 1)); + const target = document.createElement("section"); + target.className = "custom-target"; + + return target; + }, render: Panel, }); diff --git a/tests/integration/browser/content/render-props/src/headless.relay.tsx b/tests/integration/browser/content/render-props/src/headless.relay.tsx index d231a60f..7a9b1504 100644 --- a/tests/integration/browser/content/render-props/src/headless.relay.tsx +++ b/tests/integration/browser/content/render-props/src/headless.relay.tsx @@ -11,10 +11,14 @@ export default defineRelay({ throw new Error("Headless mode must not create a container"); }, + target: () => { + throw new Error("Headless mode must not create a target"); + }, + init: () => ({}), main(_instance, context) { const [node] = context.nodes; - node.anchor.setAttribute("data-headless", String(!node.container && !node.target)); + node.anchor.setAttribute("data-headless", String(!node.container && !node.boundary && !node.target)); }, }); diff --git a/tests/integration/browser/content/render-props/src/probe.relay.tsx b/tests/integration/browser/content/render-props/src/probe.relay.tsx index 4e583926..a38a8b5e 100644 --- a/tests/integration/browser/content/render-props/src/probe.relay.tsx +++ b/tests/integration/browser/content/render-props/src/probe.relay.tsx @@ -1,3 +1,4 @@ +import {setupBoundary} from "./shared/boundary"; import {defineRelay} from "adnbn"; import {Panel} from "./shared/Panel"; import {prepare} from "./shared/prepare"; @@ -9,6 +10,8 @@ export default defineRelay({ anchor: "#relay", isolation: "shadow", prepare, + boundary: setupBoundary, + target: "aside", render: Panel, init: () => ({ diff --git a/tests/integration/browser/content/render-props/src/shadow.content.tsx b/tests/integration/browser/content/render-props/src/shadow.content.tsx index f0b6bb1f..ab9e1817 100644 --- a/tests/integration/browser/content/render-props/src/shadow.content.tsx +++ b/tests/integration/browser/content/render-props/src/shadow.content.tsx @@ -1,3 +1,4 @@ +import {setupBoundary} from "./shared/boundary"; import {defineContentScript} from "adnbn"; import {Panel} from "./shared/Panel"; import {prepare} from "./shared/prepare"; @@ -8,6 +9,8 @@ export default defineContentScript({ anchor: ".react-anchor", isolation: {type: "shadow", mode: "closed"}, prepare, + boundary: setupBoundary, main, + target: {tagName: "span", className: "custom-target"}, render: Panel, }); diff --git a/tests/integration/browser/content/render-props/src/shared/Panel.tsx b/tests/integration/browser/content/render-props/src/shared/Panel.tsx index b1256a23..8c3266a0 100644 --- a/tests/integration/browser/content/render-props/src/shared/Panel.tsx +++ b/tests/integration/browser/content/render-props/src/shared/Panel.tsx @@ -5,7 +5,13 @@ import type {ContentScriptProps} from "adnbn"; const targets = new WeakMap(); let nextTarget = 0; -export function Panel({anchor, container, target, data}: ContentScriptProps<{label: string; allowed: boolean}>) { +export function Panel({ + anchor, + container, + boundary, + target, + data, +}: ContentScriptProps<{label: string; allowed: boolean}>) { const [count, setCount] = useState(0); useLayoutEffect(() => { @@ -28,11 +34,18 @@ export function Panel({anchor, container, target, data}: ContentScriptProps<{lab shadow, closed: shadow && container.shadowRoot === null, frame: target.ownerDocument !== anchor.ownerDocument, + boundaryMatches: shadow + ? boundary === root + : !!boundary && "contentDocument" in boundary && boundary.contentDocument === target.ownerDocument, + frameTitle: boundary && "title" in boundary ? boundary.title : undefined, + frameHeight: boundary && "style" in boundary ? boundary.style.height : undefined, + targetTag: target.tagName, + targetClass: target.className, portal: target.querySelector("[data-portal]")?.textContent, targetId: targets.get(target), }) ); - }, [anchor, container, target, data, count]); + }, [anchor, container, boundary, target, data, count]); return ( <> diff --git a/tests/integration/browser/content/render-props/src/shared/boundary.ts b/tests/integration/browser/content/render-props/src/shared/boundary.ts new file mode 100644 index 00000000..d1ac453c --- /dev/null +++ b/tests/integration/browser/content/render-props/src/shared/boundary.ts @@ -0,0 +1,22 @@ +import type {ContentScriptBoundaryProps} from "adnbn"; + +export function setupBoundary({anchor, boundary}: ContentScriptBoundaryProps) { + anchor.setAttribute("data-boundary-calls", String(Number(anchor.getAttribute("data-boundary-calls") ?? 0) + 1)); + const onProbe = () => { + anchor.setAttribute( + "data-boundary-events", + String(Number(anchor.getAttribute("data-boundary-events") ?? 0) + 1) + ); + }; + + boundary.addEventListener("boundary-probe", onProbe); + + return () => { + boundary.removeEventListener("boundary-probe", onProbe); + boundary.dispatchEvent(new Event("boundary-probe")); + anchor.setAttribute( + "data-boundary-cleanups", + String(Number(anchor.getAttribute("data-boundary-cleanups") ?? 0) + 1) + ); + }; +} diff --git a/tests/integration/browser/content/render-props/src/shared/lifecycle.ts b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts index 7128b6d1..e516e4a5 100644 --- a/tests/integration/browser/content/render-props/src/shared/lifecycle.ts +++ b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts @@ -21,6 +21,10 @@ export function main(context: ContentScriptContext) { mounted = node.mount(); } + if (command === "unmount") { + node.unmount(); + } + if (command === "mount") { mounted = node.mount(); } @@ -30,7 +34,11 @@ export function main(context: ContentScriptContext) { } if (command === "frame-reload") { - node.container!.querySelector("iframe")!.contentWindow!.location.reload(); + const boundary = node.boundary; + + if (boundary && "contentWindow" in boundary) { + boundary.contentWindow!.location.reload(); + } } node.anchor.setAttribute("data-tracked", String(context.nodes.size)); diff --git a/tests/integration/build/isolation/states/iframe.ts b/tests/integration/build/isolation/states/iframe.ts index 90dfd67c..06084e8a 100644 --- a/tests/integration/build/isolation/states/iframe.ts +++ b/tests/integration/build/isolation/states/iframe.ts @@ -2,7 +2,10 @@ import {defineContentScriptAppend} from "adnbn"; import "./watch.css?isolation"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: {type: "iframe", height: 220}, + isolation: {type: "iframe"}, + boundary: ({boundary}) => { + boundary.style.height = "220px"; + }, render: "Watch UI", main() { console.info("isolation-watch-iframe"); diff --git a/tests/integration/types/content.integration.test.ts b/tests/integration/types/content.integration.test.ts index 93837719..30df98a6 100644 --- a/tests/integration/types/content.integration.test.ts +++ b/tests/integration/types/content.integration.test.ts @@ -29,7 +29,13 @@ describe("content contracts", () => { const host = ts.createCompilerHost(options); const program = ts.createProgram( - [fixture, watchFixture, path.join(__dirname, "fixtures/content/prepare.tsx")], + [ + fixture, + watchFixture, + path.join(__dirname, "fixtures/content/prepare.tsx"), + path.join(__dirname, "fixtures/content/target.tsx"), + path.join(__dirname, "fixtures/content/boundary.tsx"), + ], options, host ); diff --git a/tests/integration/types/fixtures/content/boundary.tsx b/tests/integration/types/fixtures/content/boundary.tsx new file mode 100644 index 00000000..9c661d94 --- /dev/null +++ b/tests/integration/types/fixtures/content/boundary.tsx @@ -0,0 +1,80 @@ +import {defineContentScript, defineContentScriptAppend, defineRelay} from "adnbn"; +import type {ContentScriptBoundaryHandler, ContentScriptBoundaryProps} from "adnbn"; + +defineContentScript({ + isolation: "iframe", + prepare: async ({anchor}) => ({title: anchor.tagName}), + + boundary: props => { + const frame: HTMLIFrameElement = props.boundary; + const title: string = props.data.title; + const container: Element = props.container; + // @ts-expect-error: iframe setup does not receive a ShadowRoot. + props.boundary.host; + // @ts-expect-error: boundary data retains the prepare result. + props.data.missing; + // @ts-expect-error: the inner target does not exist during setup. + props.target; + // @ts-expect-error: iframe navigation does not promise an accessible document. + props.document; + frame.title = title; + + return () => frame.removeAttribute("title"); + }, + + target: ({data}) => ({tagName: "section", title: data.title}), + render: ({data}) => data.title, +}); + +defineContentScriptAppend({ + isolation: {type: "shadow", mode: "closed"}, + prepare: () => ({ready: true}), + + boundary: ({boundary, data}) => { + const root: ShadowRoot = boundary; + const ready: boolean = data.ready; + // @ts-expect-error: a ShadowRoot is not an iframe. + boundary.src; + root.host.setAttribute("data-ready", String(ready)); + }, + + render: "Shadow", +}); + +defineRelay({ + init: () => ({ping: () => true}), + isolation: {type: "iframe", page: "panel"}, + prepare: () => ({height: 240}), + + boundary: ({boundary, data}) => { + const frame: HTMLIFrameElement = boundary; + frame.style.height = `${data.height}px`; + // @ts-expect-error: Relay preserves prepared data. + data.missing; + }, +}); + +defineContentScript({ + isolation: {type: "iframe", src: "https://example.com"}, + boundary: ({boundary}) => { + const frame: HTMLIFrameElement = boundary; + frame.style.width = "100%"; + }, +}); + +const setup: ContentScriptBoundaryHandler = props => { + const context: ContentScriptBoundaryProps = props; + const root: ShadowRoot = context.boundary; +}; + +defineContentScript({isolation: "shadow", boundary: setup}); +// @ts-expect-error: a callback cannot select the isolation mode. +defineContentScript({boundary: setup}); +// @ts-expect-error: there is no boundary without isolation. +defineContentScriptAppend({isolation: {type: "none"}, boundary: () => {}}); +// @ts-expect-error: iframe setup cannot consume ShadowRoot props. +defineContentScript({isolation: "iframe", boundary: setup}); +// @ts-expect-error: boundary setup is synchronous. +defineContentScript({isolation: "iframe", boundary: async () => {}}); +// @ts-expect-error: setup returns cleanup or nothing, not an element. +defineContentScript({isolation: "iframe", boundary: () => document.createElement("div")}); diff --git a/tests/integration/types/fixtures/content/definition.tsx b/tests/integration/types/fixtures/content/definition.tsx index dd00a8c1..226afebb 100644 --- a/tests/integration/types/fixtures/content/definition.tsx +++ b/tests/integration/types/fixtures/content/definition.tsx @@ -24,7 +24,13 @@ const vanillaValues: ContentScriptVanillaRenderValue[] = [ const reactValues: ContentScriptReactRenderValue[] = [ Panel, - , + , [, "text"], ]; @@ -61,7 +67,7 @@ defineContentScript({ defineContentScriptAppend({ anchor: ".product", append: ContentScriptAppend.After, - isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + isolation: {type: ContentScriptIsolation.Iframe}, render: props => { const sharedProps: ContentScriptProps = props; diff --git a/tests/integration/types/fixtures/content/prepare.tsx b/tests/integration/types/fixtures/content/prepare.tsx index f4cb20b0..820f1127 100644 --- a/tests/integration/types/fixtures/content/prepare.tsx +++ b/tests/integration/types/fixtures/content/prepare.tsx @@ -34,7 +34,13 @@ defineContentScript({ defineContentScriptAppend({ append: ContentScriptAppend.Last, - prepare: () => ({count: 2}), + + prepare: ({anchor}) => { + // @ts-expect-error: append retains contextual prepare props. + const invalid: number = anchor; + + return {count: anchor.childElementCount}; + }, render: ({data}) => { const count: number = data.count; @@ -48,7 +54,18 @@ defineContentScriptAppend({ defineRelay({ name: "prepared", init: () => service, - prepare: async () => ({product: await service.get()}), + + prepare: async ({anchor}) => { + const product = await service.get(); + // @ts-expect-error: Relay retains contextual prepare props. + const invalid: number = anchor; + + if (!anchor.isConnected) { + return false; + } + + return {product}; + }, render: ({data}) => { const title: string = data.product.title; diff --git a/tests/integration/types/fixtures/content/target.tsx b/tests/integration/types/fixtures/content/target.tsx new file mode 100644 index 00000000..ae188de3 --- /dev/null +++ b/tests/integration/types/fixtures/content/target.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import {ContentScriptIsolation, defineContentScript, defineContentScriptAppend, defineRelay} from "adnbn"; +import type {ContentScriptDefinition, ContentScriptProps, ContentScriptTargetFactory} from "adnbn"; +import {Builder as VanillaBuilder} from "adnbn/entry/content/vanilla"; +import {Builder as ReactBuilder} from "adnbn/entry/content/react"; + +defineContentScript({ + isolation: "iframe", + prepare: async () => ({title: "Frame"}), + + target: ({document, boundary, data, container, anchor}) => { + const frame: HTMLIFrameElement = boundary; + const title: string = data.title; + const doc: Document = document; + const outer: Element = container; + const source: Element = anchor; + // @ts-expect-error: iframe boundaries are not ShadowRoots. + boundary.host; + // @ts-expect-error: target data is inferred from prepare. + data.missing; + frame.title = title; + + return doc.createElement("section"); + }, + + render: ({boundary, data, target}) => { + const frame: HTMLIFrameElement = boundary; + const title: string = data.title; + // @ts-expect-error: render receives the same iframe boundary type. + boundary.host; + + return {title + target.tagName + frame.title}; + }, +}); + +defineContentScriptAppend({ + isolation: {type: "shadow", mode: "closed"}, + prepare: () => ({count: 2}), + + target: ({document, boundary, data}) => { + const root: ShadowRoot = boundary; + const count: number = data.count; + // @ts-expect-error: object-form shadow boundaries are not iframe elements. + boundary.src; + // @ts-expect-error: data is not any. + data.missing; + + return {tagName: "span", title: String(count) + root.mode}; + }, + + render: ({boundary}) => { + const root: ShadowRoot = boundary; + // @ts-expect-error: append render props are contextually typed. + boundary.src; + + return root.mode; + }, +}); + +defineContentScript({isolation: ContentScriptIsolation.Shadow, target: "span", render: ({boundary}) => boundary.mode}); +defineContentScript({ + isolation: {type: ContentScriptIsolation.Iframe}, + target: {tagName: "section"}, + render: ({boundary}) => boundary.src, +}); +defineContentScript({ + render: ({boundary}) => { + const absent: undefined = boundary; + + return "plain"; + }, +}); + +defineRelay({ + name: "target", + isolation: {type: "iframe"}, + init: () => ({ping: () => true}), + prepare: () => ({title: "Relay"}), + target: ({document, boundary, data}) => { + const frame: HTMLIFrameElement = boundary; + const title: string = data.title; + // @ts-expect-error: Relay preserves boundary inference. + boundary.host; + + return document.createElement("span"); + }, + render: ({boundary, data}) => boundary.src + data.title, +}); + +const Panel = ({boundary}: ContentScriptProps) => {boundary.mode}; +defineContentScript({isolation: "shadow", target: "span", render: Panel}); +// @ts-expect-error: selecting a props type does not configure runtime isolation. +defineContentScript({render: Panel}); + +const target: ContentScriptTargetFactory = ({boundary}) => ({ + tagName: "section", + title: boundary.mode, +}); +const saved: ContentScriptDefinition = defineContentScript({ + isolation: "shadow", + target, + render: Panel, +}); +defineContentScriptAppend({...saved, render: ({boundary}) => boundary.mode}); +new VanillaBuilder(saved); +new ReactBuilder(saved); +// @ts-expect-error: a shadow component cannot receive an iframe boundary. +defineContentScript({isolation: "iframe", render: Panel}); + +defineContentScript({ + isolation: "shadow", + render: props => { + // @ts-expect-error: document is supplied to the target factory, not render props. + props.document; + + return props.target.ownerDocument.createElement("span"); + }, +}); + +// @ts-expect-error: without isolation, target is the container. +defineContentScript({target: "span"}); +// @ts-expect-error: explicit none cannot configure a second element. +defineContentScriptAppend({isolation: "none", target: "span"}); +// @ts-expect-error: object-form none also forbids a target. +defineContentScript({isolation: {type: "none"}, target: "span"}); +// @ts-expect-error: a navigated iframe does not render into a local target. +defineContentScript({isolation: {type: "iframe", src: "https://example.com"}, target: "span"}); +// @ts-expect-error: a named page owns its target independently of the content script. +defineContentScriptAppend({isolation: {type: "iframe", page: "panel"}, target: "span"}); +// @ts-expect-error: target creation must remain synchronous. +defineContentScript({isolation: "shadow", target: async ({document}) => document.createElement("div")}); +// @ts-expect-error: factories cannot return a Promise. +const invalid: ContentScriptTargetFactory = async ({document}) => document.createElement("span"); From dcc57418356ce3e1cc6c535b2ceef4d2ad2e4663 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:37:50 +0300 Subject: [PATCH 9/9] test: wait for locale service worker initialization --- .../browser/locale/chunks.integration.test.ts | 42 ++++++++++-- .../locale/dynamic.integration.test.ts | 64 +++++++++++++++++-- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/tests/integration/browser/locale/chunks.integration.test.ts b/tests/integration/browser/locale/chunks.integration.test.ts index 29ba690c..0383a9ee 100644 --- a/tests/integration/browser/locale/chunks.integration.test.ts +++ b/tests/integration/browser/locale/chunks.integration.test.ts @@ -14,7 +14,11 @@ jest.setTimeout(90_000); test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue through common content", async () => { const binary = findChromeBinary(ADNBN_TEST_ROOT); - if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + + if (!binary) { + throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + } + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, localeFixtureDirectory); const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-locale-chrome-")); let process: ChildProcess | undefined; @@ -25,6 +29,7 @@ test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue const directory = await fixture.build(); const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); const port = await getFreePort(); + process = spawn( binary, [ @@ -38,6 +43,7 @@ test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue ], {stdio: "ignore"} ); + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); client = await CdpClient.connect(webSocketDebuggerUrl); const extension = await client.send("Extensions.loadUnpacked", {path: directory}); @@ -46,48 +52,74 @@ test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue const attach = async (targetId: string): Promise => { const {sessionId} = await client!.send("Target.attachToTarget", {targetId, flatten: true}); await client!.send("Runtime.enable", {}, sessionId); + return sessionId; }; + const evaluate = async (session: string, expression: string): Promise => { const result = await client!.send("Runtime.evaluate", {expression, returnByValue: true}, session); - if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + return result.result.value; }; + const worker = await waitFor(async () => { const {targetInfos} = await client!.send("Target.getTargets"); + return targetInfos.find( (target: {url: string}) => target.url === `${origin}/${manifest.background.service_worker}` ); }); + const background = await attach(worker.targetId); - expect(await evaluate(background, "globalThis.catalogue.en.greeting")).toBe( - "Hello from the locale chunk fixture" + + // DevTools can expose the worker before its background module has executed. + const greeting = await waitFor( + () => evaluate(background, "globalThis.catalogue?.en?.greeting"), + 15_000, + "the locale catalogue in the extension service worker" ); + expect(greeting).toBe("Hello from the locale chunk fixture"); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const pages = [ {url: site.origin, contexts: ["isolated", "isolated-secondary", "main", "main-secondary"]}, {url: `${origin}/${manifest.action.default_popup}`, contexts: ["popup"]}, {url: `${origin}/${manifest.options_ui.page}`, contexts: ["options"]}, ]; + for (const page of pages) { const {targetId} = await client.send("Target.createTarget", {url: "about:blank"}); const session = await attach(targetId); await client.send("Page.navigate", {url: page.url}, session); + await waitFor(async () => { expectPanels(await evaluate(session, readPanels), page.contexts); + return true; }); + if (page.url === site.origin) { expect(await evaluate(session, "typeof globalThis.chrome?.i18n")).toBe("undefined"); } + await evaluate(session, changeLanguage); expectPanels(await evaluate(session, readPanels), page.contexts, "fr"); } + expect(client.runtimeErrors).toEqual([]); } finally { await client?.close(); - if (process) await stop(process); + + if (process) { + await stop(process); + } + await site?.close(); await fixture.dispose(); await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); diff --git a/tests/integration/browser/locale/dynamic.integration.test.ts b/tests/integration/browser/locale/dynamic.integration.test.ts index 7aa06298..33f347f5 100644 --- a/tests/integration/browser/locale/dynamic.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -12,19 +12,26 @@ jest.setTimeout(90_000); test("Chrome switches DynamicLocale in both worlds and synchronizes extension storage without loading translations", async () => { const binary = findChromeBinary(ADNBN_TEST_ROOT); - if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + + if (!binary) { + throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + } + const fixture = await createIntegrationFixture( ADNBN_TEST_ROOT, path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/dynamic-fixture") ); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-dynamic-locale-chrome-")); let browser: ChildProcess | undefined; let client: CdpClient | undefined; let site: IntegrationSite | undefined; + try { const directory = await fixture.build(); const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); const port = await getFreePort(); + browser = spawn( binary, [ @@ -38,53 +45,77 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st ], {stdio: "ignore"} ); + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); client = await CdpClient.connect(webSocketDebuggerUrl); const extension = await client.send("Extensions.loadUnpacked", {path: directory}); const origin = `chrome-extension://${extension.id}`; + const attach = async (targetId: string): Promise => { const {sessionId} = await client!.send("Target.attachToTarget", {targetId, flatten: true}); await client!.send("Runtime.enable", {}, sessionId); await client!.send("Network.enable", {}, sessionId); + return sessionId; }; + const evaluate = async (session: string, expression: string): Promise => { const result = await client!.send( "Runtime.evaluate", {expression, returnByValue: true, awaitPromise: true}, session ); - if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + return result.result.value; }; + const worker = await waitFor(async () => { const {targetInfos} = await client!.send("Target.getTargets"); + return targetInfos.find( (target: {url: string}) => target.url === `${origin}/${manifest.background.service_worker}` ); }); + const background = await attach(worker.targetId); - expect(await evaluate(background, 'dynamicLocale.trans("greeting")')).toBe("Hello from DynamicLocale!"); + + // DevTools can expose the worker before its background module has executed. + const greeting = await waitFor( + () => evaluate(background, 'globalThis.dynamicLocale?.trans("greeting")'), + 15_000, + "DynamicLocale initialization in the extension service worker" + ); + + expect(greeting).toBe("Hello from DynamicLocale!"); site = await startIntegrationSite(path.join(fixture.directory, "site")); const sessions: string[] = []; + const urls = [ site.origin, `${origin}/${manifest.action.default_popup}`, `${origin}/${manifest.options_ui.page}`, ]; + const expectLanguage = async (session: string, lang: string): Promise => { await waitFor(async () => { const panel = await evaluate( session, `(() => { const panel = document.querySelector('[data-locale-context]:not([data-locale-context="main"])'); return panel && {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; })()` ); + expect(panel).toEqual({ lang, message: lang === "fr" ? "Bonjour depuis DynamicLocale !" : "Hello from DynamicLocale!", }); + return true; }); }; + for (const url of urls) { const {targetId} = await client.send("Target.createTarget", {url: "about:blank"}); const session = await attach(targetId); @@ -92,13 +123,17 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st await client.send("Page.navigate", {url}, session); await expectLanguage(session, "en"); } + await waitFor(async () => { expect( await evaluate(sessions[0], `document.querySelector('[data-locale-context="main"]')?.dataset.language`) ).toBe("en"); + return true; }); + const before = client.requests.length; + expect( await evaluate( sessions[0], @@ -111,12 +146,14 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st })()` ) ).toEqual({lang: "fr", message: "Bonjour depuis DynamicLocale !"}); + expect( await evaluate( background, `(async () => (await chrome.storage.local.get('adnbn:locale'))['adnbn:locale'])()` ) ).toBeUndefined(); + const state = await evaluate( background, `(async () => { @@ -133,6 +170,7 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st return {immediate, stored: (await chrome.storage.local.get('adnbn:locale'))['adnbn:locale']}; })()` ); + expect(state).toEqual({ immediate: { language: "fr", @@ -144,11 +182,16 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st }, stored: "fr", }); - for (const session of sessions) await expectLanguage(session, "fr"); + + for (const session of sessions) { + await expectLanguage(session, "fr"); + } + expect(client.requests.slice(before).filter(url => url.startsWith(origin))).toEqual([]); await client.send("Page.navigate", {url: urls[1]}, sessions[1]); await expectLanguage(sessions[1], "fr"); const afterReload = client.requests.length; + expect( await evaluate( sessions[1], @@ -161,17 +204,26 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st })()` ) ).toBe("Hello from DynamicLocale!"); - for (const session of sessions) await expectLanguage(session, "en"); + + for (const session of sessions) { + await expectLanguage(session, "en"); + } + expect( await evaluate(sessions[0], `document.querySelector('[data-locale-context="main"]').dataset.language`) ).toBe("fr"); + expect(await evaluate(background, "dynamicLocale.sync()")).toBe("en"); expect(client.requests.slice(afterReload).filter(url => url.startsWith(origin))).toEqual([]); expect(client.requests.filter(url => /_locales\/|messages\.json/.test(url))).toEqual([]); expect(client.runtimeErrors).toEqual([]); } finally { await client?.close(); - if (browser) await stop(browser); + + if (browser) { + await stop(browser); + } + await site?.close(); await fixture.dispose(); await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200});