Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .release-it.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const createReleaseConfig = () => {

github: {
release: true,
releaseName: "Addon Bone v${version}",
releaseName: "v${version}",
autoGenerate: false,
releaseNotes: ({changelog}) => changelog,
},
Expand All @@ -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"],
Expand Down
80 changes: 79 additions & 1 deletion src/cli/builders/manifest/Manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions src/cli/builders/manifest/ManifestBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ManifestIcons,
ManifestIncognito,
ManifestOptionalPermissions,
ManifestOptions,
ManifestPermissions,
ManifestPopup,
ManifestSandbox,
Expand Down Expand Up @@ -62,6 +63,7 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
protected background?: ManifestBackground;
protected popup?: ManifestPopup;
protected sidebar?: ManifestSidebar;
protected options?: ManifestOptions;
protected sandboxes: ManifestSandboxes = new Set();
protected sandboxCsp: CspBuilder<SandboxCspConfig> = new SandboxCsp();
protected csp: CspBuilder<CspConfig> = new Csp();
Expand Down Expand Up @@ -265,6 +267,12 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
return this;
}

public setOptions(options?: ManifestOptions): this {
this.options = options;

return this;
}

public addSandbox(sandbox: ManifestSandbox): this {
this.sandboxes.add(sandbox);

Expand Down Expand Up @@ -436,6 +444,7 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
this.buildCommands(),
this.buildAction(),
this.buildSidebar(),
this.buildOptions(),
this.buildContentScripts(),
this.buildPermissions(),
this.buildOptionalPermissions(),
Expand Down Expand Up @@ -654,6 +663,19 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
: {side_panel: {...commonProps, default_path: path}};
}

protected buildOptions(): Partial<CoreManifest> {
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<Manifest> | undefined {
const optionalSettings = this.combinedRaws.browser_specific_settings;
const {safari, gecko, geckoAndroid} = this.specific || {};
Expand Down Expand Up @@ -731,6 +753,8 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
commands,
action,
sidebar,
options_ui,
options_page,
content_scripts,
permissions,
optional_permissions,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/entrypoint/finder/AbstractEntrypointFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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";

import {EntrypointFile, EntrypointFileExtensions, EntrypointOptions} from "@typing/entrypoint";
import {ReadonlyConfig} from "@typing/config";

export default abstract class<O extends EntrypointOptions> extends AbstractOptionsFinder<O> {
export default abstract class<O extends EntrypointOptions> extends AbstractParsedFinder<O> {
protected fileExtensionsPattern: string;

protected possibleIndexFiles: Set<string>;
Expand Down
151 changes: 151 additions & 0 deletions src/cli/entrypoint/finder/OptionsFinder.test.ts
Original file line number Diff line number Diff line change
@@ -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<EntrypointFile, OptionsEntrypointOptions>
) {
super(config);
}

public plugin(): EntrypointOptionsFinder<OptionsEntrypointOptions> {
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<EntrypointFile> {
return this.findFiles(directory);
}
}

const makeConfig = (overrides: Partial<ReadonlyConfig> = {}) =>
({
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<EntrypointFile, OptionsEntrypointOptions>([
[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<EntrypointFile, OptionsEntrypointOptions>([
[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([]);
});
});
43 changes: 43 additions & 0 deletions src/cli/entrypoint/finder/OptionsFinder.ts
Original file line number Diff line number Diff line change
@@ -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<OptionsEntrypointOptions> {
public constructor(config: ReadonlyConfig) {
super(config);
}

public type(): EntrypointType {
return EntrypointType.Options;
}

protected getParser(): EntrypointParser<OptionsEntrypointOptions> {
return new OptionsParser(this.config);
}

protected getPlugin(): EntrypointOptionsFinder<OptionsEntrypointOptions> {
return new PluginFinder(this.config, "options", this);
}

protected async getViews(): Promise<ViewItems<OptionsEntrypointOptions>> {
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;
}
}
Loading
Loading