From e86f08fb45f02c18f8911a8564177ee24104c307 Mon Sep 17 00:00:00 2001 From: devtechedge Date: Mon, 14 Sep 2026 05:31:26 +0530 Subject: [PATCH 1/4] feat: resolve the actual browser for browser-specific code generation --- cspell.yml | 1 + .../wxt/src/internal-utils/target-browser.ts | 29 ++++++++++ .../internal-utils/testing/fake-objects.ts | 7 +++ .../src/internal/__tests__/manifest.test.ts | 46 +++++++++++++++ .../internal/__tests__/resolve-config.test.ts | 57 +++++++++++++++++++ packages/wxt/src/internal/manifest.ts | 36 ++++++------ packages/wxt/src/internal/resolve-config.ts | 12 +++- packages/wxt/src/types.ts | 24 ++++++++ 8 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 packages/wxt/src/internal-utils/target-browser.ts diff --git a/cspell.yml b/cspell.yml index 3613375a0..7b2990a1f 100644 --- a/cspell.yml +++ b/cspell.yml @@ -133,6 +133,7 @@ words: - USSS - verkit - vitepress + - waterfox - webext - webextension - webextrc diff --git a/packages/wxt/src/internal-utils/target-browser.ts b/packages/wxt/src/internal-utils/target-browser.ts new file mode 100644 index 000000000..7bd681145 --- /dev/null +++ b/packages/wxt/src/internal-utils/target-browser.ts @@ -0,0 +1,29 @@ +import type { ActualBrowser, TargetBrowser, TargetBrowserMap } from '../types'; + +/** + * Default mapping from `--browser` values to the actual browser WXT generates + * code for. Custom `--browser` values are only mapped when the project adds an + * entry for them in its `targetBrowserMap` config. + */ +export const defaultTargetBrowserMap: TargetBrowserMap = { + chrome: 'chromium', + edge: 'chromium', + brave: 'chromium', + opera: 'chromium', + chromium: 'chromium', + firefox: 'firefox', + safari: 'safari', + ios: 'safari', +}; + +/** + * Resolve the actual browser to generate code for from a `--browser` value. + * Unknown values resolve to themselves so their behavior is unchanged until the + * project maps them to an actual browser. + */ +export function resolveActualBrowser( + browser: TargetBrowser, + targetBrowserMap: TargetBrowserMap = defaultTargetBrowserMap, +): ActualBrowser { + return targetBrowserMap[browser] ?? (browser as ActualBrowser); +} diff --git a/packages/wxt/src/internal-utils/testing/fake-objects.ts b/packages/wxt/src/internal-utils/testing/fake-objects.ts index b9836092b..8a819d065 100644 --- a/packages/wxt/src/internal-utils/testing/fake-objects.ts +++ b/packages/wxt/src/internal-utils/testing/fake-objects.ts @@ -24,6 +24,7 @@ import { import { mock } from 'vitest-mock-extended'; import { vi } from 'vitest'; import { setWxtForTesting } from '../../internal/wxt'; +import { resolveActualBrowser } from '../target-browser'; import type { Browser } from '@wxt-dev/browser'; faker.seed(__TEST_SEED__); @@ -232,6 +233,7 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { return { browser, + actualBrowser: resolveActualBrowser(browser), targetBrowsers: [], command, entrypointsDir: fakeDir(), @@ -345,6 +347,11 @@ export const fakeWxtDevServer = fakeObjectCreator(() => ({ export function setFakeWxt(overrides?: DeepPartial) { const wxt = fakeWxt(overrides); + // Mirror resolveConfig: `actualBrowser` is resolved from `browser` unless + // the test overrides it directly. + if (overrides?.config?.actualBrowser == null) { + wxt.config.actualBrowser = resolveActualBrowser(wxt.config.browser); + } setWxtForTesting(wxt); return wxt; } diff --git a/packages/wxt/src/internal/__tests__/manifest.test.ts b/packages/wxt/src/internal/__tests__/manifest.test.ts index ca4f56a52..309d19803 100644 --- a/packages/wxt/src/internal/__tests__/manifest.test.ts +++ b/packages/wxt/src/internal/__tests__/manifest.test.ts @@ -1380,6 +1380,52 @@ describe('Manifest Utils', () => { ]); } }); + + it('should key browser-specific checks off actualBrowser, not the raw browser label', async () => { + const cs: ContentScriptEntrypoint = { + type: 'content-script', + name: 'one', + inputPath: 'entrypoints/one.content.ts', + outputDir: contentScriptOutDir, + options: { + matches: ['*://google.com/*'], + cssInjectionMode: 'ui', + }, + skipped: false, + }; + const styles: OutputAsset = { + type: 'asset', + fileName: 'content-scripts/one.css', + }; + + const entrypoints = [cs]; + const buildOutput: Omit = { + publicAssets: [], + steps: [{ entrypoints: cs, chunks: [styles] }], + }; + + setFakeWxt({ + config: { + outDir, + command: 'build', + manifestVersion: 3, + browser: 'ios', + actualBrowser: 'safari', + }, + }); + + const { manifest: actual } = await generateManifest( + entrypoints, + buildOutput, + ); + + expect(actual.web_accessible_resources).toEqual([ + { + matches: ['*://google.com/*'], + resources: ['content-scripts/one.css'], + }, + ]); + }); }); describe('registration', () => { diff --git a/packages/wxt/src/internal/__tests__/resolve-config.test.ts b/packages/wxt/src/internal/__tests__/resolve-config.test.ts index d91d0d22e..f54d13ee0 100644 --- a/packages/wxt/src/internal/__tests__/resolve-config.test.ts +++ b/packages/wxt/src/internal/__tests__/resolve-config.test.ts @@ -37,4 +37,61 @@ describe('resolveConfig', () => { ); }); }); + + describe('actualBrowser', () => { + it.each<[string, string]>([ + ['chrome', 'chromium'], + ['edge', 'chromium'], + ['brave', 'chromium'], + ['opera', 'chromium'], + ['chromium', 'chromium'], + ['firefox', 'firefox'], + ['safari', 'safari'], + ['ios', 'safari'], + ])( + 'should resolve browser "%s" to actualBrowser "%s"', + async (browser, expected) => { + const config = await resolveConfig({ browser }, 'build'); + + expect(config.actualBrowser).toBe(expected); + }, + ); + + it('should resolve unknown browsers to themselves', async () => { + const config = await resolveConfig({ browser: 'waterfox' }, 'build'); + + expect(config.actualBrowser).toBe('waterfox'); + }); + + it('should let a custom targetBrowserMap entry win over the defaults', async () => { + const config = await resolveConfig( + { + browser: 'waterfox', + targetBrowserMap: { waterfox: 'firefox' }, + }, + 'build', + ); + + expect(config.actualBrowser).toBe('firefox'); + }); + + it('should not change the raw browser label', async () => { + const config = await resolveConfig({ browser: 'ios' }, 'build'); + + expect(config.browser).toBe('ios'); + }); + + it('should keep the raw browser label in the output directory', async () => { + const config = await resolveConfig({ browser: 'ios' }, 'build'); + + expect(config.outDir).toContain(`ios-mv${config.manifestVersion}`); + }); + + it('should default the manifest version based on the actual browser', async () => { + const config = await resolveConfig({ browser: 'ios' }, 'build'); + + expect(config.actualBrowser).toBe('safari'); + expect(config.manifestVersion).toBe(2); + }); + }); }); diff --git a/packages/wxt/src/internal/manifest.ts b/packages/wxt/src/internal/manifest.ts index 441aedcc9..bf9621000 100644 --- a/packages/wxt/src/internal/manifest.ts +++ b/packages/wxt/src/internal/manifest.ts @@ -111,13 +111,13 @@ export async function generateManifest( manifest.version = version; manifest.version_name = // Firefox doesn't support version_name - wxt.config.browser === 'firefox' || versionName === version + wxt.config.actualBrowser === 'firefox' || versionName === version ? undefined : versionName; // Warn if building for Firefox without data_collection_permissions if ( - wxt.config.browser === 'firefox' && + wxt.config.actualBrowser === 'firefox' && !userManifest.browser_specific_settings?.gecko ?.data_collection_permissions && !wxt.config.suppressWarnings?.firefoxDataCollection @@ -130,7 +130,7 @@ export async function generateManifest( } if ( - wxt.config.browser === 'firefox' && + wxt.config.actualBrowser === 'firefox' && !manifest.browser_specific_settings?.gecko?.id && !wxt.config.suppressWarnings?.firefoxId ) { @@ -142,7 +142,7 @@ export async function generateManifest( addEntrypoints(manifest, entrypoints, buildOutput); - if (wxt.config.browser === 'firefox') { + if (wxt.config.actualBrowser === 'firefox') { addDiscoveredThemeIcons(manifest, buildOutput); } @@ -237,7 +237,10 @@ function addEntrypoints( wxt.config.outDir, '.js', ); - if (wxt.config.browser === 'firefox' && wxt.config.manifestVersion === 3) { + if ( + wxt.config.actualBrowser === 'firefox' && + wxt.config.manifestVersion === 3 + ) { manifest.background = { type: background.options.type, scripts: [script], @@ -256,7 +259,7 @@ function addEntrypoints( } if (bookmarks) { - if (wxt.config.browser === 'firefox') { + if (wxt.config.actualBrowser === 'firefox') { wxt.logger.warn( 'Bookmarks are not supported by Firefox. chrome_url_overrides.bookmarks was not added to the manifest', ); @@ -271,7 +274,7 @@ function addEntrypoints( } if (history) { - if (wxt.config.browser === 'firefox') { + if (wxt.config.actualBrowser === 'firefox') { wxt.logger.warn( 'Bookmarks are not supported by Firefox. chrome_url_overrides.history was not added to the manifest', ); @@ -320,7 +323,7 @@ function addEntrypoints( const actionKey = wxt.config.manifestVersion === 2 ? (popup.options.actionType ?? 'browser_action') - : wxt.config.browser === 'firefox' && + : wxt.config.actualBrowser === 'firefox' && popup.options.actionType === 'page_action' ? 'page_action' : 'action'; @@ -343,16 +346,16 @@ function addEntrypoints( if (options) { const page = getEntrypointBundlePath(options, wxt.config.outDir, '.html'); manifest.options_ui = { - ...(wxt.config.browser !== 'safari' && { + ...(wxt.config.actualBrowser !== 'safari' && { open_in_tab: options.options.openInTab ?? false, }), // @ts-expect-error: Not typed by @wxt-dev/browser, but supported by Firefox browser_style: - wxt.config.browser === 'firefox' + wxt.config.actualBrowser === 'firefox' ? options.options.browserStyle : undefined, chrome_style: - wxt.config.browser !== 'firefox' + wxt.config.actualBrowser !== 'firefox' ? options.options.chromeStyle : undefined, page, @@ -360,7 +363,7 @@ function addEntrypoints( } if (sandboxes?.length) { - if (wxt.config.browser === 'firefox') { + if (wxt.config.actualBrowser === 'firefox') { wxt.logger.warn( 'Sandboxed pages not supported by Firefox. sandbox.pages was not added to the manifest', ); @@ -382,7 +385,7 @@ function addEntrypoints( '.html', ); - if (wxt.config.browser === 'firefox') { + if (wxt.config.actualBrowser === 'firefox') { manifest.sidebar_action = { default_panel: page, browser_style: defaultSidepanel.options.browserStyle, @@ -593,7 +596,8 @@ export function getContentScriptCssWebAccessibleResources( // Chrome-only MV3 field (obscures the resource URL behind a per-session // token); Firefox ignores it, and Safari's web extension converter // rejects it as an unsupported key. - ...(wxt.config.browser !== 'firefox' && wxt.config.browser !== 'safari' + ...(wxt.config.actualBrowser !== 'firefox' && + wxt.config.actualBrowser !== 'safari' ? { use_dynamic_url: true } : {}), matches: @@ -734,11 +738,11 @@ function stripKeys(manifest: Browser.runtime.Manifest): void { let keysToRemove: string[] = []; if (wxt.config.manifestVersion === 2) { keysToRemove.push(...mv3OnlyKeys); - if (wxt.config.browser === 'firefox') + if (wxt.config.actualBrowser === 'firefox') keysToRemove.push(...firefoxMv3OnlyKeys); } else { keysToRemove.push(...mv2OnlyKeys); - if (wxt.config.browser !== 'firefox') + if (wxt.config.actualBrowser !== 'firefox') keysToRemove.push(...chromeMv2OnlyKeys); } diff --git a/packages/wxt/src/internal/resolve-config.ts b/packages/wxt/src/internal/resolve-config.ts index a1d8d4981..2029c01bd 100644 --- a/packages/wxt/src/internal/resolve-config.ts +++ b/packages/wxt/src/internal/resolve-config.ts @@ -23,6 +23,10 @@ import { NullishRequired } from '../internal-utils/type-utils'; import { pathExists } from '../internal-utils/fs-utils'; import { normalizePath } from '../internal-utils/path-utils'; import { glob } from 'tinyglobby'; +import { + defaultTargetBrowserMap, + resolveActualBrowser, +} from '../internal-utils/target-browser'; import { builtinModules } from './builtin-modules'; import { getEslintVersion } from '../internal-utils/eslint-utils'; import { safeStringToNumber } from '../internal-utils/number-utils'; @@ -74,9 +78,14 @@ export async function resolveConfig( `Current target browser \`${browser}\` is not in your \`targetBrowsers\` list!`, ); } + const targetBrowserMap = { + ...defaultTargetBrowserMap, + ...mergedConfig.targetBrowserMap, + }; + const actualBrowser = resolveActualBrowser(browser, targetBrowserMap); const manifestVersion = mergedConfig.manifestVersion ?? - (browser === 'firefox' || browser === 'safari' ? 2 : 3); + (actualBrowser === 'firefox' || actualBrowser === 'safari' ? 2 : 3); const mode = mergedConfig.mode ?? COMMAND_MODES[command]; const env: ConfigEnv = { browser, command, manifestVersion, mode }; @@ -192,6 +201,7 @@ export async function resolveConfig( return { browser, targetBrowsers, + actualBrowser, command, debug, entrypointsDir, diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 1e2b9b477..4451cdd90 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -125,6 +125,18 @@ export interface InlineConfig { * @default [ ] */ targetBrowsers?: TargetBrowser[]; + /** + * Map `--browser` values to the actual browser WXT generates code for. + * + * WXT makes build-time decisions, like which manifest options to generate, + * based on the actual browser, not the `--browser` label. Use this to map + * custom `--browser` values to the browser they actually run in. Values not + * in the map are not mapped to any browser, so add an entry here for any + * custom label. + * + * @default `{ chrome: "chromium", edge: "chromium", brave: "chromium", opera: "chromium", chromium: "chromium", firefox: "firefox", safari: "safari", ios: "safari" }` + */ + targetBrowserMap?: TargetBrowserMap; /** * Explicitly set a manifest version to target. This will override the default * manifest version for each command, and can be overridden by the command @@ -629,6 +641,13 @@ export interface ReloadContentScriptPayload { export type TargetBrowser = string; export type TargetManifestVersion = 2 | 3; +/** + * The actual browser WXT generates code for, resolved from the `--browser` + * value via `targetBrowserMap`. + */ +export type ActualBrowser = 'chromium' | 'firefox' | 'safari'; +/** Maps a `--browser` value to the {@link ActualBrowser} WXT generates code for. */ +export type TargetBrowserMap = Record; export type UserConfig = Omit; @@ -1599,6 +1618,11 @@ export interface ResolvedConfig { command: WxtCommand; browser: TargetBrowser; targetBrowsers: TargetBrowser[]; + /** + * The actual browser WXT generates code for, resolved from `browser` via + * `targetBrowserMap`. + */ + actualBrowser: ActualBrowser; manifestVersion: TargetManifestVersion; env: ConfigEnv; logger: WxtLogger; From a0bfd6359109ab8f071ef803bfa0cd3e946f9349 Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sun, 1 Nov 2026 22:01:51 +0530 Subject: [PATCH 2/4] refactor(wxt): fall back to chromium for unmapped browsers Returning the raw label forced a type assertion on unknown --browser values. Chromium is the code WXT already generated for labels it does not recognize, so fall back to it explicitly and keep ActualBrowser honest. Also check own properties, so prototype keys passed to --browser cannot resolve to a function. --- .../wxt/src/internal-utils/target-browser.ts | 14 +++++-- .../internal/__tests__/resolve-config.test.ts | 41 ++++++++++++++++++- packages/wxt/src/types.ts | 12 +++--- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/wxt/src/internal-utils/target-browser.ts b/packages/wxt/src/internal-utils/target-browser.ts index 7bd681145..eab13fc3a 100644 --- a/packages/wxt/src/internal-utils/target-browser.ts +++ b/packages/wxt/src/internal-utils/target-browser.ts @@ -18,12 +18,20 @@ export const defaultTargetBrowserMap: TargetBrowserMap = { /** * Resolve the actual browser to generate code for from a `--browser` value. - * Unknown values resolve to themselves so their behavior is unchanged until the - * project maps them to an actual browser. + * + * Values missing from the map fall back to `chromium`, the code WXT generated + * for unrecognized labels before `targetBrowserMap` existed. Projects can opt a + * custom label into Firefox or Safari behavior by mapping it. */ export function resolveActualBrowser( browser: TargetBrowser, targetBrowserMap: TargetBrowserMap = defaultTargetBrowserMap, ): ActualBrowser { - return targetBrowserMap[browser] ?? (browser as ActualBrowser); + // Check own properties, so `--browser constructor` doesn't resolve to + // `Object.prototype.constructor`. + const mapped = Object.hasOwn(targetBrowserMap, browser) + ? targetBrowserMap[browser] + : undefined; + + return mapped ?? 'chromium'; } diff --git a/packages/wxt/src/internal/__tests__/resolve-config.test.ts b/packages/wxt/src/internal/__tests__/resolve-config.test.ts index 33f87e81c..b2a1d5fbc 100644 --- a/packages/wxt/src/internal/__tests__/resolve-config.test.ts +++ b/packages/wxt/src/internal/__tests__/resolve-config.test.ts @@ -57,10 +57,10 @@ describe('resolveConfig', () => { }, ); - it('should resolve unknown browsers to themselves', async () => { + it('should fall back to chromium for labels missing from the map', async () => { const config = await resolveConfig({ browser: 'waterfox' }, 'build'); - expect(config.actualBrowser).toBe('waterfox'); + expect(config.actualBrowser).toBe('chromium'); }); it('should let a custom targetBrowserMap entry win over the defaults', async () => { @@ -75,6 +75,43 @@ describe('resolveConfig', () => { expect(config.actualBrowser).toBe('firefox'); }); + it('should map several custom labels at once', async () => { + const targetBrowserMap = { + 'my-firefox': 'firefox', + 'my-safari': 'safari', + } as const; + + const firefox = await resolveConfig( + { browser: 'my-firefox', targetBrowserMap }, + 'build', + ); + const safari = await resolveConfig( + { browser: 'my-safari', targetBrowserMap }, + 'build', + ); + + expect(firefox.actualBrowser).toBe('firefox'); + expect(safari.actualBrowser).toBe('safari'); + }); + + it('should let a custom targetBrowserMap entry override a default mapping', async () => { + const config = await resolveConfig( + { + browser: 'chrome', + targetBrowserMap: { chrome: 'firefox' }, + }, + 'build', + ); + + expect(config.actualBrowser).toBe('firefox'); + }); + + it('should not resolve inherited object properties', async () => { + const config = await resolveConfig({ browser: 'constructor' }, 'build'); + + expect(config.actualBrowser).toBe('chromium'); + }); + it('should not change the raw browser label', async () => { const config = await resolveConfig({ browser: 'ios' }, 'build'); diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 77e252fb7..55369a376 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -130,9 +130,10 @@ export interface InlineConfig { * * WXT makes build-time decisions, like which manifest options to generate, * based on the actual browser, not the `--browser` label. Use this to map - * custom `--browser` values to the browser they actually run in. Values not - * in the map are not mapped to any browser, so add an entry here for any - * custom label. + * custom `--browser` values to the browser they actually run in. + * + * Labels missing from the map fall back to `"chromium"`. Add an entry for + * every custom label that runs somewhere else. * * @default `{ chrome: "chromium", edge: "chromium", brave: "chromium", opera: "chromium", chromium: "chromium", firefox: "firefox", safari: "safari", ios: "safari" }` */ @@ -643,7 +644,8 @@ export type TargetBrowser = string; export type TargetManifestVersion = 2 | 3; /** * The actual browser WXT generates code for, resolved from the `--browser` - * value via `targetBrowserMap`. + * value via `targetBrowserMap`. Labels missing from the map fall back to + * `"chromium"`. */ export type ActualBrowser = 'chromium' | 'firefox' | 'safari'; /** Maps a `--browser` value to the {@link ActualBrowser} WXT generates code for. */ @@ -1620,7 +1622,7 @@ export interface ResolvedConfig { targetBrowsers: TargetBrowser[]; /** * The actual browser WXT generates code for, resolved from `browser` via - * `targetBrowserMap`. + * `targetBrowserMap`. Labels missing from the map resolve to `"chromium"`. */ actualBrowser: ActualBrowser; manifestVersion: TargetManifestVersion; From b794d5663788f9bce624232d987f9fafc94df57f Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sun, 1 Nov 2026 22:02:03 +0530 Subject: [PATCH 3/4] chore(wxt): cover actualBrowser behavior in manifest tests Compare complete manifests instead of single keys: an ios build must match the safari build for both manifest versions, custom labels must match the browser they map to, and Chrome-only fields must still appear for a chromium build so the negative assertions are not vacuous. Adds an e2e case that builds both labels, plus a unit test keeping BROWSER and the browser booleans on the raw label. --- .../wxt/e2e/tests/manifest-content.test.ts | 58 +++++++ .../src/internal/__tests__/manifest.test.ts | 151 ++++++++++++++++++ .../__tests__/runtime-env-entries.test.ts | 39 +++++ 3 files changed, 248 insertions(+) create mode 100644 packages/wxt/src/internal/__tests__/runtime-env-entries.test.ts diff --git a/packages/wxt/e2e/tests/manifest-content.test.ts b/packages/wxt/e2e/tests/manifest-content.test.ts index b9b68f353..d8a6053d4 100644 --- a/packages/wxt/e2e/tests/manifest-content.test.ts +++ b/packages/wxt/e2e/tests/manifest-content.test.ts @@ -30,4 +30,62 @@ describe('Manifest Content', () => { expect(safariManifest.background.persistent).toBe(expected); }, ); + + it('should generate the same manifest for an ios build as for a safari build', async () => { + const project = new TestProject(); + + project.addFile( + 'entrypoints/background.ts', + `export default defineBackground({ main: () => {} })`, + ); + project.addFile('entrypoints/popup.html', ''); + project.addFile( + 'entrypoints/one.content/index.ts', + `import './style.css'; + + export default defineContentScript({ + matches: ['*://google.com/*'], + cssInjectionMode: 'ui', + main: () => {}, + })`, + ); + project.addFile('entrypoints/one.content/style.css', `body { color: red }`); + + await project.build({ browser: 'safari' }); + const safariManifest = await project.getOutputManifest( + '.output/safari-mv2/manifest.json', + ); + + // The output directory and the default manifest version still use the raw + // `--browser` label, only the generated manifest follows the actual browser. + await project.build({ browser: 'ios' }); + const iosManifest = await project.getOutputManifest( + '.output/ios-mv2/manifest.json', + ); + + expect(iosManifest).toEqual(safariManifest); + }); + + it('should apply a custom targetBrowserMap', async () => { + const project = new TestProject(); + project.setConfigFileConfig({ + targetBrowserMap: { + waterfox: 'firefox', + }, + }); + + project.addFile( + 'entrypoints/background.ts', + `export default defineBackground({ main: () => {} })`, + ); + + await project.build({ browser: 'waterfox' }); + const manifest = await project.getOutputManifest( + '.output/waterfox-mv2/manifest.json', + ); + + // Firefox is the only browser that defaults to MV2, so an MV2 build of a + // custom label proves the mapping was applied. + expect(manifest.manifest_version).toBe(2); + }); }); diff --git a/packages/wxt/src/internal/__tests__/manifest.test.ts b/packages/wxt/src/internal/__tests__/manifest.test.ts index 309d19803..9c5b48a28 100644 --- a/packages/wxt/src/internal/__tests__/manifest.test.ts +++ b/packages/wxt/src/internal/__tests__/manifest.test.ts @@ -19,10 +19,12 @@ import { Entrypoint, OutputAsset, TargetManifestVersion, + ActualBrowser, } from '../../types'; import { wxt } from '../wxt'; import { mock } from 'vitest-mock-extended'; import type { Browser } from '@wxt-dev/browser'; +import { resolveActualBrowser } from '../../internal-utils/target-browser'; const outDir = '/output'; const contentScriptOutDir = '/output/content-scripts'; @@ -2277,6 +2279,155 @@ describe('Manifest Utils', () => { ); }); }); + + describe('actualBrowser', () => { + const background = fakeBackgroundEntrypoint({ + outputDir: outDir, + options: { persistent: true, type: 'module' }, + skipped: false, + }); + const popup = fakePopupEntrypoint({ + outputDir: outDir, + options: { + actionType: 'page_action', + defaultIcon: { '16': '/icon/16.png' }, + defaultTitle: 'Default Title', + }, + skipped: false, + }); + const options = fakeOptionsEntrypoint({ + outputDir: outDir, + options: { + openInTab: true, + chromeStyle: true, + browserStyle: true, + }, + skipped: false, + }); + const sidepanel = fakeSidepanelEntrypoint({ + outputDir: outDir, + options: { + browserStyle: true, + defaultTitle: 'Sidepanel', + }, + skipped: false, + }); + const styles: OutputAsset = { + type: 'asset', + fileName: 'content-scripts/one.css', + }; + const contentScript: ContentScriptEntrypoint = { + type: 'content-script', + name: 'one', + inputPath: 'entrypoints/one.content.ts', + outputDir: contentScriptOutDir, + options: { + matches: ['*://google.com/*'], + cssInjectionMode: 'ui', + }, + skipped: false, + }; + const entrypoints: Entrypoint[] = [ + background, + popup, + options, + sidepanel, + contentScript, + ]; + const buildOutput: Omit = { + publicAssets: [], + steps: [{ entrypoints: contentScript, chunks: [styles] }], + }; + + async function generateFor(params: { + browser: string; + actualBrowser: ActualBrowser; + manifestVersion: TargetManifestVersion; + }) { + setFakeWxt({ + config: { + outDir, + command: 'build', + mode: 'production', + browser: params.browser, + actualBrowser: params.actualBrowser, + manifestVersion: params.manifestVersion, + manifest: { + name: 'wxt-test', + version: '1.2.3', + version_name: '1.2.3-beta', + }, + }, + }); + + return (await generateManifest(entrypoints, buildOutput)).manifest; + } + + const getDynamicUrlFlags = (manifest: Browser.runtime.Manifest) => { + const resources = manifest.web_accessible_resources; + if (!Array.isArray(resources)) return []; + + return resources.map((resource) => + typeof resource === 'object' && 'use_dynamic_url' in resource + ? Boolean(resource.use_dynamic_url) + : undefined, + ); + }; + + it.each<[TargetManifestVersion]>([[2], [3]])( + 'should generate the same manifest for an ios build as a safari build (MV%i)', + async (manifestVersion) => { + const safari = await generateFor({ + browser: 'safari', + actualBrowser: 'safari', + manifestVersion, + }); + const ios = await generateFor({ + browser: 'ios', + actualBrowser: resolveActualBrowser('ios'), + manifestVersion, + }); + + expect(ios).toEqual(safari); + expect(ios.options_ui?.open_in_tab).toBeUndefined(); + expect(getDynamicUrlFlags(ios)).toEqual([undefined]); + }, + ); + + it('should keep Chrome-only manifest fields when the actual browser is chromium', async () => { + const chrome = await generateFor({ + browser: 'chrome', + actualBrowser: 'chromium', + manifestVersion: 3, + }); + + expect(chrome.options_ui?.open_in_tab).toBe(true); + expect(getDynamicUrlFlags(chrome)).toEqual([true]); + }); + + it.each([ + { label: 'my-firefox', actualBrowser: 'firefox' }, + { label: 'my-safari', actualBrowser: 'safari' }, + ] as const)( + 'should generate the $actualBrowser manifest for the custom label "$label"', + async ({ label, actualBrowser }) => { + const mapped = await generateFor({ + browser: label, + actualBrowser: resolveActualBrowser(label, { + [label]: actualBrowser, + }), + manifestVersion: 2, + }); + const native = await generateFor({ + browser: actualBrowser, + actualBrowser, + manifestVersion: 2, + }); + + expect(mapped).toEqual(native); + }, + ); + }); }); describe('stripPathFromMatchPattern', () => { diff --git a/packages/wxt/src/internal/__tests__/runtime-env-entries.test.ts b/packages/wxt/src/internal/__tests__/runtime-env-entries.test.ts new file mode 100644 index 000000000..ac3714ca0 --- /dev/null +++ b/packages/wxt/src/internal/__tests__/runtime-env-entries.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { getRuntimeEnvEntries } from '../runtime-env-entries'; +import { fakeResolvedConfig } from '../../internal-utils/testing/fake-objects'; + +describe('getRuntimeEnvEntries', () => { + it('should expose the raw --browser label, not the actual browser', () => { + const config = fakeResolvedConfig({ + browser: 'ios', + actualBrowser: 'safari', + }); + + const values = Object.fromEntries( + getRuntimeEnvEntries(config).map((entry) => [entry.name, entry.value]), + ); + + // `BROWSER` and the per-browser booleans are documented as comparisons + // against the target browser, so they must keep using the label even when + // the actual browser is different. + expect(values.BROWSER).toBe('ios'); + expect(values.SAFARI).toBe(false); + expect(values.CHROME).toBe(false); + expect(values.FIREFOX).toBe(false); + }); + + it('should keep the browser booleans keyed off the label', () => { + const config = fakeResolvedConfig({ + browser: 'edge', + actualBrowser: 'chromium', + }); + + const values = Object.fromEntries( + getRuntimeEnvEntries(config).map((entry) => [entry.name, entry.value]), + ); + + expect(values.BROWSER).toBe('edge'); + expect(values.EDGE).toBe(true); + expect(values.CHROME).toBe(false); + }); +}); From e8abcbc1f5977ab01aa12312502c59183c892a1c Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sun, 1 Nov 2026 22:02:11 +0530 Subject: [PATCH 4/4] docs: document targetBrowserMap Explains which labels WXT knows, the Chromium fallback for anything else, and that output paths and import.meta.env.BROWSER keep the label you built with. --- .../essentials/target-different-browsers.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/guide/essentials/target-different-browsers.md b/docs/guide/essentials/target-different-browsers.md index 985fdf78a..ace8d3485 100644 --- a/docs/guide/essentials/target-different-browsers.md +++ b/docs/guide/essentials/target-different-browsers.md @@ -27,12 +27,28 @@ if (import.meta.env.FIREFOX) { Read about [Built-in Environment Variables](/guide/essentials/config/environment-variables.html#built-in-environment-variables) for more details. +## Map Custom Browsers + +WXT knows how a handful of labels behave: `chrome`, `edge`, `brave`, `opera` and `chromium` generate Chromium code, `firefox` generates Firefox code, and `safari` and `ios` generate Safari code. + +When you build with a custom label, WXT falls back to Chromium, since it has no way to know what that label runs in. Use `targetBrowserMap` to tell it: + +```ts +export default defineConfig({ + targetBrowserMap: { + waterfox: 'firefox', + }, +}); +``` + +Now `wxt -b waterfox` generates the same manifest, and the same default manifest version, as `wxt -b firefox`. The build still outputs to `.output/waterfox-mv2`, and `import.meta.env.BROWSER` still reports `"waterfox"`, so output paths and runtime checks keep the label you built with. + ## Target a Manifest Version To target specific manifest versions, use the `--mv2` or `--mv3` CLI flags. :::tip Default Manifest Version -By default, WXT will target MV2 for Safari and Firefox and MV3 for all other browsers. +By default, WXT will target MV2 for Safari and Firefox, including custom labels mapped to them, and MV3 for all other browsers. ::: Similar to the browser, you can get the target manifest version at runtime using the [built-in environment variable](/guide/essentials/config/environment-variables.html#built-in-environment-variables):