diff --git a/.release-it.cjs b/.release-it.cjs index a578b776..e684d10a 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -191,7 +191,7 @@ const createReleaseConfig = () => { github: { release: true, - releaseName: "Addon Bone v${version}", + releaseName: "v${version}", autoGenerate: false, releaseNotes: ({changelog}) => changelog, }, @@ -208,6 +208,8 @@ const createReleaseConfig = () => { preset: "conventionalcommits", parserOpts: { + // The preset's breaking pattern is incompatible with our extra breaking capture. + breakingHeaderPattern: null, headerPattern: /^(\w+)(?:\(([^)]+)\))?(!)?:\s(.+?)(?:\s\(#\d+\))?$/, headerCorrespondence: ["type", "scope", "breaking", "subject"], noteKeywords: ["BREAKING CHANGE", "BREAKING-CHANGE"], diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts index 1b8054c9..ae2cab93 100644 --- a/src/cli/builders/manifest/Manifest.test.ts +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -1,7 +1,8 @@ +import ManifestV2 from "./ManifestV2"; import ManifestV3 from "./ManifestV3"; import {Browser, DataCollectionPermission} from "@typing/browser"; import {Language} from "@typing/locale"; -import {ManifestIncognito} from "@typing/manifest"; +import {ManifestIncognito, type OptionalManifest} from "@typing/manifest"; describe("Manifest primitive properties", () => { it("name", () => { @@ -264,6 +265,83 @@ describe("Manifest common builder methods", () => { }); }); +describe.each([ + ["ManifestV2", ManifestV2], + ["ManifestV3", ManifestV3], +] as const)("%s options page", (_, Builder) => { + describe.each([Browser.Chrome, Browser.Edge, Browser.Opera, Browser.Safari, Browser.Firefox])("%s", browser => { + const rawOptionsUi = { + page: "raw-options.html", + open_in_tab: false, + browser_style: true, + chrome_style: true, + }; + const rawOptions = { + options_ui: rawOptionsUi, + options_page: "legacy-options.html", + }; + const rawCases: {name: string; raw: OptionalManifest}[] = [ + {name: "options_ui", raw: {options_ui: rawOptionsUi}}, + {name: "options_page", raw: {options_page: "legacy-options.html"}}, + {name: "both options keys", raw: rawOptions}, + ]; + + it("defaults the generated options page to a browser tab", () => { + const manifest = new Builder(browser).setOptions({path: "options.html"}).build(); + + expect(manifest.options_ui).toStrictEqual({page: "options.html", open_in_tab: true}); + expect(manifest).not.toHaveProperty("options_page"); + }); + + it.each([true, false])("preserves explicit openInTab=%s", openInTab => { + const manifest = new Builder(browser).setOptions({path: "options.html", openInTab}).build(); + + expect(manifest.options_ui).toStrictEqual({page: "options.html", open_in_tab: openInTab}); + }); + + it.each(rawCases)("preserves raw $name without an options entrypoint", ({raw}) => { + const manifest = new Builder(browser).raw(raw).build(); + + expect(manifest.options_ui).toStrictEqual(raw.options_ui); + expect(manifest.options_page).toBe(raw.options_page); + }); + + it("replaces both raw options keys with only the generated page and open_in_tab", () => { + const manifest = new Builder(browser).setOptions({path: "options.html"}).raw(rawOptions).build(); + + expect(manifest.options_ui).toStrictEqual({page: "options.html", open_in_tab: true}); + expect(manifest).not.toHaveProperty("options_page"); + }); + + it("restores both raw options keys after clearing a generated options page", () => { + const builder = new Builder(browser).raw(rawOptions).setOptions({path: "options.html"}); + + builder.build(); + const manifest = builder.setOptions(undefined).build(); + + expect(manifest.options_ui).toStrictEqual(rawOptions.options_ui); + expect(manifest.options_page).toBe(rawOptions.options_page); + }); + + it("omits both options keys when there is no options page", () => { + const manifest = new Builder(browser).build(); + + expect(manifest).not.toHaveProperty("options_ui"); + expect(manifest).not.toHaveProperty("options_page"); + }); + + it("omits both options keys after clearing an options page without raw fallback", () => { + const builder = new Builder(browser).setOptions({path: "options.html", openInTab: false}); + + builder.build(); + const manifest = builder.setOptions(undefined).build(); + + expect(manifest).not.toHaveProperty("options_ui"); + expect(manifest).not.toHaveProperty("options_page"); + }); + }); +}); + describe("Manifest browser specific settings", () => { it("sets and merges Firefox browser specific settings", () => { const builder = new ManifestV3(Browser.Firefox); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index c38b5f3c..d5c30c32 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -18,6 +18,7 @@ import { ManifestIcons, ManifestIncognito, ManifestOptionalPermissions, + ManifestOptions, ManifestPermissions, ManifestPopup, ManifestSandbox, @@ -62,6 +63,7 @@ export default abstract class implements ManifestBuilder protected background?: ManifestBackground; protected popup?: ManifestPopup; protected sidebar?: ManifestSidebar; + protected options?: ManifestOptions; protected sandboxes: ManifestSandboxes = new Set(); protected sandboxCsp: CspBuilder = new SandboxCsp(); protected csp: CspBuilder = new Csp(); @@ -265,6 +267,12 @@ export default abstract class implements ManifestBuilder return this; } + public setOptions(options?: ManifestOptions): this { + this.options = options; + + return this; + } + public addSandbox(sandbox: ManifestSandbox): this { this.sandboxes.add(sandbox); @@ -436,6 +444,7 @@ export default abstract class implements ManifestBuilder this.buildCommands(), this.buildAction(), this.buildSidebar(), + this.buildOptions(), this.buildContentScripts(), this.buildPermissions(), this.buildOptionalPermissions(), @@ -654,6 +663,19 @@ export default abstract class implements ManifestBuilder : {side_panel: {...commonProps, default_path: path}}; } + protected buildOptions(): Partial { + if (this.options) { + return { + options_ui: { + page: this.options.path, + open_in_tab: this.options.openInTab ?? true, + }, + }; + } + + return _.pick(this.combinedRaws, ["options_ui", "options_page"]); + } + protected buildBrowserSpecificSettings(): Partial | undefined { const optionalSettings = this.combinedRaws.browser_specific_settings; const {safari, gecko, geckoAndroid} = this.specific || {}; @@ -731,6 +753,8 @@ export default abstract class implements ManifestBuilder commands, action, sidebar, + options_ui, + options_page, content_scripts, permissions, optional_permissions, diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index 401947dd..b5c6eca0 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -2,7 +2,7 @@ import fs, {Dirent} from "fs"; import path from "path"; import pluralize from "pluralize"; -import AbstractOptionsFinder from "./AbstractOptionsFinder"; +import AbstractParsedFinder from "./AbstractParsedFinder"; import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; import {getAppSourcePath, getSharedPath} from "@cli/resolvers/path"; @@ -10,7 +10,7 @@ import {getAppSourcePath, getSharedPath} from "@cli/resolvers/path"; import {EntrypointFile, EntrypointFileExtensions, EntrypointOptions} from "@typing/entrypoint"; import {ReadonlyConfig} from "@typing/config"; -export default abstract class extends AbstractOptionsFinder { +export default abstract class extends AbstractParsedFinder { protected fileExtensionsPattern: string; protected possibleIndexFiles: Set; diff --git a/src/cli/entrypoint/finder/AbstractOptionsFinder.ts b/src/cli/entrypoint/finder/AbstractParsedFinder.ts similarity index 100% rename from src/cli/entrypoint/finder/AbstractOptionsFinder.ts rename to src/cli/entrypoint/finder/AbstractParsedFinder.ts diff --git a/src/cli/entrypoint/finder/OptionsFinder.test.ts b/src/cli/entrypoint/finder/OptionsFinder.test.ts new file mode 100644 index 00000000..3792da9f --- /dev/null +++ b/src/cli/entrypoint/finder/OptionsFinder.test.ts @@ -0,0 +1,151 @@ +import path from "path"; + +import OptionsFinder from "./OptionsFinder"; +import View from "@cli/plugins/view/View"; +import {toPosix} from "@cli/utils/path"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {OptionsEntrypointOptions} from "@typing/options"; +import {EntrypointFile, EntrypointOptionsFinder, EntrypointType} from "@typing/entrypoint"; + +class TestOptionsFinder extends OptionsFinder { + public constructor( + config: ReadonlyConfig, + private readonly pluginOptions: Map + ) { + super(config); + } + + public plugin(): EntrypointOptionsFinder { + return { + type: () => EntrypointType.Options, + options: async () => this.pluginOptions, + contracts: async () => new Map(Array.from(this.pluginOptions.keys()).map(entry => [entry, undefined])), + files: async () => new Set(this.pluginOptions.keys()), + empty: async () => this.pluginOptions.size === 0, + exists: async () => this.pluginOptions.size > 0, + clear() { + return this; + }, + holds: entry => this.pluginOptions.has(entry), + }; + } + + public scan(directory: string): Set { + return this.findFiles(directory); + } +} + +const makeConfig = (overrides: Partial = {}) => + ({ + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + plugins: [], + rootDir: "/project", + sharedDir: ".", + srcDir: "src", + ...overrides, + }) as ReadonlyConfig; + +const config = makeConfig(); + +const file = (filename: string): EntrypointFile => ({ + file: filename, + import: filename, +}); + +describe("OptionsFinder", () => { + test("discovers options files and index directories without treating options as a separate plural group", () => { + const fixtures = path.resolve(__dirname, "tests", "fixtures", "options", "discovery"); + const finder = new TestOptionsFinder(config, new Map()); + + const files = [...finder.scan(fixtures)].map(({file}) => toPosix(path.relative(fixtures, file))).sort(); + + expect(files).toEqual(["account.options.tsx", "advanced.options/index.tsx", "options.ts", "options/index.ts"]); + }); + + test("selects the highest-priority options page and only collects its CSP", async () => { + const plugin = file("/plugins/default/options.ts"); + const shared = file("/project/src/shared/options.ts"); + const app = file("/project/src/apps/app/options.tsx"); + const finder = new TestOptionsFinder( + config, + new Map([ + [plugin, {csp: {sources: {connect: ["https://plugin.example.com"]}}}], + [shared, {csp: {sources: {connect: ["https://shared.example.com"]}}}], + [app, {openInTab: false, csp: {sources: {connect: ["https://app.example.com"]}}}], + ]) + ); + + await expect(finder.views()).resolves.toEqual( + new Map([["options", {alias: "options", filename: "options.html", file: app, options: {}}]]) + ); + await expect(finder.csp()).resolves.toEqual([{sources: {connect: ["https://app.example.com"]}}]); + }); + + test("keeps manifest settings out of HTML tags while preserving the parsed source options", async () => { + const entry = file("/project/src/options.ts"); + const options: OptionsEntrypointOptions = { + openInTab: false, + title: "Preferences", + template: "./options.html", + links: ["options.css"], + metas: {attributes: {name: "viewport", content: "width=device-width, initial-scale=1"}}, + csp: {wasm: true}, + }; + const viewConfig = makeConfig({htmlDir: "pages"}); + const finder = new TestOptionsFinder(viewConfig, new Map([[entry, options]])); + const view = new View(viewConfig, finder); + + await expect(view.tags()).resolves.toEqual([ + { + links: ["options.css"], + metas: {attributes: {name: "viewport", content: "width=device-width, initial-scale=1"}}, + files: ["pages/options.html"], + }, + ]); + await expect(view.html()).resolves.toMatchObject([ + { + filename: "pages/options.html", + title: "Preferences", + template: path.resolve("/project/src/options.html"), + chunks: ["options"], + }, + ]); + await expect(view.entries()).resolves.toEqual(new Map([["options", new Set([entry])]])); + expect((await finder.plugin().options()).get(entry)).toEqual(options); + expect(options.openInTab).toBe(false); + expect(options.csp).toEqual({wasm: true}); + }); + + test("supports view naming and resets its caches without incrementing the output filename", async () => { + const entry = file("/project/src/options.ts"); + const options = new Map([ + [entry, {as: "preferences", csp: {wasm: true}}], + ]); + const finder = new TestOptionsFinder(config, options); + + await expect(finder.views()).resolves.toMatchObject( + new Map([["preferences.options", {filename: "preferences.options.html"}]]) + ); + await expect(finder.csp()).resolves.toEqual([{wasm: true}]); + + options.set(entry, {as: "preferences", openInTab: true, title: "Updated"}); + finder.clear(); + + await expect(finder.views()).resolves.toMatchObject( + new Map([["preferences.options", {filename: "preferences.options.html", options: {title: "Updated"}}]]) + ); + await expect(finder.csp()).resolves.toEqual([]); + }); + + test("does not create a view when no options entrypoint is selected", async () => { + const finder = new TestOptionsFinder(config, new Map()); + + await expect(finder.views()).resolves.toEqual(new Map()); + await expect(finder.csp()).resolves.toEqual([]); + }); +}); diff --git a/src/cli/entrypoint/finder/OptionsFinder.ts b/src/cli/entrypoint/finder/OptionsFinder.ts new file mode 100644 index 00000000..c79689ad --- /dev/null +++ b/src/cli/entrypoint/finder/OptionsFinder.ts @@ -0,0 +1,43 @@ +import ViewCspFinder from "./ViewCspFinder"; +import PluginFinder from "./PluginFinder"; + +import {OptionsParser} from "../parser"; + +import type {ViewItems} from "./AbstractViewFinder"; +import type {ReadonlyConfig} from "@typing/config"; +import type {OptionsEntrypointOptions} from "@typing/options"; +import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; + +export default class extends ViewCspFinder { + public constructor(config: ReadonlyConfig) { + super(config); + } + + public type(): EntrypointType { + return EntrypointType.Options; + } + + protected getParser(): EntrypointParser { + return new OptionsParser(this.config); + } + + protected getPlugin(): EntrypointOptionsFinder { + return new PluginFinder(this.config, "options", this); + } + + protected async getViews(): Promise> { + const views = await super.getViews(); + + for (const view of views.values()) { + const {openInTab, ...options} = view.options; + + view.options = options; + } + + return views; + } + + public allowMultiple(): boolean { + return false; + } +} diff --git a/src/cli/entrypoint/finder/PageFinder.test.ts b/src/cli/entrypoint/finder/PageFinder.test.ts index 53486416..0322806c 100644 --- a/src/cli/entrypoint/finder/PageFinder.test.ts +++ b/src/cli/entrypoint/finder/PageFinder.test.ts @@ -64,10 +64,10 @@ describe("PageFinder", () => { expect(new PageFinder(makeConfig({mergePages: false})).canMerge()).toBe(false); }); - test("keeps page filenames away from reserved entrypoint output", async () => { - const page = new ExposedPageFinder(config, new Map([[file("sandbox.ts"), {as: "sandbox"}]])); + test.each(["sandbox", "options"])("keeps page filenames away from reserved %s output", async name => { + const page = new ExposedPageFinder(config, new Map([[file(`${name}.ts`), {as: name}]])); - await expect(page.views()).resolves.toMatchObject(new Map([["sandbox.page", {filename: "sandbox1.html"}]])); + await expect(page.views()).resolves.toMatchObject(new Map([[`${name}.page`, {filename: `${name}1.html`}]])); }); test("uses page name as an alias when it is defined", () => { diff --git a/src/cli/entrypoint/finder/PluginFinder.ts b/src/cli/entrypoint/finder/PluginFinder.ts index 147838d5..f2704390 100644 --- a/src/cli/entrypoint/finder/PluginFinder.ts +++ b/src/cli/entrypoint/finder/PluginFinder.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import AbstractOptionsFinder from "./AbstractOptionsFinder"; +import AbstractParsedFinder from "./AbstractParsedFinder"; import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; import {processPluginHandler} from "@cli/resolvers/plugin"; @@ -9,11 +9,11 @@ import {ReadonlyConfig} from "@typing/config"; import {PluginHandlerKeys} from "@typing/plugin"; import {EntrypointFile, EntrypointOptions, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractOptionsFinder { +export default class extends AbstractParsedFinder { constructor( config: ReadonlyConfig, protected readonly key: PluginHandlerKeys, - protected readonly finder: AbstractOptionsFinder + protected readonly finder: AbstractParsedFinder ) { super(config); } diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index dc862c90..c964633e 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -9,7 +9,7 @@ export { type ViewFileToFilename, } from "./AbstractViewFinder"; export {default as ViewCspFinder} from "./ViewCspFinder"; -export {default as AbstractOptionsFinder} from "./AbstractOptionsFinder"; +export {default as AbstractParsedFinder} from "./AbstractParsedFinder"; export {default as BackgroundFinder} from "./BackgroundFinder"; export {default as CommandFinder} from "./CommandFinder"; export {default as ContentFinder} from "./ContentFinder"; @@ -17,6 +17,7 @@ export {default as IconFinder, type IconName, type IconGroups, type IconItem, ty export {default as LocaleFinder} from "./LocaleFinder"; export {default as OffscreenFinder} from "./OffscreenFinder"; export {default as OffscreenViewFinder} from "./OffscreenViewFinder"; +export {default as OptionsFinder} from "./OptionsFinder"; export {default as PageFinder} from "./PageFinder"; export {default as PopupFinder} from "./PopupFinder"; export {default as RelayFinder} from "./RelayFinder"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/options/discovery/account.options.tsx b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/account.options.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/account.options.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/options/discovery/advanced.options/index.tsx b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/advanced.options/index.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/advanced.options/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options.ts b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options/index.ts b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/options/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/options/discovery/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/options/discovery/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/parser/OptionsParser.test.ts b/src/cli/entrypoint/parser/OptionsParser.test.ts new file mode 100644 index 00000000..2a2ed898 --- /dev/null +++ b/src/cli/entrypoint/parser/OptionsParser.test.ts @@ -0,0 +1,84 @@ +import path from "path"; + +import OptionsParser from "./OptionsParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "options"); + +const parser = new OptionsParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return { + file: filename, + import: filename, + }; +}; + +const parseOptions = (...parts: string[]) => parser.options(file(...parts)); + +describe("OptionsParser", () => { + test("parses defineOptions with inherited view, CSP and build filters", () => { + expect(parseOptions("options", "full", "options.ts")).toEqual({ + openInTab: true, + as: "settings", + title: "Extension options", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: ["chrome"], + excludeBrowser: ["safari"], + mode: "production", + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: ["'self'", "https://api.example.com"], + image: ["'self'", "data:", "blob:"], + style: ["'self'", "'unsafe-inline'"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "options-test", + content: "enabled", + }, + }, + }); + }); + + test("leaves omitted openInTab for the manifest builder to default", () => { + expect(parseOptions("options", "defaults", "options.ts")).toEqual({}); + }); + + test("reads named exports alongside a default render function", () => { + expect(parseOptions("options", "named-exports", "options.ts")).toEqual({ + openInTab: false, + title: "Named options", + }); + }); + + test("keeps explicit false from a default object over a named export", () => { + expect(parseOptions("options", "default-object", "options.ts")).toEqual({ + openInTab: false, + title: "Default options", + }); + }); + + test.each(["default-as", "default-satisfies"])("reads a %s definition", scenario => { + expect(parseOptions("options", scenario, "options.ts")).toEqual({ + openInTab: false, + title: "Typed options", + }); + }); + + test("rejects a non-boolean openInTab value", () => { + expect(() => parseOptions("invalid", "open-in-tab.ts")).toThrow("Invalid options openInTab"); + }); +}); diff --git a/src/cli/entrypoint/parser/OptionsParser.ts b/src/cli/entrypoint/parser/OptionsParser.ts new file mode 100644 index 00000000..3ea72173 --- /dev/null +++ b/src/cli/entrypoint/parser/OptionsParser.ts @@ -0,0 +1,17 @@ +import {z} from "zod"; + +import ViewCspParser from "./ViewCspParser"; + +import {OptionsEntrypointOptions} from "@typing/options"; + +export default class extends ViewCspParser { + protected definition(): string { + return "defineOptions"; + } + + protected schema(): typeof this.CommonPropertiesSchema { + return super.schema().extend({ + openInTab: z.boolean().optional(), + }); + } +} diff --git a/src/cli/entrypoint/parser/index.ts b/src/cli/entrypoint/parser/index.ts index d24b6291..542a5dc5 100644 --- a/src/cli/entrypoint/parser/index.ts +++ b/src/cli/entrypoint/parser/index.ts @@ -1,6 +1,7 @@ export {default as BackgroundParser} from "./BackgroundParser"; export {default as CommandParser} from "./CommandParser"; export {default as ContentParser} from "./ContentParser"; +export {default as OptionsParser} from "./OptionsParser"; export {default as PageParser} from "./PageParser"; export {default as PopupParser} from "./PopupParser"; export {default as RelayParser} from "./RelayParser"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/invalid/open-in-tab.ts b/src/cli/entrypoint/parser/tests/fixtures/options/invalid/open-in-tab.ts new file mode 100644 index 00000000..0d92dc98 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/invalid/open-in-tab.ts @@ -0,0 +1,3 @@ +export default { + openInTab: "true", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts new file mode 100644 index 00000000..df5998a5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts @@ -0,0 +1,6 @@ +import type {OptionsDefinition} from "adnbn"; + +export default { + openInTab: false, + title: "Typed options", +} as OptionsDefinition; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts new file mode 100644 index 00000000..fa6fc0ed --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts @@ -0,0 +1,6 @@ +export const openInTab = true; + +export default { + openInTab: false, + title: "Default options", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts new file mode 100644 index 00000000..7a0d9ccf --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts @@ -0,0 +1,6 @@ +import type {OptionsDefinition} from "adnbn"; + +export default { + openInTab: false, + title: "Typed options", +} satisfies OptionsDefinition; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/defaults/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/defaults/options.ts new file mode 100644 index 00000000..782d1335 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/defaults/options.ts @@ -0,0 +1,5 @@ +import {defineOptions} from "adnbn"; + +export default defineOptions({ + render: () => "Options", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts new file mode 100644 index 00000000..554bfe91 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts @@ -0,0 +1,34 @@ +import {Browser, CspSource, defineOptions, Mode} from "adnbn"; + +import {openInTab} from "./values"; + +export default defineOptions({ + openInTab, + as: "settings", + title: "Extension options", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Safari], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, "data:", "blob:"], + style: [CspSource.Self, CspSource.UnsafeInline], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "options-test", + content: "enabled", + }, + }, + render: ({title}) => title, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/values.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/values.ts new file mode 100644 index 00000000..12ba4867 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/values.ts @@ -0,0 +1 @@ +export const openInTab = true; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts new file mode 100644 index 00000000..86eae0a9 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts @@ -0,0 +1,4 @@ +export const openInTab = false; +export const title = "Named options"; + +export default () => "Options"; diff --git a/src/cli/plugins/index.ts b/src/cli/plugins/index.ts index 4cfbb14d..c07fb892 100644 --- a/src/cli/plugins/index.ts +++ b/src/cli/plugins/index.ts @@ -11,6 +11,7 @@ export {default as pluginIcon} from "./icon"; export {default as pluginLocale} from "./locale"; export {default as pluginMeta} from "./meta"; export {default as pluginOffscreen} from "./offscreen"; +export {default as pluginOptions} from "./options"; export {default as pluginPage} from "./page"; export {default as pluginPopup} from "./popup"; export {default as pluginPublic} from "./public"; diff --git a/src/cli/plugins/options/Options.test.ts b/src/cli/plugins/options/Options.test.ts new file mode 100644 index 00000000..47dc0ea4 --- /dev/null +++ b/src/cli/plugins/options/Options.test.ts @@ -0,0 +1,102 @@ +import Options from "./Options"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {OptionsEntrypointOptions} from "@typing/options"; +import {EntrypointFile, EntrypointOptionsFinder, EntrypointType} from "@typing/entrypoint"; + +class TestOptions extends Options { + public constructor( + config: Partial, + private readonly pluginOptions: Map + ) { + super(config as ReadonlyConfig); + } + + public plugin(): EntrypointOptionsFinder { + return { + type: () => EntrypointType.Options, + options: async () => this.pluginOptions, + contracts: async () => new Map(Array.from(this.pluginOptions.keys()).map(entry => [entry, undefined])), + files: async () => new Set(this.pluginOptions.keys()), + empty: async () => this.pluginOptions.size === 0, + exists: async () => this.pluginOptions.size > 0, + clear() { + return this; + }, + holds: entry => this.pluginOptions.has(entry), + }; + } +} + +const config: Partial = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: "pages", + plugins: [], + rootDir: "/project", + sharedDir: ".", + srcDir: "src", +}; + +const file = (filename: string): EntrypointFile => ({ + file: filename, + import: filename, +}); + +describe("Options", () => { + test("uses the selected view filename and retains false after stripping manifest settings from the view", async () => { + const fallback = file("/plugins/default/options.ts"); + const selected = file("/project/src/options.tsx"); + const options = new TestOptions( + config, + new Map([ + [fallback, {as: "fallback", openInTab: true}], + [selected, {as: "preferences", openInTab: false}], + ]) + ); + + const [view] = (await options.views()).values(); + + expect(view.options).not.toHaveProperty("openInTab"); + await expect(options.manifest()).resolves.toEqual({ + path: "pages/preferences.options.html", + openInTab: false, + }); + }); + + test("returns no manifest options when there is no selected view", async () => { + await expect(new TestOptions(config, new Map()).manifest()).resolves.toBeUndefined(); + }); + + test("clear refreshes the view, manifest and CSP without incrementing the output name", async () => { + const initial = file("/project/src/options.ts"); + const replacement = file("/project/src/replacement.options.tsx"); + const parsed = new Map([ + [initial, {as: "preferences", openInTab: true, csp: {wasm: true}}], + ]); + const options = new TestOptions(config, parsed); + const firstView = options.view(); + + await expect(options.manifest()).resolves.toEqual({ + path: "pages/preferences.options.html", + openInTab: true, + }); + await expect(options.csp()).resolves.toEqual([{wasm: true}]); + + parsed.clear(); + parsed.set(replacement, {as: "preferences", openInTab: false}); + options.clear(); + + expect(options.view()).not.toBe(firstView); + await expect(options.csp()).resolves.toEqual([]); + await expect(options.manifest()).resolves.toEqual({ + path: "pages/preferences.options.html", + openInTab: false, + }); + await expect(options.view().entries()).resolves.toEqual( + new Map([["preferences.options", new Set([replacement])]]) + ); + }); +}); diff --git a/src/cli/plugins/options/Options.ts b/src/cli/plugins/options/Options.ts new file mode 100644 index 00000000..33af8539 --- /dev/null +++ b/src/cli/plugins/options/Options.ts @@ -0,0 +1,31 @@ +import View from "../view/View"; +import OptionsFinder from "@cli/entrypoint/finder/OptionsFinder"; + +import type {OptionsEntrypointOptions} from "@typing/options"; +import type {ManifestOptions} from "@typing/manifest"; + +export default class extends OptionsFinder { + protected _view?: View; + + public view(): View { + return (this._view ??= new View(this.config, this)); + } + + public async manifest(): Promise { + const [view] = (await this.views()).values(); + + if (!view) { + return; + } + + const {openInTab} = (await this.plugin().options()).get(view.file) ?? {}; + + return {path: view.filename, openInTab}; + } + + public clear(): this { + this._view = undefined; + + return super.clear(); + } +} diff --git a/src/cli/plugins/options/index.ts b/src/cli/plugins/options/index.ts new file mode 100644 index 00000000..2d7289d3 --- /dev/null +++ b/src/cli/plugins/options/index.ts @@ -0,0 +1,47 @@ +import {Configuration as RspackConfig, HtmlRspackPlugin} from "@rspack/core"; +import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; + +import Options from "./Options"; + +import {definePlugin} from "@main/plugin"; +import {EntrypointPlugin} from "@cli/bundler"; +import {virtualViewModule} from "@cli/virtual"; + +import {Command} from "@typing/app"; + +export default definePlugin(() => { + let options: Options; + + return { + name: "adnbn:options", + startup: ({config}) => { + options = new Options(config); + }, + options: () => options.files(), + bundler: async ({config}) => { + if (await options.empty()) { + if (config.debug) { + console.info("Options entry not found"); + } + + return {}; + } + + const plugin = EntrypointPlugin.from(await options.view().entries()).virtual(virtualViewModule); + + if (config.command === Command.Watch) { + plugin.watch(async () => options.clear().view().entries()); + } + + const htmlPlugins = (await options.view().html()).map(options => new HtmlRspackPlugin(options)); + const tagsPlugins = (await options.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + + return { + plugins: [plugin, ...htmlPlugins, ...tagsPlugins], + } satisfies RspackConfig; + }, + manifest: async ({manifest}) => { + manifest.setOptions(await options.manifest()).appendCsp(await options.csp()); + }, + }; +}); diff --git a/src/cli/plugins/view/index.ts b/src/cli/plugins/view/index.ts index 3222ba79..8c344183 100644 --- a/src/cli/plugins/view/index.ts +++ b/src/cli/plugins/view/index.ts @@ -11,7 +11,7 @@ export default definePlugin(() => { return { name: "adnbn:view", bundler: ({config}) => { - const entryTypeFilter = onlyViaTopLevelEntry(["page", "popup", "sidebar", "offscreen"]); + const entryTypeFilter = onlyViaTopLevelEntry(["page", "popup", "sidebar", "offscreen", "options"]); return { optimization: { diff --git a/src/cli/resolvers/config.test.ts b/src/cli/resolvers/config.test.ts index e0f845b0..54e55345 100644 --- a/src/cli/resolvers/config.test.ts +++ b/src/cli/resolvers/config.test.ts @@ -14,6 +14,7 @@ jest.mock("../plugins", () => { pluginMeta: plugin("meta"), pluginOffscreen: plugin("offscreen"), pluginOptimization: plugin("optimization"), + pluginOptions: plugin("options"), pluginOutput: plugin("output"), pluginPage: plugin("page"), pluginPopup: plugin("popup"), diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 32f2cf9a..6a1d1de0 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -16,6 +16,7 @@ import { pluginOffscreen, pluginManifest, pluginOptimization, + pluginOptions, pluginOutput, pluginPage, pluginPopup, @@ -367,6 +368,7 @@ export default async (config: OptionalConfig): Promise => { pluginMeta(), pluginContent(), pluginBackground(), + pluginOptions(), pluginPopup(), pluginPublic(), pluginSidebar(), diff --git a/src/main/index.ts b/src/main/index.ts index 09667176..3d9cb5b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ export * from "./env"; export * from "./icon"; export * from "./manifest"; export * from "./offscreen"; +export * from "./options"; export * from "./page"; export * from "./plugin"; export * from "./popup"; diff --git a/src/main/options.ts b/src/main/options.ts new file mode 100644 index 00000000..d42afe93 --- /dev/null +++ b/src/main/options.ts @@ -0,0 +1,7 @@ +import {OptionsConfig, OptionsDefinition, OptionsProps} from "@typing/options"; + +export type {OptionsDefinition, OptionsProps, OptionsConfig}; + +export const defineOptions = (options: OptionsDefinition): OptionsDefinition => { + return options; +}; diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 1c9d9a7b..a311001b 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -128,6 +128,8 @@ export interface ManifestBuilder { setSidebar(sidebar?: ManifestSidebar): this; + setOptions(options?: ManifestOptions): this; + // Sandbox addSandbox(sandbox: ManifestSandbox): this; @@ -236,6 +238,20 @@ export interface ManifestSidebar { path?: string; } +export interface ManifestOptions { + /** + * Path to the options page HTML file relative to the extension root. + * Written to `options_ui.page` in the manifest. + */ + path: string; + /** + * Whether the options page should open in a browser tab. + * Written to `options_ui.open_in_tab` and defaults to `true` when omitted. + * Set to `false` to request an embedded page where supported by the browser. + */ + openInTab?: boolean; +} + export interface ManifestAccessibleResource { resources: string[]; matches?: string[]; diff --git a/src/types/options.ts b/src/types/options.ts new file mode 100644 index 00000000..59ec4b0e --- /dev/null +++ b/src/types/options.ts @@ -0,0 +1,13 @@ +import {ViewDefinition, ViewOptions} from "@typing/view"; +import {CspOptions} from "@typing/csp"; + +export interface OptionsConfig { + /** Open in a browser tab. Defaults to true; embedded mode depends on browser support. */ + openInTab?: boolean; +} + +export type OptionsEntrypointOptions = OptionsConfig & CspOptions & ViewOptions; + +export type OptionsProps = OptionsEntrypointOptions; + +export type OptionsDefinition = OptionsEntrypointOptions & ViewDefinition; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 2b84eced..a08f4b89 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -81,6 +81,7 @@ export interface Plugin extends PluginName { bundler?: PluginHandler; command?: PluginHandler; content?: PluginHandler; + options?: PluginHandler; page?: PluginHandler; popup?: PluginHandler; relay?: PluginHandler; @@ -102,7 +103,17 @@ export type PluginHandlerKeys = keyof Omit; export type PluginEntrypointKeys = keyof Pick< Plugin, - "background" | "command" | "content" | "page" | "popup" | "relay" | "sandbox" | "service" | "sidebar" | "offscreen" + | "background" + | "command" + | "content" + | "options" + | "page" + | "popup" + | "relay" + | "sandbox" + | "service" + | "sidebar" + | "offscreen" >; export type PluginAssetKeys = keyof Pick; diff --git a/tests/integration/browser/offscreen-service.integration.test.ts b/tests/integration/browser/offscreen-service.integration.test.ts index 7e0fc9f6..6ad73706 100644 --- a/tests/integration/browser/offscreen-service.integration.test.ts +++ b/tests/integration/browser/offscreen-service.integration.test.ts @@ -1,297 +1,26 @@ /** @jest-environment node */ import {mkdir, mkdtemp, rm, symlink} from "fs/promises"; -import {createServer} from "net"; import os from "os"; import path from "path"; -import {spawn, spawnSync, type ChildProcess} from "child_process"; - -type CdpMessage = { - id?: number; - method?: string; - params?: Record; - result?: Record; - error?: {message: string}; - sessionId?: string; -}; - -type CdpTarget = { - id: string; - type: string; - url: string; -}; - -type CdpPendingRequest = { - resolve: (value: Record) => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; -}; +import {spawn, type ChildProcess} from "child_process"; + +import { + browserVersion, + CdpClient, + type CdpTarget, + findChromeBinary, + getFreePort, + run, + stop, + targets, + waitFor, +} from "./utils/chrome"; const rootDir = path.resolve(__dirname, "..", "..", ".."); const fixtureDir = path.join(__dirname, "offscreen-service"); const extensionDir = path.join(fixtureDir, "dist", "myapp-chrome-mv3"); -const findChromeBinary = (): string | undefined => { - if (process.env.ADNBN_CHROME_BIN) { - return process.env.ADNBN_CHROME_BIN; - } - - const result = spawnSync( - process.execPath, - [path.join(rootDir, "node_modules", "chrome-launcher", "bin", "print-chrome-path.cjs")], - {encoding: "utf8"} - ); - const chromePath = result.status === 0 ? result.stdout.trim() : ""; - - return chromePath || undefined; -}; - -const chromeBinary = findChromeBinary(); - -const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); - -const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { - const deadline = Date.now() + timeout; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const value = await callback(); - - if (value !== undefined) { - return value; - } - } catch (error) { - lastError = error; - } - - await delay(100); - } - - const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; - - throw new Error(`Timed out waiting for Chrome${detail}`); -}; - -const getFreePort = (): Promise => { - return new Promise((resolve, reject) => { - const server = createServer(); - - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Unable to reserve a debugging port")); - - return; - } - - server.close(error => (error ? reject(error) : resolve(address.port))); - }); - }); -}; - -const run = (command: string, args: string[], cwd: string, timeout = 30_000): Promise => { - return new Promise((resolve, reject) => { - const process = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); - let output = ""; - let settled = false; - - const finish = (callback: () => void): void => { - if (settled) { - return; - } - - settled = true; - clearTimeout(runTimeout); - callback(); - }; - const runTimeout = setTimeout(() => { - process.kill("SIGKILL"); - finish(() => reject(new Error(`${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`))); - }, timeout); - - process.stdout.on("data", chunk => (output += chunk)); - process.stderr.on("data", chunk => (output += chunk)); - process.once("error", error => finish(() => reject(error))); - process.once("exit", code => { - if (code === 0) { - finish(resolve); - } else { - finish(() => reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`))); - } - }); - }); -}; - -class CdpClient { - private nextId = 1; - private readonly pending = new Map(); - - 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.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(); - } -} - -const targets = async (port: number): Promise => { - const response = await fetch(`http://127.0.0.1:${port}/json/list`, {signal: AbortSignal.timeout(5_000)}); - - return response.json() as Promise; -}; - -const browserVersion = async (port: number): Promise<{webSocketDebuggerUrl: string}> => { - const response = await fetch(`http://127.0.0.1:${port}/json/version`, {signal: AbortSignal.timeout(5_000)}); - - return response.json() as Promise<{webSocketDebuggerUrl: string}>; -}; - -const stop = async (process: ChildProcess): Promise => { - if (process.exitCode !== null || process.killed) { - return; - } - - const waitForExit = (timeout: number): Promise => - new Promise(resolve => { - const onExit = () => { - clearTimeout(timer); - resolve(true); - }; - const timer = setTimeout(() => { - process.off("exit", onExit); - resolve(false); - }, timeout); - - process.once("exit", onExit); - }); - - const stopProcess = (signal: NodeJS.Signals): void => { - try { - process.kill(signal); - } catch (error) { - if (!(error instanceof Error) || !error.message.includes("ESRCH")) { - throw error; - } - } - }; - - const stopped = waitForExit(5_000); - - stopProcess("SIGTERM"); - - if (!(await stopped) && process.exitCode === null) { - const killed = waitForExit(5_000); - - stopProcess("SIGKILL"); - await killed; - } -}; +const chromeBinary = findChromeBinary(rootDir); jest.setTimeout(60_000); diff --git a/tests/integration/browser/options.integration.test.ts b/tests/integration/browser/options.integration.test.ts new file mode 100644 index 00000000..35186e85 --- /dev/null +++ b/tests/integration/browser/options.integration.test.ts @@ -0,0 +1,224 @@ +/** @jest-environment node */ + +import {mkdir, mkdtemp, readFile, rm, symlink} from "fs/promises"; +import os from "os"; +import path from "path"; +import {spawn, type ChildProcess} from "child_process"; + +import {browserVersion, CdpClient, findChromeBinary, getFreePort, run, stop, targets, waitFor} from "./utils/chrome"; + +const rootDir = path.resolve(__dirname, "..", "..", ".."); +const fixturesDir = path.join(__dirname, "options"); +const chromeBinary = findChromeBinary(rootDir); +const artifactDir = (fixtureDir: string, browser = "chrome", manifestVersion = 3): string => + path.join(fixtureDir, "dist", `myapp-${browser}-mv${manifestVersion}`); + +const buildFixture = async ( + fixtureDir: string, + { + react = false, + browser = "chrome", + manifestVersion = 3, + }: {react?: boolean; browser?: string; manifestVersion?: 2 | 3} = {} +): Promise => { + const modulesDir = path.join(fixtureDir, "node_modules"); + + await mkdir(modulesDir, {recursive: true}); + await symlink(rootDir, path.join(modulesDir, "adnbn"), "dir"); + + if (react) { + for (const dependency of ["react", "react-dom", "scheduler"]) { + await symlink(path.join(rootDir, "node_modules", dependency), path.join(modulesDir, dependency), "dir"); + } + } + + await run( + process.execPath, + [ + path.join(rootDir, "bin", "adnbn.js"), + "build", + ".", + "-b", + browser, + ...(manifestVersion === 2 ? ["--mv2"] : []), + ], + fixtureDir + ); +}; + +const cleanFixture = async (fixtureDir: string): Promise => { + for (const directory of ["node_modules", ".adnbn", "dist"]) { + await rm(path.join(fixtureDir, directory), {recursive: true, force: true}); + } +}; + +jest.setTimeout(90_000); + +test.each([ + {adapter: "vanilla", page: "options.html", title: "Vanilla Options", help: "help.html"}, + {adapter: "react", page: "ui/preferences.options.html", title: "React Options", help: "ui/help.html"}, +])("Chrome MV3 opens and renders $adapter options from the background", async ({adapter, page, title, help}) => { + if (!chromeBinary || !path.isAbsolute(chromeBinary)) { + throw new Error( + "Chrome is not installed or could not be found. Install Chrome or set ADNBN_CHROME_BIN to its absolute executable path." + ); + } + + const fixtureDir = path.join(fixturesDir, adapter); + const extensionDir = artifactDir(fixtureDir); + const userDataDir = await mkdtemp(path.join(os.tmpdir(), `adnbn-options-${adapter}-`)); + const debuggingPort = await getFreePort(); + let chrome: ChildProcess | undefined; + let browser: CdpClient | undefined; + let chromeOutput = ""; + + try { + await buildFixture(fixtureDir, {react: adapter === "react"}); + + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + const optionsHtml = await readFile(path.join(extensionDir, page), "utf8"); + const helpHtml = await readFile(path.join(extensionDir, help), "utf8"); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.options_ui).toEqual({page, open_in_tab: true}); + expect(manifest.options_page).toBeUndefined(); + expect(optionsHtml).toContain("common.view.js"); + expect(helpHtml).toContain("common.view.js"); + + chrome = spawn( + chromeBinary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + "--enable-logging=stderr", + "--v=0", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${userDataDir}`, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + chrome.stderr?.on("data", chunk => (chromeOutput += chunk)); + + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(debuggingPort)); + browser = await CdpClient.connect(webSocketDebuggerUrl); + const extension = await browser.send("Extensions.loadUnpacked", {path: extensionDir}); + const extensionId = extension.id as string | undefined; + + if (!extensionId) { + throw new Error("Chrome did not return an extension ID after loading the Options fixture"); + } + + const evaluate = async (sessionId: string, expression: string): Promise => { + const result = await browser!.send( + "Runtime.evaluate", + {expression, awaitPromise: true, returnByValue: true}, + sessionId + ); + + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + } + + return result.result.value; + }; + + const worker = await waitFor(async () => { + return (await targets(debuggingPort)).find( + target => + target.type === "service_worker" && + target.url === `chrome-extension://${extensionId}/${manifest.background.service_worker}` + ); + }); + const {sessionId: workerSessionId} = await browser.send("Target.attachToTarget", { + targetId: worker.id, + flatten: true, + }); + + await browser.send("Runtime.enable", {}, workerSessionId); + await waitFor(async () => { + return (await evaluate(workerSessionId, "globalThis.__adnbnOptionsReady === true")) ? true : undefined; + }); + await evaluate(workerSessionId, "chrome.runtime.openOptionsPage()"); + + const optionsTarget = await waitFor(async () => { + return (await targets(debuggingPort)).find( + target => target.type === "page" && target.url === `chrome-extension://${extensionId}/${page}` + ); + }); + const {sessionId: optionsSessionId} = await browser.send("Target.attachToTarget", { + targetId: optionsTarget.id, + flatten: true, + }); + + await browser.send("Runtime.enable", {}, optionsSessionId); + const rendered = await waitFor(async () => { + return ( + (await evaluate( + optionsSessionId, + `(() => { + const root = document.querySelector('[data-testid="options"]'); + if (!root) return null; + return { + adapter: root.dataset.adapter, + title: document.title, + count: root.querySelector('[data-testid="count"]')?.textContent, + color: getComputedStyle(root).color, + topLevel: window === window.top, + }; + })()` + )) ?? undefined + ); + }); + + expect(rendered).toEqual({adapter, title, count: "0", color: "rgb(31, 78, 121)", topLevel: true}); + + await evaluate(optionsSessionId, "document.querySelector('[data-testid=increment]').click()"); + const count = await waitFor(async () => { + const value = await evaluate(optionsSessionId, "document.querySelector('[data-testid=count]').textContent"); + + return value === "1" ? value : undefined; + }); + + expect(count).toBe("1"); + expect(browser.runtimeErrors).toEqual([]); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; Runtime errors: ${JSON.stringify( + browser?.runtimeErrors ?? [] + )}; Chrome output: ${chromeOutput}`, + {cause: error} + ); + } finally { + await browser?.close(); + + if (chrome) { + await stop(chrome); + } + + await cleanFixture(fixtureDir); + await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); + +describe.each(["chrome", "edge", "opera", "safari", "firefox"])("%s options manifest", browser => { + test.each([2, 3] as const)("MV%s build preserves explicit openInTab false", async manifestVersion => { + const fixtureDir = path.join(fixturesDir, "embedded"); + + try { + await buildFixture(fixtureDir, {browser, manifestVersion}); + + const extensionDir = artifactDir(fixtureDir, browser, manifestVersion); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(manifest.options_ui).toEqual({page: "options.html", open_in_tab: false}); + expect(manifest.options_page).toBeUndefined(); + expect(await readFile(path.join(extensionDir, manifest.options_ui.page), "utf8")).toContain("Embedded settings", +}); diff --git a/tests/integration/browser/options/embedded/tsconfig.json b/tests/integration/browser/options/embedded/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/options/embedded/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/browser/options/react/adnbn.config.ts b/tests/integration/browser/options/react/adnbn.config.ts new file mode 100644 index 00000000..d4f921e4 --- /dev/null +++ b/tests/integration/browser/options/react/adnbn.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "React Options Integration", + description: "Chrome MV3 options page using the React view adapter.", + version: "1.0.0", + htmlDir: "ui", + jsFilename: "[name].js", + cssFilename: "[name].css", +}); diff --git a/tests/integration/browser/options/react/package.json b/tests/integration/browser/options/react/package.json new file mode 100644 index 00000000..593ebf53 --- /dev/null +++ b/tests/integration/browser/options/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "addon-bone-options-react-fixture", + "private": true, + "type": "module" +} diff --git a/tests/integration/browser/options/react/src/background.ts b/tests/integration/browser/options/react/src/background.ts new file mode 100644 index 00000000..8682cbc0 --- /dev/null +++ b/tests/integration/browser/options/react/src/background.ts @@ -0,0 +1,11 @@ +import {defineBackground} from "adnbn"; + +declare global { + var __adnbnOptionsReady: boolean | undefined; +} + +export default defineBackground({ + main() { + globalThis.__adnbnOptionsReady = true; + }, +}); diff --git a/tests/integration/browser/options/react/src/help.page.tsx b/tests/integration/browser/options/react/src/help.page.tsx new file mode 100644 index 00000000..73a47eb4 --- /dev/null +++ b/tests/integration/browser/options/react/src/help.page.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import {definePage} from "adnbn"; + +export default definePage({ + title: "Options Help", + render() { + return
Options help
; + }, +}); diff --git a/tests/integration/browser/options/react/src/options.tsx b/tests/integration/browser/options/react/src/options.tsx new file mode 100644 index 00000000..c3692f41 --- /dev/null +++ b/tests/integration/browser/options/react/src/options.tsx @@ -0,0 +1,23 @@ +import React, {useState} from "react"; +import {defineOptions} from "adnbn"; + +import "./styles.css"; + +export default defineOptions({ + as: "preferences", + title: "React Options", + openInTab: true, + render() { + const [count, setCount] = useState(0); + + return ( +
+

React settings

+ {count} + +
+ ); + }, +}); diff --git a/tests/integration/browser/options/react/src/styles.css b/tests/integration/browser/options/react/src/styles.css new file mode 100644 index 00000000..61ccfda2 --- /dev/null +++ b/tests/integration/browser/options/react/src/styles.css @@ -0,0 +1,3 @@ +[data-testid="options"] { + color: rgb(31, 78, 121); +} diff --git a/tests/integration/browser/options/react/tsconfig.json b/tests/integration/browser/options/react/tsconfig.json new file mode 100644 index 00000000..a732df41 --- /dev/null +++ b/tests/integration/browser/options/react/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} diff --git a/tests/integration/browser/options/vanilla/adnbn.config.ts b/tests/integration/browser/options/vanilla/adnbn.config.ts new file mode 100644 index 00000000..99e6dc14 --- /dev/null +++ b/tests/integration/browser/options/vanilla/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Vanilla Options Integration", + description: "Chrome MV3 options page using the Vanilla view adapter.", + version: "1.0.0", + jsFilename: "[name].js", + cssFilename: "[name].css", +}); diff --git a/tests/integration/browser/options/vanilla/package.json b/tests/integration/browser/options/vanilla/package.json new file mode 100644 index 00000000..81874e29 --- /dev/null +++ b/tests/integration/browser/options/vanilla/package.json @@ -0,0 +1,5 @@ +{ + "name": "addon-bone-options-vanilla-fixture", + "private": true, + "type": "module" +} diff --git a/tests/integration/browser/options/vanilla/src/background.ts b/tests/integration/browser/options/vanilla/src/background.ts new file mode 100644 index 00000000..8682cbc0 --- /dev/null +++ b/tests/integration/browser/options/vanilla/src/background.ts @@ -0,0 +1,11 @@ +import {defineBackground} from "adnbn"; + +declare global { + var __adnbnOptionsReady: boolean | undefined; +} + +export default defineBackground({ + main() { + globalThis.__adnbnOptionsReady = true; + }, +}); diff --git a/tests/integration/browser/options/vanilla/src/help.page.ts b/tests/integration/browser/options/vanilla/src/help.page.ts new file mode 100644 index 00000000..c18c083e --- /dev/null +++ b/tests/integration/browser/options/vanilla/src/help.page.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "Options Help", + render: "
Options help
", +}); diff --git a/tests/integration/browser/options/vanilla/src/options.ts b/tests/integration/browser/options/vanilla/src/options.ts new file mode 100644 index 00000000..28c9e308 --- /dev/null +++ b/tests/integration/browser/options/vanilla/src/options.ts @@ -0,0 +1,30 @@ +import {defineOptions} from "adnbn"; + +import "./styles.css"; + +export default defineOptions({ + title: "Vanilla Options", + render() { + const main = document.createElement("main"); + main.dataset.testid = "options"; + main.dataset.adapter = "vanilla"; + + const heading = document.createElement("h1"); + heading.textContent = "Vanilla settings"; + + const count = document.createElement("output"); + count.dataset.testid = "count"; + count.textContent = "0"; + + const increment = document.createElement("button"); + increment.dataset.testid = "increment"; + increment.textContent = "Increment"; + increment.addEventListener("click", () => { + count.textContent = String(Number(count.textContent) + 1); + }); + + main.append(heading, count, increment); + + return main; + }, +}); diff --git a/tests/integration/browser/options/vanilla/src/styles.css b/tests/integration/browser/options/vanilla/src/styles.css new file mode 100644 index 00000000..61ccfda2 --- /dev/null +++ b/tests/integration/browser/options/vanilla/src/styles.css @@ -0,0 +1,3 @@ +[data-testid="options"] { + color: rgb(31, 78, 121); +} diff --git a/tests/integration/browser/options/vanilla/tsconfig.json b/tests/integration/browser/options/vanilla/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/options/vanilla/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/browser/utils/chrome.ts b/tests/integration/browser/utils/chrome.ts new file mode 100644 index 00000000..5a702a28 --- /dev/null +++ b/tests/integration/browser/utils/chrome.ts @@ -0,0 +1,301 @@ +import {createServer} from "net"; +import path from "path"; +import {spawn, spawnSync, type ChildProcess} from "child_process"; + +type CdpMessage = { + id?: number; + method?: string; + params?: Record; + result?: Record; + error?: {message: string}; + sessionId?: string; +}; + +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; + } + + const result = spawnSync( + process.execPath, + [path.join(rootDir, "node_modules", "chrome-launcher", "bin", "print-chrome-path.cjs")], + {encoding: "utf8"} + ); + const chromePath = result.status === 0 ? result.stdout.trim() : ""; + + return chromePath || undefined; +}; + +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); + +export const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { + const deadline = Date.now() + timeout; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const value = await callback(); + + if (value !== undefined) { + return value; + } + } catch (error) { + lastError = error; + } + + await delay(100); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + + throw new Error(`Timed out waiting for Chrome${detail}`); +}; + +export const getFreePort = (): Promise => { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Unable to reserve a debugging port")); + + return; + } + + server.close(error => (error ? reject(error) : resolve(address.port))); + }); + }); +}; + +export const run = (command: string, args: string[], cwd: string, timeout = 30_000): Promise => { + return new Promise((resolve, reject) => { + const process = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); + let output = ""; + let settled = false; + + const finish = (callback: () => void): void => { + if (settled) { + return; + } + + settled = true; + clearTimeout(runTimeout); + callback(); + }; + const runTimeout = setTimeout(() => { + process.kill("SIGKILL"); + finish(() => reject(new Error(`${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`))); + }, timeout); + + process.stdout.on("data", chunk => (output += chunk)); + process.stderr.on("data", chunk => (output += chunk)); + process.once("error", error => finish(() => reject(error))); + process.once("exit", code => { + if (code === 0) { + finish(resolve); + } else { + finish(() => reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`))); + } + }); + }); +}; + +export class CdpClient { + private nextId = 1; + private readonly pending = new Map(); + + public readonly runtimeErrors: 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 === "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)}); + + return response.json() as Promise; +}; + +export const browserVersion = async (port: number): Promise<{webSocketDebuggerUrl: string}> => { + const response = await fetch(`http://127.0.0.1:${port}/json/version`, {signal: AbortSignal.timeout(5_000)}); + + return response.json() as Promise<{webSocketDebuggerUrl: string}>; +}; + +export const stop = async (process: ChildProcess): Promise => { + if (process.exitCode !== null || process.killed) { + return; + } + + const waitForExit = (timeout: number): Promise => + new Promise(resolve => { + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + process.off("exit", onExit); + resolve(false); + }, timeout); + + process.once("exit", onExit); + }); + + const stopProcess = (signal: NodeJS.Signals): void => { + try { + process.kill(signal); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("ESRCH")) { + throw error; + } + } + }; + + const stopped = waitForExit(5_000); + + stopProcess("SIGTERM"); + + if (!(await stopped) && process.exitCode === null) { + const killed = waitForExit(5_000); + + stopProcess("SIGKILL"); + await killed; + } +}; diff --git a/tests/release-it.test.ts b/tests/release-it.test.ts index 8f8d922e..e424744c 100644 --- a/tests/release-it.test.ts +++ b/tests/release-it.test.ts @@ -62,37 +62,40 @@ describe("release-it version policy", () => { }); describe("release-it GitHub release notes", () => { - let release: {version: string; name: string; notes: string}; + type Release = {version: string; name: string; notes: string}; - beforeAll(() => { - // Exercise the installed ESM parser and writer without Jest transforms or release side effects. - release = JSON.parse( + const renderRelease = (message: string, currentVersion = "0.8.0"): Release => + // Exercise the installed preset, parser, and writer without Jest transforms or release side effects. + JSON.parse( execFileSync( process.execPath, [ "--input-type=module", "-e", ` + import {readFileSync} from "node:fs"; import {CommitParser} from "conventional-commits-parser"; + import {loadPreset} from "conventional-changelog-preset-loader"; import {Bumper} from "conventional-recommended-bump"; import {writeChangelogString} from "conventional-changelog-writer"; import semver from "semver"; import createReleaseConfig from "./.release-it.cjs"; + const {message, currentVersion} = JSON.parse(readFileSync(0, "utf8")); const config = createReleaseConfig(); const options = config.plugins["@release-it/conventional-changelog"]; - const commit = new CommitParser(options.parserOpts).parse( - "feat(relay)!: update relay targets\\n\\nBREAKING CHANGE: targets are mutually exclusive" - ); + const preset = await loadPreset(options.preset); + const commit = new CommitParser({...preset.parser, ...options.parserOpts}).parse(message); const {releaseType} = await new Bumper().commits([commit]).bump( - commits => options.whatBump(commits, "0.8.0") + commits => options.whatBump(commits, currentVersion) ); - const version = semver.inc("0.8.0", releaseType); + const version = semver.inc(currentVersion, releaseType); const changelog = await writeChangelogString([commit], { ...options.context, + contributors: [], version, date: "2026-08-27", - }, options.writerOpts); + }, {...preset.writer, ...options.writerOpts}); process.stdout.write(JSON.stringify({ version, @@ -101,17 +104,29 @@ describe("release-it GitHub release notes", () => { })); `, ], - {cwd: path.resolve(__dirname, ".."), encoding: "utf8", timeout: 10_000} + { + cwd: path.resolve(__dirname, ".."), + encoding: "utf8", + input: JSON.stringify({message, currentVersion}), + timeout: 10_000, + } ) ); + + let release: Release; + + beforeAll(() => { + release = renderRelease( + "feat(relay)!: update relay targets\n\nBREAKING CHANGE: targets are mutually exclusive" + ); }); test("recommends 0.9.0 for a parsed pre-1.0 breaking change", () => { expect(release.version).toBe("0.9.0"); }); - test("uses the framework name in the GitHub release title and notes heading", () => { - expect(release.name).toBe("Addon Bone v0.9.0"); + test("uses only the version in the GitHub release title and the framework name in the notes heading", () => { + expect(release.name).toBe("v0.9.0"); expect(release.notes).toMatch(/^## 🚀 Release Addon Bone v0\.9\.0 \(2026-08-27\)/); expect(release.notes).not.toContain("`adnbn`"); }); @@ -120,4 +135,50 @@ describe("release-it GitHub release notes", () => { expect(release.notes).toContain("### 💥 Breaking Changes"); expect(release.notes).toContain("targets are mutually exclusive"); }); + + test("renders a breaking commit description without repeating its header", () => { + expect(release.notes).toContain("* **relay:** update relay targets\n"); + }); + + test.each([ + ["feat(relay)!: update relay targets", "* **relay:** update relay targets\n"], + ["feat!: update relay targets", "* update relay targets\n"], + ])("renders %s without a breaking footer", (message, commitLine) => { + const {notes} = renderRelease(message); + + expect(notes).toContain("### 💥 Breaking Changes\n\n* update relay targets\n"); + expect(notes).toContain(`### ✨ Features\n\n${commitLine}`); + }); + + test.each([ + ["0.8.0", "0.9.0"], + ["1.4.2", "2.0.0"], + ])("bumps %s to %s for a breaking fix without a footer", (currentVersion, expectedVersion) => { + const result = renderRelease("fix(relay)!: update relay targets", currentVersion); + + expect(result.version).toBe(expectedVersion); + }); + + test("omits a pull request suffix from a breaking commit description", () => { + const {notes} = renderRelease("feat(relay)!: update relay targets (#42)"); + + expect(notes).toContain("### 💥 Breaking Changes\n\n* update relay targets\n"); + expect(notes).toContain("### ✨ Features\n\n* **relay:** update relay targets\n"); + }); + + test.each(["BREAKING CHANGE", "BREAKING-CHANGE"])("recognizes a %s footer without an exclamation mark", keyword => { + const result = renderRelease(`fix(relay): update relay targets\n\n${keyword}: targets are mutually exclusive`); + + expect(result.version).toBe("0.9.0"); + expect(result.notes).toContain("### 💥 Breaking Changes\n\n* targets are mutually exclusive\n"); + expect(result.notes).toContain("### 🐛 Bug Fixed\n\n* **relay:** update relay targets\n"); + }); + + test("keeps an ordinary fix as a patch without a breaking changes section", () => { + const result = renderRelease("fix(relay): update relay targets"); + + expect(result.version).toBe("0.8.1"); + expect(result.notes).toContain("### 🐛 Bug Fixed\n\n* **relay:** update relay targets\n"); + expect(result.notes).not.toContain("### 💥 Breaking Changes"); + }); });