diff --git a/packages/analytics/entrypoints/popup/index.html b/packages/analytics/entrypoints/popup/index.html index 6725f56e6..3c701dd96 100644 --- a/packages/analytics/entrypoints/popup/index.html +++ b/packages/analytics/entrypoints/popup/index.html @@ -10,7 +10,7 @@  Analytics enabled - + diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts index 85bac1097..f08676aed 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/client.ts @@ -2,30 +2,51 @@ import { UAParser } from 'ua-parser-js'; import type { Analytics, AnalyticsConfig, + AnalyticsEventMetadata, AnalyticsPageViewEvent, + AnalyticsProvider, AnalyticsStorageItem, AnalyticsTrackEvent, BaseAnalyticsEvent, - AnalyticsEventMetadata, - AnalyticsProvider, + TAnalyticsMessage, + TAnalyticsMethod, + TMethodForwarder, } from './types'; import { browser } from '@wxt-dev/browser'; const ANALYTICS_PORT = '@wxt-dev/analytics'; +const INTERACTIVE_TAGS = new Set([ + 'A', + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', +]); + +const INTERACTIVE_ROLES = new Set([ + 'button', + 'link', + 'checkbox', + 'menuitem', + 'tab', + 'radio', +]); + export function createAnalytics(config?: AnalyticsConfig): Analytics { if (!browser?.runtime?.id) throw Error( 'Cannot use WXT analytics in contexts without access to the browser.runtime APIs', ); - if (config == null) { + + if (config === null) { console.warn( "[@wxt-dev/analytics] Config not provided to createAnalytics. If you're using WXT, add the 'analytics' property to '/app.config.ts'.", ); } // TODO: This only works for standard WXT extensions, add a more generic - // background script detector that works with non-WXT projects. + // Background script detector that works with non-WXT projects. if (location.pathname === '/background.js') return createBackgroundAnalytics(config); @@ -54,16 +75,18 @@ function createBackgroundAnalytics( // Cached values const platformInfo = browser.runtime.getPlatformInfo(); const userAgent = UAParser(); + let userId = Promise.resolve(userIdStorage.getValue()).then( (id) => id ?? globalThis.crypto.randomUUID(), ); let userProperties = userPropertiesStorage.getValue(); + const manifest = browser.runtime.getManifest(); const getBackgroundMeta = () => ({ timestamp: Date.now(), - // Don't track sessions for the background, it can be running - // indefinitely, and will inflate session duration stats. + // Don't track sessions for the background, it can be running indefinitely + // and will inflate session duration stats. sessionId: undefined, language: navigator.language, referrer: undefined, @@ -75,7 +98,8 @@ function createBackgroundAnalytics( const getBaseEvent = async ( meta: AnalyticsEventMetadata, ): Promise => { - const platform = await platformInfo; + const { arch, os } = await platformInfo; + return { meta, user: { @@ -84,8 +108,8 @@ function createBackgroundAnalytics( version: config?.version ?? manifest.version_name ?? manifest.version, wxtMode: import.meta.env.MODE, wxtBrowser: import.meta.env.BROWSER, - arch: platform.arch, - os: platform.os, + arch, + os, browser: userAgent.browser.name, browserVersion: userAgent.browser.version, ...(await userProperties), @@ -110,7 +134,9 @@ function createBackgroundAnalytics( ]); // Notify providers const event = await getBaseEvent(meta); + if (config?.debug) console.debug('[@wxt-dev/analytics] identify', event); + if (await enabled.getValue()) { await Promise.allSettled( providers.map((provider) => provider.identify(event)), @@ -134,7 +160,9 @@ function createBackgroundAnalytics( title: meta?.title ?? globalThis.document?.title, }, }; + if (config?.debug) console.debug('[@wxt-dev/analytics] page', event); + if (await enabled.getValue()) { await Promise.allSettled( providers.map((provider) => provider.page(event)), @@ -155,7 +183,9 @@ function createBackgroundAnalytics( ...baseEvent, event: { name: eventName, properties: eventProperties }, }; + if (config?.debug) console.debug('[@wxt-dev/analytics] track', event); + if (await enabled.getValue()) { await Promise.allSettled( providers.map((provider) => provider.track(event)), @@ -181,9 +211,8 @@ function createBackgroundAnalytics( // Listen for messages from the rest of the extension browser.runtime.onConnect.addListener((port) => { if (port.name === ANALYTICS_PORT) { - port.onMessage.addListener(({ fn, args }) => { - // @ts-expect-error: Untyped fn key - void analytics[fn]?.(...args); + port.onMessage.addListener(({ fn, args }: TAnalyticsMessage) => { + void (analytics[fn] as TAnalyticsMethod)?.(...args); }); } }); @@ -197,21 +226,20 @@ function createBackgroundAnalytics( function createFrontendAnalytics(): Analytics { const port = browser.runtime.connect({ name: ANALYTICS_PORT }); const sessionId = Date.now(); + const getFrontendMetadata = (): AnalyticsEventMetadata => ({ sessionId, timestamp: Date.now(), language: navigator.language, - referrer: globalThis.document?.referrer || undefined, - screen: globalThis.window - ? `${globalThis.window.screen.width}x${globalThis.window.screen.height}` - : undefined, + referrer: globalThis.document?.referrer, + screen: `${globalThis.window.screen.width}x${globalThis.window.screen.height}`, url: location.href, - title: document.title || undefined, + title: document.title, }); - const methodForwarder = - (fn: string) => - (...args: any[]) => { + const methodForwarder: TMethodForwarder = + (fn) => + (...args) => { port.postMessage({ fn, args: [...args, getFrontendMetadata()] }); }; @@ -222,59 +250,47 @@ function createFrontendAnalytics(): Analytics { setEnabled: methodForwarder('setEnabled'), autoTrack: (root) => { const onClick = (event: Event) => { - const element = event.target as any; + const element = event.target as HTMLElement | null; + if ( !element || (!INTERACTIVE_TAGS.has(element.tagName) && - !INTERACTIVE_ROLES.has(element.getAttribute('role'))) - ) + !INTERACTIVE_ROLES.has(element.getAttribute('role') ?? '')) + ) { return; + } void analytics.track('click', { tagName: element.tagName?.toLowerCase(), - id: element.id || undefined, - className: element.className || undefined, - textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length - href: element.href, + id: element.id, + className: element.className, + textContent: element.textContent?.substring(0, 50), // Limit text content length + href: (element as HTMLAnchorElement).href, }); }; root.addEventListener('click', onClick, { capture: true, passive: true }); + return () => { root.removeEventListener('click', onClick); }; }, }; + return analytics; } function defineStorageItem( key: string, - defaultValue?: NonNullable, + defaultValue?: T, ): AnalyticsStorageItem { return { getValue: async () => - (await browser.storage.local.get>(key))[key] ?? - defaultValue, + (((await browser.storage.local.get(key)) as Record)[key] ?? + defaultValue) as T, setValue: (newValue) => browser.storage.local.set({ [key]: newValue }), }; } -const INTERACTIVE_TAGS = new Set([ - 'A', - 'BUTTON', - 'INPUT', - 'SELECT', - 'TEXTAREA', -]); -const INTERACTIVE_ROLES = new Set([ - 'button', - 'link', - 'checkbox', - 'menuitem', - 'tab', - 'radio', -]); - export function defineAnalyticsProvider( definition: ( /** The analytics object. */ diff --git a/packages/analytics/modules/analytics/index.ts b/packages/analytics/modules/analytics/index.ts index f5ef4b00c..116dcf95a 100644 --- a/packages/analytics/modules/analytics/index.ts +++ b/packages/analytics/modules/analytics/index.ts @@ -22,6 +22,7 @@ export default defineWxtModule({ // Paths const wxtAnalyticsFolder = resolve(wxt.config.wxtDir, 'analytics'); const wxtAnalyticsIndex = resolve(wxtAnalyticsFolder, 'index.ts'); + const clientModuleId = process.env.NPM ? '@wxt-dev/analytics' : resolve(wxt.config.modulesDir, 'analytics/client'); @@ -44,11 +45,10 @@ export default defineWxtModule({ ? clientModuleId : relative(wxtAnalyticsFolder, clientModuleId) }';`, - `import { useAppConfig } from '#imports';`, - ``, - `export const analytics = createAnalytics(useAppConfig().analytics);`, - ``, + `import { useAppConfig } from '#imports';\n`, + `export const analytics = createAnalytics(useAppConfig().analytics);\n`, ].join('\n'); + addAlias(wxt, '#analytics', wxtAnalyticsIndex); wxt.hook('prepare:types', async (_, entries) => { entries.push({ @@ -62,6 +62,7 @@ export default defineWxtModule({ const hasBackground = entrypoints.find( (entry) => entry.type === 'background', ); + if (!hasBackground) { entrypoints.push({ type: 'background', diff --git a/packages/analytics/modules/analytics/providers/google-analytics-4.ts b/packages/analytics/modules/analytics/providers/google-analytics-4.ts index 278be6794..61fe66ff3 100644 --- a/packages/analytics/modules/analytics/providers/google-analytics-4.ts +++ b/packages/analytics/modules/analytics/providers/google-analytics-4.ts @@ -15,13 +15,15 @@ export const googleAnalytics4 = data: BaseAnalyticsEvent, eventName: string, eventProperties: Record | undefined, - ): Promise => { + ) => { const url = new URL( config?.debug ? '/debug/mp/collect' : '/mp/collect', 'https://www.google-analytics.com', ); + if (options.apiSecret) url.searchParams.set('api_secret', options.apiSecret); + if (options.measurementId) url.searchParams.set('measurement_id', options.measurementId); @@ -30,10 +32,11 @@ export const googleAnalytics4 = screen: data.meta.screen, ...data.user.properties, }; + const mappedUserProperties = Object.fromEntries( Object.entries(userProperties).map(([name, value]) => [ name, - value == null ? undefined : { value }, + value === null ? undefined : { value }, ]), ); diff --git a/packages/analytics/modules/analytics/providers/umami.ts b/packages/analytics/modules/analytics/providers/umami.ts index 99a4e4914..89f5fa888 100644 --- a/packages/analytics/modules/analytics/providers/umami.ts +++ b/packages/analytics/modules/analytics/providers/umami.ts @@ -12,6 +12,7 @@ export const umami = defineAnalyticsProvider( if (config.debug) { console.debug('[@wxt-dev/analytics] Sending event to Umami:', payload); } + return fetch(`${options.apiUrl}/send`, { method: 'POST', headers: { @@ -66,5 +67,5 @@ interface UmamiPayload { url?: string; website: string; name: string; - data?: Record; + data?: Record; } diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts index d14d59f81..312f9601c 100644 --- a/packages/analytics/modules/analytics/types.ts +++ b/packages/analytics/modules/analytics/types.ts @@ -60,7 +60,7 @@ export interface BaseAnalyticsEvent { meta: AnalyticsEventMetadata; user: { id: string; - properties: Record; + properties: Record; }; } @@ -97,3 +97,18 @@ export interface AnalyticsTrackEvent extends BaseAnalyticsEvent { properties?: Record; }; } + +export type TAnalyticsMessage = { + [K in keyof Analytics]: { + fn: K; + args: Parameters; + }; +}[keyof Analytics]; + +export type TAnalyticsMethod = + | ((...args: Parameters) => void) + | undefined; + +export type TMethodForwarder = ( + fn: K, +) => (...args: Parameters) => void; diff --git a/packages/auto-icons/src/index.ts b/packages/auto-icons/src/index.ts index a97aeede5..3508249f7 100644 --- a/packages/auto-icons/src/index.ts +++ b/packages/auto-icons/src/index.ts @@ -1,9 +1,9 @@ import 'wxt'; import { defineWxtModule } from 'wxt/modules'; -import { resolve, relative } from 'node:path'; +import { relative, resolve } from 'node:path'; import defu from 'defu'; import sharp from 'sharp'; -import { ensureDir, exists } from 'fs-extra'; +import { ensureDir, pathExists } from 'fs-extra'; export default defineWxtModule({ name: '@wxt-dev/auto-icons', @@ -19,6 +19,9 @@ export default defineWxtModule({ }, ); + // TODO: MAYBE DROP THIS COMPATIBILITY? + // TODO: It has a while, and for 1.0.0 we should be as much "up to date" as we can, + // TODO: because later it'll be harder to make breaking change? // Backward compatibility for the deprecated option if (options?.grayscaleOnDevelopment !== undefined) { wxt.logger.warn( @@ -34,20 +37,22 @@ export default defineWxtModule({ const resolvedPath = resolve(wxt.config.srcDir, parsedOptions.baseIconPath); - if (!parsedOptions.enabled) + if (!parsedOptions.enabled) { return wxt.logger.warn(`\`[auto-icons]\` ${this.name} disabled`); + } - if (!(await exists(resolvedPath))) { + if (!(await pathExists(resolvedPath))) { return wxt.logger.warn( `\`[auto-icons]\` Skipping icon generation, no base icon found at ${relative(process.cwd(), resolvedPath)}`, ); } wxt.hooks.hook('build:manifestGenerated', async (wxt, manifest) => { - if (manifest.icons) + if (manifest.icons) { return wxt.logger.warn( '`[auto-icons]` icons property found in manifest, overwriting with auto-generated icons', ); + } manifest.icons = Object.fromEntries( parsedOptions.sizes.map((size) => [size, `icons/${size}.png`]), @@ -65,8 +70,9 @@ export default defineWxtModule({ resizedImage.grayscale(); } else if (parsedOptions.developmentIndicator === 'overlay') { // Helper to build an overlay that places a yellow rectangle at the bottom - // of the icon with the text "DEV" in black. The overlay has the same - // dimensions as the icon so we can composite it with default gravity. + // of the icon with the text "DEV" in black. + // The overlay has the same dimensions as the icon, + // so we can composite it with default gravity. const buildDevOverlay = (size: number) => { const rectHeight = Math.round(size * 0.5); const fontSize = Math.round(size * 0.35); @@ -77,6 +83,7 @@ export default defineWxtModule({ DEV `); }; + const overlayBuffer = await sharp(buildDevOverlay(size)) .png() .toBuffer(); @@ -91,7 +98,7 @@ export default defineWxtModule({ } } - ensureDir(resolve(outputFolder, 'icons')); + await ensureDir(resolve(outputFolder, 'icons')); await resizedImage.toFile(resolve(outputFolder, `icons/${size}.png`)); output.publicAssets.push({ diff --git a/packages/browser/scripts/generate.ts b/packages/browser/scripts/generate.ts index ddbf1d816..b40849d29 100644 --- a/packages/browser/scripts/generate.ts +++ b/packages/browser/scripts/generate.ts @@ -5,12 +5,10 @@ import { dirname, join, resolve, sep } from 'node:path'; import { sep as posixSep } from 'node:path/posix'; // Fetch latest version - console.log('Getting latest version of \x1b[36m@types/chrome\x1b[0m'); await spawn('pnpm', ['i', '--ignore-scripts', '-D', '@types/chrome@latest']); // Generate new package.json - console.log('Generating new \x1b[36mpackage.json\x1b[0m'); const pkgJsonPath = fileURLToPath( @@ -31,7 +29,6 @@ await fs.writeJson(outPkgJsonPath, newPkgJson); await spawn('pnpm', ['-w', 'prettier', '--write', outPkgJsonPath]); // Generate declaration files - console.log('Generating declaration files'); const outDir = resolve('src/gen'); const declarationFileMapping = ( @@ -51,7 +48,7 @@ const declarationFileMapping = ( for (const { file, srcPath, destPath } of declarationFileMapping) { const content = await fs.readFile(srcPath, 'utf8'); - const transformedContent = transformFile(file, content); + const transformedContent = transformFileContent(content); const destDir = dirname(destPath); await fs.mkdir(destDir, { recursive: true }); await fs.writeFile(destPath, transformedContent); @@ -59,12 +56,10 @@ for (const { file, srcPath, destPath } of declarationFileMapping) { } // Done! - console.log('\x1b[32m✔\x1b[0m Done in ' + performance.now().toFixed(0) + ' ms'); // Transformations - -function transformFile(file: string, content: string): string { +function transformFileContent(content: string) { return ( // Add prefix `/* DO NOT EDIT - generated by scripts/generate.ts */\n\n${content}\n` diff --git a/packages/browser/src/__tests__/index.test.ts b/packages/browser/src/__tests__/index.test.ts index 07b40f518..ad0193dfe 100644 --- a/packages/browser/src/__tests__/index.test.ts +++ b/packages/browser/src/__tests__/index.test.ts @@ -5,19 +5,19 @@ import { browser, type Browser } from '../index'; describe('browser', () => { describe('types', () => { it('should provide types via the Browser import', () => { - expectTypeOf().toMatchTypeOf(); - expectTypeOf().toMatchTypeOf(); - expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('should provide values via the browser import', () => { - expectTypeOf(browser.runtime.id).toMatchTypeOf(); - expectTypeOf( - browser.storage.local, - ).toMatchTypeOf(); - expectTypeOf( - browser.i18n.detectLanguage('Hello, world!'), - ).resolves.toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + typeof browser.storage.local + >().toEqualTypeOf(); + expectTypeOf< + typeof browser.i18n.detectLanguage + >().returns.resolves.toEqualTypeOf(); }); }); }); diff --git a/packages/browser/src/gen/index.d.ts b/packages/browser/src/gen/index.d.ts index 456894b6e..bd9a1a198 100644 --- a/packages/browser/src/gen/index.d.ts +++ b/packages/browser/src/gen/index.d.ts @@ -9239,7 +9239,7 @@ export namespace Browser { // Required manifest_version: number; name: string; - version: string; + version: string | null; // Recommended default_locale?: string | undefined; diff --git a/packages/i18n/src/__tests__/build.test.ts b/packages/i18n/src/__tests__/build.test.ts index ef05be0c4..13ff659bf 100644 --- a/packages/i18n/src/__tests__/build.test.ts +++ b/packages/i18n/src/__tests__/build.test.ts @@ -56,6 +56,7 @@ describe('Built Tools', () => { const messages = await parseMessagesFile(`file.yml`); await generateChromeMessagesFile('output.json', messages); await generateTypeFile('output.d.ts', messages); + const actualChromeMessagesFile = mockWriteFile.mock.calls[0][1]; const actualDtsFile = mockWriteFile.mock.calls[1][1]; diff --git a/packages/i18n/src/__tests__/index.test.ts b/packages/i18n/src/__tests__/index.test.ts index 11ed8d60b..6f9fe7791 100644 --- a/packages/i18n/src/__tests__/index.test.ts +++ b/packages/i18n/src/__tests__/index.test.ts @@ -53,13 +53,13 @@ describe('createI18n', () => { (rawMessage, count, expected) => { const i18n = createI18n(); getMessageMock.mockReturnValue(rawMessage); - const key = 'items'; + const KEY = 'items'; - const actual = i18n.t(key, count); + const actual = i18n.t(KEY, count); expect(actual).toBe(expected); expect(getMessageMock).toBeCalledTimes(1); - expect(getMessageMock).toBeCalledWith(key, [String(count)]); + expect(getMessageMock).toBeCalledWith(KEY, [String(count)]); }, ); diff --git a/packages/i18n/src/__tests__/types.test.ts b/packages/i18n/src/__tests__/types.test.ts index 540732092..c34b33484 100644 --- a/packages/i18n/src/__tests__/types.test.ts +++ b/packages/i18n/src/__tests__/types.test.ts @@ -4,6 +4,7 @@ import { browser } from '@wxt-dev/browser'; vi.mock('@wxt-dev/browser', async () => { const { vi } = await import('vitest'); + return { browser: { i18n: { @@ -14,7 +15,7 @@ vi.mock('@wxt-dev/browser', async () => { }); const getMessageMock = vi.mocked(browser.i18n.getMessage); -const n: number = 1; +const N = 1; describe('I18n Types', () => { beforeEach(() => { @@ -29,7 +30,7 @@ describe('I18n Types', () => { i18n.t('any'); i18n.t('any', ['one']); i18n.t('any', ['one', 'two']); - i18n.t('any', n, ['one', 'two']); + i18n.t('any', N, ['one', 'two']); }); }); }); @@ -47,66 +48,42 @@ describe('I18n Types', () => { describe('t', () => { it('should only allow passing valid combinations of arguments', () => { i18n.t('simple'); - // @ts-expect-error i18n.t('simple', []); - // @ts-expect-error i18n.t('simple', ['one']); - // @ts-expect-error - i18n.t('simple', n); + i18n.t('simple', N); i18n.t('simpleSub1', ['one']); - // @ts-expect-error i18n.t('simpleSub1'); - // @ts-expect-error i18n.t('simpleSub1', []); - // @ts-expect-error i18n.t('simpleSub1', ['one', 'two']); - // @ts-expect-error - i18n.t('simpleSub1', n); + i18n.t('simpleSub1', N); i18n.t('simpleSub2', ['one', 'two']); - // @ts-expect-error i18n.t('simpleSub2'); - // @ts-expect-error i18n.t('simpleSub2', ['one']); - // @ts-expect-error i18n.t('simpleSub2', ['one', 'two', 'three']); - // @ts-expect-error - i18n.t('simpleSub2', n); + i18n.t('simpleSub2', N); - i18n.t('plural', n); - // @ts-expect-error + i18n.t('plural', N); i18n.t('plural'); - // @ts-expect-error i18n.t('plural', []); - // @ts-expect-error i18n.t('plural', ['one']); - // @ts-expect-error - i18n.t('plural', n, ['sub']); + i18n.t('plural', N, ['sub']); - i18n.t('pluralSub1', n); - i18n.t('pluralSub1', n, undefined); - i18n.t('pluralSub1', n, ['one']); - // @ts-expect-error + i18n.t('pluralSub1', N); + i18n.t('pluralSub1', N, undefined); + i18n.t('pluralSub1', N, ['one']); i18n.t('pluralSub1'); - // @ts-expect-error i18n.t('pluralSub1', ['one']); - // @ts-expect-error - i18n.t('pluralSub1', n, []); - // @ts-expect-error - i18n.t('pluralSub1', n, ['one', 'two']); + i18n.t('pluralSub1', N, []); + i18n.t('pluralSub1', N, ['one', 'two']); - i18n.t('pluralSub2', n, ['one', 'two']); - // @ts-expect-error + i18n.t('pluralSub2', N, ['one', 'two']); i18n.t('pluralSub2'); - // @ts-expect-error i18n.t('pluralSub2', ['one', 'two']); - // @ts-expect-error - i18n.t('pluralSub2', n, ['one']); - // @ts-expect-error - i18n.t('pluralSub2', n, ['one', 'two', 'three']); - // @ts-expect-error - i18n.t('pluralSub2', n); + i18n.t('pluralSub2', N, ['one']); + i18n.t('pluralSub2', N, ['one', 'two', 'three']); + i18n.t('pluralSub2', N); }); }); }); diff --git a/packages/i18n/src/__tests__/utils.test.ts b/packages/i18n/src/__tests__/utils.test.ts index 7c44bc46f..d9fd38266 100644 --- a/packages/i18n/src/__tests__/utils.test.ts +++ b/packages/i18n/src/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { ChromeMessage } from '../build'; import { applyChromeMessagePlaceholders, @@ -43,7 +43,7 @@ describe('Utils', () => { }); describe('getSubstitutionCount', () => { - it('should return the last substution present in the message', () => { + it('should return the last substitution present in the message', () => { expect(getSubstitutionCount('I like $1, but I like $2 better')).toBe(2); }); diff --git a/packages/i18n/src/build.ts b/packages/i18n/src/build.ts index 65144939e..5bc62f0cd 100644 --- a/packages/i18n/src/build.ts +++ b/packages/i18n/src/build.ts @@ -6,7 +6,7 @@ */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { parseYAML, parseJSON5, parseTOML } from 'confbox'; +import { parseJSON5, parseTOML, parseYAML } from 'confbox'; import { dirname, extname } from 'node:path'; import { applyChromeMessagePlaceholders, getSubstitutionCount } from './utils'; @@ -94,7 +94,7 @@ const EXT_FORMATS_MAP: Record = { '.toml': 'TOML', }; -const PARSERS: Record any> = { +const PARSERS: Record unknown> = { YAML: parseYAML, JSON5: parseJSON5, TOML: parseTOML, @@ -118,6 +118,7 @@ export async function parseMessagesFile( ): Promise { const text = await readFile(file, 'utf8'); const ext = extname(file).toLowerCase(); + return parseMessagesText(text, EXT_FORMATS_MAP[ext] ?? 'JSON5'); } @@ -134,11 +135,11 @@ export function parseMessagesText( /** * Given the JS object form of a raw messages file, extract the messages. */ -export function parseMessagesObject(object: any): ParsedMessage[] { +export function parseMessagesObject(object: unknown): ParsedMessage[] { return _parseMessagesObject( [], { - ...object, + ...(object as Record), ...PREDEFINED_MESSAGES, }, 0, @@ -147,7 +148,7 @@ export function parseMessagesObject(object: any): ParsedMessage[] { function _parseMessagesObject( path: string[], - object: any, + object: unknown, depth: number, ): ParsedMessage[] { switch (typeof object) { @@ -168,54 +169,62 @@ function _parseMessagesObject( ]; } case 'object': - if ([null, undefined].includes(object)) { + if (object === null || object === undefined) { throw new Error( `Messages file should not contain \`${object}\` (found at "${path.join('.')}")`, ); } + if (Array.isArray(object)) return object.flatMap((item, i) => _parseMessagesObject(path.concat(String(i)), item, depth + 1), ); + if (isPluralMessage(object)) { const message = Object.values(object).join('|'); const substitutions = getSubstitutionCount(message); + return [ { type: 'plural', key: path, substitutions, - plurals: object, + plurals: object as Record, }, ]; } - if (depth === 1 && isChromeMessage(object)) { - const message = applyChromeMessagePlaceholders(object); + + if (depth === 1 && isChromeMessage(object as object)) { + const message = applyChromeMessagePlaceholders(object as ChromeMessage); const substitutions = getSubstitutionCount(message); + return [ { type: 'chrome', key: path, substitutions, - ...object, + ...(object as ChromeMessage), }, ]; } - return Object.entries(object).flatMap(([key, value]) => - _parseMessagesObject(path.concat(key), value, depth + 1), + return Object.entries(object as Record).flatMap( + ([key, value]) => + _parseMessagesObject(path.concat(key), value, depth + 1), ); default: throw Error(`"Could not parse object of type "${typeof object}"`); } } -function isPluralMessage(object: any): object is Record { +function isPluralMessage( + object: object, +): object is Record { return Object.keys(object).every( (key) => key === 'n' || isFinite(Number(key)), ); } -function isChromeMessage(object: any): object is ChromeMessage { +function isChromeMessage(object: object): object is ChromeMessage { return Object.keys(object).every((key) => ALLOWED_CHROME_MESSAGE_KEYS.has(key), ); @@ -227,7 +236,7 @@ function isChromeMessage(object: any): object is ChromeMessage { export function generateTypeText(messages: ParsedMessage[]): string { const renderMessageEntry = (message: ParsedMessage): string => { - // Use . for deep keys at runtime and types + // Use '.' for deep keys at runtime and types const key = message.key.join('.'); const features = [ diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 559083b06..5abedca15 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -2,20 +2,21 @@ * @module @wxt-dev/i18n */ import { - I18nStructure, DefaultI18nStructure, I18n, + I18nStructure, Substitution, } from './types'; import { browser } from '@wxt-dev/browser'; export function createI18n< - T extends I18nStructure = DefaultI18nStructure, + T extends I18nStructure | DefaultI18nStructure = DefaultI18nStructure, >(): I18n { - const t = (key: string, ...args: any[]) => { + const t = (key: string, ...args: unknown[]) => { // Resolve args let sub: Substitution[] | undefined; let count: number | undefined; + args.forEach((arg, i) => { if (arg == null) { // ignore nullish args @@ -37,6 +38,7 @@ export function createI18n< // Load the localization let message: string; + if (sub?.length) { // Convert all substitutions to strings const stringSubs = sub?.map((sub) => String(sub)); @@ -44,9 +46,11 @@ export function createI18n< } else { message = browser.i18n.getMessage(key.replaceAll('.', '_')); } + if (!message) { console.warn(`[i18n] Message not found: "${key}"`); } + if (count == null) return message; // Apply pluralization diff --git a/packages/i18n/src/module.ts b/packages/i18n/src/module.ts index 68c914894..bc09549d7 100644 --- a/packages/i18n/src/module.ts +++ b/packages/i18n/src/module.ts @@ -14,8 +14,8 @@ import 'wxt'; import { addAlias, defineWxtModule } from 'wxt/modules'; import { generateChromeMessagesText, - parseMessagesFile, generateTypeText, + parseMessagesFile, SUPPORTED_LOCALES, } from './build'; import glob from 'fast-glob'; @@ -36,6 +36,7 @@ export default defineWxtModule({ ); return; } + wxt.logger.info( '`[i18n]` Default locale: ' + wxt.config.manifest.default_locale, ); @@ -54,7 +55,9 @@ export default defineWxtModule({ const res = files.map((file) => { const rawLocale = basename(file).replace(extname(file), ''); const locale = standardizeLocale(rawLocale); + if (!SUPPORTED_LOCALES.has(locale)) unsupportedLocales.push(locale); + return { file, locale }; }); @@ -70,7 +73,8 @@ export default defineWxtModule({ GeneratedPublicFile[] > => { const files = await getLocalizationFiles(); - return await Promise.all( + + return Promise.all( files.map(async ({ file, locale }) => { const messages = await parseMessagesFile(file); return { @@ -86,13 +90,15 @@ export default defineWxtModule({ const defaultLocaleFile = files.find( ({ locale }) => locale === wxt.config.manifest.default_locale, )!; - if (defaultLocaleFile == null) { + + if (defaultLocaleFile === null) { throw Error( `\`[i18n]\` Required localization file does not exist: \`/${wxt.config.manifest.default_locale}.{json|json5|yml|yaml|toml}\``, ); } const messages = await parseMessagesFile(defaultLocaleFile.file); + return { path: typesPath, text: generateTypeText(messages), @@ -152,7 +158,7 @@ export { type GeneratedI18nStructure } addAlias(wxt, '#i18n', sourcePath); - // Generate separate declaration file containing types - this prevents + // Generate a separate declaration file containing types - this prevents // firing the dev server's default file watcher when updating the types, // which would cause a full rebuild and reload of the extension. diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts index d742c873c..905c5fed9 100644 --- a/packages/i18n/src/types.ts +++ b/packages/i18n/src/types.ts @@ -7,13 +7,12 @@ export type I18nStructure = { [K: string]: I18nFeatures; }; -export type DefaultI18nStructure = { - [K: string]: any; -}; +export type DefaultI18nStructure = Record; // prettier-ignore export type SubstitutionTuple = - T extends 1 ? [$1: Substitution] + T extends 0 ? [] + : T extends 1 ? [$1: Substitution] : T extends 2 ? [$1: Substitution, $2: Substitution] : T extends 3 ? [$1: Substitution, $2: Substitution, $3: Substitution] : T extends 4 ? [$1: Substitution, $2: Substitution, $3: Substitution, $4: Substitution] @@ -22,7 +21,7 @@ export type SubstitutionTuple = : T extends 7 ? [$1: Substitution, $2: Substitution, $3: Substitution, $4: Substitution, $5: Substitution, $6: Substitution, $7: Substitution] : T extends 8 ? [$1: Substitution, $2: Substitution, $3: Substitution, $4: Substitution, $5: Substitution, $6: Substitution, $7: Substitution, $8: Substitution] : T extends 9 ? [$1: Substitution, $2: Substitution, $3: Substitution, $4: Substitution, $5: Substitution, $6: Substitution, $7: Substitution, $8: Substitution, $9: Substitution] - : never + : [] export type TFunction = { // Non-plural, no substitutions @@ -37,7 +36,7 @@ export type TFunction = { key: K & { [P in keyof T]: T[P] extends { plural: false; substitutions: SubstitutionCount } ? P : never; }[keyof T], substitutions: T[K] extends I18nFeatures ? SubstitutionTuple - : never, + : [], ): string; // Plural with 1 substitution @@ -62,12 +61,16 @@ export type TFunction = { n: number, substitutions: T[K] extends I18nFeatures ? SubstitutionTuple - : never, + : [], ): string; }; -export interface I18n { - t: TFunction; +export interface I18n< + T extends I18nStructure | DefaultI18nStructure = DefaultI18nStructure, +> { + t: T extends DefaultI18nStructure + ? (key: string, ...args: unknown[]) => string + : TFunction>; } export type Substitution = string | number; diff --git a/packages/i18n/src/utils.ts b/packages/i18n/src/utils.ts index 6856c79ad..1762bf7be 100644 --- a/packages/i18n/src/utils.ts +++ b/packages/i18n/src/utils.ts @@ -1,7 +1,7 @@ import { ChromeMessage } from './build'; export function applyChromeMessagePlaceholders(message: ChromeMessage): string { - if (message.placeholders == null) return message.message; + if (message.placeholders === null) return message.message; return Object.entries(message.placeholders ?? {}).reduce( (text, [name, value]) => { @@ -28,6 +28,7 @@ export function standardizeLocale(locale: string): string { const [is_match, prefix, suffix] = locale.match(/^([a-z]{2})[-_]([a-z]{2,3})$/i) ?? []; + if (is_match) { return `${prefix.toLowerCase()}_${suffix.toUpperCase()}`; } diff --git a/packages/module-react/components/App.tsx b/packages/module-react/components/App.tsx index 2124b6126..d39faafe2 100644 --- a/packages/module-react/components/App.tsx +++ b/packages/module-react/components/App.tsx @@ -1,5 +1,7 @@ export default function () { const [count, setCount] = useState(0); + const increment = () => setCount((count) => count + 1); + return ; } diff --git a/packages/module-react/entrypoints/content/index.tsx b/packages/module-react/entrypoints/content/index.tsx index 87ac4e917..cb36ed3d9 100644 --- a/packages/module-react/entrypoints/content/index.tsx +++ b/packages/module-react/entrypoints/content/index.tsx @@ -22,6 +22,7 @@ function createUi(ctx: ContentScriptContext) { append: 'first', onMount(container) { const root = ReactDOM.createRoot(container); + root.render( diff --git a/packages/module-solid/components/App.tsx b/packages/module-solid/components/App.tsx index c7c50c6ce..87361faa8 100644 --- a/packages/module-solid/components/App.tsx +++ b/packages/module-solid/components/App.tsx @@ -2,6 +2,8 @@ import { Component } from 'solid-js'; export const App: Component = () => { const [count, setCount] = createSignal(0); + const increment = () => setCount((count) => count + 1); + return ; }; diff --git a/packages/runner/src/__tests__/install.test.ts b/packages/runner/src/__tests__/install.test.ts index 4ac12d9ba..95626303b 100644 --- a/packages/runner/src/__tests__/install.test.ts +++ b/packages/runner/src/__tests__/install.test.ts @@ -14,54 +14,62 @@ const createBidiConnectionMock = vi.mocked(createBidiConnection); describe('Install', () => { describe('Chromium', () => { it('Should send the install command to the process', async () => { + const EXTENSION_DIR = '/path/to/extension'; + const EXPECTED_EXTENSION_ID = 'chromium-extension-id'; + const browserProcess = mock(); const connection = mock({ [Symbol.dispose]: vi.fn(), }); - const extensionDir = '/path/to/extension'; - const expectedExtensionId = 'chromium-extension-id'; createCdpConnectionMock.mockReturnValue(connection); connection.send.mockImplementation(async (method) => { - if (method === 'Extensions.loadUnpacked') - return { id: expectedExtensionId }; + if (method === 'Extensions.loadUnpacked') { + return { id: EXPECTED_EXTENSION_ID }; + } + throw Error('Unknown method'); }); - const res = await installChromium(browserProcess, extensionDir); + const res = await installChromium(browserProcess, EXTENSION_DIR); expect(createCdpConnectionMock).toBeCalledTimes(1); expect(createCdpConnectionMock).toBeCalledWith(browserProcess); expect(connection.send).toBeCalledTimes(1); expect(connection.send).toBeCalledWith('Extensions.loadUnpacked', { - path: extensionDir, + path: EXTENSION_DIR, }); - expect(res).toEqual({ id: expectedExtensionId }); + expect(res).toEqual({ id: EXPECTED_EXTENSION_ID }); }); }); describe('Firefox', () => { it('Should connect to the server, start a session, then install the extension', async () => { - const debuggerUrl = 'http://127.0.0.1:9222'; - const extensionDir = '/path/to/extension'; - const expectedExtensionId = 'firefox-extension-id'; + const DEBUGGER_URL = 'http://127.0.0.1:9222'; + const EXTENSION_DIR = '/path/to/extension'; + const EXPECTED_EXTENSION_ID = 'firefox-extension-id'; + const connection = mock({ [Symbol.dispose]: vi.fn(), }); createBidiConnectionMock.mockResolvedValue(connection); connection.send.mockImplementation(async (method) => { - if (method === 'session.new') return { sessionId: 'session-id' }; - if (method === 'webExtension.install') - return { extension: expectedExtensionId }; + if (method === 'session.new') { + return { sessionId: 'session-id' }; + } + + if (method === 'webExtension.install') { + return { extension: EXPECTED_EXTENSION_ID }; + } }); - const res = await installFirefox(debuggerUrl, extensionDir); + const res = await installFirefox(DEBUGGER_URL, EXTENSION_DIR); expect(createBidiConnectionMock).toBeCalledTimes(1); - expect(createBidiConnectionMock).toBeCalledWith(debuggerUrl); + expect(createBidiConnectionMock).toBeCalledWith(DEBUGGER_URL); expect(connection.send).toBeCalledTimes(2); expect(connection.send).toBeCalledWith('session.new', { @@ -70,11 +78,11 @@ describe('Install', () => { expect(connection.send).toBeCalledWith('webExtension.install', { extensionData: { type: 'path', - path: extensionDir, + path: EXTENSION_DIR, }, }); - expect(res).toEqual({ extension: expectedExtensionId }); + expect(res).toEqual({ extension: EXPECTED_EXTENSION_ID }); }); }); }); diff --git a/packages/runner/src/__tests__/options.test.ts b/packages/runner/src/__tests__/options.test.ts index 23ec3e4ab..4d578a7e1 100644 --- a/packages/runner/src/__tests__/options.test.ts +++ b/packages/runner/src/__tests__/options.test.ts @@ -1,17 +1,17 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { ResolvedRunOptions, resolveRunOptions } from '../options'; -import { resolve, join } from 'node:path'; -import { tmpdir, homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; import { mkdir } from 'node:fs/promises'; vi.mock('node:os', async () => { const { vi } = await import('vitest'); - const os: any = await vi.importActual('node:os'); + const os = (await vi.importActual('node:os')) as typeof import('node:os'); const { join } = await import('node:path'); + return { ...os, tmpdir: () => join(os.tmpdir(), 'tmpdir-mock'), - homedir: () => join(os.tmpdir(), 'homedir-mock'), }; }); @@ -25,6 +25,7 @@ describe('Options', () => { describe('extensionDir', () => { it('should default to the current working directory', async () => { const actual = await resolveRunOptions({}); + expect(actual).toMatchObject>({ extensionDir: process.cwd(), }); @@ -34,6 +35,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ extensionDir: './path/to/extension', }); + expect(actual).toMatchObject>({ extensionDir: resolve(process.cwd(), './path/to/extension'), }); @@ -45,6 +47,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ extensionDir: 'path/to/extension', }); + expect(actual).toMatchObject>({ target: 'chrome', }); @@ -58,6 +61,7 @@ describe('Options', () => { custom: '/path/to/custom/browser', }, }); + expect(actual).toMatchObject>({ target: 'custom', }); @@ -68,6 +72,7 @@ describe('Options', () => { extensionDir: 'path/to/extension', target: 'custom', }); + await expect(actual).rejects.toThrow('Could not find "custom" binary.'); }); }); @@ -82,6 +87,7 @@ describe('Options', () => { process.platform === 'win32' ? 'C:\\path\\to\\custom\\browser.exe' : path; + const actual = await resolveRunOptions({ extensionDir: 'path/to/extension', target: 'custom', @@ -89,6 +95,7 @@ describe('Options', () => { custom: path, }, }); + expect(actual).toMatchObject>({ browserBinary: expectedPath, }); @@ -101,6 +108,7 @@ describe('Options', () => { await resolveRunOptions({ chromiumArgs: ['--user-data-dir=some/custom/path'], }); + expect(warnSpy).toBeCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -114,6 +122,7 @@ describe('Options', () => { await resolveRunOptions({ chromiumArgs: ['--remote-debugging-port=9222'], }); + expect(warnSpy).toBeCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -126,6 +135,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ chromiumArgs: ['--window-size=1920,1080'], }); + expect(actual.chromiumArgs).toEqual([ // Defaults '--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4', @@ -164,6 +174,7 @@ describe('Options', () => { await resolveRunOptions({ firefoxArgs: ['--profile=some/custom/path'], }); + expect(warnSpy).toBeCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Custom Firefox --profile argument ignored'), @@ -175,6 +186,7 @@ describe('Options', () => { await resolveRunOptions({ firefoxArgs: ['--remote-debugging-port=9222'], }); + expect(warnSpy).toBeCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -187,6 +199,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ firefoxArgs: ['--window-size=1920,1080'], }); + expect(actual.firefoxArgs).toEqual([ // Defaults '--new-instance', @@ -204,6 +217,7 @@ describe('Options', () => { describe('chromiumRemoteDebuggingPort', () => { it('should default to 0', async () => { const actual = await resolveRunOptions({}); + expect(actual).toMatchObject>({ chromiumRemoteDebuggingPort: 0, chromiumArgs: expect.arrayContaining([`--remote-debugging-port=0`]), @@ -214,6 +228,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ chromiumRemoteDebuggingPort: 9222, }); + expect(actual).toMatchObject>({ chromiumRemoteDebuggingPort: 9222, chromiumArgs: expect.arrayContaining([`--remote-debugging-port=9222`]), @@ -224,6 +239,7 @@ describe('Options', () => { describe('firefoxRemoteDebuggingPort', () => { it('should default to 0', async () => { const actual = await resolveRunOptions({}); + expect(actual).toMatchObject>({ firefoxRemoteDebuggingPort: 0, firefoxArgs: expect.arrayContaining([`--remote-debugging-port=0`]), @@ -234,6 +250,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ firefoxRemoteDebuggingPort: 9222, }); + expect(actual).toMatchObject>({ firefoxRemoteDebuggingPort: 9222, firefoxArgs: expect.arrayContaining([`--remote-debugging-port=9222`]), @@ -244,6 +261,7 @@ describe('Options', () => { describe('dataPersistence', () => { it('should default to "none"', async () => { const actual = await resolveRunOptions({}); + expect(actual).toMatchObject>({ dataPersistence: 'none', }); @@ -253,6 +271,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ dataPersistence: 'none', }); + expect(actual).toMatchObject>({ dataPersistence: 'none', dataDir: expect.stringContaining(join(tmpdir(), 'wxt-runner-')), @@ -271,6 +290,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ dataPersistence: 'project', }); + expect(actual).toMatchObject>({ dataPersistence: 'project', dataDir: expect.stringContaining(join(process.cwd(), '.wxt-runner')), @@ -289,6 +309,7 @@ describe('Options', () => { const actual = await resolveRunOptions({ dataPersistence: 'user', }); + expect(actual).toMatchObject>({ dataPersistence: 'user', dataDir: expect.stringContaining(join(homedir(), '.wxt-runner')), diff --git a/packages/runner/src/bidi.ts b/packages/runner/src/bidi.ts index d784d3552..dd433a8ed 100644 --- a/packages/runner/src/bidi.ts +++ b/packages/runner/src/bidi.ts @@ -4,7 +4,11 @@ import { debug } from './debug'; const debugBidi = debug.scoped('bidi'); export interface BidiConnection extends Disposable { - send(method: string, params: any, timeout?: number): Promise; + send( + method: string, + params: Record, + timeout?: number, + ): Promise; close(): void; } @@ -23,6 +27,7 @@ export async function createBidiConnection( send(method, params, timeout = 10e3) { const id = ++requestId; const command = { id, method, params }; + debugBidi('Sending command:', command); return new Promise((resolve, reject) => { @@ -31,7 +36,7 @@ export async function createBidiConnection( webSocket.removeEventListener('error', onError); }; - setTimeout(() => { + const timeoutId = setTimeout(() => { cleanup(); reject( new Error( @@ -42,15 +47,23 @@ export async function createBidiConnection( const onMessage = (event: MessageEvent) => { const data = JSON.parse(event.data); + if (data.id === id) { debugBidi('Received response:', data); + clearTimeout(timeoutId); cleanup(); - if (data.type === 'success') resolve(data.result); - else reject(Error(data.message, { cause: data })); + + if (data.type === 'success') { + resolve(data.result); + } else { + reject(Error(data.message, { cause: data })); + } } }; - const onError = (error: any) => { + const onError = (error: unknown) => { + clearTimeout(timeoutId); cleanup(); + reject(new Error('Error sending request', { cause: error })); }; diff --git a/packages/runner/src/cdp.ts b/packages/runner/src/cdp.ts index b96ac3ca8..252796259 100644 --- a/packages/runner/src/cdp.ts +++ b/packages/runner/src/cdp.ts @@ -5,7 +5,11 @@ import { debug } from './debug'; const debugCdp = debug.scoped('cdp'); export interface CDPConnection extends Disposable { - send(method: string, params: any, timeout?: number): Promise; + send( + method: string, + params: Record, + timeout?: number, + ): Promise; close(): void; } @@ -21,6 +25,7 @@ export function createCdpConnection( send(method, params, timeout = 10e3) { const id = ++requestId; const command = { id, method, params }; + debugCdp('Sending command:', command); return new Promise((resolve, reject) => { @@ -36,6 +41,7 @@ export function createCdpConnection( if (res.id !== id) return; debugCdp('Received response:', res); + clearTimeout(timer); outputStream.removeListener('data', onData); diff --git a/packages/runner/src/debug.ts b/packages/runner/src/debug.ts index fbf91e395..72de694a4 100644 --- a/packages/runner/src/debug.ts +++ b/packages/runner/src/debug.ts @@ -1,11 +1,13 @@ export interface Debug { - (...args: any[]): void; scoped: (scope: string) => Debug; + + (...args: unknown[]): void; } function createDebug(scopes: string[]): Debug { - const debug = (...args: any[]) => { + const debug = (...args: unknown[]) => { const scope = scopes.join(':'); + if ( process.env.DEBUG === '1' || process.env.DEBUG === 'true' || diff --git a/packages/runner/src/install.ts b/packages/runner/src/install.ts index 2338f22a0..b29096fda 100644 --- a/packages/runner/src/install.ts +++ b/packages/runner/src/install.ts @@ -17,15 +17,12 @@ export async function installFirefox( await bidi.send('session.new', { capabilities: {} }); // Install the extension - return await bidi.send( - 'webExtension.install', - { - extensionData: { - type: 'path', - path: extensionDir, - }, + return bidi.send('webExtension.install', { + extensionData: { + type: 'path', + path: extensionDir, }, - ); + }); } export type BidiWebExtensionInstallResponse = { @@ -46,7 +43,7 @@ export async function installChromium( extensionDir: string, ): Promise { using cdp = createCdpConnection(browserProcess); - return await cdp.send( + return cdp.send( 'Extensions.loadUnpacked', { path: extensionDir, diff --git a/packages/runner/src/options.ts b/packages/runner/src/options.ts index 9aab2bf52..96c18820b 100644 --- a/packages/runner/src/options.ts +++ b/packages/runner/src/options.ts @@ -7,7 +7,8 @@ import { import { resolve, join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { debug } from './debug'; -import { mkdtemp, open } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; +import { pathExists } from 'fs-extra'; const debugOptions = debug.scoped('options'); @@ -59,6 +60,7 @@ export async function resolveRunOptions( const _browserBinary = options?.browserBinaries?.[target] ?? (await findBrowserBinary(target)); + if (!_browserBinary) throw Error( `Could not find "${target}" binary.\n\nIf it is installed in a custom location, you can specify the path with the browserPaths option.`, @@ -101,20 +103,24 @@ export async function resolveRunOptions( target, }; debugOptions('Resolved options:', resolved); + return resolved; } async function findBrowserBinary(target: string): Promise { const targets = new Set([target as KnownTarget]); + FALLBACK_TARGETS[target as KnownTarget]?.forEach((fallback) => targets.add(fallback), ); + const platform = getPlatform(); for (const target of targets) { const potentialPaths = KNOWN_BROWSER_PATHS[target]?.[platform] ?? []; + for (const path of potentialPaths) { - if (await exists(path)) return path; + if (await pathExists(path)) return path; } } } @@ -192,10 +198,11 @@ function deduplicateArgs( return arg.startsWith('--') ? arg.split('=')[0] : arg; }; const alreadyAdded = new Set(requiredArgs.map(getKey)); - const args = [...requiredArgs]; + userArgs?.forEach((arg) => { const key = getKey(arg); + if (alreadyAdded.has(key)) { if (warnings[key]) console.warn(`[@wxt-dev/runner] ${warnings[key]}`); } else { @@ -207,17 +214,6 @@ function deduplicateArgs( return args; } -async function exists(path: string): Promise { - try { - await open(path, 'r'); - return true; - } catch (err) { - // @ts-expect-error: Unknown error type - if (err?.code === 'ENOENT') return false; - throw err; - } -} - /** * Copied from https://github.com/GoogleChrome/chrome-launcher/blob/main/src/flags.ts * with some flags commented out. Run tests after updating to compare. diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 580ac149a..29e7ed969 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -44,6 +44,7 @@ async function runFirefox(options: ResolvedRunOptions): Promise { shell: true, }, ); + const debugFirefoxStderr = debugFirefox.scoped('stderr'); browserProcess.stderr.on('data', (data: string) => { const message = data.toString().trim(); @@ -54,6 +55,7 @@ async function runFirefox(options: ResolvedRunOptions): Promise { urlRes.resolve(message.slice(28)); } }); + const debugFirefoxStdout = debugFirefox.scoped('stdout'); browserProcess.stdout.on('data', (data: string) => { const message = data.toString().trim(); @@ -96,6 +98,7 @@ async function runChromium(options: ResolvedRunOptions): Promise { opened.resolve(); } }); + const debugChromeStdout = debugChrome.scoped('stdout'); browserProcess.stdout!.on('data', (data: string) => { const message = data.toString().trim(); diff --git a/packages/runner/src/web-socket.ts b/packages/runner/src/web-socket.ts index baf5bc1f9..f2443a64e 100644 --- a/packages/runner/src/web-socket.ts +++ b/packages/runner/src/web-socket.ts @@ -13,10 +13,12 @@ export function openWebSocket(url: string): Promise { webSocket.removeEventListener('error', onError); webSocket.removeEventListener('close', onClose); }; + const onOpen = async () => { cleanup(); resolve(webSocket); }; + const onClose = (event: CloseEvent) => { cleanup(); reject( @@ -25,7 +27,8 @@ export function openWebSocket(url: string): Promise { ), ); }; - const onError = (error: any) => { + + const onError = (error: unknown) => { cleanup(); reject(new Error('Error connecting to WebSocket', { cause: error })); }; diff --git a/packages/storage/src/__tests__/index.test.ts b/packages/storage/src/__tests__/index.test.ts index 1ba64e3cd..0a6d5ea61 100644 --- a/packages/storage/src/__tests__/index.test.ts +++ b/packages/storage/src/__tests__/index.test.ts @@ -30,21 +30,22 @@ describe('Storage Utils', () => { (storageArea) => { describe('getItem', () => { it('should return the value from the correct storage area', async () => { - const expected = 123; - await fakeBrowser.storage[storageArea].set({ count: expected }); + const EXPECTED = 123; + + await fakeBrowser.storage[storageArea].set({ count: EXPECTED }); const actual = await storage.getItem(`${storageArea}:count`); - expect(actual).toBe(expected); + expect(actual).toBe(EXPECTED); }); it('should return the value if multiple : are used in the key', async () => { - const expected = 'value'; - await fakeBrowser.storage[storageArea].set({ 'some:key': expected }); + const EXPECTED = 'value'; + await fakeBrowser.storage[storageArea].set({ 'some:key': EXPECTED }); const actual = await storage.getItem(`${storageArea}:some:key`); - expect(actual).toBe(expected); + expect(actual).toBe(EXPECTED); }); it("should return null if the value doesn't exist", async () => { @@ -54,12 +55,13 @@ describe('Storage Utils', () => { }); it('should return the default value if passed in options', async () => { - const expected = 0; + const EXPECTED = 0; + const actual = await storage.getItem(`${storageArea}:count`, { - defaultValue: expected, + defaultValue: EXPECTED, }); - expect(actual).toBe(expected); + expect(actual).toBe(EXPECTED); }); }); @@ -69,6 +71,7 @@ describe('Storage Utils', () => { key: `${storageArea}:one`, expectedValue: 1, } as const; + const item2 = { key: `${storageArea}:two`, expectedValue: null, @@ -87,80 +90,83 @@ describe('Storage Utils', () => { }); it('should get values from multiple storage items', async () => { + const EXPECTED_VALUE_1 = 1; + const EXPECTED_VALUE_2 = null; + const item1 = storage.defineItem(`${storageArea}:one`); - const expectedValue1 = 1; const item2 = storage.defineItem(`${storageArea}:two`); - const expectedValue2 = null; await fakeBrowser.storage[storageArea].set({ - one: expectedValue1, + one: EXPECTED_VALUE_1, }); const actual = await storage.getItems([item1, item2]); expect(actual).toEqual([ - { key: item1.key, value: expectedValue1 }, - { key: item2.key, value: expectedValue2 }, + { key: item1.key, value: EXPECTED_VALUE_1 }, + { key: item2.key, value: EXPECTED_VALUE_2 }, ]); }); it('should get values for a combination of different input types', async () => { + const EXPECTED_VALUE_1 = 1; + const EXPECTED_VALUE_2 = 2; + const key1 = `${storageArea}:one` as const; - const expectedValue1 = 1; const item2 = storage.defineItem(`${storageArea}:two`); - const expectedValue2 = 2; await fakeBrowser.storage[storageArea].set({ - one: expectedValue1, - two: expectedValue2, + one: EXPECTED_VALUE_1, + two: EXPECTED_VALUE_2, }); const actual = await storage.getItems([key1, item2]); expect(actual).toEqual([ - { key: key1, value: expectedValue1 }, - { key: item2.key, value: expectedValue2 }, + { key: key1, value: EXPECTED_VALUE_1 }, + { key: item2.key, value: EXPECTED_VALUE_2 }, ]); }); it('should return fallback values for keys when provided', async () => { + const EXPECTED_VALUE_1 = null; const key1 = `${storageArea}:one` as const; - const expectedValue1 = null; + const key2 = `${storageArea}:two` as const; - const fallback2 = 2; - const expectedValue2 = fallback2; + const EXPECTED_VALUE_2 = 2; const actual = await storage.getItems([ key1, - { key: key2, options: { fallback: fallback2 } }, + { key: key2, options: { fallback: EXPECTED_VALUE_2 } }, ]); expect(actual).toEqual([ - { key: key1, value: expectedValue1 }, - { key: key2, value: expectedValue2 }, + { key: key1, value: EXPECTED_VALUE_1 }, + { key: key2, value: EXPECTED_VALUE_2 }, ]); }); it('should return fallback values for items when provided', async () => { const item1 = storage.defineItem(`${storageArea}:one`); - const expectedValue1 = null; const item2 = storage.defineItem(`${storageArea}:two`, { fallback: 2, }); - const expectedValue2 = item2.fallback; + + const EXPECTED_VALUE_1 = null; + const EXPECTED_VALUE_2 = item2.fallback; const actual = await storage.getItems([item1, item2]); expect(actual).toEqual([ - { key: item1.key, value: expectedValue1 }, - { key: item2.key, value: expectedValue2 }, + { key: item1.key, value: EXPECTED_VALUE_1 }, + { key: item2.key, value: EXPECTED_VALUE_2 }, ]); }); }); describe('getMeta', () => { it('should return item metadata from key+$', async () => { - const expected = { v: 1 }; + const expected = { v: 1 } as const; await fakeBrowser.storage[storageArea].set({ count$: expected }); const actual = await storage.getMeta(`${storageArea}:count`); @@ -178,9 +184,9 @@ describe('Storage Utils', () => { describe('setItem', () => { it('should set the value in the correct storage area', async () => { const key = `${storageArea}:count` as const; - const value = 321; + const VALUE = 321; - await storage.setItem(key, value); + await storage.setItem(key, VALUE); }); it.each([undefined, null])( @@ -203,12 +209,14 @@ describe('Storage Utils', () => { { key: `${storageArea}:count` as const, value: 234 }, { key: `${storageArea}:installDate` as const, value: null }, ]; + await fakeBrowser.storage[storageArea].set({ count: 123, installDate: 321, }); await storage.setItems(expected); + const actual = await storage.getItems( expected.map((item) => item.key), ); @@ -222,8 +230,10 @@ describe('Storage Utils', () => { describe('setMeta', () => { it('should set metadata at key+$', async () => { - const existing = { v: 1 }; + const existing = { v: 1 } as const; + await browser.storage[storageArea].set({ count$: existing }); + const newValues = { date: Date.now(), }; @@ -238,8 +248,10 @@ describe('Storage Utils', () => { it.each([undefined, null])( 'should remove any properties set to %s', async (version) => { - const existing = { v: 1 }; + const existing = { v: 1 } as const; + await browser.storage[storageArea].set({ count$: existing }); + const expected = {}; await storage.setMeta(`${storageArea}:count`, { v: version }); @@ -254,17 +266,17 @@ describe('Storage Utils', () => { it('should set key metadata correctly', async () => { const key1 = `${storageArea}:one` as const; const initialMeta1 = {}; - const setMeta1 = { v: 1 }; + const setMeta1 = { v: 1 } as const; const expectedMeta1 = setMeta1; const key2 = `${storageArea}:two` as const; - const initialMeta2 = { v: 1 }; - const setMeta2 = { v: 2 }; + const initialMeta2 = { v: 1 } as const; + const setMeta2 = { v: 2 } as const; const expectedMeta2 = setMeta2; const key3 = `${storageArea}:three` as const; - const initialMeta3 = { v: 1 }; - const setMeta3 = { d: Date.now() }; + const initialMeta3 = { v: 1 } as const; + const setMeta3 = { d: Date.now() } as const; const expectedMeta3 = { ...initialMeta3, ...setMeta3 }; await fakeBrowser.storage[storageArea].set({ @@ -287,16 +299,16 @@ describe('Storage Utils', () => { it('should set item metadata correctly', async () => { const item1 = storage.defineItem(`${storageArea}:one`); const initialMeta1 = {}; - const setMeta1 = { v: 1 }; + const setMeta1 = { v: 1 } as const; const expectedMeta1 = setMeta1; const item2 = storage.defineItem(`${storageArea}:two`); - const initialMeta2 = { v: 1 }; - const setMeta2 = { v: 2 }; + const initialMeta2 = { v: 1 } as const; + const setMeta2 = { v: 2 } as const; const expectedMeta2 = setMeta2; const item3 = storage.defineItem(`${storageArea}:three`); - const initialMeta3 = { v: 1 }; + const initialMeta3 = { v: 1 } as const; const setMeta3 = { d: Date.now() }; const expectedMeta3 = { ...initialMeta3, ...setMeta3 }; @@ -329,7 +341,8 @@ describe('Storage Utils', () => { }); it('should not remove the metadata by default', async () => { - const expected = { v: 1 }; + const expected = { v: 1 } as const; + await fakeBrowser.storage[storageArea].set({ count$: expected, count: 3, @@ -360,6 +373,7 @@ describe('Storage Utils', () => { it('should remove multiple keys', async () => { const key1 = `${storageArea}:one` as const; const key2 = `${storageArea}:two` as const; + await fakeBrowser.storage[storageArea].set({ one: 1, two: 2, @@ -374,6 +388,7 @@ describe('Storage Utils', () => { it('should remove multiple keys and metadata when requested', async () => { const key1 = `${storageArea}:one` as const; const key2 = `${storageArea}:two` as const; + await fakeBrowser.storage[storageArea].set({ one: 1, one$: { v: 1 }, @@ -395,6 +410,7 @@ describe('Storage Utils', () => { it('should remove multiple items', async () => { const item1 = storage.defineItem(`${storageArea}:one`); const item2 = storage.defineItem(`${storageArea}:two`); + await fakeBrowser.storage[storageArea].set({ one: 1, two: 2, @@ -409,6 +425,7 @@ describe('Storage Utils', () => { it('should remove multiple items and metadata when requested', async () => { const item1 = storage.defineItem(`${storageArea}:one`); const item2 = storage.defineItem(`${storageArea}:two`); + await fakeBrowser.storage[storageArea].set({ one: 1, one$: { v: 1 }, @@ -562,10 +579,12 @@ describe('Storage Utils', () => { one: 'one', two: 'two', }; + const existing = { two: 'two-two', three: 'three', }; + await fakeBrowser.storage[storageArea].set(existing); await storage.restoreSnapshot(storageArea, data); @@ -581,15 +600,18 @@ describe('Storage Utils', () => { v: 2, }, }; + const data = { count$: { restoredAt: Date.now(), }, }; + const expected = { ...existing, count$: data.count$, }; + await fakeBrowser.storage[storageArea].set(existing); await storage.restoreSnapshot(storageArea, data); @@ -610,26 +632,28 @@ describe('Storage Utils', () => { }); it("should not trigger if the value doesn't change", async () => { + const VALUE = '123'; + const cb = vi.fn(); - const value = '123'; - await storage.setItem(`${storageArea}:key`, value); + await storage.setItem(`${storageArea}:key`, VALUE); storage.watch(`${storageArea}:key`, cb); - await storage.setItem(`${storageArea}:key`, value); + await storage.setItem(`${storageArea}:key`, VALUE); expect(cb).not.toBeCalled(); }); it('should call the callback when the value changes', async () => { + const OLD_VALUE = null; + const NEW_VALUE = '123'; + const cb = vi.fn(); - const newValue = '123'; - const oldValue = null; storage.watch(`${storageArea}:key`, cb); - await storage.setItem(`${storageArea}:key`, newValue); + await storage.setItem(`${storageArea}:key`, NEW_VALUE); expect(cb).toBeCalledTimes(1); - expect(cb).toBeCalledWith(newValue, oldValue); + expect(cb).toBeCalledWith(NEW_VALUE, OLD_VALUE); }); it('should remove the listener when calling the returned function', async () => { @@ -637,6 +661,7 @@ describe('Storage Utils', () => { const unwatch = storage.watch(`${storageArea}:key`, cb); unwatch(); + await storage.setItem(`${storageArea}:key`, '123'); expect(cb).not.toBeCalled(); @@ -659,6 +684,8 @@ describe('Storage Utils', () => { describe('Invalid storage areas', () => { it('should not accept keys without a valid storage area prefix', async () => { + // TODO: THIS HAVE SENSE? + // TODO: TS CHECKING IS ENOUGH TO PREVENT THIS CASE // @ts-expect-error await storage.getItem('test').catch(() => {}); // @ts-expect-error @@ -666,6 +693,7 @@ describe('Storage Utils', () => { }); it('should throw an error when using an invalid storage area', async () => { + // TODO: THE SAME QUESTION AS ABOVE // @ts-expect-error: Test passes if there is a type error here await expect(storage.getItem('invalidArea:key')).rejects.toThrow( 'Invalid area', @@ -680,6 +708,7 @@ describe('Storage Utils', () => { count: 2, count$: { v: 1 }, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); @@ -691,6 +720,7 @@ describe('Storage Utils', () => { 3: migrateToV3, }, }); + await waitForMigrations(); const actualValue = await item.getValue(); @@ -711,6 +741,7 @@ describe('Storage Utils', () => { count: 2, count$: { v: 1 }, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); const onMigrationComplete = vi.fn((count, _v) => count); @@ -724,6 +755,7 @@ describe('Storage Utils', () => { }, onMigrationComplete, }); + await waitForMigrations(); expect(onMigrationComplete).toBeCalledTimes(1); @@ -742,6 +774,7 @@ describe('Storage Utils', () => { 3: migrateToV3, }, }); + await waitForMigrations(); const actualValue = await item.getValue(); @@ -758,6 +791,7 @@ describe('Storage Utils', () => { await fakeBrowser.storage.local.set({ count: 2, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const item = storage.defineItem(`local:count`, { @@ -767,6 +801,7 @@ describe('Storage Utils', () => { 2: migrateToV2, }, }); + await waitForMigrations(); const actualValue = await item.getValue(); @@ -784,6 +819,7 @@ describe('Storage Utils', () => { count: 2, count$: { v: 3 }, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); @@ -806,6 +842,7 @@ describe('Storage Utils', () => { count: 2, count$: { v: 0 }, }); + const migrateToV1 = vi.fn((oldCount) => oldCount * 1); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); @@ -817,6 +854,7 @@ describe('Storage Utils', () => { 3: migrateToV3, }, }); + await waitForMigrations(); const actualValue = await item.getValue(); @@ -833,17 +871,19 @@ describe('Storage Utils', () => { }); it('should throw an error if the new version is less than the previous version', async () => { - const prevVersion = 2; - const nextVersion = 1; + const PREV_VERSION = 2; + const NEXT_VERSION = 1; + await fakeBrowser.storage.local.set({ count: 0, - count$: { v: prevVersion }, + count$: { v: PREV_VERSION }, }); const item = storage.defineItem(`local:count`, { defaultValue: 0, - version: nextVersion, + version: NEXT_VERSION, }); + await waitForMigrations(); await expect(item.migrate()).rejects.toThrow( @@ -868,6 +908,7 @@ describe('Storage Utils', () => { }, }, }); + await fakeBrowser.storage.local.set({ key: 1, key$: { v: 1 } }); await expect(item.migrate()).rejects.toThrow(expectedError); @@ -878,6 +919,7 @@ describe('Storage Utils', () => { count: 2, count$: { v: 1 }, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); const consoleSpy = vi.spyOn(console, 'debug'); @@ -891,6 +933,7 @@ describe('Storage Utils', () => { }, debug: true, }); + await waitForMigrations(); expect(consoleSpy).toHaveBeenCalledTimes(4); @@ -915,6 +958,7 @@ describe('Storage Utils', () => { count2: 2, count2$: { v: 1 }, }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); const migrateToV3 = vi.fn((oldCount) => oldCount * 3); const consoleSpy = vi.spyOn(console, 'debug'); @@ -936,20 +980,23 @@ describe('Storage Utils', () => { }, debug: false, }); + await waitForMigrations(); + expect(consoleSpy).toHaveBeenCalledTimes(0); }); }); describe('getValue', () => { it('should return the value from storage', async () => { - const expected = 2; + const EXPECTED = 2; + const item = storage.defineItem(`local:count`); - await fakeBrowser.storage.local.set({ count: expected }); + await fakeBrowser.storage.local.set({ count: EXPECTED }); const actual = await item.getValue(); - expect(actual).toBe(expected); + expect(actual).toBe(EXPECTED); }); it('should return null if missing', async () => { @@ -961,21 +1008,23 @@ describe('Storage Utils', () => { }); it('should return the provided default value if missing', async () => { - const expected = 0; + const EXPECTED = 0; + const item = storage.defineItem(`local:count`, { - defaultValue: expected, + defaultValue: EXPECTED, }); const actual = await item.getValue(); - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); }); describe('getMeta', () => { it('should return the value from storage at key+$', async () => { - const expected = { v: 2 }; + const expected = { v: 2 } as const; const item = storage.defineItem(`local:count`); + await fakeBrowser.storage.local.set({ count$: expected }); const actual = await item.getMeta(); @@ -984,7 +1033,8 @@ describe('Storage Utils', () => { }); it('should return an empty object if missing', async () => { - const expected = {}; + const expected = {} as const; + const item = storage.defineItem(`local:count`); const actual = await item.getMeta(); @@ -995,22 +1045,22 @@ describe('Storage Utils', () => { describe('setValue', () => { it('should set the value in storage', async () => { - const expected = 1; + const EXPECTED = 1; + const item = storage.defineItem(`local:count`); + await item.setValue(EXPECTED); - await item.setValue(expected); const actual = await item.getValue(); - expect(actual).toBe(expected); + expect(actual).toBe(EXPECTED); }); it.each([undefined, null])( 'should remove the value in storage when %s is passed in', async (value) => { const item = storage.defineItem(`local:count`); + await item.setValue(value!); - // @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose - await item.setValue(value); const actual = await item.getValue(); expect(actual).toBeNull(); @@ -1020,7 +1070,7 @@ describe('Storage Utils', () => { describe('setMeta', () => { it('should set metadata at key+$', async () => { - const expected = { date: Date.now() }; + const expected = { date: Date.now() } as const; const item = storage.defineItem( `local:count`, ); @@ -1032,12 +1082,14 @@ describe('Storage Utils', () => { }); it('should add to metadata if already present', async () => { - const existing = { v: 2 }; - const newFields = { date: Date.now() }; - const expected = { ...existing, ...newFields }; + const existing = { v: 2 } as const; + const newFields = { date: Date.now() } as const; + const expected = { ...existing, ...newFields } as const; + const item = storage.defineItem( `local:count`, ); + await fakeBrowser.storage.local.set({ count$: existing, }); @@ -1052,6 +1104,7 @@ describe('Storage Utils', () => { describe('removeValue', () => { it('should remove the key from storage', async () => { const item = storage.defineItem(`local:count`); + await fakeBrowser.storage.local.set({ count: 456 }); await item.removeValue(); @@ -1063,6 +1116,7 @@ describe('Storage Utils', () => { it('should not remove the metadata by default', async () => { const item = storage.defineItem(`local:count`); const expected = { v: 1 }; + await fakeBrowser.storage.local.set({ count$: expected, count: 3, @@ -1076,6 +1130,7 @@ describe('Storage Utils', () => { it('should remove the metadata when requested', async () => { const item = storage.defineItem(`local:count`); + await fakeBrowser.storage.local.set({ count$: { v: 1 }, count: 3, @@ -1091,6 +1146,7 @@ describe('Storage Utils', () => { describe('removeMeta', () => { it('should remove all metadata', async () => { const item = storage.defineItem(`local:count`); + await fakeBrowser.storage.local.set({ count$: { v: 4 } }); await item.removeMeta(); @@ -1103,6 +1159,7 @@ describe('Storage Utils', () => { const item = storage.defineItem( `local:count`, ); + await fakeBrowser.storage.local.set({ count$: { v: 4, d: Date.now() }, }); @@ -1126,60 +1183,66 @@ describe('Storage Utils', () => { }); it("should not trigger if the value doesn't change", async () => { + const VALUE = '123'; + const item = storage.defineItem(`local:key`); const cb = vi.fn(); - const value = '123'; - await item.setValue(value); + await item.setValue(VALUE); item.watch(cb); - await item.setValue(value); + await item.setValue(VALUE); expect(cb).not.toBeCalled(); }); it('should call the callback when the value changes', async () => { + const NEW_VALUE = '123'; + const OLD_VALUE = null; + const item = storage.defineItem(`local:key`); const cb = vi.fn(); - const newValue = '123'; - const oldValue = null; item.watch(cb); - await item.setValue(newValue); + await item.setValue(NEW_VALUE); expect(cb).toBeCalledTimes(1); - expect(cb).toBeCalledWith(newValue, oldValue); + expect(cb).toBeCalledWith(NEW_VALUE, OLD_VALUE); }); it('should use the default value for the newValue when the item is removed', async () => { - const defaultValue = 'default'; + const OLD_VALUE = '123'; + const DEFAULT_VALUE = 'default'; + const item = storage.defineItem(`local:key`, { - defaultValue, + defaultValue: DEFAULT_VALUE, }); + const cb = vi.fn(); - const oldValue = '123'; - await item.setValue(oldValue); + await item.setValue(OLD_VALUE); item.watch(cb); await item.removeValue(); expect(cb).toBeCalledTimes(1); - expect(cb).toBeCalledWith(defaultValue, oldValue); + expect(cb).toBeCalledWith(DEFAULT_VALUE, OLD_VALUE); }); it("should use the default value for the oldItem when the item didn't exist in storage yet", async () => { - const defaultValue = 'default'; + const DEFAULT_VALUE = 'default'; + const NEW_VALUE = '123'; + const item = storage.defineItem(`local:key`, { - defaultValue, + defaultValue: DEFAULT_VALUE, }); + const cb = vi.fn(); - const newValue = '123'; await item.removeValue(); item.watch(cb); - await item.setValue(newValue); + await item.setValue(NEW_VALUE); expect(cb).toBeCalledTimes(1); - expect(cb).toBeCalledWith(newValue, defaultValue); + expect(cb).toBeCalledWith(NEW_VALUE, DEFAULT_VALUE); }); it('should remove the listener when calling the returned function', async () => { @@ -1188,6 +1251,7 @@ describe('Storage Utils', () => { const unwatch = item.watch(cb); unwatch(); + await item.setValue('123'); expect(cb).not.toBeCalled(); @@ -1201,41 +1265,41 @@ describe('Storage Utils', () => { item.watch(cb); storage.unwatch(); + await item.setValue('123'); expect(cb).not.toBeCalled(); }); }); - describe.each(['fallback', 'defaultValue'] as const)( - '%s option', - (fallbackKey) => { - it('should return the default value when provided', () => { - const fallback = 123; - const item = storage.defineItem(`local:test`, { - [fallbackKey]: fallback, - }); + describe.each(['fallback', 'defaultValue'])('%s option', (fallbackKey) => { + it('should return the default value when provided', () => { + const FALLBACK = 123; - expect(item.fallback).toBe(fallback); - expect(item.defaultValue).toBe(fallback); + const item = storage.defineItem(`local:test`, { + [fallbackKey]: FALLBACK, }); - it('should return null when not provided', () => { - const item = storage.defineItem(`local:test`); + expect(item.fallback).toBe(FALLBACK); + expect(item.defaultValue).toBe(FALLBACK); + }); - expect(item.fallback).toBeNull(); - expect(item.defaultValue).toBeNull(); - }); - }, - ); + it('should return null when not provided', () => { + const item = storage.defineItem(`local:test`); + + expect(item.fallback).toBeNull(); + expect(item.defaultValue).toBeNull(); + }); + }); describe('init option', () => { it('should only call init once (per JS context) when calling getValue successively, avoiding race conditions', async () => { - const expected = 1; + const EXPECTED = 1; + const init = vi .fn() - .mockResolvedValueOnce(expected) - .mockResolvedValue('not' + expected); + .mockResolvedValueOnce(EXPECTED) + .mockResolvedValue('not' + EXPECTED); const item = storage.defineItem('local:test', { init }); await waitForInit(); @@ -1243,25 +1307,27 @@ describe('Storage Utils', () => { const p1 = item.getValue(); const p2 = item.getValue(); - await expect(p1).resolves.toBe(expected); - await expect(p2).resolves.toBe(expected); + await expect(p1).resolves.toBe(EXPECTED); + await expect(p2).resolves.toBe(EXPECTED); expect(init).toBeCalledTimes(1); }); it('should initialize the value in storage immediately', async () => { - const expected = 1; - const init = vi.fn().mockReturnValue(expected); + const EXPECTED = 1; + + const init = vi.fn().mockReturnValue(EXPECTED); storage.defineItem('local:test', { init }); await waitForInit(); - await expect(storage.getItem('local:test')).resolves.toBe(expected); + await expect(storage.getItem('local:test')).resolves.toBe(EXPECTED); }); it("should re-initialize a value on the next call to getValue if it's been removed", async () => { const init = vi.fn().mockImplementation(Math.random); const item = storage.defineItem('local:key', { init }); + await waitForInit(); await item.removeValue(); @@ -1273,6 +1339,7 @@ describe('Storage Utils', () => { item.getValue(), item.getValue(), ]); + expect(init).toBeCalledTimes(1); expect(value1).toBe(value2); }); @@ -1412,6 +1479,7 @@ describe('Storage Utils', () => { expect(localGetSpy).toBeCalledTimes(1); expect(localGetSpy).toBeCalledWith(['item1$', 'item3$']); + expect(sessionGetSpy).toBeCalledTimes(1); expect(sessionGetSpy).toBeCalledWith(['item2$']); }); @@ -1441,25 +1509,27 @@ describe('Storage Utils', () => { describe('setItems', () => { it('should set the values of multiple storage items efficiently', async () => { const item1 = storage.defineItem('local:item1'); - const value1 = 100; + const VALUE_1 = 100; + const item2 = storage.defineItem('session:item2'); - const value2 = 'test'; + const VALUE_2 = 'test'; + const item3 = storage.defineItem('local:item3'); - const value3 = true; + const VALUE_3 = true; const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set'); const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set'); await storage.setItems([ - { item: item1, value: value1 }, - { item: item2, value: value2 }, - { item: item3, value: value3 }, + { item: item1, value: VALUE_1 }, + { item: item2, value: VALUE_2 }, + { item: item3, value: VALUE_3 }, ]); expect(localSetSpy).toBeCalledTimes(1); - expect(localSetSpy).toBeCalledWith({ item1: value1, item3: value3 }); + expect(localSetSpy).toBeCalledWith({ item1: VALUE_1, item3: VALUE_3 }); expect(sessionSetSpy).toBeCalledTimes(1); - expect(sessionSetSpy).toBeCalledWith({ item2: value2 }); + expect(sessionSetSpy).toBeCalledWith({ item2: VALUE_2 }); }); }); @@ -1468,10 +1538,12 @@ describe('Storage Utils', () => { const item1 = storage.defineItem('local:one'); const item2 = storage.defineItem('session:two'); const item3 = storage.defineItem('local:three'); + await waitForInit(); const localGetSpy = vi.spyOn(fakeBrowser.storage.local, 'get'); const sessionGetSpy = vi.spyOn(fakeBrowser.storage.session, 'get'); + const localSetSpy = vi.spyOn(fakeBrowser.storage.local, 'set'); const sessionSetSpy = vi.spyOn(fakeBrowser.storage.session, 'set'); @@ -1482,8 +1554,10 @@ describe('Storage Utils', () => { ]); console.log(localGetSpy.mock.calls); + expect(localGetSpy).toBeCalledTimes(1); expect(localGetSpy).toBeCalledWith(['one$', 'three$']); + expect(sessionGetSpy).toBeCalledTimes(1); expect(sessionGetSpy).toBeCalledWith(['two$']); @@ -1492,6 +1566,7 @@ describe('Storage Utils', () => { one$: { v: 1 }, three$: { v: 3 }, }); + expect(sessionSetSpy).toBeCalledTimes(1); expect(sessionSetSpy).toBeCalledWith({ two$: { v: 2 }, diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index b629ec4de..716199fca 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -17,6 +17,7 @@ function createStorage(): WxtStorage { sync: createDriver('sync'), managed: createDriver('managed'), }; + const getDriver = (area: StorageArea) => { const driver = drivers[area]; if (driver == null) { @@ -25,10 +26,12 @@ function createStorage(): WxtStorage { } return driver; }; + const resolveKey = (key: StorageItemKey) => { const deliminatorIndex = key.indexOf(':'); const driverArea = key.substring(0, deliminatorIndex) as StorageArea; const driverKey = key.substring(deliminatorIndex + 1); + if (driverKey == null) throw Error( `Storage key should be in the form of "area:key", but received "${key}"`, @@ -40,68 +43,85 @@ function createStorage(): WxtStorage { driver: getDriver(driverArea), }; }; + const getMetaKey = (key: string) => key + '$'; - const mergeMeta = (oldMeta: any, newMeta: any): any => { + const mergeMeta = ( + oldMeta: Record, + newMeta: Record, + ): Record => { const newFields = { ...oldMeta }; + Object.entries(newMeta).forEach(([key, value]) => { if (value == null) delete newFields[key]; else newFields[key] = value; }); + return newFields; }; - const getValueOrFallback = (value: any, fallback: any) => + const getValueOrFallback = (value: T | null | undefined, fallback: T) => value ?? fallback ?? null; - const getMetaValue = (properties: any) => - typeof properties === 'object' && !Array.isArray(properties) - ? properties + + const getMetaValue = (properties: unknown): Record => + typeof properties === 'object' && + properties !== null && + !Array.isArray(properties) + ? (properties as Record) : {}; - const getItem = async ( + const getItem = async ( driver: WxtStorageDriver, driverKey: string, - opts: GetItemOptions | undefined, - ) => { - const res = await driver.getItem(driverKey); - return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue); + opts: GetItemOptions | undefined, + ): Promise => { + const res = await driver.getItem(driverKey); + return getValueOrFallback(res, (opts?.fallback ?? opts?.defaultValue) as T); }; + const getMeta = async (driver: WxtStorageDriver, driverKey: string) => { const metaKey = getMetaKey(driverKey); - const res = await driver.getItem(metaKey); + const res = await driver.getItem>(metaKey); return getMetaValue(res); }; - const setItem = async ( + + const setItem = async ( driver: WxtStorageDriver, driverKey: string, - value: any, + value: T, ) => { await driver.setItem(driverKey, value ?? null); }; + const setMeta = async ( driver: WxtStorageDriver, driverKey: string, - properties: any | undefined, + properties: Record | undefined, ) => { const metaKey = getMetaKey(driverKey); const existingFields = getMetaValue(await driver.getItem(metaKey)); - await driver.setItem(metaKey, mergeMeta(existingFields, properties)); + + await driver.setItem(metaKey, mergeMeta(existingFields, properties ?? {})); }; + const removeItem = async ( driver: WxtStorageDriver, driverKey: string, opts: RemoveItemOptions | undefined, ) => { await driver.removeItem(driverKey); + if (opts?.removeMeta) { const metaKey = getMetaKey(driverKey); await driver.removeItem(metaKey); } }; + const removeMeta = async ( driver: WxtStorageDriver, driverKey: string, properties: string | string[] | undefined, ) => { const metaKey = getMetaKey(driverKey); + if (properties == null) { await driver.removeItem(metaKey); } else { @@ -110,27 +130,32 @@ function createStorage(): WxtStorage { await driver.setItem(metaKey, newFields); } }; - const watch = ( + + const watch = ( driver: WxtStorageDriver, driverKey: string, - cb: WatchCallback, + cb: WatchCallback, ) => { - return driver.watch(driverKey, cb); + return driver.watch(driverKey, cb); }; - const storage: WxtStorage = { + return { getItem: async (key, opts) => { const { driver, driverKey } = resolveKey(key); - return await getItem(driver, driverKey, opts); + return getItem(driver, driverKey, opts); }, getItems: async (keys) => { const areaToKeyMap = new Map(); - const keyToOptsMap = new Map | undefined>(); + const keyToOptsMap = new Map< + string, + GetItemOptions | undefined + >(); const orderedKeys: StorageItemKey[] = []; keys.forEach((key) => { let keyStr: StorageItemKey; - let opts: GetItemOptions | undefined; + let opts: GetItemOptions | undefined; + if (typeof key === 'string') { // key: string keyStr = key; @@ -143,14 +168,17 @@ function createStorage(): WxtStorage { keyStr = key.key; opts = key.options; } + orderedKeys.push(keyStr); + const { driverArea, driverKey } = resolveKey(keyStr); const areaKeys = areaToKeyMap.get(driverArea) ?? []; areaToKeyMap.set(driverArea, areaKeys.concat(driverKey)); keyToOptsMap.set(keyStr, opts); }); - const resultsMap = new Map(); + const resultsMap = new Map(); + await Promise.all( Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => { const driverResults = await drivers[driverArea].getItems(keys); @@ -171,14 +199,15 @@ function createStorage(): WxtStorage { value: resultsMap.get(key), })); }, - getMeta: async (key) => { + getMeta: async >(key: StorageItemKey) => { const { driver, driverKey } = resolveKey(key); - return await getMeta(driver, driverKey); + return (await getMeta(driver, driverKey)) as T; }, getMetas: async (args) => { const keys = args.map((arg) => { const key = typeof arg === 'string' ? arg : arg.key; const { driverArea, driverKey } = resolveKey(key); + return { key, driverArea, @@ -186,6 +215,7 @@ function createStorage(): WxtStorage { driverMetaKey: getMetaKey(driverKey), }; }); + const areaToDriverMetaKeysMap = keys.reduce< Partial> >((map, key) => { @@ -194,14 +224,18 @@ function createStorage(): WxtStorage { return map; }, {}); - const resultsMap: Record = {}; + const resultsMap: Record> = {}; + await Promise.all( Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => { const areaRes = await browser.storage[area as StorageArea].get( keys.map((key) => key.driverMetaKey), ); keys.forEach((key) => { - resultsMap[key.key] = areaRes[key.driverMetaKey] ?? {}; + resultsMap[key.key] = (areaRes[key.driverMetaKey] ?? {}) as Record< + string, + unknown + >; }); }), ); @@ -217,18 +251,20 @@ function createStorage(): WxtStorage { }, setItems: async (items) => { const areaToKeyValueMap: Partial< - Record> + Record> > = {}; items.forEach((item) => { const { driverArea, driverKey } = resolveKey( 'key' in item ? item.key : item.item.key, ); + areaToKeyValueMap[driverArea] ??= []; - areaToKeyValueMap[driverArea].push({ + areaToKeyValueMap[driverArea]!.push({ key: driverKey, value: item.value, }); }); + await Promise.all( Object.entries(areaToKeyValueMap).map(async ([driverArea, values]) => { const driver = getDriver(driverArea as StorageArea); @@ -238,20 +274,24 @@ function createStorage(): WxtStorage { }, setMeta: async (key, properties) => { const { driver, driverKey } = resolveKey(key); - await setMeta(driver, driverKey, properties); + await setMeta(driver, driverKey, properties as Record); }, setMetas: async (items) => { const areaToMetaUpdatesMap: Partial< - Record + Record< + StorageArea, + { key: string; properties: Record }[] + > > = {}; items.forEach((item) => { const { driverArea, driverKey } = resolveKey( 'key' in item ? item.key : item.item.key, ); + areaToMetaUpdatesMap[driverArea] ??= []; - areaToMetaUpdatesMap[driverArea].push({ + areaToMetaUpdatesMap[driverArea]!.push({ key: driverKey, - properties: item.meta, + properties: item.meta as Record, }); }); @@ -260,6 +300,7 @@ function createStorage(): WxtStorage { async ([storageArea, updates]) => { const driver = getDriver(storageArea as StorageArea); const metaKeys = updates.map(({ key }) => getMetaKey(key)); + const existingMetas = await driver.getItems(metaKeys); const existingMetaMap = Object.fromEntries( existingMetas.map(({ key, value }) => [key, getMetaValue(value)]), @@ -267,6 +308,7 @@ function createStorage(): WxtStorage { const metaUpdates = updates.map(({ key, properties }) => { const metaKey = getMetaKey(key); + return { key: metaKey, value: mergeMeta(existingMetaMap[metaKey] ?? {}, properties), @@ -288,6 +330,7 @@ function createStorage(): WxtStorage { keys.forEach((key) => { let keyStr: StorageItemKey; let opts: RemoveItemOptions | undefined; + if (typeof key === 'string') { // key: string keyStr = key; @@ -303,9 +346,12 @@ function createStorage(): WxtStorage { keyStr = key.key; opts = key.options; } + const { driverArea, driverKey } = resolveKey(keyStr); + areaToKeysMap[driverArea] ??= []; areaToKeysMap[driverArea].push(driverKey); + if (opts?.removeMeta) { areaToKeysMap[driverArea].push(getMetaKey(driverKey)); } @@ -329,10 +375,12 @@ function createStorage(): WxtStorage { snapshot: async (base, opts) => { const driver = getDriver(base); const data = await driver.snapshot(); + opts?.excludeKeys?.forEach((key) => { delete data[key]; delete data[getMetaKey(key)]; }); + return data; }, restoreSnapshot: async (base, data) => { @@ -348,7 +396,10 @@ function createStorage(): WxtStorage { driver.unwatch(); }); }, - defineItem: (key, opts?: WxtStorageItemOptions) => { + defineItem: ( + key: StorageItemKey, + opts?: WxtStorageItemOptions, + ) => { const { driver, driverKey } = resolveKey(key); const { @@ -357,6 +408,7 @@ function createStorage(): WxtStorage { onMigrationComplete, debug = false, } = opts ?? {}; + if (targetVersion < 1) { throw Error( 'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.', @@ -364,23 +416,28 @@ function createStorage(): WxtStorage { } const migrate = async () => { const driverMetaKey = getMetaKey(driverKey); - const [{ value }, { value: meta }] = await driver.getItems([ + const [itemRes, metaRes] = await driver.getItems([ driverKey, driverMetaKey, ]); + const value = itemRes.value; + const meta = getMetaValue(metaRes.value); + if (value == null) return; - const currentVersion = meta?.v ?? 1; + const currentVersion = (meta?.v as number | undefined) ?? 1; + if (currentVersion > targetVersion) { throw Error( `Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`, ); } + if (currentVersion === targetVersion) { return; } - if (debug === true) { + if (debug) { console.debug( `[@wxt-dev/storage] Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`, ); @@ -389,13 +446,15 @@ function createStorage(): WxtStorage { { length: targetVersion - currentVersion }, (_, i) => currentVersion + i + 1, ); + let migratedValue = value; + for (const migrateToVersion of migrationsToRun) { try { migratedValue = (await migrations?.[migrateToVersion]?.(migratedValue)) ?? migratedValue; - if (debug === true) { + if (debug) { console.debug( `[@wxt-dev/storage] Storage migration processed for version: v${migrateToVersion}`, ); @@ -406,19 +465,25 @@ function createStorage(): WxtStorage { }); } } + await driver.setItems([ { key: driverKey, value: migratedValue }, - { key: driverMetaKey, value: { ...meta, v: targetVersion } }, + { + key: driverMetaKey, + value: { ...meta, v: targetVersion }, + }, ]); - if (debug === true) { + if (debug) { console.debug( `[@wxt-dev/storage] Storage migration completed for ${key} v${targetVersion}`, { migratedValue }, ); } - onMigrationComplete?.(migratedValue, targetVersion); + + onMigrationComplete?.(migratedValue as TValue, targetVersion); }; + const migrationsDone = opts?.migrations == null ? Promise.resolve() @@ -435,12 +500,13 @@ function createStorage(): WxtStorage { const getOrInitValue = () => initMutex.runExclusive(async () => { - const value = await driver.getItem(driverKey); + const value = await driver.getItem(driverKey); // Don't init value if it already exists or the init function isn't provided if (value != null || opts?.init == null) return value; const newValue = await opts.init(); - await driver.setItem(driverKey, newValue); + await driver.setItem(driverKey, newValue); + return newValue; }); @@ -450,48 +516,59 @@ function createStorage(): WxtStorage { return { key, get defaultValue() { - return getFallback(); + return getFallback() as TValue; }, get fallback() { - return getFallback(); + return getFallback() as TValue; }, getValue: async () => { await migrationsDone; + if (opts?.init) { - return await getOrInitValue(); + return getOrInitValue() as TValue; } else { - return await getItem(driver, driverKey, opts); + return getItem(driver, driverKey, opts) as TValue; } }, getMeta: async () => { await migrationsDone; - return await getMeta(driver, driverKey); + + return getMeta(driver, driverKey); }, setValue: async (value) => { await migrationsDone; - return await setItem(driver, driverKey, value); + return setItem(driver, driverKey, value); }, setMeta: async (properties) => { await migrationsDone; - return await setMeta(driver, driverKey, properties); + + return setMeta( + driver, + driverKey, + properties as Record, + ); }, removeValue: async (opts) => { await migrationsDone; - return await removeItem(driver, driverKey, opts); + + return removeItem(driver, driverKey, opts); }, removeMeta: async (properties) => { await migrationsDone; - return await removeMeta(driver, driverKey, properties); + + return removeMeta(driver, driverKey, properties); }, - watch: (cb) => - watch(driver, driverKey, (newValue, oldValue) => - cb(newValue ?? getFallback(), oldValue ?? getFallback()), + watch: (cb: WatchCallback) => + watch(driver, driverKey, (newValue, oldValue) => + cb( + (newValue ?? getFallback()) as TValue, + (oldValue ?? getFallback()) as TValue, + ), ), migrate, }; }, }; - return storage; } function createDriver(storageArea: StorageArea): WxtStorageDriver { @@ -505,6 +582,7 @@ function createDriver(storageArea: StorageArea): WxtStorageDriver { ].join('\n'), ); } + if (browser.storage == null) { throw Error( "You must add the 'storage' permission to your manifest to use 'wxt/storage'", @@ -512,18 +590,23 @@ function createDriver(storageArea: StorageArea): WxtStorageDriver { } const area = browser.storage[storageArea]; - if (area == null) + + if (area == null) { throw Error(`"browser.storage.${storageArea}" is undefined`); + } + return area; }; const watchListeners = new Set<(changes: StorageAreaChanges) => void>(); return { - getItem: async (key) => { - const res = await getStorageArea().get>(key); - return res[key]; + getItem: async (key: string) => { + const res = await getStorageArea().get>(key); + + return (res[key] as T) ?? null; }, getItems: async (keys) => { const result = await getStorageArea().get(keys); + return keys.map((key) => ({ key, value: result[key] ?? null })); }, setItem: async (key, value) => { @@ -541,6 +624,7 @@ function createDriver(storageArea: StorageArea): WxtStorageDriver { }, {}, ); + await getStorageArea().set(map); }, removeItem: async (key) => { @@ -553,23 +637,28 @@ function createDriver(storageArea: StorageArea): WxtStorageDriver { await getStorageArea().clear(); }, snapshot: async () => { - return await getStorageArea().get(); + return getStorageArea().get(); }, restoreSnapshot: async (data) => { await getStorageArea().set(data); }, - watch(key, cb) { + watch(key: string, cb: WatchCallback) { const listener = (changes: StorageAreaChanges) => { const change = changes[key] as { - newValue?: any; - oldValue?: any | null; + newValue?: T; + oldValue?: T | null; } | null; + if (change == null) return; + if (dequal(change.newValue, change.oldValue)) return; + cb(change.newValue ?? null, change.oldValue ?? null); }; + getStorageArea().onChanged.addListener(listener); watchListeners.add(listener); + return () => { getStorageArea().onChanged.removeListener(listener); watchListeners.delete(listener); @@ -611,10 +700,10 @@ export interface WxtStorage { getItems( keys: Array< | StorageItemKey - | WxtStorageItem - | { key: StorageItemKey; options?: GetItemOptions } + | WxtStorageItem> + | { key: StorageItemKey; options?: GetItemOptions } >, - ): Promise>; + ): Promise>; /** * Return an object containing metadata about the key. Object is stored at `key + "$"`. If value * is not an object, it returns an empty object. @@ -626,12 +715,14 @@ export interface WxtStorage { /** * Get the metadata of multiple storage items. * - * @param items List of keys or items to get the metadata of. * @returns An array containing storage keys and their metadata. + * @param keys List of keys or items to get the metadata of. */ getMetas( - keys: Array>, - ): Promise>; + keys: Array< + StorageItemKey | WxtStorageItem> + >, + ): Promise }>>; /** * Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling * `removeItem`. @@ -651,8 +742,11 @@ export interface WxtStorage { */ setItems( values: Array< - | { key: StorageItemKey; value: any } - | { item: WxtStorageItem; value: any } + | { key: StorageItemKey; value: unknown } + | { + item: WxtStorageItem>; + value: unknown; + } >, ): Promise; /** @@ -669,12 +763,15 @@ export interface WxtStorage { /** * Set the metadata of multiple storage items. * - * @param items List of storage keys or items and metadata to set for each. + * @param metas List of storage keys or items and metadata to set for each. */ setMetas( metas: Array< - | { key: StorageItemKey; meta: Record } - | { item: WxtStorageItem; meta: Record } + | { key: StorageItemKey; meta: Record } + | { + item: WxtStorageItem>; + meta: Record; + } >, ): Promise; /** @@ -690,9 +787,12 @@ export interface WxtStorage { removeItems( keys: Array< | StorageItemKey - | WxtStorageItem + | WxtStorageItem> | { key: StorageItemKey; options?: RemoveItemOptions } - | { item: WxtStorageItem; options?: RemoveItemOptions } + | { + item: WxtStorageItem>; + options?: RemoveItemOptions; + } >, ): Promise; @@ -726,7 +826,10 @@ export interface WxtStorage { * Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are * not overridden. Only values existing in the snapshot are overridden. */ - restoreSnapshot(base: StorageArea, data: any): Promise; + restoreSnapshot( + base: StorageArea, + data: Record, + ): Promise; /** * Watch for changes to a specific key in storage. */ @@ -741,24 +844,39 @@ export interface WxtStorage { * * Read full docs: https://wxt.dev/storage.html#defining-storage-items */ - defineItem = {}>( + defineItem< + TValue, + TMetadata extends Record = Record, + >( key: StorageItemKey, ): WxtStorageItem; - defineItem = {}>( + defineItem< + TValue, + TMetadata extends Record = Record, + >( key: StorageItemKey, options: WxtStorageItemOptions & { fallback: TValue }, ): WxtStorageItem; - defineItem = {}>( + defineItem< + TValue, + TMetadata extends Record = Record, + >( key: StorageItemKey, options: WxtStorageItemOptions & { defaultValue: TValue }, ): WxtStorageItem; - defineItem = {}>( + defineItem< + TValue, + TMetadata extends Record = Record, + >( key: StorageItemKey, options: WxtStorageItemOptions & { init: () => TValue | Promise; }, ): WxtStorageItem; - defineItem = {}>( + defineItem< + TValue, + TMetadata extends Record = Record, + >( key: StorageItemKey, options: WxtStorageItemOptions, ): WxtStorageItem; @@ -766,9 +884,9 @@ export interface WxtStorage { interface WxtStorageDriver { getItem(key: string): Promise; - getItems(keys: string[]): Promise<{ key: string; value: any }[]>; + getItems(keys: string[]): Promise<{ key: string; value: unknown }[]>; setItem(key: string, value: T | null): Promise; - setItems(values: Array<{ key: string; value: any }>): Promise; + setItems(values: Array<{ key: string; value: unknown }>): Promise; removeItem(key: string): Promise; removeItems(keys: string[]): Promise; clear(): Promise; @@ -786,6 +904,7 @@ export interface WxtStorageItem< * The storage key passed when creating the storage item. */ key: StorageItemKey; + // TODO: MAYBE REMOVE IT BEFORE 1.0.0 RELEASE? /** * @deprecated Renamed to fallback, use it instead. */ @@ -825,7 +944,7 @@ export interface WxtStorageItem< /** * If there are migrations defined on the storage item, migrate to the latest version. * - * **This function is ran automatically whenever the extension updates**, so you don't have to call it + * **This function is run automatically whenever the extension updates**, so you don't have to call it * manually. */ migrate(): Promise; @@ -835,6 +954,7 @@ export type StorageArea = 'local' | 'session' | 'sync' | 'managed'; export type StorageItemKey = `${StorageArea}:${string}`; export interface GetItemOptions { + // TODO: MAYBE REMOVE IT BEFORE 1.0 RELEASE? /** * @deprecated Renamed to `fallback`, use it instead. */ @@ -863,6 +983,7 @@ export interface SnapshotOptions { } export interface WxtStorageItemOptions { + // TODO: MAYBE REMOVE IT BEFORE 1.0 RELEASE? /** * @deprecated Renamed to `fallback`, use it instead. */ @@ -886,7 +1007,7 @@ export interface WxtStorageItemOptions { /** * A map of version numbers to the functions used to migrate the data to that version. */ - migrations?: Record any>; + migrations?: Record unknown>; /** * Print debug logs, such as migration process. * @default false diff --git a/packages/unocss/src/index.ts b/packages/unocss/src/index.ts index 5abb37da8..188bd21d0 100644 --- a/packages/unocss/src/index.ts +++ b/packages/unocss/src/index.ts @@ -16,10 +16,12 @@ export default defineWxtModule({ }, ); - if (!resolvedOptions.enabled) + if (!resolvedOptions.enabled) { return wxt.logger.warn(`\`[unocss]\` ${this.name} disabled`); + } const excludedEntrypoints = new Set(resolvedOptions.excludeEntrypoints); + if (wxt.config.debug) { wxt.logger.debug( `\`[unocss]\` Excluded entrypoints:`, diff --git a/packages/wxt-demo/modules/example.ts b/packages/wxt-demo/modules/example.ts index 8ac1cf51e..ff05310d4 100644 --- a/packages/wxt-demo/modules/example.ts +++ b/packages/wxt-demo/modules/example.ts @@ -1,17 +1,6 @@ import { defineWxtModule } from 'wxt/modules'; -// Example of adding option types to wxt config file -export interface ExampleModuleOptions { - a: string; - b?: string; -} -declare module 'wxt' { - interface InlineConfig { - example?: ExampleModuleOptions; - } -} - -export default defineWxtModule({ +export default defineWxtModule({ configKey: 'example', setup(wxt, options) { wxt.logger.info('Example module with options:', options); diff --git a/packages/wxt-demo/src/entrypoints/__tests__/background.test.ts b/packages/wxt-demo/src/entrypoints/__tests__/background.test.ts index c46f84d2b..5d575523a 100644 --- a/packages/wxt-demo/src/entrypoints/__tests__/background.test.ts +++ b/packages/wxt-demo/src/entrypoints/__tests__/background.test.ts @@ -12,12 +12,12 @@ describe('Background Entrypoint', () => { }); it("should log the extension's runtime ID", () => { - const id = 'some-id'; - fakeBrowser.runtime.id = id; + const ID = 'some-id'; + fakeBrowser.runtime.id = ID; background.main(); - expect(logMock).toBeCalledWith(id); + expect(logMock).toBeCalledWith(ID); }); it('should set the start time in storage', async () => { diff --git a/packages/wxt-demo/src/entrypoints/automount.content/index.ts b/packages/wxt-demo/src/entrypoints/automount.content/index.ts index 7cac00330..5713cbd79 100644 --- a/packages/wxt-demo/src/entrypoints/automount.content/index.ts +++ b/packages/wxt-demo/src/entrypoints/automount.content/index.ts @@ -13,9 +13,12 @@ export default defineContentScript({ onMount: (container) => { const app = document.createElement('div'); container.id = 'automount-anchor'; + app.classList.add('m-4', 'text-center', 'text-red-500'); app.textContent = i18n.t('prompt_for_name'); + container.append(app); + return { container, app }; }, onRemove() { @@ -30,8 +33,10 @@ export default defineContentScript({ onMount: (container) => { const app = document.createElement('div'); app.id = 'automount-ui'; + app.classList.add('m-0', 'text-center', 'text-blue-500'); app.textContent = `Hello, I'm automount UI.`; + container.append(app); }, onRemove() { @@ -51,14 +56,18 @@ export default defineContentScript({ anchor: 'form[role=search]', onMount: (container) => { const app = document.createElement('button'); + container.classList.add('flex', 'flex-justify-center'); app.classList.add('mt-4', 'p-2'); app.textContent = 'Stop auto-mount'; + app.onclick = (e) => { e.preventDefault(); autoMountUi.remove(); }; + container.append(app); + return { container, app }; }, }); diff --git a/packages/wxt-demo/src/entrypoints/background.ts b/packages/wxt-demo/src/entrypoints/background.ts index 096261015..4aaeef7c9 100644 --- a/packages/wxt-demo/src/entrypoints/background.ts +++ b/packages/wxt-demo/src/entrypoints/background.ts @@ -19,12 +19,9 @@ export default defineBackground({ browser.runtime.getURL('/icons/128.png'); browser.runtime.getURL('/example.html#hash'); browser.runtime.getURL('/example.html?query=param'); - // @ts-expect-error: should only accept entrypoints or public assets browser.runtime.getURL('/unknown'); - // @ts-expect-error: should only allow hashes/query params on HTML files browser.runtime.getURL('/icon-128.png?query=param'); - // @ts-expect-error: should only accept known message names i18n.t('test'); i18n.t('prompt_for_name'); i18n.t('hello', ['test']); diff --git a/packages/wxt-demo/src/entrypoints/ui.content/index.ts b/packages/wxt-demo/src/entrypoints/ui.content/index.ts index 1104aed38..85ab7a9cb 100644 --- a/packages/wxt-demo/src/entrypoints/ui.content/index.ts +++ b/packages/wxt-demo/src/entrypoints/ui.content/index.ts @@ -18,8 +18,10 @@ export default defineContentScript({ anchor: 'form[role=search]', onMount: (container) => { const app = document.createElement('div'); + app.classList.add('m-4', 'text-red-500'); app.textContent = i18n.t('prompt_for_name'); + container.append(app); }, }); diff --git a/packages/wxt-demo/wxt.config.ts b/packages/wxt-demo/wxt.config.ts index e68b93b46..83503833a 100644 --- a/packages/wxt-demo/wxt.config.ts +++ b/packages/wxt-demo/wxt.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'wxt'; -import { presetUno } from 'unocss'; +import { presetWind3 } from 'unocss'; export default defineConfig({ srcDir: 'src', @@ -25,7 +25,6 @@ export default defineConfig({ }, example: { a: 'a', - // @ts-expect-error: c is not defined, this should be a type error, but it should show up in the module c: 'c', }, unocss: { @@ -53,7 +52,7 @@ export default defineConfig({ ], }, }, - presets: [presetUno()], + presets: [presetWind3()], }, }, }); diff --git a/packages/wxt/e2e/tests/analysis.test.ts b/packages/wxt/e2e/tests/analysis.test.ts index 0ba662f9f..b14cfb344 100644 --- a/packages/wxt/e2e/tests/analysis.test.ts +++ b/packages/wxt/e2e/tests/analysis.test.ts @@ -17,8 +17,8 @@ describe('Analysis', () => { it('should output a stats.html with no part files by default', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -38,8 +38,8 @@ describe('Analysis', () => { it('should save part files when requested', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -59,8 +59,8 @@ describe('Analysis', () => { it('should support customizing the stats output directory', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -78,8 +78,8 @@ describe('Analysis', () => { it('should place artifacts next to the custom output file', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', @@ -100,8 +100,8 @@ describe('Analysis', () => { it('should open the stats in the browser when requested', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', diff --git a/packages/wxt/e2e/tests/auto-imports.test.ts b/packages/wxt/e2e/tests/auto-imports.test.ts index 8746656ff..e3dec8d92 100644 --- a/packages/wxt/e2e/tests/auto-imports.test.ts +++ b/packages/wxt/e2e/tests/auto-imports.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { TestProject } from '../utils'; import spawn from 'nano-spawn'; @@ -6,7 +6,7 @@ describe('Auto Imports', () => { describe('imports: { ... }', () => { it('should generate a declaration file, imports.d.ts, for auto-imports', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare(); @@ -36,31 +36,22 @@ describe('Auto Imports', () => { } // for type re-export declare global { - // @ts-ignore export type { Browser } from 'wxt/browser' import('wxt/browser') - // @ts-ignore export type { StorageArea, WxtStorage, WxtStorageItem, StorageItemKey, StorageAreaChanges, MigrationError } from 'wxt/utils/storage' import('wxt/utils/storage') - // @ts-ignore export type { WxtWindowEventMap } from 'wxt/utils/content-script-context' import('wxt/utils/content-script-context') - // @ts-ignore export type { IframeContentScriptUi, IframeContentScriptUiOptions } from 'wxt/utils/content-script-ui/iframe' import('wxt/utils/content-script-ui/iframe') - // @ts-ignore export type { IntegratedContentScriptUi, IntegratedContentScriptUiOptions } from 'wxt/utils/content-script-ui/integrated' import('wxt/utils/content-script-ui/integrated') - // @ts-ignore export type { ShadowRootContentScriptUi, ShadowRootContentScriptUiOptions } from 'wxt/utils/content-script-ui/shadow-root' import('wxt/utils/content-script-ui/shadow-root') - // @ts-ignore export type { ContentScriptUi, ContentScriptUiOptions, ContentScriptOverlayAlignment, ContentScriptAppendMode, ContentScriptInlinePositioningOptions, ContentScriptOverlayPositioningOptions, ContentScriptModalPositioningOptions, ContentScriptPositioningOptions, ContentScriptAnchoredOptions, AutoMountOptions, StopAutoMount, AutoMount } from 'wxt/utils/content-script-ui/types' import('wxt/utils/content-script-ui/types') - // @ts-ignore export type { WxtAppConfig } from 'wxt/utils/define-app-config' import('wxt/utils/define-app-config') - // @ts-ignore export type { ScriptPublicPath, InjectScriptOptions } from 'wxt/utils/inject-script' import('wxt/utils/inject-script') } @@ -70,7 +61,7 @@ describe('Auto Imports', () => { it('should include auto-imports in the project', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare(); @@ -91,7 +82,7 @@ describe('Auto Imports', () => { it('should generate the #imports module', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); // Project auto-imports should also be present project.addFile( 'utils/time.ts', @@ -138,7 +129,7 @@ describe('Auto Imports', () => { project.setConfigFileConfig({ imports: false, }); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare(); @@ -150,8 +141,7 @@ describe('Auto Imports', () => { project.setConfigFileConfig({ imports: false, }); - project.addFile('entrypoints/popup.html', ``); - + project.addFile('entrypoints/popup.html', ``); await project.prepare(); expect( @@ -176,7 +166,7 @@ describe('Auto Imports', () => { project.setConfigFileConfig({ imports: false, }); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); // Project auto-imports should also be present project.addFile( 'utils/time.ts', @@ -219,7 +209,7 @@ describe('Auto Imports', () => { describe('eslintrc', () => { it('"enabled: true" should output a JSON config file compatible with ESlint 8', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare({ imports: { @@ -236,7 +226,7 @@ describe('Auto Imports', () => { it('"enabled: 8" should output a JSON config file compatible with ESlint 8', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare({ imports: { @@ -253,7 +243,7 @@ describe('Auto Imports', () => { it('"enabled: 9" should output a flat config file compatible with ESlint 9', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare({ imports: { @@ -270,7 +260,7 @@ describe('Auto Imports', () => { it('"enabled: false" should NOT output an ESlint config file', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare({ imports: { @@ -290,7 +280,7 @@ describe('Auto Imports', () => { it('should NOT output an ESlint config file by default', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare(); @@ -304,7 +294,7 @@ describe('Auto Imports', () => { it('should allow customizing the output', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ``); + project.addFile('entrypoints/popup.html', ``); await project.prepare({ imports: { @@ -331,7 +321,8 @@ describe('Auto Imports', () => { await project.prepare({ imports: { eslintrc: { enabled: version } }, }); - return await spawn('pnpm', ['eslint', 'entrypoints/background.js'], { + + return spawn('pnpm', ['eslint', 'entrypoints/background.js'], { cwd: project.root, }); } diff --git a/packages/wxt/e2e/tests/dev.test.ts b/packages/wxt/e2e/tests/dev.test.ts index c782bb94c..40539f855 100644 --- a/packages/wxt/e2e/tests/dev.test.ts +++ b/packages/wxt/e2e/tests/dev.test.ts @@ -10,6 +10,7 @@ describe('Dev Mode', () => { ); const server = await project.startServer({ + // TODO: MAYBE REMOVE IT BEFORE 1.0.0? runner: { disabled: true, }, diff --git a/packages/wxt/e2e/tests/hooks.test.ts b/packages/wxt/e2e/tests/hooks.test.ts index 9442c4088..a5f068eb0 100644 --- a/packages/wxt/e2e/tests/hooks.test.ts +++ b/packages/wxt/e2e/tests/hooks.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TestProject } from '../utils'; -import { WxtHooks } from '../../src/types'; +import { WxtHooks } from '../../src'; const hooks: WxtHooks = { ready: vi.fn(), @@ -34,6 +34,7 @@ function expectHooksToBeCalled( const hookName = key as keyof WxtHooks; const value = called[hookName]; const times = typeof value === 'number' ? value : value ? 1 : 0; + expect( hooks[hookName], `Expected "${hookName}" to be called ${times} time(s)`, @@ -48,7 +49,7 @@ describe('Hooks', () => { it('prepare should call hooks', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); await project.prepare({ hooks }); @@ -80,7 +81,7 @@ describe('Hooks', () => { it('build should call hooks', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); await project.build({ hooks }); @@ -112,7 +113,7 @@ describe('Hooks', () => { it('zip should call hooks', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); await project.zip({ hooks }); @@ -144,7 +145,7 @@ describe('Hooks', () => { it('zip -b firefox should call hooks', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); await project.zip({ hooks, browser: 'firefox' }); @@ -176,7 +177,7 @@ describe('Hooks', () => { it('server.start should call hooks', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); const server = await project.startServer({ hooks, @@ -184,6 +185,7 @@ describe('Hooks', () => { disabled: true, }, }); + expect(hooks['server:closed']).not.toBeCalled(); await server.stop(); diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts index dcbe6c90a..0aa821d53 100644 --- a/packages/wxt/e2e/tests/modules.test.ts +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { TestProject } from '../utils'; -import type { GenericEntrypoint, InlineConfig } from '../../src/types'; +import type { GenericEntrypoint, InlineConfig } from '../../src'; import { readFile } from 'fs-extra'; import { normalizePath } from '../../src/core/utils/paths'; describe('Module Helpers', () => { describe('options', () => { it('should receive the options defined in wxt.config.ts based on the configKey field', async () => { - const options = { key: '123' }; + const options = { key: '123' } as const; const reportOptions = vi.fn(); vi.stubGlobal('reportOptions', reportOptions); const project = new TestProject(); @@ -33,7 +33,6 @@ describe('Module Helpers', () => { ); await project.build({ - // @ts-expect-error: untyped field for testing example: options, }); @@ -57,6 +56,7 @@ describe('Module Helpers', () => { outputDir: project.resolvePath('.output/chrome-mv3'), skipped: false, }; + project.addFile( 'modules/test/injected.ts', `export default defineUnlistedScript(() => {})`, @@ -71,6 +71,7 @@ describe('Module Helpers', () => { }) `, ); + const config: InlineConfig = { browser: 'chrome', }; @@ -90,7 +91,6 @@ describe('Module Helpers', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {})', ); - project.addFile('modules/test/public/module.txt'); const dir = project.resolvePath('modules/test/public'); project.addFile( @@ -131,7 +131,6 @@ describe('Module Helpers', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {})', ); - project.addFile('public/user.txt', 'from-user'); project.addFile('modules/test/public/user.txt', 'from-module'); const dir = project.resolvePath('modules/test/public'); @@ -161,15 +160,17 @@ describe('Module Helpers', () => { describe('addWxtPlugin', () => { function addPluginModule(project: TestProject) { - const expectedText = 'Hello from plugin!'; + const EXPECTED_TEXT = 'Hello from plugin!'; + const pluginPath = project.addFile( 'modules/test/client-plugin.ts', ` export default defineWxtPlugin(() => { - console.log("${expectedText}") + console.log("${EXPECTED_TEXT}") }) `, ); + project.addFile( 'modules/test.ts', ` @@ -180,7 +181,8 @@ describe('Module Helpers', () => { }); `, ); - return expectedText; + + return EXPECTED_TEXT; } it('should include the plugin in the background', async () => { @@ -189,6 +191,7 @@ describe('Module Helpers', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {})', ); + const expectedText = addPluginModule(project); await project.build(); @@ -201,11 +204,12 @@ describe('Module Helpers', () => { project.addFile( 'entrypoints/popup/index.html', ` - + `, ); + const expectedText = addPluginModule(project); await project.build(); @@ -224,6 +228,7 @@ describe('Module Helpers', () => { }) `, ); + const expectedText = addPluginModule(project); await project.build(); @@ -233,10 +238,12 @@ describe('Module Helpers', () => { it('should include the plugin in unlisted scripts', async () => { const project = new TestProject(); + project.addFile( 'entrypoints/unlisted.ts', 'export default defineUnlistedScript(() => {})', ); + const expectedText = addPluginModule(project); await project.build(); @@ -247,7 +254,8 @@ describe('Module Helpers', () => { describe('imports', () => { it('should add auto-imports', async () => { - const expectedText = 'customImport!'; + const EXPECTED_TEXT = 'customImport!'; + const project = new TestProject(); project.addFile( 'entrypoints/background.ts', @@ -258,7 +266,7 @@ describe('Module Helpers', () => { const utils = project.addFile( 'custom.ts', `export function customImport() { - console.log("${expectedText}") + console.log("${EXPECTED_TEXT}") }`, ); project.addFile( @@ -274,11 +282,12 @@ describe('Module Helpers', () => { await project.build(); - await expect(project.serializeOutput()).resolves.toContain(expectedText); + await expect(project.serializeOutput()).resolves.toContain(EXPECTED_TEXT); }); it('should add preset', async () => { const project = new TestProject(); + project.addFile( 'entrypoints/background.ts', `export default defineBackground(() => { diff --git a/packages/wxt/e2e/tests/npm-packages.test.ts b/packages/wxt/e2e/tests/npm-packages.test.ts index f5d387d3f..02e5fc55c 100644 --- a/packages/wxt/e2e/tests/npm-packages.test.ts +++ b/packages/wxt/e2e/tests/npm-packages.test.ts @@ -17,8 +17,11 @@ test('Only one version of esbuild should be installed (each version is ~20mb of ]); const projects: NpmListProject[] = JSON.parse(stdout); const esbuildVersions = new Set(); + iterateDependencies(projects, (name, meta) => { - if (name === 'esbuild') esbuildVersions.add(meta.version); + if (name === 'esbuild') { + esbuildVersions.add(meta.version); + } }); expect([...esbuildVersions]).toHaveLength(1); @@ -31,10 +34,15 @@ function iterateDependencies( const recurse = (dependencies: Record) => { Object.entries(dependencies).forEach(([name, meta]) => { cb(name, meta); - if (meta.dependencies) recurse(meta.dependencies); + if (meta.dependencies) { + recurse(meta.dependencies); + } }); }; + projects.forEach((project) => { - if (project.dependencies) recurse(project.dependencies); + if (project.dependencies) { + recurse(project.dependencies); + } }); } diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index 0db84f041..9ddac9126 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -5,9 +5,12 @@ describe('Output Directory Structure', () => { it('should not output hidden files and directories that start with "."', async () => { const project = new TestProject(); project.addFile('entrypoints/.DS_Store'); - project.addFile('entrypoints/.hidden1/index.html', ''); - project.addFile('entrypoints/.hidden2.html', ''); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile( + 'entrypoints/.hidden1/index.html', + '', + ); + project.addFile('entrypoints/.hidden2.html', ''); + project.addFile('entrypoints/unlisted.html', ''); await project.build(); @@ -23,7 +26,7 @@ describe('Output Directory Structure', () => { ================================================================================ .output/chrome-mv3/unlisted.html ---------------------------------------- - + " `); }); @@ -113,7 +116,7 @@ describe('Output Directory Structure', () => { it('should not include an entrypoint if the target browser is not in the list of included targets', async () => { const project = new TestProject(); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', ` @@ -133,7 +136,7 @@ describe('Output Directory Structure', () => { it('should not include an entrypoint if the target browser is in the list of excluded targets', async () => { const project = new TestProject(); - project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/options.html', ''); project.addFile( 'entrypoints/background.ts', ` @@ -163,7 +166,7 @@ describe('Output Directory Structure', () => { 'entrypoints/background.ts', `export default defineBackground(() => {});`, ); - project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/popup.html', ''); project.addFile( 'entrypoints/overlay.content.ts', `export default defineContentScript({ @@ -230,7 +233,6 @@ describe('Output Directory Structure', () => { color: #333; }`, ); - project.addFile( 'entrypoints/plain-two.content.css', `body { @@ -238,7 +240,6 @@ describe('Output Directory Structure', () => { color: #333; }`, ); - project.addFile( 'entrypoints/sass-one.scss', `$font-stack: Helvetica, sans-serif; @@ -249,7 +250,6 @@ describe('Output Directory Structure', () => { color: $primary-color; }`, ); - project.addFile( 'entrypoints/sass-two.content.scss', `$font-stack: Helvetica, sans-serif; @@ -293,7 +293,7 @@ describe('Output Directory Structure', () => { it("should output to a custom directory when overriding 'outDir'", async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); project.setConfigFileConfig({ outDir: 'dist', }); @@ -324,7 +324,7 @@ describe('Output Directory Structure', () => { ); project.addFile( 'entrypoints/popup/index.html', - ` + ` @@ -335,7 +335,7 @@ describe('Output Directory Structure', () => { await project.build({ vite: () => ({ build: { - // Make output for snapshot readible + // Make output for the snapshot readable minify: false, }, }), @@ -397,7 +397,7 @@ describe('Output Directory Structure', () => { ); project.addFile( 'entrypoints/popup/index.html', - ` + ` @@ -408,7 +408,7 @@ describe('Output Directory Structure', () => { await project.build({ vite: () => ({ build: { - // Make output for snapshot readible + // Make output for the snapshot readable minify: false, }, }), diff --git a/packages/wxt/e2e/tests/remote-code.test.ts b/packages/wxt/e2e/tests/remote-code.test.ts index c445cc564..6517e2da1 100644 --- a/packages/wxt/e2e/tests/remote-code.test.ts +++ b/packages/wxt/e2e/tests/remote-code.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { TestProject } from '../utils'; describe('Remote Code', () => { it('should download "url:*" modules and include them in the final bundle', async () => { - const url = 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js'; + const URL = 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js'; const project = new TestProject(); project.addFile( 'entrypoints/popup.ts', - `import "url:${url}" + `import "url:${URL}" export default defineUnlistedScript(() => {})`, ); @@ -18,9 +18,9 @@ describe('Remote Code', () => { // Some text that will hopefully be in future versions of this script '__lodash_placeholder__', ); - expect(output).not.toContain(url); + expect(output).not.toContain(URL); expect( - await project.fileExists(`.wxt/cache/${encodeURIComponent(url)}`), + await project.fileExists(`.wxt/cache/${encodeURIComponent(URL)}`), ).toBe(true); }); }); diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts index 35d35a6aa..330a32b19 100644 --- a/packages/wxt/e2e/tests/typescript-project.test.ts +++ b/packages/wxt/e2e/tests/typescript-project.test.ts @@ -4,7 +4,7 @@ import { TestProject } from '../utils'; describe('TypeScript Project', () => { it('should generate defined constants correctly', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -33,9 +33,9 @@ describe('TypeScript Project', () => { it('should augment the types for browser.runtime.getURL', async () => { const project = new TestProject(); - project.addFile('entrypoints/popup.html', ''); - project.addFile('entrypoints/options.html', ''); - project.addFile('entrypoints/sandbox.html', ''); + project.addFile('entrypoints/popup.html', ''); + project.addFile('entrypoints/options.html', ''); + project.addFile('entrypoints/sandbox.html', ''); await project.prepare(); @@ -53,7 +53,7 @@ describe('TypeScript Project', () => { | "/options.html" | "/popup.html" | "/sandbox.html" - type HtmlPublicPath = Extract + const HtmlPublicPath = Extract export interface WxtRuntime { getURL(path: PublicPath): string; getURL(path: \`\${HtmlPublicPath}\${string}\`): string; @@ -65,7 +65,7 @@ describe('TypeScript Project', () => { it('should augment the types for browser.i18n.getMessage', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); project.addFile( 'public/_locales/en/messages.json', JSON.stringify({ @@ -227,7 +227,7 @@ describe('TypeScript Project', () => { it('should reference all the required types in a single declaration file', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -248,7 +248,7 @@ describe('TypeScript Project', () => { it('should generate a TSConfig file for the project', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); await project.prepare(); @@ -289,7 +289,7 @@ describe('TypeScript Project', () => { it('should generate correct path aliases for a custom srcDir', async () => { const project = new TestProject(); - project.addFile('src/entrypoints/unlisted.html', ''); + project.addFile('src/entrypoints/unlisted.html', ''); project.setConfigFileConfig({ srcDir: 'src', }); @@ -333,7 +333,7 @@ describe('TypeScript Project', () => { it('should add additional path aliases listed in the alias config, preventing defaults from being overridden', async () => { const project = new TestProject(); - project.addFile('src/entrypoints/unlisted.html', ''); + project.addFile('src/entrypoints/unlisted.html', ''); project.setConfigFileConfig({ srcDir: 'src', alias: { @@ -383,7 +383,7 @@ describe('TypeScript Project', () => { it('should start path aliases with "./" for paths inside the .wxt dir', async () => { const project = new TestProject(); - project.addFile('src/entrypoints/unlisted.html', ''); + project.addFile('src/entrypoints/unlisted.html', ''); project.setConfigFileConfig({ srcDir: 'src', alias: { @@ -399,7 +399,7 @@ describe('TypeScript Project', () => { it('should set correct import.meta.env.BROWSER type based on targetBrowsers', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); project.setConfigFileConfig({ targetBrowsers: ['firefox', 'chrome'], }); diff --git a/packages/wxt/e2e/tests/user-config.test.ts b/packages/wxt/e2e/tests/user-config.test.ts index 7eecaae4a..b781d7ac0 100644 --- a/packages/wxt/e2e/tests/user-config.test.ts +++ b/packages/wxt/e2e/tests/user-config.test.ts @@ -60,7 +60,7 @@ describe('User Config', () => { it('should merge inline and user config based manifests', async () => { const project = new TestProject(); - project.addFile('entrypoints/unlisted.html', ''); + project.addFile('entrypoints/unlisted.html', ''); project.addFile( 'wxt.config.ts', `import { defineConfig } from 'wxt'; diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index c370097bc..abc196f12 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -83,8 +83,8 @@ describe('Zipping', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {});', ); - const artifactZip = '.output/test-1.0.0-firefox-development.zip'; - const sourcesZip = '.output/test-1.0.0-development-sources.zip'; + const ARTIFACT_ZIP_PATH = '.output/test-1.0.0-firefox-development.zip'; + const SOURCES_ZIP_PATH = '.output/test-1.0.0-development-sources.zip'; await project.zip({ browser: 'firefox', @@ -95,8 +95,8 @@ describe('Zipping', () => { }, }); - expect(await project.fileExists(artifactZip)).toBe(true); - expect(await project.fileExists(sourcesZip)).toBe(true); + expect(await project.fileExists(ARTIFACT_ZIP_PATH)).toBe(true); + expect(await project.fileExists(SOURCES_ZIP_PATH)).toBe(true); }); it('should not zip hidden files into sources by default', async () => { @@ -110,12 +110,14 @@ describe('Zipping', () => { ); project.addFile('.env'); project.addFile('.hidden-dir/file'); + const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); await project.zip({ browser: 'firefox', }); + await extract(sourcesZip, { dir: unzipDir }); expect(await project.fileExists(unzipDir, '.env')).toBe(false); expect(await project.fileExists(unzipDir, '.hidden-dir/file')).toBe(false); @@ -132,6 +134,7 @@ describe('Zipping', () => { ); project.addFile('.hidden-dir/file'); project.addFile('.hidden-dir/nested/file'); + const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); @@ -141,6 +144,7 @@ describe('Zipping', () => { includeSources: ['.hidden-dir'], }, }); + await extract(sourcesZip, { dir: unzipDir }); expect(await project.fileExists(unzipDir, '.hidden-dir/file')).toBe(false); expect(await project.fileExists(unzipDir, '.hidden-dir/nested/file')).toBe( @@ -161,6 +165,7 @@ describe('Zipping', () => { project.addFile('.hidden-dir/file'); project.addFile('.hidden-dir/nested/file1'); project.addFile('.hidden-dir/nested/file2'); + const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); @@ -170,6 +175,7 @@ describe('Zipping', () => { includeSources: ['.env', '.hidden-dir/file', '.hidden-dir/nested/**'], }, }); + await extract(sourcesZip, { dir: unzipDir }); expect(await project.fileExists(unzipDir, '.env')).toBe(true); expect(await project.fileExists(unzipDir, '.hidden-dir/file')).toBe(true); @@ -202,12 +208,14 @@ describe('Zipping', () => { }); `, ); + const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); await project.zip({ browser: 'firefox', }); + await extract(sourcesZip, { dir: unzipDir }); expect( await project.fileExists(unzipDir, 'entrypoints/not-firefox.content.ts'), @@ -228,6 +236,7 @@ describe('Zipping', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {});', ); + const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); await project.zip({ @@ -249,6 +258,7 @@ describe('Zipping', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {});', ); + // TODO: TELL ME, IF YOU WANT TO DO CONST LIKE THIS UPPER_CASE, BECAUSE IT ISN'T REAL CONST, BUT LOOK LIKE CONST const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); await project.zip({ @@ -273,6 +283,7 @@ describe('Zipping', () => { 'entrypoints/background.ts', 'export default defineBackground(() => {});', ); + const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); await project.zip({ diff --git a/packages/wxt/e2e/utils.ts b/packages/wxt/e2e/utils.ts index 8a44c9859..4c9e9d4fd 100644 --- a/packages/wxt/e2e/utils.ts +++ b/packages/wxt/e2e/utils.ts @@ -122,6 +122,7 @@ export class TestProject { await spawn('pnpm', ['--ignore-workspace', 'i', '--ignore-scripts'], { cwd: this.root, }); + await mkdir(resolve(this.root, 'public'), { recursive: true }).catch( () => {}, ); @@ -138,14 +139,6 @@ export class TestProject { return this.serializeDir('.output', ignoreContentsOfFilenames); } - /** - * Read all the files from the test project's `.wxt` directory and combine them into a string - * that can be used in a snapshot. - */ - serializeWxtDir(): Promise { - return this.serializeDir(resolve(this.root, '.wxt/types')); - } - /** * Deeply print the filename and contents of all files in a directory. * @@ -186,12 +179,12 @@ export class TestProject { } fileExists(...path: string[]): Promise { - return fs.exists(this.resolvePath(...path)); + return fs.pathExists(this.resolvePath(...path)); } async getOutputManifest( path: string = '.output/chrome-mv3/manifest.json', ): Promise { - return await fs.readJson(this.resolvePath(path)); + return fs.readJson(this.resolvePath(path)); } } diff --git a/packages/wxt/globals.d.ts b/packages/wxt/globals.d.ts new file mode 100644 index 000000000..dcf2642c5 --- /dev/null +++ b/packages/wxt/globals.d.ts @@ -0,0 +1,3 @@ +declare module globalThis { + var __ENTRYPOINT__: string; +} diff --git a/packages/wxt/src/@types/modules.d.ts b/packages/wxt/src/@types/modules.d.ts index 99fbc79af..1bcfaac8e 100644 --- a/packages/wxt/src/@types/modules.d.ts +++ b/packages/wxt/src/@types/modules.d.ts @@ -1,20 +1,20 @@ // Custom TS definitions for non-TS packages declare module 'zip-dir' { - // Represents the options object for zipdir function + // Represents the options object for zipDir function interface ZipDirOptions { saveTo?: string; filter?: (path: string, stat: import('fs').Stats) => boolean; each?: (path: string) => void; } - function zipdir( + function zipDir( dirPath: string, options?: ZipDirOptions, callback?: (error: Error | null, buffer: Buffer) => void, ): Promise; - export = zipdir; + export = zipDir; } declare module 'web-ext-run' { @@ -39,13 +39,16 @@ declare module 'web-ext-run/util/logger' { write(packet: Packet, options: unknown): void; flushCapturedLogs(options: unknown): void; } + export interface Packet { name: string; msg: string; level: number; } + export class ConsoleStream implements IConsoleStream { constructor(options?: { verbose: false }); } + export const consoleStream: IConsoleStream; } diff --git a/packages/wxt/src/__tests__/modules.test.ts b/packages/wxt/src/__tests__/modules.test.ts index 6481e0f7c..cf07b9dc3 100644 --- a/packages/wxt/src/__tests__/modules.test.ts +++ b/packages/wxt/src/__tests__/modules.test.ts @@ -9,9 +9,10 @@ describe('Module Utilities', () => { const wxt = fakeWxt({ hooks: createHooks(), }); - const expected = { build: { sourcemap: true } }; - const userConfig = {}; - const moduleConfig = { build: { sourcemap: true } }; + + const expected = { build: { sourcemap: true } } as const; + const moduleConfig = { build: { sourcemap: true } } as const; + const userConfig = {} as const; wxt.config.vite = () => Promise.resolve(userConfig); addViteConfig(wxt, () => moduleConfig); @@ -25,9 +26,10 @@ describe('Module Utilities', () => { const wxt = fakeWxt({ hooks: createHooks(), }); - const expected = { build: { sourcemap: true, test: 2 } }; - const userConfig = { build: { sourcemap: true } }; - const moduleConfig = { build: { sourcemap: false, test: 2 } }; + + const expected = { build: { sourcemap: true, test: 2 } } as const; + const userConfig = { build: { sourcemap: true } } as const; + const moduleConfig = { build: { sourcemap: false, test: 2 } } as const; wxt.config.vite = () => userConfig; addViteConfig(wxt, () => moduleConfig); @@ -40,19 +42,21 @@ describe('Module Utilities', () => { describe('addImportPreset', () => { it('should add the import to the config', async () => { - const preset = 'vue'; + const PRESET = 'vue'; + const wxt = fakeWxt({ hooks: createHooks() }); - addImportPreset(wxt, preset); + addImportPreset(wxt, PRESET); await wxt.hooks.callHook('config:resolved', wxt); expect(wxt.config.imports && wxt.config.imports.presets).toContain( - preset, + PRESET, ); }); it('should not add duplicate presets', async () => { - const preset = 'vue'; + const PRESET = 'vue'; + const wxt = fakeWxt({ hooks: createHooks(), config: { @@ -62,7 +66,7 @@ describe('Module Utilities', () => { }, }); - addImportPreset(wxt, preset); + addImportPreset(wxt, PRESET); await wxt.hooks.callHook('config:resolved', wxt); expect(wxt.config.imports && wxt.config.imports.presets).toHaveLength(2); diff --git a/packages/wxt/src/builtin-modules/unimport.ts b/packages/wxt/src/builtin-modules/unimport.ts index c49bde02c..21bdecf2d 100644 --- a/packages/wxt/src/builtin-modules/unimport.ts +++ b/packages/wxt/src/builtin-modules/unimport.ts @@ -13,6 +13,7 @@ export default defineWxtModule({ name: 'wxt:built-in:unimport', setup(wxt) { let unimport: Unimport; + const isEnabled = () => !wxt.config.imports.disabled; // Add user module imports to config @@ -49,7 +50,7 @@ export default defineWxtModule({ // Only create global types when user has enabled auto-imports entries.push(await getImportsDeclarationEntry(unimport)); - if (wxt.config.imports.eslintrc.enabled === false) return; + if (!wxt.config.imports.eslintrc.enabled) return; // Only generate ESLint config if that feature is enabled entries.push( @@ -75,7 +76,11 @@ async function getImportsDeclarationEntry( path: 'types/imports.d.ts', text: [ '// Generated by wxt', - await unimport.generateTypeDeclarations(), + (await unimport.generateTypeDeclarations()) + .replaceAll('// @ts-ignore', '') + .split('\n') + .filter((line) => line.trim() !== '') + .join('\n'), '', ].join('\n'), tsReference: true, @@ -87,6 +92,7 @@ async function getImportsModuleEntry( unimport: Unimport, ): Promise { const imports = await unimport.getImports(); + return { path: 'types/imports-module.d.ts', text: [ @@ -115,8 +121,11 @@ async function getEslintConfigEntry( return globals; }, {}); - if (version <= 8) return getEslint8ConfigEntry(options, globals); - else return getEslint9ConfigEntry(options, globals); + if (version <= 8) { + return getEslint8ConfigEntry(options, globals); + } else { + return getEslint9ConfigEntry(options, globals); + } } export function getEslint8ConfigEntry( diff --git a/packages/wxt/src/cli/__tests__/index.test.ts b/packages/wxt/src/cli/__tests__/index.test.ts index 074934338..13385ad31 100644 --- a/packages/wxt/src/cli/__tests__/index.test.ts +++ b/packages/wxt/src/cli/__tests__/index.test.ts @@ -1,10 +1,12 @@ import { describe, it, vi, beforeEach, expect } from 'vitest'; -import { build } from '../../core/build'; -import { createServer } from '../../core/create-server'; -import { zip } from '../../core/zip'; -import { prepare } from '../../core/prepare'; -import { clean } from '../../core/clean'; -import { initialize } from '../../core/initialize'; +import { + build, + createServer, + zip, + prepare, + clean, + initialize, +} from '../../core'; import { mock } from 'vitest-mock-extended'; import consola from 'consola'; @@ -117,14 +119,15 @@ describe('CLI', () => { }); it('should respect passing --port', async () => { - const expectedPort = 3100; - mockArgv('--port', String(expectedPort)); + const EXPECTED_PORT = 3100; + + mockArgv('--port', String(EXPECTED_PORT)); await importCli(); expect(createServerMock).toBeCalledWith({ dev: { server: { - port: expectedPort, + port: EXPECTED_PORT, }, }, }); diff --git a/packages/wxt/src/cli/cli-utils.ts b/packages/wxt/src/cli/cli-utils.ts index 2f525ae6f..fc2819d4c 100644 --- a/packages/wxt/src/cli/cli-utils.ts +++ b/packages/wxt/src/cli/cli-utils.ts @@ -19,14 +19,14 @@ export function wrapAction( }, ) { return async (...args: any[]) => { - // Enable consola's debug mode globally at the start of all commands when the `--debug` flag is - // passed + // Enable consola's debug mode globally at the start of all commands when the `--debug` flag is passed const isDebug = !!args.find((arg) => arg?.debug); + const startTime = Date.now(); + if (isDebug) { consola.level = LogLevels.debug; } - const startTime = Date.now(); try { printHeader(); @@ -40,11 +40,11 @@ export function wrapAction( consola.fail( `Command failed after ${formatDuration(Date.now() - startTime)}`, ); - if (err instanceof ValidationError) { - // Don't log these errors, they've already been logged - } else { + + if (!(err instanceof ValidationError)) { consola.error(err); } + process.exit(1); } }; @@ -60,10 +60,12 @@ export function getArrayFromFlags( ): T[] | undefined { const array = toArray(flags[name]); const result = filterTruthy(array); + return result.length ? result : undefined; } const aliasCommandNames = new Set(); + /** * @param base Command to add this one to * @param name The command name to add @@ -88,6 +90,7 @@ export function createAliasedCommand( const args = process.argv.slice( process.argv.indexOf(aliasedCommand.name) + 1, ); + await spawn(bin, args, { stdio: 'inherit', }); @@ -98,6 +101,7 @@ export function createAliasedCommand( }); aliasCommandNames.add(aliasedCommand.name); } + export function isAliasedCommand(command: Command | undefined): boolean { return !!command && aliasCommandNames.has(command.name); } diff --git a/packages/wxt/src/cli/commands.ts b/packages/wxt/src/cli/commands.ts index d9e8c82ab..357e013b8 100644 --- a/packages/wxt/src/cli/commands.ts +++ b/packages/wxt/src/cli/commands.ts @@ -32,6 +32,7 @@ cli const serverOptions: NonNullable< NonNullable[0]>['dev'] >['server'] = {}; + if (flags.host) serverOptions.host = flags.host; if (flags.port) serverOptions.port = parseInt(flags.port); @@ -49,6 +50,7 @@ cli : { server: serverOptions }, }); await server.start(); + return { isOngoing: true }; }), ); diff --git a/packages/wxt/src/cli/index.ts b/packages/wxt/src/cli/index.ts index d9b7ddfa9..6e7f2780f 100644 --- a/packages/wxt/src/cli/index.ts +++ b/packages/wxt/src/cli/index.ts @@ -1,5 +1,5 @@ import cli from './commands'; -import { version } from '../version'; +import { VERSION } from '../version'; import { isAliasedCommand } from './cli-utils'; // Grab the command that we're trying to run @@ -8,7 +8,7 @@ cli.parse(process.argv, { run: false }); // If it's not an alias, add the help and version options, then parse again if (!isAliasedCommand(cli.matchedCommand)) { cli.help(); - cli.version(version); + cli.version(VERSION); cli.parse(process.argv, { run: false }); } diff --git a/packages/wxt/src/core/builders/vite/__tests__/fixtures/module.ts b/packages/wxt/src/core/builders/vite/__tests__/fixtures/module.ts index 1ca73a9d8..e4f456e2e 100644 --- a/packages/wxt/src/core/builders/vite/__tests__/fixtures/module.ts +++ b/packages/wxt/src/core/builders/vite/__tests__/fixtures/module.ts @@ -1,4 +1,4 @@ -import { a } from './test'; +import { A } from './test'; function defineSomething(config: T): T { return config; @@ -7,6 +7,6 @@ function defineSomething(config: T): T { export default defineSomething({ option: 'some value', main: () => { - console.log('main', a); + console.log('main', A); }, }); diff --git a/packages/wxt/src/core/builders/vite/__tests__/fixtures/test.ts b/packages/wxt/src/core/builders/vite/__tests__/fixtures/test.ts index a68289eed..c9bf45d77 100644 --- a/packages/wxt/src/core/builders/vite/__tests__/fixtures/test.ts +++ b/packages/wxt/src/core/builders/vite/__tests__/fixtures/test.ts @@ -1,2 +1,2 @@ console.log('Side-effect in test.ts'); -export const a = 'a'; +export const A = 'a'; diff --git a/packages/wxt/src/core/builders/vite/index.ts b/packages/wxt/src/core/builders/vite/index.ts index c798749da..4c934bada 100644 --- a/packages/wxt/src/core/builders/vite/index.ts +++ b/packages/wxt/src/core/builders/vite/index.ts @@ -55,10 +55,12 @@ export async function createViteBuilder( config.build.copyPublicDir = false; config.build.outDir = wxtConfig.outDir; config.build.emptyOutDir = false; + // Disable minification for the dev command if (config.build.minify == null && wxtConfig.command === 'serve') { config.build.minify = false; } + // Enable inline sourcemaps for the dev command (so content scripts have sourcemaps) if (config.build.sourcemap == null && wxtConfig.command === 'serve') { config.build.sourcemap = 'inline'; @@ -71,7 +73,6 @@ export async function createViteBuilder( // TODO: Remove once https://github.com/wxt-dev/wxt/pull/1411 is merged config.legacy ??= {}; - // @ts-ignore: Untyped option: config.legacy.skipWebSocketTokenCheck = true; const server = getWxtDevServer?.(); @@ -89,6 +90,7 @@ export async function createViteBuilder( wxtPlugins.wxtPluginLoader(wxtConfig), wxtPlugins.resolveAppConfig(wxtConfig), ); + if ( wxtConfig.analysis.enabled && // If included, vite-node entrypoint loader will increment the @@ -173,6 +175,7 @@ export async function createViteBuilder( const htmlEntrypoints = new Set( entrypoints.filter(isHtmlEntrypoint).map((e) => e.name), ); + return { mode: wxtConfig.mode, plugins: [wxtPlugins.entrypointGroupGlobals(entrypoints)], @@ -233,21 +236,22 @@ export async function createViteBuilder( baseConfig.optimizeDeps ??= {}; baseConfig.optimizeDeps.noDiscovery = true; baseConfig.optimizeDeps.include = []; + const envConfig: vite.InlineConfig = { plugins: paths.map((path) => wxtPlugins.removeEntrypointMainFunction(wxtConfig, path), ), }; const config = vite.mergeConfig(baseConfig, envConfig); + const server = await vite.createServer(config); await server.pluginContainer.buildStart({}); - const node = new ViteNodeServer( - // @ts-ignore: Some weird type error... - server, - ); + + const node = new ViteNodeServer(server); installSourcemapsSupport({ getSourceMap: (source) => node.getSourceMap(source), }); + const runner = new ViteNodeRunner({ root: server.config.root, base: server.config.base, @@ -261,11 +265,13 @@ export async function createViteBuilder( return node.resolveId(id, importer); }, }); + return { runner, server }; }; const requireDefaultExport = (path: string, mod: any) => { const relativePath = relative(wxtConfig.root, path); + if (mod?.default == null) { const defineFn = relativePath.includes('.content') ? 'defineContentScript' @@ -286,43 +292,55 @@ export async function createViteBuilder( const env = createExtensionEnvironment(); const { runner, server } = await createViteNodeImporter([path]); const res = await env.run(() => runner.executeFile(path)); + await server.close(); requireDefaultExport(path, res); + return res.default; }, async importEntrypoints(paths) { const env = createExtensionEnvironment(); const { runner, server } = await createViteNodeImporter(paths); + const res = await env.run(() => Promise.all( paths.map(async (path) => { const mod = await runner.executeFile(path); requireDefaultExport(path, mod); + return mod.default; }), ), ); + await server.close(); return res; }, async build(group) { let entryConfig; - if (Array.isArray(group)) entryConfig = getMultiPageConfig(group); - else if ( + + if (Array.isArray(group)) { + entryConfig = getMultiPageConfig(group); + } else if ( group.type === 'content-script-style' || group.type === 'unlisted-style' - ) + ) { entryConfig = getCssConfig(group); - else entryConfig = getLibModeConfig(group); + } else { + entryConfig = getLibModeConfig(group); + } const buildConfig = vite.mergeConfig(await getBaseConfig(), entryConfig); + await hooks.callHook( 'vite:build:extendConfig', toArray(group), buildConfig, ); + const result = await vite.build(buildConfig); const chunks = getBuildOutputChunks(result); + return { entrypoints: group, chunks: await moveHtmlFiles(wxtConfig, group, chunks), @@ -339,6 +357,7 @@ export async function createViteBuilder( }; const baseConfig = await getBaseConfig(); const finalConfig = vite.mergeConfig(baseConfig, serverConfig); + await hooks.callHook('vite:devServer:extendConfig', finalConfig); const viteServer = await vite.createServer(finalConfig); @@ -375,7 +394,9 @@ function getBuildOutputChunks( result: Awaited>, ): BuildStepOutput['chunks'] { if ('on' in result) throw Error('wxt does not support vite watch mode.'); + if (Array.isArray(result)) return result.flatMap(({ output }) => output); + return result.output; } @@ -385,6 +406,7 @@ function getBuildOutputChunks( */ function getRollupEntry(entrypoint: Entrypoint): string { let virtualEntrypointType: VirtualEntrypointType | undefined; + switch (entrypoint.type) { case 'background': case 'unlisted-script': @@ -400,8 +422,10 @@ function getRollupEntry(entrypoint: Entrypoint): string { if (virtualEntrypointType) { const moduleId: VirtualModuleId = `virtual:wxt-${virtualEntrypointType}-entrypoint`; + return `${moduleId}?${entrypoint.inputPath}`; } + return entrypoint.inputPath; } @@ -426,12 +450,15 @@ async function moveHtmlFiles( const entryMap = group.reduce>((map, entry) => { const a = normalizePath(relative(config.root, entry.inputPath)); map[a] = entry; + return map; }, {}); const movedChunks = await Promise.all( chunks.map(async (chunk) => { - if (!chunk.fileName.endsWith('.html')) return chunk; + if (!chunk.fileName.endsWith('.html')) { + return chunk; + } const entry = entryMap[chunk.fileName]; const oldBundlePath = chunk.fileName; @@ -442,6 +469,7 @@ async function moveHtmlFiles( ); const oldAbsPath = join(config.outDir, oldBundlePath); const newAbsPath = join(config.outDir, newBundlePath); + await fs.ensureDir(dirname(newAbsPath)); await fs.move(oldAbsPath, newAbsPath, { overwrite: true }); @@ -453,7 +481,7 @@ async function moveHtmlFiles( ); // TODO: Optimize and only delete old path directories - removeEmptyDirs(config.outDir); + await removeEmptyDirs(config.outDir); return movedChunks; } @@ -463,9 +491,11 @@ async function moveHtmlFiles( */ export async function removeEmptyDirs(dir: string): Promise { const files = await fs.readdir(dir); + for (const file of files) { const filePath = join(dir, file); const stats = await fs.stat(filePath); + if (stats.isDirectory()) { await removeEmptyDirs(filePath); } diff --git a/packages/wxt/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts b/packages/wxt/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts index db51f3c5b..84822570b 100644 --- a/packages/wxt/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts +++ b/packages/wxt/src/core/builders/vite/plugins/__tests__/devHtmlPrerender.test.ts @@ -32,17 +32,18 @@ describe('Dev HTML Prerender Plugin', () => { // URLs should not be changed ['https://example.com/style.css', 'https://example.com/style.css'], ])('should transform "%s" into "%s"', (input, expected) => { - const { document } = parseHTML(''); - const root = '/some/root'; + const { document } = parseHTML(''); + const ROOT = '/some/root'; + const config = fakeResolvedConfig({ - root, + root: ROOT, alias: { '~local': '.', - '~absolute': `${root}/assets`, - '~file': `${root}/example.css`, - '~outside': `${root}/../non-root`, - '~~': root, - '~': root, + '~absolute': `${ROOT}/assets`, + '~file': `${ROOT}/example.css`, + '~outside': `${ROOT}/../non-root`, + '~~': ROOT, + '~': ROOT, }, }); const server = fakeDevServer({ @@ -50,10 +51,10 @@ describe('Dev HTML Prerender Plugin', () => { port: 5173, origin: 'http://localhost:5173', }); - const id = root + '/entrypoints/popup/index.html'; + const ID = ROOT + '/entrypoints/popup/index.html'; document.head.innerHTML = ``; - pointToDevServer(config, server, id, document, 'link', 'href'); + pointToDevServer(config, server, ID, document, 'link', 'href'); const actual = document.querySelector('link')!; expect(actual.getAttribute('href')).toBe(expected); diff --git a/packages/wxt/src/core/builders/vite/plugins/bundleAnalysis.ts b/packages/wxt/src/core/builders/vite/plugins/bundleAnalysis.ts index 744e3032a..d88b53093 100644 --- a/packages/wxt/src/core/builders/vite/plugins/bundleAnalysis.ts +++ b/packages/wxt/src/core/builders/vite/plugins/bundleAnalysis.ts @@ -16,7 +16,7 @@ export function bundleAnalysis(config: ResolvedConfig): vite.Plugin { } /** - * @deprecated FOR TESTING ONLY. + * @internal FOR TESTING ONLY. */ export function resetBundleIncrement() { increment = 0; diff --git a/packages/wxt/src/core/builders/vite/plugins/devHtmlPrerender.ts b/packages/wxt/src/core/builders/vite/plugins/devHtmlPrerender.ts index 0830cd86a..86c3100fc 100644 --- a/packages/wxt/src/core/builders/vite/plugins/devHtmlPrerender.ts +++ b/packages/wxt/src/core/builders/vite/plugins/devHtmlPrerender.ts @@ -16,14 +16,15 @@ export function devHtmlPrerender( config: ResolvedConfig, server: WxtDevServer | undefined, ): vite.PluginOption { - const htmlReloadId = '@wxt/reload-html'; + const HTML_RELOAD_ID = '@wxt/reload-html'; + const resolvedHtmlReloadId = resolve( config.wxtModuleDir, 'dist/virtual/reload-html.mjs', ); - const virtualInlineScript = 'virtual:wxt-inline-script'; - const resolvedVirtualInlineScript = '\0' + virtualInlineScript; + const VIRTUAL_INLINE_SCRIPT = 'virtual:wxt-inline-script'; + const RESOLVED_VIRTUAL_INLINE_SCRIPT = `\0${VIRTUAL_INLINE_SCRIPT}`; return [ { @@ -33,7 +34,7 @@ export function devHtmlPrerender( return { resolve: { alias: { - [htmlReloadId]: resolvedHtmlReloadId, + [HTML_RELOAD_ID]: resolvedHtmlReloadId, }, }, }; @@ -57,14 +58,17 @@ export function devHtmlPrerender( // Add a script to add page reloading const reloader = document.createElement('script'); - reloader.src = htmlReloadId; + reloader.src = HTML_RELOAD_ID; reloader.type = 'module'; + document.head.appendChild(reloader); const newHtml = document.toString(); + config.logger.debug('transform ' + id); config.logger.debug('Old HTML:\n' + code); config.logger.debug('New HTML:\n' + newHtml); + return newHtml; }, @@ -75,7 +79,9 @@ export function devHtmlPrerender( const originalUrl = `${server.origin}${ctx.path}`; const name = getEntrypointName(config.entrypointsDir, ctx.filename); const url = `${server.origin}/${name}.html`; + const serverHtml = await server.transformHtml(url, html, originalUrl); + const { document } = parseHTML(serverHtml); // Replace inline script with virtual module served via dev server. @@ -91,7 +97,8 @@ export function devHtmlPrerender( // Replace unsafe inline script const virtualScript = document.createElement('script'); virtualScript.type = 'module'; - virtualScript.src = `${server.origin}/@id/${virtualInlineScript}?${key}`; + virtualScript.src = `${server.origin}/@id/${VIRTUAL_INLINE_SCRIPT}?${key}`; + script.replaceWith(virtualScript); }); @@ -99,6 +106,7 @@ export function devHtmlPrerender( const viteClientScript = document.querySelector( "script[src='/@vite/client']", ); + if (viteClientScript) { viteClientScript.src = `${server.origin}${viteClientScript.src}`; } @@ -107,6 +115,7 @@ export function devHtmlPrerender( config.logger.debug('transformIndexHtml ' + ctx.filename); config.logger.debug('Old HTML:\n' + html); config.logger.debug('New HTML:\n' + newHtml); + return newHtml; }, }, @@ -115,8 +124,8 @@ export function devHtmlPrerender( apply: 'serve', resolveId(id) { // Resolve inline scripts - if (id.startsWith(virtualInlineScript)) { - return '\0' + id; + if (id.startsWith(VIRTUAL_INLINE_SCRIPT)) { + return `\0${id}`; } // Ignore chunks during HTML file pre-rendering @@ -126,7 +135,7 @@ export function devHtmlPrerender( }, load(id) { // Resolve virtualized inline scripts - if (id.startsWith(resolvedVirtualInlineScript)) { + if (id.startsWith(RESOLVED_VIRTUAL_INLINE_SCRIPT)) { // id="virtual:wxt-inline-script?" const key = id.substring(id.indexOf('?') + 1); return inlineScriptContents[key]; @@ -166,6 +175,7 @@ export function pointToDevServer( const matchingAlias = Object.entries(config.alias).find(([key]) => src.startsWith(key), ); + if (matchingAlias) { // Matches a import alias const [alias, replacement] = matchingAlias; @@ -189,6 +199,7 @@ export function pointToDevServer( let path = normalizePath(resolvedAbsolutePath); // Add "/" to start of windows paths ("D:/some/path" -> "/D:/some/path") if (!path.startsWith('/')) path = '/' + path; + element.setAttribute(attr, `${server.origin}/@fs${path}`); } else { // Inside the project, use relative path diff --git a/packages/wxt/src/core/builders/vite/plugins/download.ts b/packages/wxt/src/core/builders/vite/plugins/download.ts index 1046041d2..de42301bd 100644 --- a/packages/wxt/src/core/builders/vite/plugins/download.ts +++ b/packages/wxt/src/core/builders/vite/plugins/download.ts @@ -20,7 +20,8 @@ export function download(config: ResolvedConfig): Plugin { // Load file from network or cache const url = id.replace('\0url:', ''); - return await fetchCached(url, config); + + return fetchCached(url, config); }, }; } diff --git a/packages/wxt/src/core/builders/vite/plugins/entrypointGroupGlobals.ts b/packages/wxt/src/core/builders/vite/plugins/entrypointGroupGlobals.ts index a9e3e1aea..e98859b0c 100644 --- a/packages/wxt/src/core/builders/vite/plugins/entrypointGroupGlobals.ts +++ b/packages/wxt/src/core/builders/vite/plugins/entrypointGroupGlobals.ts @@ -13,12 +13,12 @@ export function entrypointGroupGlobals( config() { const define: vite.InlineConfig['define'] = {}; let name = Array.isArray(entrypointGroup) ? 'html' : entrypointGroup.name; + for (const global of getEntrypointGlobals(name)) { define[`import.meta.env.${global.name}`] = JSON.stringify(global.value); } - return { - define, - }; + + return { define }; }, }; } diff --git a/packages/wxt/src/core/builders/vite/plugins/extensionApiMock.ts b/packages/wxt/src/core/builders/vite/plugins/extensionApiMock.ts index ee46f4ef1..36bf6491e 100644 --- a/packages/wxt/src/core/builders/vite/plugins/extensionApiMock.ts +++ b/packages/wxt/src/core/builders/vite/plugins/extensionApiMock.ts @@ -6,8 +6,8 @@ import { ResolvedConfig } from '../../../../types'; * Mock `wxt/browser` and stub the global `browser`/`chrome` types with a fake version of the extension APIs */ export function extensionApiMock(config: ResolvedConfig): vite.PluginOption { - const virtualSetupModule = 'virtual:wxt-setup'; - const resolvedVirtualSetupModule = '\0' + virtualSetupModule; + const VIRTUAL_SETUP_MODULE = 'virtual:wxt-setup'; + const RESOLVED_VIRTUAL_SETUP_MODULE = `\0${VIRTUAL_SETUP_MODULE}`; return { name: 'wxt:extension-api-mock', @@ -18,7 +18,7 @@ export function extensionApiMock(config: ResolvedConfig): vite.PluginOption { ); return { test: { - setupFiles: [virtualSetupModule], + setupFiles: [VIRTUAL_SETUP_MODULE], }, resolve: { alias: [ @@ -27,21 +27,22 @@ export function extensionApiMock(config: ResolvedConfig): vite.PluginOption { ], }, ssr: { - // Inline all WXT modules subdependencies can be mocked + // Inline all WXT modules sub dependencies can be mocked noExternal: ['wxt'], }, }; }, resolveId(id) { - if (id.endsWith(virtualSetupModule)) return resolvedVirtualSetupModule; + if (id.endsWith(VIRTUAL_SETUP_MODULE)) + return RESOLVED_VIRTUAL_SETUP_MODULE; }, load(id) { - if (id === resolvedVirtualSetupModule) return setupTemplate; + if (id === RESOLVED_VIRTUAL_SETUP_MODULE) return SETUP_TEMPLATE; }, }; } -const setupTemplate = ` +const SETUP_TEMPLATE = ` import { vi } from 'vitest'; import { fakeBrowser } from 'wxt/testing/fake-browser'; diff --git a/packages/wxt/src/core/builders/vite/plugins/globals.ts b/packages/wxt/src/core/builders/vite/plugins/globals.ts index 5d59e24e7..964fe2f32 100644 --- a/packages/wxt/src/core/builders/vite/plugins/globals.ts +++ b/packages/wxt/src/core/builders/vite/plugins/globals.ts @@ -7,9 +7,11 @@ export function globals(config: ResolvedConfig): vite.PluginOption { name: 'wxt:globals', config() { const define: vite.InlineConfig['define'] = {}; + for (const global of getGlobals(config)) { define[`import.meta.env.${global.name}`] = JSON.stringify(global.value); } + return { define, }; diff --git a/packages/wxt/src/core/builders/vite/plugins/noopBackground.ts b/packages/wxt/src/core/builders/vite/plugins/noopBackground.ts index 660399e3e..532e6d4bb 100644 --- a/packages/wxt/src/core/builders/vite/plugins/noopBackground.ts +++ b/packages/wxt/src/core/builders/vite/plugins/noopBackground.ts @@ -6,15 +6,16 @@ import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../../../utils/constants'; * connection is setup and the extension reloads HTML pages and content scripts correctly. */ export function noopBackground(): Plugin { - const virtualModuleId = VIRTUAL_NOOP_BACKGROUND_MODULE_ID; - const resolvedVirtualModuleId = '\0' + virtualModuleId; + const VIRTUAL_MODULE_ID = VIRTUAL_NOOP_BACKGROUND_MODULE_ID; + const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; + return { name: 'wxt:noop-background', resolveId(id) { - if (id === virtualModuleId) return resolvedVirtualModuleId; + if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID; }, load(id) { - if (id === resolvedVirtualModuleId) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { return `import { defineBackground } from 'wxt/utils/define-background';\nexport default defineBackground(() => void 0)`; } }, diff --git a/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts b/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts index 91cc0b995..a5131d59c 100644 --- a/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts +++ b/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts @@ -19,9 +19,11 @@ export function removeEntrypointMainFunction( handler(code, id) { if (id === absPath) { const newCode = removeMainFunctionCode(code); + config.logger.debug('vite-node transformed entrypoint', path); config.logger.debug(`Original:\n---\n${code}\n---`); config.logger.debug(`Transformed:\n---\n${newCode.code}\n---`); + return newCode; } }, diff --git a/packages/wxt/src/core/builders/vite/plugins/resolveAppConfig.ts b/packages/wxt/src/core/builders/vite/plugins/resolveAppConfig.ts index e2b4e670e..e713db43a 100644 --- a/packages/wxt/src/core/builders/vite/plugins/resolveAppConfig.ts +++ b/packages/wxt/src/core/builders/vite/plugins/resolveAppConfig.ts @@ -1,4 +1,4 @@ -import { exists } from 'fs-extra'; +import { pathExists } from 'fs-extra'; import { resolve } from 'node:path'; import type * as vite from 'vite'; import { ResolvedConfig } from '../../../../types'; @@ -7,8 +7,9 @@ import { ResolvedConfig } from '../../../../types'; * When importing `virtual:app-config`, resolve it to the `app.config.ts` file in the project. */ export function resolveAppConfig(config: ResolvedConfig): vite.Plugin { - const virtualModuleId = 'virtual:app-config'; - const resolvedVirtualModuleId = '\0' + virtualModuleId; + const VIRTUAL_MODULE_ID = 'virtual:app-config'; + const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`; + const appConfigFile = resolve(config.srcDir, 'app.config.ts'); return { @@ -18,19 +19,19 @@ export function resolveAppConfig(config: ResolvedConfig): vite.Plugin { optimizeDeps: { // Prevent ESBuild from attempting to resolve the virtual module // while optimizing WXT. - exclude: [virtualModuleId], + exclude: [VIRTUAL_MODULE_ID], }, }; }, async resolveId(id) { - if (id !== virtualModuleId) return; + if (id !== VIRTUAL_MODULE_ID) return; - return (await exists(appConfigFile)) + return (await pathExists(appConfigFile)) ? appConfigFile - : resolvedVirtualModuleId; + : RESOLVED_VIRTUAL_MODULE_ID; }, load(id) { - if (id === resolvedVirtualModuleId) return `export default {}`; + if (id === RESOLVED_VIRTUAL_MODULE_ID) return `export default {}`; }, }; } diff --git a/packages/wxt/src/core/builders/vite/plugins/resolveVirtualModules.ts b/packages/wxt/src/core/builders/vite/plugins/resolveVirtualModules.ts index 231d932df..b748ea8d7 100644 --- a/packages/wxt/src/core/builders/vite/plugins/resolveVirtualModules.ts +++ b/packages/wxt/src/core/builders/vite/plugins/resolveVirtualModules.ts @@ -13,28 +13,33 @@ import { resolve } from 'path'; */ export function resolveVirtualModules(config: ResolvedConfig): Plugin[] { return virtualModuleNames.map((name) => { - const virtualId: `${VirtualModuleId}?` = `virtual:wxt-${name}?`; - const resolvedVirtualId = '\0' + virtualId; + const VIRTUAL_ID: `${VirtualModuleId}?` = `virtual:wxt-${name}?`; + const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`; + return { name: `wxt:resolve-virtual-${name}`, resolveId(id) { // Id doesn't start with prefix, it looks like this: // /path/to/project/virtual:wxt-background?/path/to/project/entrypoints/background.ts - const index = id.indexOf(virtualId); + const index = id.indexOf(VIRTUAL_ID); if (index === -1) return; - const inputPath = normalizePath(id.substring(index + virtualId.length)); - return resolvedVirtualId + inputPath; + const inputPath = normalizePath( + id.substring(index + VIRTUAL_ID.length), + ); + + return RESOLVED_VIRTUAL_ID + inputPath; }, async load(id) { - if (!id.startsWith(resolvedVirtualId)) return; + if (!id.startsWith(RESOLVED_VIRTUAL_ID)) return; - const inputPath = id.replace(resolvedVirtualId, ''); - const template = await fs.readFile( + const inputPath = id.replace(RESOLVED_VIRTUAL_ID, ''); + const TEMPLATE = await fs.readFile( resolve(config.wxtModuleDir, `dist/virtual/${name}.mjs`), 'utf-8', ); - return template.replace(`virtual:user-${name}`, inputPath); + + return TEMPLATE.replace(`virtual:user-${name}`, inputPath); }, }; }); diff --git a/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts b/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts index c6808bac5..0e0e82193 100644 --- a/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts +++ b/packages/wxt/src/core/builders/vite/plugins/wxtPluginLoader.ts @@ -7,19 +7,19 @@ import { ResolvedConfig } from '../../../../types'; * Resolve and load plugins for each entrypoint. This handles both JS entrypoints via the `virtual:wxt-plugins` import, and HTML files by adding `virtual:wxt-html-plugins` to the document's `` */ export function wxtPluginLoader(config: ResolvedConfig): vite.Plugin { - const virtualModuleId = 'virtual:wxt-plugins'; - const resolvedVirtualModuleId = '\0' + virtualModuleId; - const virtualHtmlModuleId = 'virtual:wxt-html-plugins'; - const resolvedVirtualHtmlModuleId = '\0' + virtualHtmlModuleId; + const VIRTUAL_MODULE_ID = 'virtual:wxt-plugins'; + const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`; + const VIRTUAL_HTML_MODULE_ID = 'virtual:wxt-html-plugins'; + const RESOLVED_VIRTUAL_HTML_MODULE_ID = `\0${VIRTUAL_HTML_MODULE_ID}`; return { name: 'wxt:plugin-loader', resolveId(id) { - if (id === virtualModuleId) return resolvedVirtualModuleId; - if (id === virtualHtmlModuleId) return resolvedVirtualHtmlModuleId; + if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID; + if (id === VIRTUAL_HTML_MODULE_ID) return RESOLVED_VIRTUAL_HTML_MODULE_ID; }, load(id) { - if (id === resolvedVirtualModuleId) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { // Import and init all plugins const imports = config.plugins .map( @@ -27,19 +27,21 @@ export function wxtPluginLoader(config: ResolvedConfig): vite.Plugin { `import initPlugin${i} from '${normalizePath(plugin)}';`, ) .join('\n'); + const initCalls = config.plugins .map((_, i) => ` initPlugin${i}();`) .join('\n'); + return `${imports}\n\nexport function initPlugins() {\n${initCalls}\n}`; } - if (id === resolvedVirtualHtmlModuleId) { - return `import { initPlugins } from '${virtualModuleId}'; + if (id === RESOLVED_VIRTUAL_HTML_MODULE_ID) { + return `import { initPlugins } from '${VIRTUAL_MODULE_ID}'; -try { - initPlugins(); -} catch (err) { - console.error("[wxt] Failed to initialize plugins", err); -}`; + try { + initPlugins(); + } catch (err) { + console.error("[wxt] Failed to initialize plugins", err); + }`; } }, transformIndexHtml: { @@ -48,22 +50,25 @@ try { handler(html, _ctx) { const src = config.command === 'serve' - ? `${config.dev.server?.origin}/@id/${virtualHtmlModuleId}` - : virtualHtmlModuleId; + ? `${config.dev.server?.origin}/@id/${VIRTUAL_HTML_MODULE_ID}` + : VIRTUAL_HTML_MODULE_ID; const { document } = parseHTML(html); const existing = document.querySelector(`script[src='${src}']`); + if (existing) return; const script = document.createElement('script'); script.type = 'module'; script.src = src; - if (document.head == null) { + if (document.head === null) { const newHead = document.createElement('head'); document.documentElement.prepend(newHead); } + document.head.prepend(script); + return document.toString(); }, }, diff --git a/packages/wxt/src/core/clean.ts b/packages/wxt/src/core/clean.ts index f3e9c0984..8e93d3985 100644 --- a/packages/wxt/src/core/clean.ts +++ b/packages/wxt/src/core/clean.ts @@ -42,7 +42,9 @@ export async function clean(config?: string | InlineConfig) { '**/.wxt', `${path.relative(root, wxt.config.outBaseDir)}/*`, ]; + wxt.logger.debug('Looking for:', tempDirs.map(pc.cyan).join(', ')); + const directories = await glob(tempDirs, { cwd: root, absolute: true, diff --git a/packages/wxt/src/core/create-server.ts b/packages/wxt/src/core/create-server.ts index fb0d95919..557ed5a95 100644 --- a/packages/wxt/src/core/create-server.ts +++ b/packages/wxt/src/core/create-server.ts @@ -126,7 +126,7 @@ async function createServerInternal(): Promise { async stop() { wasStopped = true; keyboardShortcuts.stop(); - await runner.closeBrowser(); + await runner.closeBrowser?.(); await builderServer.close(); await wxt.hooks.callHook('server:closed', wxt, server); @@ -150,7 +150,7 @@ async function createServerInternal(): Promise { server.ws.send('wxt:reload-extension'); }, async restartBrowser() { - await runner.closeBrowser(); + await runner.closeBrowser?.(); keyboardShortcuts.stop(); await wxt.reloadConfig(); runner = await createExtensionRunner(); diff --git a/packages/wxt/src/core/define-web-ext-config.ts b/packages/wxt/src/core/define-web-ext-config.ts index 7826004fc..f12d0be97 100644 --- a/packages/wxt/src/core/define-web-ext-config.ts +++ b/packages/wxt/src/core/define-web-ext-config.ts @@ -1,6 +1,7 @@ import consola from 'consola'; import { WebExtConfig } from '../types'; +// TODO: MAYBE DROP IT, BEFORE 1.0.0? /** * @deprecated Use `defineWebExtConfig` instead. Same function, different name. */ diff --git a/packages/wxt/src/core/generate-wxt-dir.ts b/packages/wxt/src/core/generate-wxt-dir.ts index ad7aeb2ef..0c922be5c 100644 --- a/packages/wxt/src/core/generate-wxt-dir.ts +++ b/packages/wxt/src/core/generate-wxt-dir.ts @@ -86,13 +86,13 @@ async function getPathsDeclarationEntry( .map((path) => ` | "/${path}"`), ].join('\n'); - const template = `// Generated by wxt + const TEMPLATE = `// Generated by wxt import "wxt/browser"; declare module "wxt/browser" { export type PublicPath = {{ union }} - type HtmlPublicPath = Extract + const HtmlPublicPath = Extract export interface WxtRuntime { getURL(path: PublicPath): string; getURL(path: \`\${HtmlPublicPath}\${string}\`): string; @@ -102,14 +102,14 @@ declare module "wxt/browser" { return { path: 'types/paths.d.ts', - text: template.replace('{{ union }}', unions || ' | never'), + text: TEMPLATE.replace('{{ union }}', unions || ' | never'), tsReference: true, }; } async function getI18nDeclarationEntry(): Promise { const defaultLocale = wxt.config.manifest.default_locale; - const template = `// Generated by wxt + const TEMPLATE_I18N = `// Generated by wxt import "wxt/browser"; declare module "wxt/browser" { @@ -136,7 +136,8 @@ declare module "wxt/browser" { 'messages.json', ); let messages: Message[]; - if (await fs.exists(defaultLocalePath)) { + + if (await fs.pathExists(defaultLocalePath)) { const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8')); messages = parseI18nMessages(content); } else { @@ -149,15 +150,19 @@ declare module "wxt/browser" { translation?: string, ) => { const commentLines: string[] = []; + if (description) commentLines.push(...description.split('\n')); + if (translation) { if (commentLines.length > 0) commentLines.push(''); commentLines.push(`"${translation}"`); } + const comment = commentLines.length > 0 ? `/**\n${commentLines.map((line) => ` * ${line}`.trimEnd()).join('\n')}\n */\n ` : ''; + return ` ${comment}getMessage( messageName: ${keyType}, substitutions?: string | string[], @@ -166,7 +171,7 @@ declare module "wxt/browser" { }; const overrides = [ - // Generate individual overloads for each message so JSDoc contains description and base translation. + // Generate individual overloads for each message, so JSDoc contains description and base translation. ...messages.map((message) => renderGetMessageOverload( `"${message.name}"`, @@ -183,13 +188,14 @@ declare module "wxt/browser" { return { path: 'types/i18n.d.ts', - text: template.replace('{{ overrides }}', overrides.join('\n')), + text: TEMPLATE_I18N.replace('{{ overrides }}', overrides.join('\n')), tsReference: true, }; } async function getGlobalsDeclarationEntry(): Promise { const globals = [...getGlobals(wxt.config), ...getEntrypointGlobals('')]; + return { path: 'types/globals.d.ts', text: [ @@ -208,6 +214,7 @@ async function getGlobalsDeclarationEntry(): Promise { function getMainDeclarationEntry(references: WxtDirEntry[]): WxtDirFileEntry { const lines = ['// Generated by wxt']; + references.forEach((ref) => { if ('module' in ref) { return lines.push(`/// `); @@ -217,6 +224,7 @@ function getMainDeclarationEntry(references: WxtDirEntry[]): WxtDirFileEntry { lines.push(`/// `); } }); + return { path: 'wxt.d.ts', text: lines.join('\n') + '\n', @@ -241,7 +249,7 @@ async function getTsConfigEntry(): Promise { .map((line) => ` ${line}`) .join(',\n'); - const text = `{ + const TEXT = `{ "compilerOptions": { "target": "ESNext", "module": "ESNext", @@ -265,6 +273,6 @@ ${paths} return { path: 'tsconfig.json', - text, + text: TEXT, }; } diff --git a/packages/wxt/src/core/package-managers/__tests__/bun.test.ts b/packages/wxt/src/core/package-managers/__tests__/bun.test.ts index 4a351e533..bdc3d2b24 100644 --- a/packages/wxt/src/core/package-managers/__tests__/bun.test.ts +++ b/packages/wxt/src/core/package-managers/__tests__/bun.test.ts @@ -10,6 +10,7 @@ describe.skipIf(() => process.platform === 'win32')( it('should list direct dependencies', async () => { const actual = await bun.listDependencies({ cwd }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-types', version: '2.1.35' }, @@ -18,6 +19,7 @@ describe.skipIf(() => process.platform === 'win32')( it('should list all dependencies', async () => { const actual = await bun.listDependencies({ cwd, all: true }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-db', version: '1.52.0' }, diff --git a/packages/wxt/src/core/package-managers/__tests__/npm.test.ts b/packages/wxt/src/core/package-managers/__tests__/npm.test.ts index de2e8e2ae..0310932e6 100644 --- a/packages/wxt/src/core/package-managers/__tests__/npm.test.ts +++ b/packages/wxt/src/core/package-managers/__tests__/npm.test.ts @@ -2,11 +2,12 @@ import { beforeAll, describe, expect, it } from 'vitest'; import path from 'node:path'; import { npm } from '../npm'; import spawn from 'nano-spawn'; -import { exists } from 'fs-extra'; +import { pathExists } from 'fs-extra'; describe('NPM Package Management Utils', () => { describe('listDependencies', () => { const cwd = path.resolve(__dirname, 'fixtures/simple-npm-project'); + beforeAll(async () => { // NPM needs the modules installed for 'npm ls' to work await spawn('npm', ['i'], { cwd }); @@ -14,6 +15,7 @@ describe('NPM Package Management Utils', () => { it('should list direct dependencies', async () => { const actual = await npm.listDependencies({ cwd }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-types', version: '2.1.35' }, @@ -22,6 +24,7 @@ describe('NPM Package Management Utils', () => { it('should list all dependencies', async () => { const actual = await npm.listDependencies({ cwd, all: true }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-types', version: '2.1.35' }, @@ -34,14 +37,15 @@ describe('NPM Package Management Utils', () => { const cwd = path.resolve(__dirname, 'fixtures/simple-npm-project'); it('should download the dependency as a tarball', async () => { + const ID = 'mime-db@1.52.0'; + const downloadDir = path.resolve(cwd, 'dist'); - const id = 'mime-db@1.52.0'; - const expected = path.resolve(downloadDir, 'mime-db-1.52.0.tgz'); - const actual = await npm.downloadDependency(id, downloadDir); + const expected = path.resolve(downloadDir, 'mime-db-1.52.0.tgz'); + const actual = await npm.downloadDependency(ID, downloadDir); expect(actual).toEqual(expected); - expect(await exists(actual)).toBe(true); + expect(await pathExists(actual)).toBe(true); }); }); }); diff --git a/packages/wxt/src/core/package-managers/__tests__/pnpm.test.ts b/packages/wxt/src/core/package-managers/__tests__/pnpm.test.ts index 20b6312c8..a0d6a7bfb 100644 --- a/packages/wxt/src/core/package-managers/__tests__/pnpm.test.ts +++ b/packages/wxt/src/core/package-managers/__tests__/pnpm.test.ts @@ -8,6 +8,7 @@ process.env.WXT_PNPM_IGNORE_WORKSPACE = 'true'; describe('PNPM Package Management Utils', () => { describe('listDependencies', () => { const cwd = path.resolve(__dirname, 'fixtures/simple-pnpm-project'); + beforeAll(async () => { // PNPM needs the modules installed, or 'pnpm ls' will return a blank list. await spawn('pnpm', ['i', '--ignore-workspace'], { cwd }); @@ -15,6 +16,7 @@ describe('PNPM Package Management Utils', () => { it('should list direct dependencies', async () => { const actual = await pnpm.listDependencies({ cwd }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-types', version: '2.1.35' }, @@ -23,6 +25,7 @@ describe('PNPM Package Management Utils', () => { it('should list all dependencies', async () => { const actual = await pnpm.listDependencies({ cwd, all: true }); + expect(actual).toEqual([ { name: 'flatten', version: '1.0.3' }, { name: 'mime-types', version: '2.1.35' }, diff --git a/packages/wxt/src/core/package-managers/__tests__/yarn.test.ts b/packages/wxt/src/core/package-managers/__tests__/yarn.test.ts index 40415b7df..a356dab1b 100644 --- a/packages/wxt/src/core/package-managers/__tests__/yarn.test.ts +++ b/packages/wxt/src/core/package-managers/__tests__/yarn.test.ts @@ -8,6 +8,7 @@ describe('Yarn Package Management Utils', () => { it('should list direct dependencies', async () => { const actual = await yarn.listDependencies({ cwd }); + expect(actual).toEqual([ { name: 'mime-db', version: '1.52.0' }, { name: 'flatten', version: '1.0.3' }, @@ -17,6 +18,7 @@ describe('Yarn Package Management Utils', () => { it('should list all dependencies', async () => { const actual = await yarn.listDependencies({ cwd, all: true }); + expect(actual).toEqual([ { name: 'mime-db', version: '1.52.0' }, { name: 'flatten', version: '1.0.3' }, diff --git a/packages/wxt/src/core/package-managers/bun.ts b/packages/wxt/src/core/package-managers/bun.ts index e06882d11..6adb68072 100644 --- a/packages/wxt/src/core/package-managers/bun.ts +++ b/packages/wxt/src/core/package-managers/bun.ts @@ -9,10 +9,13 @@ export const bun: WxtPackageManagerImpl = { }, async listDependencies(options) { const args = ['pm', 'ls']; + if (options?.all) { args.push('--all'); } + const res = await spawn('bun', args, { cwd: options?.cwd }); + return dedupeDependencies( res.stdout .split('\n') diff --git a/packages/wxt/src/core/package-managers/index.ts b/packages/wxt/src/core/package-managers/index.ts index 0c270808c..3175004c4 100644 --- a/packages/wxt/src/core/package-managers/index.ts +++ b/packages/wxt/src/core/package-managers/index.ts @@ -25,7 +25,10 @@ export async function createWxtPackageManager( // Use requirePm to prevent throwing errors before the package manager utils are used. const requirePm = (cb: (pm: PackageManager) => T) => { - if (pm == null) throw Error('Could not detect package manager'); + if (pm == null) { + throw Error('Could not detect package manager'); + } + return cb(pm); }; diff --git a/packages/wxt/src/core/package-managers/npm.ts b/packages/wxt/src/core/package-managers/npm.ts index 7ad5386f8..0e473f345 100644 --- a/packages/wxt/src/core/package-managers/npm.ts +++ b/packages/wxt/src/core/package-managers/npm.ts @@ -8,17 +8,22 @@ export const npm: WxtPackageManagerImpl = { overridesKey: 'overrides', async downloadDependency(id, downloadDir) { await ensureDir(downloadDir); + const res = await spawn('npm', ['pack', id, '--json'], { cwd: downloadDir, }); + const packed: PackedDependency[] = JSON.parse(res.stdout); + return path.resolve(downloadDir, packed[0].filename); }, async listDependencies(options) { const args = ['ls', '--json']; + if (options?.all) { args.push('--depth', 'Infinity'); } + const res = await spawn('npm', args, { cwd: options?.cwd }); const project: NpmListProject = JSON.parse(res.stdout); @@ -30,33 +35,40 @@ export function flattenNpmListOutput(projects: NpmListProject[]): Dependency[] { const queue: Record[] = projects.flatMap( (project) => { const acc: Record[] = []; + if (project.dependencies) acc.push(project.dependencies); if (project.devDependencies) acc.push(project.devDependencies); + return acc; }, ); const dependencies: Dependency[] = []; + while (queue.length > 0) { Object.entries(queue.pop()!).forEach(([name, meta]) => { dependencies.push({ name, version: meta.version, }); + if (meta.dependencies) queue.push(meta.dependencies); if (meta.devDependencies) queue.push(meta.devDependencies); }); } + return dedupeDependencies(dependencies); } export function dedupeDependencies(dependencies: Dependency[]): Dependency[] { const hashes = new Set(); + return dependencies.filter((dep) => { - const hash = `${dep.name}@${dep.version}`; - if (hashes.has(hash)) { + const HASH = `${dep.name}@${dep.version}`; + + if (hashes.has(HASH)) { return false; } else { - hashes.add(hash); + hashes.add(HASH); return true; } }); diff --git a/packages/wxt/src/core/package-managers/pnpm.ts b/packages/wxt/src/core/package-managers/pnpm.ts index 32d89b609..ed6a023ef 100644 --- a/packages/wxt/src/core/package-managers/pnpm.ts +++ b/packages/wxt/src/core/package-managers/pnpm.ts @@ -9,6 +9,7 @@ export const pnpm: WxtPackageManagerImpl = { }, async listDependencies(options) { const args = ['ls', '-r', '--json']; + if (options?.all) { args.push('--depth', 'Infinity'); } @@ -20,6 +21,7 @@ export const pnpm: WxtPackageManagerImpl = { ) { args.push('--ignore-workspace'); } + const res = await spawn('pnpm', args, { cwd: options?.cwd }); const projects: NpmListProject[] = JSON.parse(res.stdout); diff --git a/packages/wxt/src/core/package-managers/yarn.ts b/packages/wxt/src/core/package-managers/yarn.ts index 8b5449329..b2d85101d 100644 --- a/packages/wxt/src/core/package-managers/yarn.ts +++ b/packages/wxt/src/core/package-managers/yarn.ts @@ -10,14 +10,17 @@ export const yarn: WxtPackageManagerImpl = { }, async listDependencies(options) { const args = ['list', '--json']; + if (options?.all) { args.push('--depth', 'Infinity'); } + const res = await spawn('yarn', args, { cwd: options?.cwd }); const tree = res.stdout .split('\n') .map((line) => JSON.parse(line)) .find((line) => line.type === 'tree')?.data as JsonLineTree | undefined; + if (tree == null) throw Error("'yarn list --json' did not output a tree"); const queue = [...tree.trees]; @@ -26,10 +29,12 @@ export const yarn: WxtPackageManagerImpl = { while (queue.length > 0) { const { name: treeName, children } = queue.pop()!; const match = /(@?\S+)@(\S+)$/.exec(treeName); + if (match) { const [_, name, version] = match; dependencies.push({ name, version }); } + if (children != null) { queue.push(...children); } diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index 5e14b4e8b..574d535e7 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -550,7 +550,7 @@ function resolveWxtModuleDir() { } async function isDirMissing(dir: string) { - return !(await fs.exists(dir)); + return !(await fs.pathExists(dir)); } function logMissingDir(logger: Logger, name: string, expected: string) { diff --git a/packages/wxt/src/core/runners/__tests__/index.test.ts b/packages/wxt/src/core/runners/__tests__/index.test.ts index 7b46e11bf..599a3b695 100644 --- a/packages/wxt/src/core/runners/__tests__/index.test.ts +++ b/packages/wxt/src/core/runners/__tests__/index.test.ts @@ -31,6 +31,7 @@ describe('createExtensionRunner', () => { browser: 'safari', }, }); + const safariRunner = mock(); createSafariRunnerMock.mockReturnValue(safariRunner); @@ -44,6 +45,7 @@ describe('createExtensionRunner', () => { browser: 'chrome', }, }); + const wslRunner = mock(); createWslRunnerMock.mockReturnValue(wslRunner); @@ -52,6 +54,7 @@ describe('createExtensionRunner', () => { it('should return a manual runner when `runner.disabled` is true', async () => { isWslMock.mockResolvedValueOnce(false); + setFakeWxt({ config: { browser: 'chrome', @@ -62,6 +65,7 @@ describe('createExtensionRunner', () => { }, }, }); + const manualRunner = mock(); createManualRunnerMock.mockReturnValue(manualRunner); diff --git a/packages/wxt/src/core/runners/index.ts b/packages/wxt/src/core/runners/index.ts index 65c4523f6..968c2686b 100644 --- a/packages/wxt/src/core/runners/index.ts +++ b/packages/wxt/src/core/runners/index.ts @@ -8,7 +8,6 @@ import { wxt } from '../wxt'; export async function createExtensionRunner(): Promise { if (wxt.config.browser === 'safari') return createSafariRunner(); - if (await isWsl()) return createWslRunner(); if (wxt.config.runnerConfig.config?.disabled) return createManualRunner(); diff --git a/packages/wxt/src/core/runners/manual.ts b/packages/wxt/src/core/runners/manual.ts index 8dfee1a55..2d6e58825 100644 --- a/packages/wxt/src/core/runners/manual.ts +++ b/packages/wxt/src/core/runners/manual.ts @@ -15,8 +15,5 @@ export function createManualRunner(): ExtensionRunner { )}" as an unpacked extension manually`, ); }, - async closeBrowser() { - // noop - }, }; } diff --git a/packages/wxt/src/core/runners/safari.ts b/packages/wxt/src/core/runners/safari.ts index f3d27c559..a0f77955c 100644 --- a/packages/wxt/src/core/runners/safari.ts +++ b/packages/wxt/src/core/runners/safari.ts @@ -15,8 +15,5 @@ export function createSafariRunner(): ExtensionRunner { )}" as an unpacked extension manually`, ); }, - async closeBrowser() { - // noop - }, }; } diff --git a/packages/wxt/src/core/runners/web-ext.ts b/packages/wxt/src/core/runners/web-ext.ts index f7efcdbe9..1d21012ee 100644 --- a/packages/wxt/src/core/runners/web-ext.ts +++ b/packages/wxt/src/core/runners/web-ext.ts @@ -77,6 +77,7 @@ export function createWebExtRunner(): ExtensionRunner { // Don't call `process.exit(0)` after starting web-ext shouldExitProgram: false, }; + wxt.logger.debug('web-ext config:', finalConfig); wxt.logger.debug('web-ext options:', options); @@ -88,7 +89,7 @@ export function createWebExtRunner(): ExtensionRunner { }, async closeBrowser() { - return await runner?.exit(); + return runner?.exit(); }, }; } diff --git a/packages/wxt/src/core/runners/wsl.ts b/packages/wxt/src/core/runners/wsl.ts index 4fc0b126f..34c7d0bb3 100644 --- a/packages/wxt/src/core/runners/wsl.ts +++ b/packages/wxt/src/core/runners/wsl.ts @@ -15,8 +15,5 @@ export function createWslRunner(): ExtensionRunner { )}" as an unpacked extension manually`, ); }, - async closeBrowser() { - // noop - }, }; } diff --git a/packages/wxt/src/core/utils/__tests__/entrypoints.test.ts b/packages/wxt/src/core/utils/__tests__/entrypoints.test.ts index 963be40d8..366cbc6f2 100644 --- a/packages/wxt/src/core/utils/__tests__/entrypoints.test.ts +++ b/packages/wxt/src/core/utils/__tests__/entrypoints.test.ts @@ -9,35 +9,36 @@ import { resolve } from 'path'; describe('Entrypoint Utils', () => { describe('getEntrypointName', () => { - const entrypointsDir = '/entrypoints'; + const ENTRYPOINTS_DIR = '/entrypoints'; it.each<[string, string]>([ - [resolve(entrypointsDir, 'popup.html'), 'popup'], - [resolve(entrypointsDir, 'options/index.html'), 'options'], - [resolve(entrypointsDir, 'example.sandbox/index.html'), 'example'], - [resolve(entrypointsDir, 'some.content/index.ts'), 'some'], - [resolve(entrypointsDir, 'overlay.content.ts'), 'overlay'], + [resolve(ENTRYPOINTS_DIR, 'popup.html'), 'popup'], + [resolve(ENTRYPOINTS_DIR, 'options/index.html'), 'options'], + [resolve(ENTRYPOINTS_DIR, 'example.sandbox/index.html'), 'example'], + [resolve(ENTRYPOINTS_DIR, 'some.content/index.ts'), 'some'], + [resolve(ENTRYPOINTS_DIR, 'overlay.content.ts'), 'overlay'], ])('should convert %s to %s', (inputPath, expected) => { - const actual = getEntrypointName(entrypointsDir, inputPath); + const actual = getEntrypointName(ENTRYPOINTS_DIR, inputPath); expect(actual).toBe(expected); }); }); describe('getEntrypointOutputFile', () => { - const outDir = '/.output'; + const OUT_DIR = '/.output'; + it.each<{ expected: string; name: string; ext: string; outputDir: string }>( [ { name: 'popup', ext: '.html', - outputDir: outDir, - expected: resolve(outDir, 'popup.html'), + outputDir: OUT_DIR, + expected: resolve(OUT_DIR, 'popup.html'), }, { name: 'overlay', ext: '.ts', - outputDir: resolve(outDir, 'content-scripts'), - expected: resolve(outDir, 'content-scripts', 'overlay.ts'), + outputDir: resolve(OUT_DIR, 'content-scripts'), + expected: resolve(OUT_DIR, 'content-scripts', 'overlay.ts'), }, ], )('should return %s', ({ name, ext, expected, outputDir }) => { diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index e900fdde5..37a3dc5e8 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -23,8 +23,8 @@ import { wxt } from '../../wxt'; import { mock } from 'vitest-mock-extended'; import type { Browser } from '@wxt-dev/browser'; -const outDir = '/output'; -const contentScriptOutDir = '/output/content-scripts'; +const OUT_DIR = '/output'; +const CONTENT_SCRIPT_OUT_DIR = '/output/content-scripts'; describe('Manifest Utils', () => { beforeEach(() => { @@ -34,17 +34,17 @@ describe('Manifest Utils', () => { describe('generateManifest', () => { describe('popup', () => { type ActionType = 'browser_action' | 'page_action'; + const popupEntrypoint = (type?: ActionType) => fakePopupEntrypoint({ options: { - // @ts-expect-error: Force this to be undefined instead of inheriting the random value - mv2Key: type ?? null, + mv2Key: type, defaultIcon: { '16': '/icon/16.png', }, - defaultTitle: 'Default Iitle', + defaultTitle: 'Default Title', }, - outputDir: outDir, + outputDir: OUT_DIR, skipped: false, }); @@ -55,9 +55,10 @@ describe('Manifest Utils', () => { setFakeWxt({ config: { manifestVersion: 3, - outDir, + outDir: OUT_DIR, }, }); + const expected: Partial = { action: { default_icon: popup.options.defaultIcon, @@ -86,12 +87,14 @@ describe('Manifest Utils', () => { async ({ inputType, expectedType }) => { const popup = popupEntrypoint(inputType); const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { manifestVersion: 2, - outDir, + outDir: OUT_DIR, }, }); + const expected = { default_icon: popup.options.defaultIcon, default_title: popup.options.defaultTitle, @@ -111,9 +114,10 @@ describe('Manifest Utils', () => { describe('action without popup', () => { it('should respect the action field in the manifest without a popup', async () => { const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 3, manifest: { action: { @@ -133,9 +137,10 @@ describe('Manifest Utils', () => { it('should generate `browser_action` for MV2 when only `action` is defined', async () => { const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, manifest: { action: { @@ -154,9 +159,10 @@ describe('Manifest Utils', () => { it('should keep the `page_action` for MV2 when both `action` and `page_action` are defined', async () => { const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, manifest: { action: { @@ -178,9 +184,10 @@ describe('Manifest Utils', () => { it('should keep the custom `browser_action` for MV2 when both `action` and `browser_action` are defined', async () => { const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, manifest: { action: { @@ -205,7 +212,7 @@ describe('Manifest Utils', () => { describe('options', () => { const options = fakeOptionsEntrypoint({ - outputDir: outDir, + outputDir: OUT_DIR, options: { openInTab: false, chromeStyle: true, @@ -218,16 +225,17 @@ describe('Manifest Utils', () => { setFakeWxt({ config: { manifestVersion: 3, - outDir, + outDir: OUT_DIR, browser: 'chrome', }, }); + const buildOutput = fakeBuildOutput(); const expected = { open_in_tab: false, chrome_style: true, page: 'options.html', - }; + } as const; const { manifest: actual } = await generateManifest( [options], @@ -242,9 +250,10 @@ describe('Manifest Utils', () => { config: { manifestVersion: 3, browser: 'firefox', - outDir, + outDir: OUT_DIR, }, }); + const buildOutput = fakeBuildOutput(); const expected = { open_in_tab: false, @@ -263,7 +272,7 @@ describe('Manifest Utils', () => { describe('background', () => { const background = fakeBackgroundEntrypoint({ - outputDir: outDir, + outputDir: OUT_DIR, options: { persistent: true, type: 'module', @@ -277,11 +286,12 @@ describe('Manifest Utils', () => { async (browser) => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 3, browser, }, }); + const buildOutput = fakeBuildOutput(); const expected = { type: 'module', @@ -300,11 +310,12 @@ describe('Manifest Utils', () => { it('should include a background script and type for firefox', async () => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 3, browser: 'firefox', }, }); + const buildOutput = fakeBuildOutput(); const expected = { type: 'module', @@ -326,16 +337,17 @@ describe('Manifest Utils', () => { async (browser) => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, browser, }, }); + const buildOutput = fakeBuildOutput(); const expected = { persistent: true, scripts: ['background.js'], - }; + } as const; const { manifest: actual } = await generateManifest( [background], @@ -349,11 +361,12 @@ describe('Manifest Utils', () => { it('should include a background script and persistent for firefox mv2', async () => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, browser: 'firefox', }, }); + const buildOutput = fakeBuildOutput(); const expected = { persistent: true, @@ -427,11 +440,13 @@ describe('Manifest Utils', () => { { type: 'asset', fileName: 'logo-48.png' }, ], }); + const expected = { 16: 'logo-16.png', 32: 'logo-32.png', 48: 'logo-48.png', - }; + } as const; + setFakeWxt({ config: { manifest: { @@ -455,7 +470,7 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content/index.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], }, @@ -465,11 +480,12 @@ describe('Manifest Utils', () => { type: 'asset', fileName: 'content-scripts/one.css', }; + const cs2: ContentScriptEntrypoint = { type: 'content-script', name: 'two', inputPath: 'entrypoints/two.content/index.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], runAt: 'document_end', @@ -480,11 +496,12 @@ describe('Manifest Utils', () => { type: 'asset', fileName: 'content-scripts/two.css', }; + const cs3: ContentScriptEntrypoint = { type: 'content-script', name: 'three', inputPath: 'entrypoints/three.content/index.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], runAt: 'document_end', @@ -495,11 +512,12 @@ describe('Manifest Utils', () => { type: 'asset', fileName: 'content-scripts/three.css', }; + const cs4: ContentScriptEntrypoint = { type: 'content-script', name: 'four', inputPath: 'entrypoints/four.content/index.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://duckduckgo.com/*'], runAt: 'document_end', @@ -510,11 +528,12 @@ describe('Manifest Utils', () => { type: 'asset', fileName: 'content-scripts/four.css', }; + const cs5: ContentScriptEntrypoint = { type: 'content-script', name: 'five', inputPath: 'entrypoints/five.content/index.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], world: 'MAIN', @@ -527,13 +546,15 @@ describe('Manifest Utils', () => { }; const entrypoints = [cs1, cs2, cs3, cs4, cs5]; + setFakeWxt({ config: { command: 'build', - outDir, + outDir: OUT_DIR, manifestVersion: 3, }, }); + const buildOutput: Omit = { publicAssets: [], steps: [ @@ -555,18 +576,21 @@ describe('Manifest Utils', () => { css: ['content-scripts/one.css'], js: ['content-scripts/one.js'], }); + expect(actual.content_scripts).toContainEqual({ matches: ['*://google.com/*'], run_at: 'document_end', css: ['content-scripts/two.css', 'content-scripts/three.css'], js: ['content-scripts/two.js', 'content-scripts/three.js'], }); + expect(actual.content_scripts).toContainEqual({ matches: ['*://duckduckgo.com/*'], run_at: 'document_end', css: ['content-scripts/four.css'], js: ['content-scripts/four.js'], }); + expect(actual.content_scripts).toContainEqual({ matches: ['*://google.com/*'], css: ['content-scripts/five.css'], @@ -580,16 +604,18 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], }, skipped: false, }; + const generatedContentScript = { matches: ['*://google.com/*'], js: ['content-scripts/one.js'], }; + const userContentScript = { css: ['content-scripts/two.css'], matches: ['*://*.google.com/*'], @@ -597,9 +623,10 @@ describe('Manifest Utils', () => { const entrypoints = [cs]; const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifest: { content_scripts: [userContentScript], @@ -624,13 +651,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode, }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -641,9 +669,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', }, }); @@ -670,13 +699,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode, }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -687,9 +717,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', }, }); @@ -713,13 +744,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode: 'ui', }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -730,9 +762,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifestVersion: 3, }, @@ -757,13 +790,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode: 'ui', }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -774,9 +808,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifestVersion: 2, }, @@ -797,13 +832,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://play.google.com/books/*'], cssInjectionMode: 'ui', }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -814,9 +850,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifestVersion: 3, }, @@ -843,7 +880,7 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], registration: 'runtime', @@ -860,10 +897,11 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { manifestVersion: 3, - outDir, + outDir: OUT_DIR, command: 'build', }, }); @@ -884,7 +922,7 @@ describe('Manifest Utils', () => { 'should include the side_panel and permission, ignoring all options for %s', async (browser) => { const sidepanel = fakeSidepanelEntrypoint({ - outputDir: outDir, + outputDir: OUT_DIR, skipped: false, }); const buildOutput = fakeBuildOutput(); @@ -893,10 +931,11 @@ describe('Manifest Utils', () => { config: { manifestVersion: 3, browser, - outDir, + outDir: OUT_DIR, command: 'build', }, }); + const expected = { side_panel: { default_path: 'sidepanel.html', @@ -917,7 +956,7 @@ describe('Manifest Utils', () => { 'should include a sidebar_action for %s', async (browser) => { const sidepanel = fakeSidepanelEntrypoint({ - outputDir: outDir, + outputDir: OUT_DIR, skipped: false, }); const buildOutput = fakeBuildOutput(); @@ -926,9 +965,10 @@ describe('Manifest Utils', () => { config: { manifestVersion: 3, browser, - outDir, + outDir: OUT_DIR, }, }); + const expected = { sidebar_action: { default_panel: 'sidepanel.html', @@ -955,13 +995,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode: 'ui', }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -972,9 +1013,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifestVersion: 3, manifest: { @@ -1005,13 +1047,14 @@ describe('Manifest Utils', () => { type: 'content-script', name: 'one', inputPath: 'entrypoints/one.content.ts', - outputDir: contentScriptOutDir, + outputDir: CONTENT_SCRIPT_OUT_DIR, options: { matches: ['*://google.com/*'], cssInjectionMode: 'ui', }, skipped: false, }; + const styles: OutputAsset = { type: 'asset', fileName: 'content-scripts/one.css', @@ -1022,9 +1065,10 @@ describe('Manifest Utils', () => { publicAssets: [], steps: [{ entrypoints: cs, chunks: [styles] }], }; + setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, command: 'build', manifestVersion: 2, manifest: { @@ -1047,7 +1091,7 @@ describe('Manifest Utils', () => { it('should convert mv3 items to mv2 strings automatically', async () => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 2, manifest: { web_accessible_resources: [ @@ -1078,7 +1122,7 @@ describe('Manifest Utils', () => { it('should convert mv2 strings to mv3 items with a warning automatically', async () => { setFakeWxt({ config: { - outDir, + outDir: OUT_DIR, manifestVersion: 3, manifest: { web_accessible_resources: ['/icon.svg'], @@ -1098,16 +1142,17 @@ describe('Manifest Utils', () => { it.each(['chrome', 'safari', 'edge'] as const)( 'should include version and version_name as is on %s', async (browser) => { - const version = '1.0.0'; - const versionName = '1.0.0-alpha1'; - const entrypoints: Entrypoint[] = []; + const VERSION = '1.0.0'; + const VERSION_NAME = '1.0.0-alpha1'; + const entrypoints: Entrypoint[] = [] as const; const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { browser, manifest: { - version, - version_name: versionName, + version: VERSION, + version_name: VERSION_NAME, }, }, }); @@ -1117,24 +1162,25 @@ describe('Manifest Utils', () => { buildOutput, ); - expect(actual.version).toBe(version); - expect(actual.version_name).toBe(versionName); + expect(actual.version).toBe(VERSION); + expect(actual.version_name).toBe(VERSION_NAME); }, ); it.each(['firefox'] as const)( 'should not include a version_name on %s because it is unsupported', async (browser) => { - const version = '1.0.0'; - const versionName = '1.0.0-alpha1'; - const entrypoints: Entrypoint[] = []; + const VERSION = '1.0.0'; + const VERSION_NAME = '1.0.0-alpha1'; + const entrypoints: Entrypoint[] = [] as const; const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { browser, manifest: { - version, - version_name: versionName, + version: VERSION, + version_name: VERSION_NAME, }, }, }); @@ -1144,7 +1190,7 @@ describe('Manifest Utils', () => { buildOutput, ); - expect(actual.version).toBe(version); + expect(actual.version).toBe(VERSION); expect(actual.version_name).toBeUndefined(); }, ); @@ -1152,15 +1198,16 @@ describe('Manifest Utils', () => { it.each(['chrome', 'firefox', 'safari', 'edge'])( 'should not include the version_name if it is equal to version', async (browser) => { - const version = '1.0.0'; - const entrypoints: Entrypoint[] = []; + const VERSION = '1.0.0'; + const entrypoints: Entrypoint[] = [] as const; const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { browser, manifest: { - version, - version_name: version, + version: VERSION, + version_name: VERSION, }, }, }); @@ -1170,18 +1217,18 @@ describe('Manifest Utils', () => { buildOutput, ); - expect(actual.version).toBe(version); + expect(actual.version).toBe(VERSION); expect(actual.version_name).toBeUndefined(); }, ); it('should log a warning if the version could not be detected', async () => { - const entrypoints: Entrypoint[] = []; + const entrypoints: Entrypoint[] = [] as const; const buildOutput = fakeBuildOutput(); + setFakeWxt({ config: { manifest: { - // @ts-ignore: Purposefully removing version from fake object version: null, }, }, @@ -1202,7 +1249,7 @@ describe('Manifest Utils', () => { }); describe('commands', () => { - const reloadCommandName = 'wxt:reload-extension'; + const RELOAD_COMMAND_NAME = 'wxt:reload-extension'; const reloadCommand = { description: expect.any(String), suggested_key: { @@ -1223,7 +1270,7 @@ describe('Manifest Utils', () => { ); expect(actual.commands).toEqual({ - [reloadCommandName]: reloadCommand, + [RELOAD_COMMAND_NAME]: reloadCommand, }); }); @@ -1245,7 +1292,7 @@ describe('Manifest Utils', () => { ); expect(actual.commands).toEqual({ - [reloadCommandName]: { + [RELOAD_COMMAND_NAME]: { ...reloadCommand, suggested_key: { default: 'Ctrl+E', @@ -1275,18 +1322,20 @@ describe('Manifest Utils', () => { }); it('should not override any existing commands when adding the one to reload the extension', async () => { - const customCommandName = 'custom-command'; + const CUSTOM_COMMAND_NAME = 'custom-command'; const customCommand = fakeManifestCommand(); + setFakeWxt({ config: { command: 'serve', manifest: { commands: { - [customCommandName]: customCommand, + [CUSTOM_COMMAND_NAME]: customCommand, }, }, }, }); + const output = fakeBuildOutput(); const entrypoints = fakeArray(fakeEntrypoint); @@ -1296,8 +1345,8 @@ describe('Manifest Utils', () => { ); expect(actual.commands).toEqual({ - [reloadCommandName]: reloadCommand, - [customCommandName]: customCommand, + [RELOAD_COMMAND_NAME]: reloadCommand, + [CUSTOM_COMMAND_NAME]: customCommand, }); }); @@ -1330,6 +1379,7 @@ describe('Manifest Utils', () => { setFakeWxt({ config: { command: 'build' }, }); + const output = fakeBuildOutput(); const entrypoints = fakeArray(fakeEntrypoint); @@ -1343,6 +1393,9 @@ describe('Manifest Utils', () => { }); describe('Stripping keys', () => { + // TODO: This is really necessary? + // TODO: This seems to be the same, as it would be undefined, + // TODO: that's mean we can remove all of this objects, because it's undefined by default const mv2Manifest = { page_action: {}, browser_action: {}, @@ -1362,15 +1415,18 @@ describe('Manifest Utils', () => { system_indicator: {}, user_scripts: {}, }; + const mv3Manifest = { action: {}, export: {}, optional_host_permissions: {}, side_panel: {}, }; + const hostPermissionsManifest = { host_permissions: {}, }; + const manifest: any = { ...mv2Manifest, ...mv3Manifest, @@ -1415,6 +1471,7 @@ describe('Manifest Utils', () => { it('should keep host_permissions as-is for MV3', async () => { const expectedHostPermissions = ['https://google.com/*']; const expectedPermissions = ['scripting']; + setFakeWxt({ config: { manifest: { @@ -1425,6 +1482,7 @@ describe('Manifest Utils', () => { command: 'build', }, }); + const output = fakeBuildOutput(); const { manifest: actual } = await generateManifest([], output); @@ -1439,6 +1497,7 @@ describe('Manifest Utils', () => { '*://*.youtube.com/*', 'https://google.com/*', ]; + setFakeWxt({ config: { manifest: { @@ -1449,6 +1508,7 @@ describe('Manifest Utils', () => { command: 'build', }, }); + const output = fakeBuildOutput(); const { manifest: actual } = await generateManifest([], output); @@ -1522,8 +1582,9 @@ describe('Manifest Utils', () => { origin: 'http://localhost:3000', }), }); + const output = fakeBuildOutput(); - const entrypoints: Entrypoint[] = []; + const entrypoints: Entrypoint[] = [] as const; const { manifest: actual } = await generateManifest( entrypoints, @@ -1543,13 +1604,14 @@ describe('Manifest Utils', () => { }); it('should convert MV3 CSP object to MV2 CSP string with localhost for MV2', async () => { - const entrypoints: Entrypoint[] = []; - const buildOutput = fakeBuildOutput(); - const inputCsp = + const INPUT_CSP = "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"; - const expectedCsp = + const EXPECTED_CSP = "script-src 'self' 'wasm-unsafe-eval' http://localhost:3000; object-src 'self';"; + const entrypoints: Entrypoint[] = []; + const buildOutput = fakeBuildOutput(); + // Setup WXT for Firefox and serve command setFakeWxt({ config: { @@ -1558,7 +1620,7 @@ describe('Manifest Utils', () => { manifestVersion: 2, manifest: { content_security_policy: { - extension_pages: inputCsp, + extension_pages: INPUT_CSP, }, }, }, @@ -1574,7 +1636,7 @@ describe('Manifest Utils', () => { buildOutput, ); - expect(actual.content_security_policy).toEqual(expectedCsp); + expect(actual.content_security_policy).toEqual(EXPECTED_CSP); }); }); @@ -1603,13 +1665,14 @@ describe('Manifest Utils', () => { describe('manifest_version', () => { it('should ignore and log a warning when someone sets `manifest_version` inside the manifest', async () => { + const EXPECTED_VERSION = 2; const buildOutput = fakeBuildOutput(); - const expectedVersion = 2; + setFakeWxt({ logger: mock(), config: { command: 'build', - manifestVersion: expectedVersion, + manifestVersion: EXPECTED_VERSION, manifest: { manifest_version: 3, }, @@ -1618,7 +1681,7 @@ describe('Manifest Utils', () => { const { manifest } = await generateManifest([], buildOutput); - expect(manifest.manifest_version).toBe(expectedVersion); + expect(manifest.manifest_version).toBe(EXPECTED_VERSION); expect(wxt.logger.warn).toBeCalledTimes(1); expect(wxt.logger.warn).toBeCalledWith( expect.stringContaining( diff --git a/packages/wxt/src/core/utils/__tests__/minimatch-multiple.test.ts b/packages/wxt/src/core/utils/__tests__/minimatch-multiple.test.ts index 34c078e13..88e28bb54 100644 --- a/packages/wxt/src/core/utils/__tests__/minimatch-multiple.test.ts +++ b/packages/wxt/src/core/utils/__tests__/minimatch-multiple.test.ts @@ -4,43 +4,43 @@ import { minimatchMultiple } from '../minimatch-multiple'; describe('minimatchMultiple', () => { it('should return false if the pattern array is undefined', () => { const patterns = undefined; - const search = 'test.json'; + const SEARCH = 'test.json'; - expect(minimatchMultiple(search, patterns)).toBe(false); + expect(minimatchMultiple(SEARCH, patterns)).toBe(false); }); it('should return false if the pattern array is empty', () => { const patterns: string[] = []; - const search = 'test.json'; + const SEARCH = 'test.json'; - expect(minimatchMultiple(search, patterns)).toBe(false); + expect(minimatchMultiple(SEARCH, patterns)).toBe(false); }); it('should return true if the pattern array contains a match', () => { const patterns = ['test.yml', 'test.json']; - const search = 'test.json'; + const SEARCH = 'test.json'; - expect(minimatchMultiple(search, patterns)).toBe(true); + expect(minimatchMultiple(SEARCH, patterns)).toBe(true); }); it('should return false if the pattern array does not contain a match', () => { const patterns = ['test.yml', 'test.json']; - const search = 'test.txt'; + const SEARCH = 'test.txt'; - expect(minimatchMultiple(search, patterns)).toBe(false); + expect(minimatchMultiple(SEARCH, patterns)).toBe(false); }); it('should return false if the pattern matches a negative pattern', () => { const patterns = ['test.*', '!test.json']; - const search = 'test.json'; + const SEARCH = 'test.json'; - expect(minimatchMultiple(search, patterns)).toBe(false); + expect(minimatchMultiple(SEARCH, patterns)).toBe(false); }); it('should return false if the pattern matches a negative pattern, regardless of order', () => { const patterns = ['!test.json', 'test.*']; - const search = 'test.json'; + const SEARCH = 'test.json'; - expect(minimatchMultiple(search, patterns)).toBe(false); + expect(minimatchMultiple(SEARCH, patterns)).toBe(false); }); }); diff --git a/packages/wxt/src/core/utils/__tests__/package.test.ts b/packages/wxt/src/core/utils/__tests__/package.test.ts index 4711c06ae..a76b1506d 100644 --- a/packages/wxt/src/core/utils/__tests__/package.test.ts +++ b/packages/wxt/src/core/utils/__tests__/package.test.ts @@ -20,10 +20,11 @@ describe('Package JSON Utils', () => { }); it("should return an empty object when /package.json doesn't exist", async () => { - const root = '/some/path/that/does/not/exist'; + const ROOT = '/some/path/that/does/not/exist'; const logger = mock(); + setFakeWxt({ - config: { root, logger }, + config: { root: ROOT, logger }, logger, }); diff --git a/packages/wxt/src/core/utils/__tests__/strings.test.ts b/packages/wxt/src/core/utils/__tests__/strings.test.ts index f1dbce8b2..84654173b 100644 --- a/packages/wxt/src/core/utils/__tests__/strings.test.ts +++ b/packages/wxt/src/core/utils/__tests__/strings.test.ts @@ -32,6 +32,7 @@ describe('String utils', () => { "should convert '%s' to '%s', which can be used for a variable name", (input, expected) => { const actual = safeVarName(input); + expect(actual).toBe(expected); }, ); @@ -39,7 +40,7 @@ describe('String utils', () => { describe('removeImportStatements', () => { it('should remove all import formats', () => { - const imports = ` + const IMPORTS = ` import { registerGithubService, createGithubApi } from "@/utils/github"; import { registerGithubService, @@ -54,16 +55,18 @@ import"@/utils/github" import'@/utils/github'; import * as abc from "@/utils/github" `; - expect(removeImportStatements(imports).trim()).toEqual(''); + + expect(removeImportStatements(IMPORTS).trim()).toEqual(''); }); it('should not remove import.meta or inline import statements', () => { - const imports = ` + const IMPORTS = ` import.meta.env.DEV const a = await import("example"); import("example"); `; - expect(removeImportStatements(imports)).toEqual(imports); + + expect(removeImportStatements(IMPORTS)).toEqual(IMPORTS); }); }); }); diff --git a/packages/wxt/src/core/utils/__tests__/transform.test.ts b/packages/wxt/src/core/utils/__tests__/transform.test.ts index a623aa2fc..2c4232610 100644 --- a/packages/wxt/src/core/utils/__tests__/transform.test.ts +++ b/packages/wxt/src/core/utils/__tests__/transform.test.ts @@ -6,32 +6,32 @@ describe('Transform Utils', () => { it.each(['defineBackground', 'defineUnlistedScript'])( 'should remove the first arrow function argument for %s', (def) => { - const input = ` + const INPUT = ` export default ${def}(() => { console.log(); }) `; - const expected = `export default ${def}();`; + const EXPECTED = `export default ${def}();`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }, ); it.each(['defineBackground', 'defineUnlistedScript'])( 'should remove the first function argument for %s', (def) => { - const input = ` + const INPUT = ` export default ${def}(function () { console.log(); }) `; - const expected = `export default ${def}();`; + const EXPECTED = `export default ${def}();`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }, ); @@ -40,57 +40,57 @@ describe('Transform Utils', () => { 'defineContentScript', 'defineUnlistedScript', ])('should remove the main field from %s', (def) => { - const input = ` + const INPUT = ` export default ${def}({ asdf: "asdf", main: () => {}, }) `; - const expected = `export default ${def}({ + const EXPECTED = `export default ${def}({ asdf: "asdf" })`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); it('should remove unused imports', () => { - const input = ` + const INPUT = ` import { defineBackground } from "#imports" import { test1 } from "somewhere1" import test2 from "somewhere2" export default defineBackground(() => {}) `; - const expected = `import { defineBackground } from "#imports" + const EXPECTED = `import { defineBackground } from "#imports" export default defineBackground();`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); it('should remove explict side-effect imports', () => { - const input = ` + const INPUT = ` import { defineBackground } from "#imports" import "my-polyfill" import "./style.css" export default defineBackground(() => {}) `; - const expected = `import { defineBackground } from "#imports" + const EXPECTED = `import { defineBackground } from "#imports" export default defineBackground();`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); it("should remove any functions delcared outside the main function that aren't used", () => { - const input = ` + const INPUT = ` function getMatches() { return ["*://*/*"] } @@ -104,7 +104,7 @@ export default defineBackground();`; main: () => {}, }) `; - const expected = `function getMatches() { + const EXPECTED = `function getMatches() { return ["*://*/*"] } @@ -112,13 +112,13 @@ export default defineContentScript({ matches: getMatches() })`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); it("should remove any variables delcared outside the main function that aren't used", () => { - const input = ` + const INPUT = ` const unused1 = "a", matches = ["*://*/*"]; let unused2 = unused1 + "b"; @@ -127,19 +127,19 @@ export default defineContentScript({ main: () => {} }) `; - const expected = `const matches = ["*://*/*"]; + const EXPECTED = `const matches = ["*://*/*"]; export default defineContentScript({ matches })`; - const actual = removeMainFunctionCode(input).code; + const actual = removeMainFunctionCode(INPUT).code; - expect(actual).toEqual(expected); + expect(actual).toEqual(EXPECTED); }); it('should not remove any variables delcared outside the main function that are used', () => { - const input = ` + const INPUT = ` const [ a ] = [ 123, 456 ]; const { b } = { b: 123 }; const { c: { d } } = { c: { d: 123 } }; @@ -154,7 +154,8 @@ export default defineContentScript({ export default defineBackground(() => { console.log('Hello background!', { id: browser.runtime.id }); });`; - const expected = `const [ a ] = [ 123, 456 ]; + + const EXPECTED = `const [ a ] = [ 123, 456 ]; const { b } = { b: 123 }; const { c: { d } } = { c: { d: 123 } }; const { e, ...rest } = { e: 123, f: 456 }; @@ -167,8 +168,8 @@ console.log(rest); export default defineBackground();`; - const actual = removeMainFunctionCode(input).code; - expect(actual).toEqual(expected); + const actual = removeMainFunctionCode(INPUT).code; + expect(actual).toEqual(EXPECTED); }); }); }); diff --git a/packages/wxt/src/core/utils/__tests__/validation.test.ts b/packages/wxt/src/core/utils/__tests__/validation.test.ts index 24f6fed71..8247f8ef3 100644 --- a/packages/wxt/src/core/utils/__tests__/validation.test.ts +++ b/packages/wxt/src/core/utils/__tests__/validation.test.ts @@ -25,6 +25,7 @@ describe('Validation Utils', () => { it('should return an error when exclude is not an array', () => { const entrypoint = fakeGenericEntrypoint({ options: { + // TODO: TS ISN'T ENOUGH HERE TO PREVENT THIS? // @ts-expect-error exclude: 0, }, @@ -50,6 +51,7 @@ describe('Validation Utils', () => { it('should return an error when include is not an array', () => { const entrypoint = fakeGenericEntrypoint({ options: { + // TODO: TS ISN'T ENOUGH HERE TO PREVENT THIS? // @ts-expect-error include: 0, }, @@ -76,6 +78,7 @@ describe('Validation Utils', () => { const entrypoint = fakeContentScriptEntrypoint({ options: { registration: 'manifest', + // TODO: TS ISN'T ENOUGH HERE TO PREVENT THIS? // @ts-expect-error matches: null, }, @@ -103,6 +106,7 @@ describe('Validation Utils', () => { const entrypoint = fakeContentScriptEntrypoint({ options: { registration: 'runtime', + // TODO: TS ISN'T ENOUGH HERE TO PREVENT THIS? // @ts-expect-error matches: null, }, diff --git a/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts b/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts index 3dad59a82..bc046a19e 100644 --- a/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/detect-dev-changes.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { DevModeChange, detectDevChanges } from '../../../utils/building'; +import { DevModeChange, detectDevChanges } from '../detect-dev-changes'; import { fakeBackgroundEntrypoint, fakeContentScriptEntrypoint, @@ -12,7 +12,7 @@ import { fakeOutputChunk, fakeWxt, setFakeWxt, -} from '../../../utils/testing/fake-objects'; +} from '../../testing/fake-objects'; import { BuildOutput, BuildStepOutput } from '../../../../types'; import { setWxtForTesting } from '../../../wxt'; @@ -24,6 +24,7 @@ describe('Detect Dev Changes', () => { describe('No changes', () => { it("should return 'no-change' when the changed file isn't used by any of the entrypoints", () => { const changes = ['/some/path.ts']; + const currentOutput: BuildOutput = { manifest: fakeManifest(), publicAssets: [], @@ -47,15 +48,17 @@ describe('Detect Dev Changes', () => { describe('wxt.config.ts', () => { it("should return 'full-restart' when one of the changed files is the config file", () => { - const configFile = '/root/wxt.config.ts'; + const CONFIG_FILE = '/root/wxt.config.ts'; + setFakeWxt({ config: { userConfigMetadata: { - configFile, + configFile: CONFIG_FILE, }, }, }); - const changes = ['/root/src/public/image.svg', configFile]; + + const changes = ['/root/src/public/image.svg', CONFIG_FILE]; const currentOutput: BuildOutput = { manifest: fakeManifest(), publicAssets: [], @@ -73,16 +76,19 @@ describe('Detect Dev Changes', () => { describe('modules/*', () => { it("should return 'full-restart' when one of the changed files is in the WXT modules folder", () => { - const modulesDir = '/root/modules'; + const MODULES_DIR = '/root/modules'; + setFakeWxt({ config: { - modulesDir, + modulesDir: MODULES_DIR, }, }); + const changes = [ '/root/src/public/image.svg', - `${modulesDir}/example.ts`, + `${MODULES_DIR}/example.ts`, ]; + const currentOutput: BuildOutput = { manifest: fakeManifest(), publicAssets: [], @@ -100,15 +106,17 @@ describe('Detect Dev Changes', () => { describe('web-ext.config.ts', () => { it("should return 'browser-restart' when one of the changed files is the config file", () => { - const runnerFile = '/root/web-ext.config.ts'; + const RUNNER_FILE = '/root/web-ext.config.ts'; + setFakeWxt({ config: { runnerConfig: { - configFile: runnerFile, + configFile: RUNNER_FILE, }, }, }); - const changes = ['/root/src/public/image.svg', runnerFile]; + + const changes = ['/root/src/public/image.svg', RUNNER_FILE]; const currentOutput: BuildOutput = { manifest: fakeManifest(), publicAssets: [], @@ -127,22 +135,27 @@ describe('Detect Dev Changes', () => { describe('Public Assets', () => { it("should return 'extension-reload' without any groups to rebuild when the changed file is a public asset", () => { const changes = ['/root/src/public/image.svg']; + setFakeWxt({ config: { publicDir: '/root/src/public', }, }); + const asset1 = fakeOutputAsset({ fileName: 'image.svg', }); + const asset2 = fakeOutputAsset({ fileName: 'some-other-image.svg', }); + const currentOutput: BuildOutput = { manifest: fakeManifest(), publicAssets: [asset1, asset2], steps: [], }; + const expected: DevModeChange = { type: 'extension-reload', rebuildGroups: [], @@ -157,10 +170,12 @@ describe('Detect Dev Changes', () => { describe('Background', () => { it("should rebuild the background and reload the extension when the changed file in it's chunks' `moduleIds` field", () => { - const changedPath = '/root/utils/shared.ts'; + const CHANGED_PATH = '/root/utils/shared.ts'; + const contentScript = fakeContentScriptEntrypoint({ inputPath: '/root/overlay.content.ts', }); + const background = fakeBackgroundEntrypoint({ inputPath: '/root/background.ts', }); @@ -173,11 +188,12 @@ describe('Detect Dev Changes', () => { }), ], }; + const step2: BuildStepOutput = { entrypoints: background, chunks: [ fakeOutputChunk({ - moduleIds: [fakeFile(), changedPath, fakeFile()], + moduleIds: [fakeFile(), CHANGED_PATH, fakeFile()], }), ], }; @@ -187,6 +203,7 @@ describe('Detect Dev Changes', () => { publicAssets: [], steps: [step1, step2], }; + const expected: DevModeChange = { type: 'extension-reload', cachedOutput: { @@ -196,7 +213,7 @@ describe('Detect Dev Changes', () => { rebuildGroups: [background], }; - const actual = detectDevChanges([changedPath], currentOutput); + const actual = detectDevChanges([CHANGED_PATH], currentOutput); expect(actual).toEqual(expected); }); @@ -204,13 +221,16 @@ describe('Detect Dev Changes', () => { describe('HTML Pages', () => { it('should detect changes to entrypoints/.html files', async () => { - const changedPath = '/root/page1.html'; + const CHANGED_PATH = '/root/page1.html'; + const htmlPage1 = fakePopupEntrypoint({ - inputPath: changedPath, + inputPath: CHANGED_PATH, }); + const htmlPage2 = fakeOptionsEntrypoint({ inputPath: '/root/page2.html', }); + const htmlPage3 = fakeGenericEntrypoint({ type: 'sandbox', inputPath: '/root/page3.html', @@ -224,6 +244,7 @@ describe('Detect Dev Changes', () => { }), ], }; + const step2: BuildStepOutput = { entrypoints: [htmlPage3], chunks: [ @@ -238,6 +259,7 @@ describe('Detect Dev Changes', () => { publicAssets: [], steps: [step1, step2], }; + const expected: DevModeChange = { type: 'html-reload', cachedOutput: { @@ -247,19 +269,22 @@ describe('Detect Dev Changes', () => { rebuildGroups: [[htmlPage1, htmlPage2]], }; - const actual = detectDevChanges([changedPath], currentOutput); + const actual = detectDevChanges([CHANGED_PATH], currentOutput); expect(actual).toEqual(expected); }); it('should detect changes to entrypoints//index.html files', async () => { - const changedPath = '/root/page1/index.html'; + const CHANGED_PATH = '/root/page1/index.html'; + const htmlPage1 = fakePopupEntrypoint({ - inputPath: changedPath, + inputPath: CHANGED_PATH, }); + const htmlPage2 = fakeOptionsEntrypoint({ inputPath: '/root/page2/index.html', }); + const htmlPage3 = fakeGenericEntrypoint({ type: 'sandbox', inputPath: '/root/page3/index.html', @@ -273,6 +298,7 @@ describe('Detect Dev Changes', () => { }), ], }; + const step2: BuildStepOutput = { entrypoints: [htmlPage3], chunks: [ @@ -287,6 +313,7 @@ describe('Detect Dev Changes', () => { publicAssets: [], steps: [step1, step2], }; + const expected: DevModeChange = { type: 'html-reload', cachedOutput: { @@ -296,7 +323,7 @@ describe('Detect Dev Changes', () => { rebuildGroups: [[htmlPage1, htmlPage2]], }; - const actual = detectDevChanges([changedPath], currentOutput); + const actual = detectDevChanges([CHANGED_PATH], currentOutput); expect(actual).toEqual(expected); }); @@ -304,13 +331,16 @@ describe('Detect Dev Changes', () => { describe('Content Scripts', () => { it('should rebuild then reload only the effected content scripts', async () => { - const changedPath = '/root/utils/shared.ts'; + const CHANGED_PATH = '/root/utils/shared.ts'; + const script1 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay1.content/index.ts', }); + const script2 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay2.ts', }); + const script3 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay3.content/index.ts', }); @@ -319,10 +349,11 @@ describe('Detect Dev Changes', () => { entrypoints: script1, chunks: [ fakeOutputChunk({ - moduleIds: [fakeFile(), changedPath], + moduleIds: [fakeFile(), CHANGED_PATH], }), ], }; + const step2: BuildStepOutput = { entrypoints: script2, chunks: [ @@ -331,11 +362,12 @@ describe('Detect Dev Changes', () => { }), ], }; + const step3: BuildStepOutput = { entrypoints: script3, chunks: [ fakeOutputChunk({ - moduleIds: [changedPath, fakeFile(), fakeFile()], + moduleIds: [CHANGED_PATH, fakeFile(), fakeFile()], }), ], }; @@ -345,6 +377,7 @@ describe('Detect Dev Changes', () => { publicAssets: [], steps: [step1, step2, step3], }; + const expected: DevModeChange = { type: 'content-script-reload', cachedOutput: { @@ -355,20 +388,23 @@ describe('Detect Dev Changes', () => { rebuildGroups: [script1, script3], }; - const actual = detectDevChanges([changedPath], currentOutput); + const actual = detectDevChanges([CHANGED_PATH], currentOutput); expect(actual).toEqual(expected); }); it('should detect changes to import files with `?suffix`', () => { - const importedPath = '/root/utils/shared.css?inline'; - const changedPath = '/root/utils/shared.css'; + const IMPORTED_PATH = '/root/utils/shared.css?inline'; + const CHANGED_PATH = '/root/utils/shared.css'; + const script1 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay1.content/index.ts', }); + const script2 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay2.ts', }); + const script3 = fakeContentScriptEntrypoint({ inputPath: '/root/overlay3.content/index.ts', }); @@ -377,7 +413,7 @@ describe('Detect Dev Changes', () => { entrypoints: script1, chunks: [ fakeOutputChunk({ - moduleIds: [fakeFile(), importedPath], + moduleIds: [fakeFile(), IMPORTED_PATH], }), ], }; @@ -393,7 +429,7 @@ describe('Detect Dev Changes', () => { entrypoints: script3, chunks: [ fakeOutputChunk({ - moduleIds: [importedPath, fakeFile(), fakeFile()], + moduleIds: [IMPORTED_PATH, fakeFile(), fakeFile()], }), ], }; @@ -403,6 +439,7 @@ describe('Detect Dev Changes', () => { publicAssets: [], steps: [step1, step2, step3], }; + const expected: DevModeChange = { type: 'content-script-reload', cachedOutput: { @@ -413,7 +450,7 @@ describe('Detect Dev Changes', () => { rebuildGroups: [script1, script3], }; - const actual = detectDevChanges([changedPath], currentOutput); + const actual = detectDevChanges([CHANGED_PATH], currentOutput); expect(actual).toEqual(expected); }); diff --git a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts index 05d0937f8..80f77483b 100644 --- a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts @@ -13,11 +13,8 @@ import { resolve } from 'path'; import { findEntrypoints } from '../find-entrypoints'; import fs from 'fs-extra'; import glob from 'fast-glob'; -import { - fakeResolvedConfig, - setFakeWxt, -} from '../../../utils/testing/fake-objects'; -import { unnormalizePath } from '../../../utils/paths'; +import { fakeResolvedConfig, setFakeWxt } from '../../testing/fake-objects'; +import { unnormalizePath } from '../../paths'; import { wxt } from '../../../wxt'; vi.mock('fast-glob'); @@ -48,7 +45,7 @@ describe('findEntrypoints', () => { [ 'popup.html', ` - + Default Title @@ -70,7 +67,7 @@ describe('findEntrypoints', () => { [ 'popup/index.html', ` - + Title @@ -104,7 +101,7 @@ describe('findEntrypoints', () => { [ 'options.html', ` - + Default Title @@ -122,7 +119,7 @@ describe('findEntrypoints', () => { [ 'options/index.html', ` - + Title @@ -210,6 +207,7 @@ describe('findEntrypoints', () => { const options: ContentScriptEntrypoint['options'] = { matches: [''], }; + globMock.mockResolvedValueOnce([path]); importEntrypointsMock.mockResolvedValue([options]); @@ -248,6 +246,7 @@ describe('findEntrypoints', () => { const options = { type: 'module', } satisfies BackgroundEntrypointOptions; + globMock.mockResolvedValueOnce([path]); importEntrypointsMock.mockResolvedValue([options]); @@ -263,7 +262,7 @@ describe('findEntrypoints', () => { [ 'sidepanel.html', ` - + Default Title @@ -286,7 +285,7 @@ describe('findEntrypoints', () => { ], [ 'sidepanel/index.html', - ``, + ``, { type: 'sidepanel', name: 'sidepanel', @@ -298,7 +297,7 @@ describe('findEntrypoints', () => { ], [ 'named.sidepanel.html', - ``, + ``, { type: 'sidepanel', name: 'named', @@ -310,7 +309,7 @@ describe('findEntrypoints', () => { ], [ 'named.sidepanel/index.html', - ``, + ``, { type: 'sidepanel', name: 'named', @@ -411,6 +410,7 @@ describe('findEntrypoints', () => { outputDir: config.outDir, skipped: false, }; + const options = {} satisfies BaseEntrypointOptions; globMock.mockResolvedValueOnce([path]); importEntrypointsMock.mockResolvedValue([options]); @@ -756,7 +756,7 @@ describe('findEntrypoints', () => { it("should mark the popup as skipped when include doesn't contain the target browser", async () => { globMock.mockResolvedValueOnce(['popup.html']); readFileMock.mockResolvedValueOnce( - ` + ` { globMock.mockResolvedValueOnce(['options.html']); readFileMock.mockResolvedValueOnce( - ` + ` { globMock.mockResolvedValueOnce(['unlisted.html']); readFileMock.mockResolvedValueOnce( - ` + ` @@ -872,7 +872,7 @@ describe('findEntrypoints', () => { it('should mark the options page as skipped when exclude contains the target browser', async () => { globMock.mockResolvedValueOnce(['options.html']); readFileMock.mockResolvedValueOnce( - ` + ` @@ -892,7 +892,7 @@ describe('findEntrypoints', () => { it('should mark unlisted pages as skipped when exclude contains the target browser', async () => { globMock.mockResolvedValueOnce(['unlisted.html']); readFileMock.mockResolvedValueOnce( - ` + ` diff --git a/packages/wxt/src/core/utils/building/__tests__/group-entrypoints.test.ts b/packages/wxt/src/core/utils/building/__tests__/group-entrypoints.test.ts index bb21a6fd6..d2757bf51 100644 --- a/packages/wxt/src/core/utils/building/__tests__/group-entrypoints.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/group-entrypoints.test.ts @@ -15,6 +15,7 @@ const background: Entrypoint = { options: {}, skipped: false, }; + const contentScript: Entrypoint = { type: 'content-script', name: 'overlay', @@ -25,6 +26,7 @@ const contentScript: Entrypoint = { }, skipped: false, }; + const unlistedScript: Entrypoint = { type: 'unlisted-script', name: 'injected', @@ -33,6 +35,7 @@ const unlistedScript: Entrypoint = { options: {}, skipped: false, }; + const popup: Entrypoint = { type: 'popup', name: 'popup', @@ -41,6 +44,7 @@ const popup: Entrypoint = { options: {}, skipped: false, }; + const unlistedPage: Entrypoint = { type: 'unlisted-page', name: 'onboarding', @@ -49,6 +53,7 @@ const unlistedPage: Entrypoint = { options: {}, skipped: false, }; + const options: Entrypoint = { type: 'options', name: 'options', @@ -57,6 +62,7 @@ const options: Entrypoint = { options: {}, skipped: false, }; + const sandbox1: Entrypoint = { type: 'sandbox', name: 'sandbox', @@ -65,6 +71,7 @@ const sandbox1: Entrypoint = { options: {}, skipped: false, }; + const sandbox2: Entrypoint = { type: 'sandbox', name: 'sandbox2', @@ -73,6 +80,7 @@ const sandbox2: Entrypoint = { options: {}, skipped: false, }; + const unlistedStyle: Entrypoint = { type: 'unlisted-style', name: 'injected', @@ -81,6 +89,7 @@ const unlistedStyle: Entrypoint = { options: {}, skipped: false, }; + const contentScriptStyle: Entrypoint = { type: 'content-script-style', name: 'injected', @@ -98,6 +107,7 @@ describe('groupEntrypoints', () => { unlistedScript, popup, ]; + const expected = [contentScript, background, unlistedScript, [popup]]; const actual = groupEntrypoints(entrypoints); @@ -111,6 +121,7 @@ describe('groupEntrypoints', () => { contentScriptStyle, popup, ]; + const expected = [unlistedStyle, contentScriptStyle, [popup]]; const actual = groupEntrypoints(entrypoints); @@ -126,6 +137,7 @@ describe('groupEntrypoints', () => { options, sandbox1, ]; + const expected = [[popup, unlistedPage, options], background, [sandbox1]]; const actual = groupEntrypoints(entrypoints); @@ -140,6 +152,7 @@ describe('groupEntrypoints', () => { sandbox2, contentScript, ]; + const expected = [[sandbox1, sandbox2], [popup], contentScript]; const actual = groupEntrypoints(entrypoints); @@ -176,6 +189,7 @@ describe('groupEntrypoints', () => { }, skipped: false, }); + const popup = fakePopupEntrypoint({ skipped: true, }); diff --git a/packages/wxt/src/core/utils/building/__tests__/test-entrypoints/no-default-export.ts b/packages/wxt/src/core/utils/building/__tests__/test-entrypoints/no-default-export.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/wxt/src/core/utils/building/build-entrypoints.ts b/packages/wxt/src/core/utils/building/build-entrypoints.ts index 4edd2aff5..87ea90235 100644 --- a/packages/wxt/src/core/utils/building/build-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/build-entrypoints.ts @@ -17,12 +17,14 @@ export async function buildEntrypoints( spinner: Ora, ): Promise> { const steps: BuildStepOutput[] = []; + for (let i = 0; i < groups.length; i++) { const group = groups[i]; const groupNames = toArray(group).map((e) => e.name); const groupNameColored = groupNames.join(pc.dim(', ')); spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNameColored}`; + try { steps.push(await wxt.builder.build(group)); } catch (err) { @@ -31,6 +33,7 @@ export async function buildEntrypoints( throw Error(`Failed to build ${groupNames.join(', ')}`, { cause: err }); } } + const publicAssets = await copyPublicDirectory(); return { publicAssets, steps }; @@ -41,19 +44,24 @@ async function copyPublicDirectory(): Promise { absoluteSrc: resolve(wxt.config.publicDir, file), relativeDest: file, })); + await wxt.hooks.callHook('build:publicAssets', wxt, files); + if (files.length === 0) return []; const publicAssets: BuildOutput['publicAssets'] = []; + for (const file of files) { const absoluteDest = resolve(wxt.config.outDir, file.relativeDest); await fs.ensureDir(dirname(absoluteDest)); + if ('absoluteSrc' in file) { await fs.copyFile(file.absoluteSrc, absoluteDest); } else { await fs.writeFile(absoluteDest, file.contents, 'utf8'); } + publicAssets.push({ type: 'asset', fileName: file.relativeDest, diff --git a/packages/wxt/src/core/utils/building/detect-dev-changes.ts b/packages/wxt/src/core/utils/building/detect-dev-changes.ts index 1fe00653e..174e4be5d 100644 --- a/packages/wxt/src/core/utils/building/detect-dev-changes.ts +++ b/packages/wxt/src/core/utils/building/detect-dev-changes.ts @@ -4,8 +4,8 @@ import { EntrypointGroup, OutputFile, } from '../../../types'; -import { every, some } from '../../utils/arrays'; -import { normalizePath } from '../../utils/paths'; +import { every, some } from '../arrays'; +import { normalizePath } from '../paths'; import { wxt } from '../../wxt'; /** @@ -40,17 +40,20 @@ export function detectDevChanges( changedFiles, (file) => file === wxt.config.userConfigMetadata.configFile, ); + if (isConfigChange) return { type: 'full-restart' }; const isWxtModuleChange = some(changedFiles, (file) => file.startsWith(wxt.config.modulesDir), ); + if (isWxtModuleChange) return { type: 'full-restart' }; const isRunnerChange = some( changedFiles, (file) => file === wxt.config.runnerConfig.configFile, ); + if (isRunnerChange) return { type: 'browser-restart' }; const changedSteps = new Set( @@ -58,10 +61,12 @@ export function detectDevChanges( findEffectedSteps(changedFile, currentOutput), ), ); + if (changedSteps.size === 0) { const hasPublicChange = some(changedFiles, (file) => file.startsWith(wxt.config.publicDir), ); + if (hasPublicChange) { return { type: 'extension-reload', @@ -78,6 +83,7 @@ export function detectDevChanges( steps: [], publicAssets: [...currentOutput.publicAssets], }; + const changedOutput: BuildOutput = { manifest: currentOutput.manifest, steps: [], @@ -95,6 +101,7 @@ export function detectDevChanges( const isOnlyHtmlChanges = changedFiles.length > 0 && every(changedFiles, (file) => file.endsWith('.html')); + if (isOnlyHtmlChanges) { return { type: 'html-reload', @@ -109,6 +116,7 @@ export function detectDevChanges( changedOutput.steps.flatMap((step) => step.entrypoints), (entry) => entry.type === 'content-script', ); + if (isOnlyContentScripts) { return { type: 'content-script-reload', @@ -158,6 +166,7 @@ function findEffectedSteps( for (const step of currentOutput.steps) { const effectedChunk = step.chunks.find((chunk) => isChunkEffected(chunk)); + if (effectedChunk) changes.push(step); } @@ -207,10 +216,6 @@ interface ExtensionReload extends RebuildChange { type: 'extension-reload'; } -// interface BrowserRestart extends RebuildChange { -// type: 'browser-restart'; -// } - interface ContentScriptReload extends RebuildChange { type: 'content-script-reload'; changedSteps: BuildStepOutput[]; diff --git a/packages/wxt/src/core/utils/building/find-entrypoints.ts b/packages/wxt/src/core/utils/building/find-entrypoints.ts index 3f6e4bcd6..d73718e86 100644 --- a/packages/wxt/src/core/utils/building/find-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/find-entrypoints.ts @@ -5,11 +5,11 @@ import { Entrypoint, EntrypointInfo, GenericEntrypoint, + IsolatedWorldContentScriptEntrypointOptions, + MainWorldContentScriptEntrypointOptions, OptionsEntrypoint, PopupEntrypoint, SidepanelEntrypoint, - MainWorldContentScriptEntrypointOptions, - IsolatedWorldContentScriptEntrypointOptions, } from '../../../types'; import fs from 'fs-extra'; import { minimatch } from 'minimatch'; @@ -21,9 +21,9 @@ import { isHtmlEntrypoint, isJsEntrypoint, resolvePerBrowserOptions, -} from '../../utils/entrypoints'; -import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../../utils/constants'; -import { CSS_EXTENSIONS_PATTERN } from '../../utils/paths'; +} from '../entrypoints'; +import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../constants'; +import { CSS_EXTENSIONS_PATTERN } from '../paths'; import pc from 'picocolors'; import { wxt } from '../../wxt'; import { camelCase } from 'scule'; @@ -34,6 +34,7 @@ import { camelCase } from 'scule'; export async function findEntrypoints(): Promise { // Make sure required TSConfig file exists to load dependencies await fs.mkdir(wxt.config.wxtDir, { recursive: true }); + try { await fs.writeJson( resolve(wxt.config.wxtDir, 'tsconfig.json'), @@ -53,33 +54,38 @@ export async function findEntrypoints(): Promise { relativePaths.sort(); const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP); + const entrypointInfos: EntrypointInfo[] = relativePaths .reduce((results, relativePath) => { const inputPath = resolve(wxt.config.entrypointsDir, relativePath); const name = getEntrypointName(wxt.config.entrypointsDir, inputPath); + const matchingGlob = pathGlobs.find((glob) => minimatch(relativePath, glob), ); + if (matchingGlob) { const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob]; results.push({ name, inputPath, type }); } + return results; }, []) .filter(({ name, inputPath }, _, entrypointInfos) => { // Remove /index.* if /index.html exists if (inputPath.endsWith('.html')) return true; + const isIndexFile = /index\..+$/.test(inputPath); + if (!isIndexFile) return true; const hasIndexHtml = entrypointInfos.some( (entry) => entry.name === name && entry.inputPath.endsWith('index.html'), ); - if (hasIndexHtml) return false; - return true; + return !hasIndexHtml; }); await wxt.hooks.callHook('entrypoints:found', wxt, entrypointInfos); @@ -90,11 +96,13 @@ export async function findEntrypoints(): Promise { // Import entrypoints to get their config let hasBackground = false; + const entrypointOptions = await importEntrypoints(entrypointInfos); const entrypointsWithoutSkipped: Entrypoint[] = await Promise.all( entrypointInfos.map(async (info): Promise => { const { type } = info; const options = entrypointOptions[info.inputPath] ?? {}; + switch (type) { case 'popup': return await getPopupEntrypoint(info, options); @@ -157,9 +165,11 @@ export async function findEntrypoints(): Promise { await wxt.hooks.callHook('entrypoints:resolved', wxt, entrypoints); wxt.logger.debug('All entrypoints:', entrypoints); + const skippedEntrypointNames = entrypoints .filter((item) => item.skipped) .map((item) => item.name); + if (skippedEntrypointNames.length) { wxt.logger.warn( [ @@ -184,8 +194,7 @@ async function importEntrypoints(infos: EntrypointInfo[]) { await Promise.all([ // HTML ...htmlInfos.map(async (info) => { - const res = await importHtmlEntrypoint(info); - resMap[info.inputPath] = res; + resMap[info.inputPath] = await importHtmlEntrypoint(info); }), // JS (async () => { @@ -210,6 +219,7 @@ async function importHtmlEntrypoint( const { document } = parseHTML(content); const metaTags = document.querySelectorAll('meta'); + const res: Record = { title: document.querySelector('title')?.textContent || undefined, }; @@ -219,6 +229,7 @@ async function importHtmlEntrypoint( if (!name.startsWith('manifest.')) return; const key = camelCase(name.slice(9)); + try { res[key] = JSON5.parse(tag.content); } catch { @@ -238,6 +249,7 @@ function preventDuplicateEntrypointNames(files: EntrypointInfo[]) { }, {}, ); + const errorLines = Object.entries(namesToPaths).reduce( (lines, [name, absolutePaths]) => { if (absolutePaths.length > 1) { @@ -246,12 +258,15 @@ function preventDuplicateEntrypointNames(files: EntrypointInfo[]) { lines.push(` - ${relative(wxt.config.root, absolutePath)}`); }); } + return lines; }, [], ); + if (errorLines.length > 0) { const errorContent = errorLines.join('\n'); + throw Error( `Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.\n\n${errorContent}`, ); @@ -268,7 +283,7 @@ async function getPopupEntrypoint( info: EntrypointInfo, options: Record, ): Promise { - const stictOptions: PopupEntrypoint['options'] = resolvePerBrowserOptions( + const strictOptions: PopupEntrypoint['options'] = resolvePerBrowserOptions( { browserStyle: options.browserStyle, exclude: options.exclude, @@ -279,13 +294,14 @@ async function getPopupEntrypoint( }, wxt.config.browser, ); - if (stictOptions.mv2Key && stictOptions.mv2Key !== 'page_action') - stictOptions.mv2Key = 'browser_action'; + + if (strictOptions.mv2Key && strictOptions.mv2Key !== 'page_action') + strictOptions.mv2Key = 'browser_action'; return { type: 'popup', name: 'popup', - options: stictOptions, + options: strictOptions, inputPath: info.inputPath, outputDir: wxt.config.outDir, }; @@ -433,6 +449,7 @@ function isEntrypointSkipped(entry: Omit): boolean { if (exclude?.length && !include?.length) { return exclude.includes(wxt.config.browser); } + if (include?.length && !exclude?.length) { return !include.includes(wxt.config.browser); } diff --git a/packages/wxt/src/core/utils/building/group-entrypoints.ts b/packages/wxt/src/core/utils/building/group-entrypoints.ts index 6381bbd7e..04d7b707d 100644 --- a/packages/wxt/src/core/utils/building/group-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/group-entrypoints.ts @@ -14,13 +14,16 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] { if (entry.skipped) continue; let group = ENTRY_TYPE_TO_GROUP_MAP[entry.type]; + if (entry.type === 'background' && entry.options.type === 'module') { group = 'esm'; } + if (group === 'individual') { groups.push(entry); } else { let groupIndex = groupIndexMap[group]; + if (groupIndex == null) { groupIndex = groups.push([]) - 1; groupIndexMap[group] = groupIndex; diff --git a/packages/wxt/src/core/utils/building/internal-build.ts b/packages/wxt/src/core/utils/building/internal-build.ts index da551d8af..48d581b22 100644 --- a/packages/wxt/src/core/utils/building/internal-build.ts +++ b/packages/wxt/src/core/utils/building/internal-build.ts @@ -3,10 +3,10 @@ import { BuildOutput, Entrypoint } from '../../../types'; import pc from 'picocolors'; import fs from 'fs-extra'; import { groupEntrypoints } from './group-entrypoints'; -import { formatDuration } from '../../utils/time'; -import { printBuildSummary } from '../../utils/log'; +import { formatDuration } from '../time'; +import { printBuildSummary } from '../log'; import glob from 'fast-glob'; -import { unnormalizePath } from '../../utils/paths'; +import { unnormalizePath } from '../paths'; import { rebuild } from './rebuild'; import { relative } from 'node:path'; import { @@ -32,13 +32,15 @@ import { isCI } from 'ci-info'; export async function internalBuild(): Promise { await wxt.hooks.callHook('build:before', wxt); - const verb = wxt.config.command === 'serve' ? 'Pre-rendering' : 'Building'; - const target = `${wxt.config.browser}-mv${wxt.config.manifestVersion}`; + const VERB = wxt.config.command === 'serve' ? 'Pre-rendering' : 'Building'; + const TARGET = `${wxt.config.browser}-mv${wxt.config.manifestVersion}`; + wxt.logger.info( - `${verb} ${pc.cyan(target)} for ${pc.cyan(wxt.config.mode)} with ${pc.green( + `${VERB} ${pc.cyan(TARGET)} for ${pc.cyan(wxt.config.mode)} with ${pc.green( `${wxt.builder.name} ${wxt.builder.version}`, )}`, ); + const startTime = Date.now(); // Cleanup @@ -49,10 +51,10 @@ export async function internalBuild(): Promise { wxt.logger.debug('Detected entrypoints:', entrypoints); const validationResults = validateEntrypoints(entrypoints); + if (validationResults.errorCount + validationResults.warningCount > 0) { printValidationResults(validationResults); - } - if (validationResults.errorCount > 0) { + } else if (validationResults.errorCount > 0) { throw new ValidationError(`Entrypoint validation failed`, { cause: validationResults, }); @@ -77,17 +79,21 @@ export async function internalBuild(): Promise { if (wxt.config.analysis.enabled) { await combineAnalysisStats(); + const statsPath = relative(wxt.config.root, wxt.config.analysis.outputFile); + wxt.logger.info( `Analysis complete:\n ${pc.gray('└─')} ${pc.yellow(statsPath)}`, ); + if (wxt.config.analysis.open) { if (isCI) { wxt.logger.debug(`Skipped opening ${pc.yellow(statsPath)} in CI`); } else { wxt.logger.info(`Opening ${pc.yellow(statsPath)} in browser...`); const { default: open } = await import('open'); - open(wxt.config.analysis.outputFile); + + await open(wxt.config.analysis.outputFile); } } } @@ -133,12 +139,13 @@ function printValidationResults({ }, new Map()); Array.from(entrypointErrors.entries()).forEach(([entrypoint, errors]) => { - wxt.logger.log(relative(cwd, entrypoint.inputPath)); - console.log(); + wxt.logger.log(relative(cwd, entrypoint.inputPath) + '\n'); + errors.forEach((err) => { const type = err.type === 'error' ? pc.red('ERROR') : pc.yellow('WARN'); - const recieved = pc.dim(`(recieved: ${JSON.stringify(err.value)})`); - wxt.logger.log(` - ${type} ${err.message} ${recieved}`); + const received = pc.dim(`(received: ${JSON.stringify(err.value)})`); + + wxt.logger.log(` - ${type} ${err.message} ${received}`); }); console.log(); }); diff --git a/packages/wxt/src/core/utils/fs.ts b/packages/wxt/src/core/utils/fs.ts index ff9cdfdc5..3d3f28878 100644 --- a/packages/wxt/src/core/utils/fs.ts +++ b/packages/wxt/src/core/utils/fs.ts @@ -28,7 +28,7 @@ export async function writeFileIfDifferent( * `config.publicDir`. */ export async function getPublicFiles(): Promise { - if (!(await fs.exists(wxt.config.publicDir))) return []; + if (!(await fs.pathExists(wxt.config.publicDir))) return []; const files = await glob('**/*', { cwd: wxt.config.publicDir }); return files.map(unnormalizePath); diff --git a/packages/wxt/src/core/utils/log/printHeader.ts b/packages/wxt/src/core/utils/log/printHeader.ts index 5d7dbd7c6..25307f73f 100644 --- a/packages/wxt/src/core/utils/log/printHeader.ts +++ b/packages/wxt/src/core/utils/log/printHeader.ts @@ -1,8 +1,7 @@ import pc from 'picocolors'; -import { version } from '../../../version'; +import { VERSION } from '../../../version'; import { consola } from 'consola'; export function printHeader() { - console.log(); - consola.log(`${pc.gray('WXT')} ${pc.gray(pc.bold(version))}`); + consola.log(`\n${pc.gray('WXT')} ${pc.gray(pc.bold(VERSION))}`); } diff --git a/packages/wxt/src/core/utils/manifest.ts b/packages/wxt/src/core/utils/manifest.ts index 3496ac13b..f7aa5991a 100644 --- a/packages/wxt/src/core/utils/manifest.ts +++ b/packages/wxt/src/core/utils/manifest.ts @@ -60,12 +60,14 @@ export async function generateManifest( wxt.config.manifest.version_name ?? wxt.config.manifest.version ?? pkg?.version; - if (versionName == null) { + + if (!versionName) { versionName = '0.0.0'; wxt.logger.warn( 'Extension version not found, defaulting to "0.0.0". Add a version to your `package.json` or `wxt.config.ts` file. For more details, see: https://wxt.dev/guide/key-concepts/manifest.html#version-and-version-name', ); } + const version = wxt.config.manifest.version ?? simplifyVersion(versionName); const baseManifest: Browser.runtime.Manifest = { @@ -76,7 +78,9 @@ export async function generateManifest( short_name: pkg?.shortName, icons: discoverIcons(buildOutput), }; + const userManifest = wxt.config.manifest; + if (userManifest.manifest_version) { delete userManifest.manifest_version; wxt.logger.warn( @@ -142,6 +146,7 @@ export async function generateManifest( throw Error( "Manifest 'name' is missing. Either:\n1. Set the name in your /package.json\n2. Set a name via the manifest option in your wxt.config.ts", ); + if (manifest.version == null) { throw Error( "Manifest 'version' is missing. Either:\n1. Add a version in your /package.json\n2. Pass the version via the manifest option in your wxt.config.ts", @@ -274,13 +279,17 @@ function addEntrypoints( '.html', ); const options: Browser.runtime.ManifestAction = {}; - if (popup.options.defaultIcon) + + if (popup.options.defaultIcon) { options.default_icon = popup.options.defaultIcon; - if (popup.options.defaultTitle) + } + if (popup.options.defaultTitle) { options.default_title = popup.options.defaultTitle; - if (popup.options.browserStyle) + } + if (popup.options.browserStyle) { // @ts-expect-error: Not typed by @wxt-dev/browser, but supported by Firefox options.browser_style = popup.options.browserStyle; + } if (manifest.manifest_version === 3) { manifest.action = { ...manifest.action, @@ -397,6 +406,7 @@ function addEntrypoints( getContentScriptCssFiles(scripts, cssMap), ), ); + if (manifestContentScripts.length >= 0) { manifest.content_scripts ??= []; manifest.content_scripts.push(...manifestContentScripts); @@ -459,19 +469,21 @@ function discoverIcons( } function addDevModeCsp(manifest: Browser.runtime.Manifest): void { - let permissonUrl = wxt.server?.origin; - if (permissonUrl) { - const permissionUrlInstance = new URL(permissonUrl); + let permissionUrl = wxt.server?.origin; + + if (permissionUrl) { + const permissionUrlInstance = new URL(permissionUrl); permissionUrlInstance.port = ''; - permissonUrl = permissionUrlInstance.toString(); + permissionUrl = permissionUrlInstance.toString(); } - const permission = `${permissonUrl}*`; + + const PERMISSION = `${permissionUrl}*`; const allowedCsp = wxt.server?.origin ?? 'http://localhost:*'; if (manifest.manifest_version === 3) { - addHostPermission(manifest, permission); + addHostPermission(manifest, PERMISSION); } else { - addPermission(manifest, permission); + addPermission(manifest, PERMISSION); } const extensionPagesCsp = new ContentSecurityPolicy( @@ -669,7 +681,7 @@ function convertCspToMv2(manifest: Browser.runtime.Manifest): void { } /** - * Make sure all resources are in MV3 format. If not, add a wanring + * Make sure all resources are in MV3 format. If not, add a warning */ function validateMv3WebAccessibleResources( manifest: Browser.runtime.Manifest, @@ -693,8 +705,10 @@ function validateMv3WebAccessibleResources( */ function stripKeys(manifest: Browser.runtime.Manifest): void { let keysToRemove: string[] = []; + if (wxt.config.manifestVersion === 2) { keysToRemove.push(...mv3OnlyKeys); + if (wxt.config.browser === 'firefox') keysToRemove.push(...firefoxMv3OnlyKeys); } else { diff --git a/packages/wxt/src/core/utils/minimatch-multiple.ts b/packages/wxt/src/core/utils/minimatch-multiple.ts index be1bbf0e8..7bae9e021 100644 --- a/packages/wxt/src/core/utils/minimatch-multiple.ts +++ b/packages/wxt/src/core/utils/minimatch-multiple.ts @@ -22,6 +22,7 @@ export function minimatchMultiple( const negatePatterns: string[] = []; const positivePatterns: string[] = []; + for (const pattern of patterns) { if (pattern[0] === '!') negatePatterns.push(pattern.slice(1)); else positivePatterns.push(pattern); diff --git a/packages/wxt/src/core/utils/network.ts b/packages/wxt/src/core/utils/network.ts index 57a7918b4..e73f55149 100644 --- a/packages/wxt/src/core/utils/network.ts +++ b/packages/wxt/src/core/utils/network.ts @@ -2,7 +2,7 @@ import dns from 'node:dns'; import { ResolvedConfig } from '../../types'; import { withTimeout } from './time'; -function isOffline(): Promise { +async function isOffline(): Promise { const isOffline = new Promise((res) => { dns.resolve('google.com', (err) => { if (err == null) { @@ -32,6 +32,7 @@ export async function fetchCached( if (await isOnline()) { const res = await fetch(url); + if (res.status < 300) { content = await res.text(); await config.fsCache.set(url, content); diff --git a/packages/wxt/src/core/utils/syntax-errors.ts b/packages/wxt/src/core/utils/syntax-errors.ts index d0aef21a9..9ee863230 100644 --- a/packages/wxt/src/core/utils/syntax-errors.ts +++ b/packages/wxt/src/core/utils/syntax-errors.ts @@ -21,10 +21,12 @@ export function logBabelSyntaxError(error: BabelSyntaxError) { if (filename.startsWith('..')) { filename = error.id; } + let message = error.message.replace( /\(\d+:\d+\)$/, `(${filename}:${error.loc.line}:${error.loc.column + 1})`, ); + if (error.frame) { message += '\n\n' + pc.red(error.frame); } diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 923113d3f..f4edbf89f 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -25,7 +25,7 @@ import { } from '../../../types'; import { mock } from 'vitest-mock-extended'; import { vi } from 'vitest'; -import { setWxtForTesting } from '../../../core/wxt'; +import { setWxtForTesting } from '../../wxt'; import type { Browser } from '@wxt-dev/browser'; faker.seed(import.meta.test.SEED); @@ -204,10 +204,6 @@ export const fakeOutputAsset = fakeObjectCreator(() => ({ fileName: fakeFileName(), })); -export function fakeOutputFile(): OutputFile { - return faker.helpers.arrayElement([fakeOutputAsset(), fakeOutputChunk()]); -} - export const fakeManifest = fakeObjectCreator(() => ({ manifest_version: faker.helpers.arrayElement([2, 3]), name: faker.string.alphanumeric(), diff --git a/packages/wxt/src/core/utils/transform.ts b/packages/wxt/src/core/utils/transform.ts index e8593c346..23ad194f2 100644 --- a/packages/wxt/src/core/utils/transform.ts +++ b/packages/wxt/src/core/utils/transform.ts @@ -15,13 +15,14 @@ export function removeMainFunctionCode(code: string): { emptyMainFunction(mod); let removedCount = 0; let depth = 0; - const maxDepth = 10; + const MAX_DEPTH = 10; + do { removedCount = 0; removedCount += removeUnusedTopLevelVariables(mod); removedCount += removeUnusedTopLevelFunctions(mod); removedCount += removeUnusedImports(mod); - } while (removedCount > 0 && depth++ <= maxDepth); + } while (removedCount > 0 && depth++ <= MAX_DEPTH); removeSideEffectImports(mod); return mod.generate(); } @@ -57,8 +58,10 @@ function removeUnusedTopLevelVariables(mod: ProxifiedModule): number { const cleanArrayPattern = (pattern: any): boolean => { const elements = pattern.elements; + for (let i = elements.length - 1; i >= 0; i--) { const el = elements[i]; + if (el?.type === 'Identifier' && !isUsed(el)) { elements.splice(i, 1); deletedCount++; @@ -69,6 +72,7 @@ function removeUnusedTopLevelVariables(mod: ProxifiedModule): number { const cleanObjectPattern = (pattern: any): boolean => { const properties = pattern.properties; + for (let i = properties.length - 1; i >= 0; i--) { const prop = properties[i]; @@ -82,6 +86,7 @@ function removeUnusedTopLevelVariables(mod: ProxifiedModule): number { } } else if (value.type === 'ArrayPattern') { const isEmpty = cleanArrayPattern(value); + if (isEmpty) { properties.splice(i, 1); } @@ -91,6 +96,7 @@ function removeUnusedTopLevelVariables(mod: ProxifiedModule): number { } } else if (prop.type === 'RestElement') { const arg = prop.argument; + if (arg.type === 'Identifier' && !isUsed(arg)) { properties.splice(i, 1); deletedCount++; @@ -136,6 +142,7 @@ function removeUnusedTopLevelFunctions(mod: ProxifiedModule): number { let deletedCount = 0; const ast = mod.$ast as any; + for (let i = ast.body.length - 1; i >= 0; i--) { if ( ast.body[i].type === 'FunctionDeclaration' && @@ -145,6 +152,7 @@ function removeUnusedTopLevelFunctions(mod: ProxifiedModule): number { deletedCount++; } } + return deletedCount; } diff --git a/packages/wxt/src/core/utils/validation.ts b/packages/wxt/src/core/utils/validation.ts index 331fe80a6..41433e341 100644 --- a/packages/wxt/src/core/utils/validation.ts +++ b/packages/wxt/src/core/utils/validation.ts @@ -14,6 +14,7 @@ export function validateEntrypoints( let errorCount = 0; let warningCount = 0; + for (const err of errors) { if (err.type === 'warning') warningCount++; else errorCount++; @@ -30,6 +31,7 @@ function validateContentScriptEntrypoint( definition: ContentScriptEntrypoint, ): ValidationResult[] { const errors = validateBaseEntrypoint(definition); + if ( definition.options.registration !== 'runtime' && definition.options.matches == null diff --git a/packages/wxt/src/core/wxt.ts b/packages/wxt/src/core/wxt.ts index eafc9429d..bbff462bd 100644 --- a/packages/wxt/src/core/wxt.ts +++ b/packages/wxt/src/core/wxt.ts @@ -85,6 +85,7 @@ export async function initWxtModules() { async function initWxtModule(module: WxtModule): Promise { if (module.hooks) wxt.hooks.addHooks(module.hooks); + await module.setup?.( wxt, // @ts-expect-error: Untyped configKey field diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 70626d6ae..12dfb84bd 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -4,7 +4,7 @@ import fs from 'fs-extra'; import { safeFilename } from './utils/strings'; import { getPackageJson } from './utils/package'; import { formatDuration } from './utils/time'; -import { printFileList } from './utils/log/printFileList'; +import { printFileList } from './utils/log'; import { findEntrypoints, internalBuild } from './utils/building'; import { registerWxt, wxt } from './wxt'; import JSZip from 'jszip'; @@ -30,15 +30,16 @@ export async function zip(config?: InlineConfig): Promise { const projectName = wxt.config.zip.name ?? safeFilename(packageJson?.name || path.basename(process.cwd())); + const applyTemplate = (template: string): string => template .replaceAll('{{name}}', projectName) .replaceAll('{{browser}}', wxt.config.browser) .replaceAll( '{{version}}', - output.manifest.version_name ?? output.manifest.version, + String(output.manifest.version_name ?? output.manifest.version), ) - .replaceAll('{{packageVersion}}', packageJson?.version) + .replaceAll('{{packageVersion}}', String(packageJson?.version)) .replaceAll('{{mode}}', wxt.config.mode) .replaceAll('{{manifestVersion}}', `mv${wxt.config.manifestVersion}`); @@ -46,8 +47,10 @@ export async function zip(config?: InlineConfig): Promise { // ZIP output directory await wxt.hooks.callHook('zip:extension:start', wxt); + const outZipFilename = applyTemplate(wxt.config.zip.artifactTemplate); const outZipPath = path.resolve(wxt.config.outBaseDir, outZipFilename); + await zipDir(wxt.config.outDir, outZipPath, { exclude: wxt.config.zip.exclude, }); @@ -57,20 +60,25 @@ export async function zip(config?: InlineConfig): Promise { if (wxt.config.zip.zipSources) { const entrypoints = await findEntrypoints(); const skippedEntrypoints = entrypoints.filter((entry) => entry.skipped); + const excludeSources = [ ...wxt.config.zip.excludeSources, ...skippedEntrypoints.map((entry) => path.relative(wxt.config.zip.sourcesRoot, entry.inputPath), ), ].map((paths) => paths.replaceAll('\\', '/')); + await wxt.hooks.callHook('zip:sources:start', wxt); + const { overrides, files: downloadedPackages } = await downloadPrivatePackages(); + const sourcesZipFilename = applyTemplate(wxt.config.zip.sourcesTemplate); const sourcesZipPath = path.resolve( wxt.config.outBaseDir, sourcesZipFilename, ); + await zipDir(wxt.config.zip.sourcesRoot, sourcesZipPath, { include: wxt.config.zip.includeSources, exclude: excludeSources, @@ -81,6 +89,7 @@ export async function zip(config?: InlineConfig): Promise { }, additionalFiles: downloadedPackages, }); + zipFiles.push(sourcesZipPath); await wxt.hooks.callHook('zip:sources:done', wxt, sourcesZipPath); } @@ -132,8 +141,10 @@ async function zipDir( path.relative(directory, file), ), ]; + for (const file of filesToZip) { const absolutePath = path.resolve(directory, file); + if (file.endsWith('.json')) { const content = await fs.readFile(absolutePath, 'utf-8'); archive.file( @@ -145,6 +156,7 @@ async function zipDir( archive.file(file, content); } } + await options?.additionalWork?.(archive); await new Promise((resolve, reject) => @@ -180,13 +192,13 @@ async function downloadPrivatePackages() { for (const pkg of downloadPackages) { wxt.logger.info(`Downloading package: ${pkg.name}@${pkg.version}`); - const id = `${pkg.name}@${pkg.version}`; + const ID = `${pkg.name}@${pkg.version}`; const tgzPath = await wxt.pm.downloadDependency( - id, + ID, wxt.config.zip.downloadedPackagesDir, ); files.push(tgzPath); - overrides[id] = tgzPath; + overrides[ID] = tgzPath; } } @@ -206,9 +218,11 @@ function addOverridesToPackageJson( ...oldPackage, [wxt.pm.overridesKey]: { ...oldPackage[wxt.pm.overridesKey] }, }; + Object.entries(overrides).forEach(([key, absolutePath]) => { newPackage[wxt.pm.overridesKey][key] = 'file://./' + normalizePath(path.relative(packageJsonDir, absolutePath)); }); + return JSON.stringify(newPackage, null, 2); } diff --git a/packages/wxt/src/modules.ts b/packages/wxt/src/modules.ts index 2cb12a071..3dee2b0fa 100644 --- a/packages/wxt/src/modules.ts +++ b/packages/wxt/src/modules.ts @@ -74,10 +74,12 @@ export function addEntrypoint(wxt: Wxt, entrypoint: Entrypoint): void { export function addPublicAssets(wxt: Wxt, dir: string): void { wxt.hooks.hook('build:publicAssets', async (wxt, files) => { const moreFiles = await glob('**/*', { cwd: dir }); + if (moreFiles.length === 0) { wxt.logger.warn('No files to copy in', dir); return; } + moreFiles.forEach((file) => { files.unshift({ absoluteSrc: resolve(dir, file), relativeDest: file }); }); @@ -108,9 +110,11 @@ export function addViteConfig( ): void { wxt.hooks.hook('config:resolved', (wxt) => { const userVite = wxt.config.vite; + wxt.config.vite = async (env) => { const fromUser = await userVite(env); const fromModule = viteConfig(env) ?? {}; + return vite.mergeConfig(fromModule, fromUser); }; }); @@ -167,17 +171,19 @@ export function addImportPreset( preset: UnimportOptions['presets'][0], ): void { wxt.hooks.hook('config:resolved', (wxt) => { + // TODO: BUT IN NEWER VERSIONS OF WXT, THIS SHOULD NEVER HAPPENED, YEAH? // In older versions of WXT, `wxt.config.imports` could be false if (!wxt.config.imports) return; wxt.config.imports.presets ??= []; - // De-dupelicate built-in named presets + // De-duplicate built-in named presets if (wxt.config.imports.presets.includes(preset)) return; wxt.config.imports.presets.push(preset); }); } +// TODO: IT SEEMS UNUSED, IT'S AVAILABLE VIA API TO DEVS? /** * Adds an import alias to the project's TSConfig paths and bundler. Path can * be absolute or relative to the project's root directory. @@ -208,12 +214,14 @@ export function addImportPreset( export function addAlias(wxt: Wxt, alias: string, path: string) { wxt.hooks.hook('config:resolved', (wxt) => { const target = resolve(wxt.config.root, path); + if (wxt.config.alias[alias] != null && wxt.config.alias[alias] !== target) { wxt.logger.warn( `Skipped adding alias (${alias} => ${target}) because an alias with the same name already exists: ${alias} => ${wxt.config.alias[alias]}`, ); return; } + wxt.config.alias[alias] = target; }); } diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 284af1d78..58007947a 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -21,7 +21,7 @@ export interface InlineConfig { * Directory containing all source code. Set to `"src"` to move all source code to a `src/` * directory. * - * After changing, don't forget to move the `public/` and `entrypoints/` directories into the new + * After changing, remember to move the `public/` and `entrypoints/` directories into the new * source dir. * * @default config.root @@ -374,6 +374,10 @@ export interface InlineConfig { * "wxt-module-analytics"). */ modules?: string[]; + /** + * Field only for testing purposes, don't use it for other cases + */ + example?: Record; } // TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field @@ -1403,7 +1407,7 @@ export interface FsCache { export interface ExtensionRunner { openBrowser(): Promise; - closeBrowser(): Promise; + closeBrowser?(): Promise; /** Whether or not this runner actually opens the browser. */ canOpen?(): boolean; } diff --git a/packages/wxt/src/utils/__tests__/content-script-context.test.ts b/packages/wxt/src/utils/__tests__/content-script-context.test.ts index f7a5ffc86..b724a0fb4 100644 --- a/packages/wxt/src/utils/__tests__/content-script-context.test.ts +++ b/packages/wxt/src/utils/__tests__/content-script-context.test.ts @@ -24,8 +24,9 @@ describe('Content Script Context', () => { const onInvalidated = vi.fn(); ctx.onInvalidated(onInvalidated); - // @ts-ignore + // @ts-expect-error -- deleting runtime.id to simulate disconnection delete fakeBrowser.runtime.id; + const isValid = ctx.isValid; expect(onInvalidated).toBeCalled(); @@ -33,16 +34,18 @@ describe('Content Script Context', () => { }); it('should invalidate the current content script when a new context is created', async () => { - const name = 'test'; + const NAME = 'test'; + const onInvalidated = vi.fn(); - const ctx = new ContentScriptContext(name); + const ctx = new ContentScriptContext(NAME); + ctx.onInvalidated(onInvalidated); - // Wait for events to run before next tick next tick + // Wait for events to run before next tick await waitForEventsToFire(); // Create a new context after first is initialized, and wait for it to initialize - new ContentScriptContext(name); + new ContentScriptContext(NAME); await waitForEventsToFire(); expect(onInvalidated).toBeCalled(); @@ -52,9 +55,10 @@ describe('Content Script Context', () => { it('should not invalidate the current content script when a new context is created with a different name', async () => { const onInvalidated = vi.fn(); const ctx = new ContentScriptContext('test1'); + ctx.onInvalidated(onInvalidated); - // Wait for events to run before next tick next tick + // Wait for events to run before next tick await waitForEventsToFire(); // Create a new context after first is initialized, and wait for it to initialize @@ -67,10 +71,12 @@ describe('Content Script Context', () => { describe('addEventListener', () => { const context = new ContentScriptContext('test'); + it('should infer types correctly for the window target', () => { context.addEventListener(window, 'DOMContentLoaded', (_) => {}); context.addEventListener(window, 'orientationchange', (_) => {}); context.addEventListener(window, 'wxt:locationchange', (_) => {}); + // TODO: IT SEEMS IT ISN'T IN 'WINDOW`, MISSING TYPE OR IT SHOULDN'T BE HERE? // @ts-expect-error context.addEventListener(window, 'visibilitychange', (_) => {}); }); @@ -82,6 +88,7 @@ describe('Content Script Context', () => { it('should infer types correctly for HTML element targets', () => { const button = document.createElement('button'); + context.addEventListener(button, 'click', (_) => {}); context.addEventListener(button, 'mouseover', (_) => {}); }); diff --git a/packages/wxt/src/utils/__tests__/split-shadow-root-css.test.ts b/packages/wxt/src/utils/__tests__/split-shadow-root-css.test.ts index b1e5a3c56..84b90434b 100644 --- a/packages/wxt/src/utils/__tests__/split-shadow-root-css.test.ts +++ b/packages/wxt/src/utils/__tests__/split-shadow-root-css.test.ts @@ -4,15 +4,15 @@ import { splitShadowRootCss } from '../split-shadow-root-css'; describe('Shadow Root Utils', () => { describe('splitShadowRootCss', () => { it('should extract @property and @font-face declarations from minified tailwindcss v4', () => { - const css = `/*! tailwindcss v4.0.13 | MIT License | https://tailwindcss.com */@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--spacing:.25rem;--container-3xl:48rem;--container-5xl:64rem;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--radius-xl:.75rem;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings);--color-primary:#ffc700;--color-primary-content:#000;--color-secondary:#c00;--color-secondary-content:#fff;--color-base:#000;--color-base-content:#fff;--color-neutral:#1c1c1c;--color-neutral-content:#fff;--color-white:#fff;--color-black:#000;--font-poppins:Poppins,sans-serif;--spacing-main-navigation:calc(20*var(--spacing))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{min-width:0;min-height:0}body{background-color:var(--color-base);color:var(--color-base-content)}}@layer components{.btn{--btn-bg:var(--color-primary);--btn-text:var(--color-primary-content);background-color:var(--btn-bg);color:var(--btn-text);border-radius:calc(2*var(--spacing));font-weight:500;font-family:var(--font-poppins);padding:calc(3*var(--spacing))calc(6*var(--spacing));justify-content:center;align-items:center;gap:calc(3*var(--spacing));cursor:pointer;transition:transform .2s;display:flex}.btn:hover{transform:scale(1.05)}.btn:active{transform:scale(.97)}.btn-neutral{--btn-bg:var(--color-neutral);--btn-text:var(--color-neutral-content)}.btn-base{--btn-bg:var(--color-base);--btn-text:var(--color-base-content)}.btn-white{--btn-bg:var(--color-white);--btn-text:var(--color-black)}.btn-secondary{--btn-bg:var(--color-secondary);--btn-text:var(--color-secondary-content)}.btn-square{padding:calc(3*var(--spacing))}.nav-link{font-family:var(--font-poppins);font-size:var(--text-xl);transition:color .2s;position:relative}.nav-link:hover,.nav-link.active{color:var(--color-primary)}.nav-link.active:after{content:"";bottom:calc(-1*var(--spacing));background-color:var(--color-primary);width:100%;height:2px;position:absolute;left:0}.link{color:color-mix(in srgb,var(--color-secondary),white 30%);font-weight:500;text-decoration:underline}.link-white{color:var(--color-white)}}@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.z-0{z-index:0}.z-1{z-index:1}.z-10{z-index:10}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-main-navigation{margin-top:var(--spacing-main-navigation)}.-mr-2{margin-right:calc(var(--spacing)*-2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.-ml-2{margin-left:calc(var(--spacing)*-2)}.i-heroicons-bars-3{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-calendar-days{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12zM12 15h.008v.008H12zm0 2.25h.008v.008H12zM9.75 15h.008v.008H9.75zm0 2.25h.008v.008H9.75zM7.5 15h.008v.008H7.5zm0 2.25h.008v.008H7.5zm6.75-4.5h.008v.008h-.008zm0 2.25h.008v.008h-.008zm0 2.25h.008v.008h-.008zm2.25-4.5h.008v.008H16.5zm0 2.25h.008v.008H16.5z'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-chevron-right{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m8.25 4.5l7.5 7.5l-7.5 7.5'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-x-mark{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 18L18 6M6 6l12 12'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.flex{display:flex}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.h-12{height:calc(var(--spacing)*12)}.h-20{height:calc(var(--spacing)*20)}.h-\\[70vh\\]{height:70vh}.h-\\[95vh\\]{height:95vh}.h-\\[100vh\\]{height:100vh}.h-full{height:100%}.h-main-navigation{height:var(--spacing-main-navigation)}.w-24{width:calc(var(--spacing)*24)}.w-40{width:calc(var(--spacing)*40)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x)var(--tw-rotate-y)var(--tw-rotate-z)var(--tw-skew-x)var(--tw-skew-y)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-items-center{justify-items:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-4{gap:calc(var(--spacing)*4)}.gap-8{gap:calc(var(--spacing)*8)}.gap-16{gap:calc(var(--spacing)*16)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-base>:not(:last-child)){border-color:var(--color-base)}.overflow-hidden{overflow:hidden}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-white\\/50{border-color:color-mix(in oklab,var(--color-white)50%,transparent)}.bg-base\\/0{background-color:color-mix(in oklab,var(--color-base)0%,transparent)}.bg-base\\/100{background-color:color-mix(in oklab,var(--color-base)100%,transparent)}.bg-neutral{background-color:var(--color-neutral)}.object-cover{object-fit:cover}.object-center{object-position:center}.p-8{padding:calc(var(--spacing)*8)}.p-16{padding:calc(var(--spacing)*16)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-20{padding-block:calc(var(--spacing)*20)}.pt-main-navigation{padding-top:var(--spacing-main-navigation)}.text-center{text-align:center}.font-poppins{font-family:var(--font-poppins)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.leading-\\[3\\]{--tw-leading:3;line-height:3}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-secondary)}.text-white{color:var(--color-white)}.opacity-0{opacity:0}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-primary\\/50{--tw-shadow-color:color-mix(in oklab,var(--color-primary)50%,transparent)}.ring-primary\\/30{--tw-ring-color:color-mix(in oklab,var(--color-primary)30%,transparent)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\\:text-primary:hover{color:var(--color-primary)}}@media (width>=48rem){.md\\:mx-16{margin-inline:calc(var(--spacing)*16)}.md\\:block{display:block}.md\\:flex{display:flex}.md\\:hidden{display:none}.md\\:aspect-square{aspect-ratio:1}.md\\:h-56{height:calc(var(--spacing)*56)}.md\\:h-full{height:100%}.md\\:w-\\[unset\\]{width:unset}.md\\:flex-1{flex:1}.md\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\\:flex-row-reverse{flex-direction:row-reverse}.md\\:items-start{align-items:flex-start}.md\\:justify-start{justify-content:flex-start}.md\\:justify-items-start{justify-items:start}.md\\:gap-12{gap:calc(var(--spacing)*12)}.md\\:px-16{padding-inline:calc(var(--spacing)*16)}.md\\:pr-\\[30vw\\]{padding-right:30vw}.md\\:text-left{text-align:left}}@media (width>=64rem){.lg\\:aspect-\\[4\\/3\\]{aspect-ratio:4/3}.lg\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false;initial-value:rotateX(0)}@property --tw-rotate-y{syntax:"*";inherits:false;initial-value:rotateY(0)}@property --tw-rotate-z{syntax:"*";inherits:false;initial-value:rotateZ(0)}@property --tw-skew-x{syntax:"*";inherits:false;initial-value:skewX(0)}@property --tw-skew-y{syntax:"*";inherits:false;initial-value:skewY(0)}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@font-face{font-family: "custom-font";font-display: swap;font-weight: 500;src: url("fonts/custom-font.otf") format("opentype");}`; + const CSS = `/*! tailwindcss v4.0.13 | MIT License | https://tailwindcss.com */@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--spacing:.25rem;--container-3xl:48rem;--container-5xl:64rem;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--radius-xl:.75rem;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings);--color-primary:#ffc700;--color-primary-content:#000;--color-secondary:#c00;--color-secondary-content:#fff;--color-base:#000;--color-base-content:#fff;--color-neutral:#1c1c1c;--color-neutral-content:#fff;--color-white:#fff;--color-black:#000;--font-poppins:Poppins,sans-serif;--spacing-main-navigation:calc(20*var(--spacing))}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{min-width:0;min-height:0}body{background-color:var(--color-base);color:var(--color-base-content)}}@layer components{.btn{--btn-bg:var(--color-primary);--btn-text:var(--color-primary-content);background-color:var(--btn-bg);color:var(--btn-text);border-radius:calc(2*var(--spacing));font-weight:500;font-family:var(--font-poppins);padding:calc(3*var(--spacing))calc(6*var(--spacing));justify-content:center;align-items:center;gap:calc(3*var(--spacing));cursor:pointer;transition:transform .2s;display:flex}.btn:hover{transform:scale(1.05)}.btn:active{transform:scale(.97)}.btn-neutral{--btn-bg:var(--color-neutral);--btn-text:var(--color-neutral-content)}.btn-base{--btn-bg:var(--color-base);--btn-text:var(--color-base-content)}.btn-white{--btn-bg:var(--color-white);--btn-text:var(--color-black)}.btn-secondary{--btn-bg:var(--color-secondary);--btn-text:var(--color-secondary-content)}.btn-square{padding:calc(3*var(--spacing))}.nav-link{font-family:var(--font-poppins);font-size:var(--text-xl);transition:color .2s;position:relative}.nav-link:hover,.nav-link.active{color:var(--color-primary)}.nav-link.active:after{content:"";bottom:calc(-1*var(--spacing));background-color:var(--color-primary);width:100%;height:2px;position:absolute;left:0}.link{color:color-mix(in srgb,var(--color-secondary),white 30%);font-weight:500;text-decoration:underline}.link-white{color:var(--color-white)}}@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.z-0{z-index:0}.z-1{z-index:1}.z-10{z-index:10}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-main-navigation{margin-top:var(--spacing-main-navigation)}.-mr-2{margin-right:calc(var(--spacing)*-2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.-ml-2{margin-left:calc(var(--spacing)*-2)}.i-heroicons-bars-3{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-calendar-days{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12zM12 15h.008v.008H12zm0 2.25h.008v.008H12zM9.75 15h.008v.008H9.75zm0 2.25h.008v.008H9.75zM7.5 15h.008v.008H7.5zm0 2.25h.008v.008H7.5zm6.75-4.5h.008v.008h-.008zm0 2.25h.008v.008h-.008zm0 2.25h.008v.008h-.008zm2.25-4.5h.008v.008H16.5zm0 2.25h.008v.008H16.5z'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-chevron-right{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m8.25 4.5l7.5 7.5l-7.5 7.5'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.i-heroicons-x-mark{width:1.5em;height:1.5em;-webkit-mask-image:var(--svg);mask-image:var(--svg);--svg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 18L18 6M6 6l12 12'/%3E%3C/svg%3E");background-color:currentColor;display:inline-block;-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.flex{display:flex}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.h-12{height:calc(var(--spacing)*12)}.h-20{height:calc(var(--spacing)*20)}.h-\\[70vh\\]{height:70vh}.h-\\[95vh\\]{height:95vh}.h-\\[100vh\\]{height:100vh}.h-full{height:100%}.h-main-navigation{height:var(--spacing-main-navigation)}.w-24{width:calc(var(--spacing)*24)}.w-40{width:calc(var(--spacing)*40)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x)var(--tw-rotate-y)var(--tw-rotate-z)var(--tw-skew-x)var(--tw-skew-y)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-items-center{justify-items:center}.gap-2{gap:calc(var(--spacing)*2)}.gap-4{gap:calc(var(--spacing)*4)}.gap-8{gap:calc(var(--spacing)*8)}.gap-16{gap:calc(var(--spacing)*16)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-base>:not(:last-child)){border-color:var(--color-base)}.overflow-hidden{overflow:hidden}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-white\\/50{border-color:color-mix(in oklab,var(--color-white)50%,transparent)}.bg-base\\/0{background-color:color-mix(in oklab,var(--color-base)0%,transparent)}.bg-base\\/100{background-color:color-mix(in oklab,var(--color-base)100%,transparent)}.bg-neutral{background-color:var(--color-neutral)}.object-cover{object-fit:cover}.object-center{object-position:center}.p-8{padding:calc(var(--spacing)*8)}.p-16{padding:calc(var(--spacing)*16)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-20{padding-block:calc(var(--spacing)*20)}.pt-main-navigation{padding-top:var(--spacing-main-navigation)}.text-center{text-align:center}.font-poppins{font-family:var(--font-poppins)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.leading-\\[3\\]{--tw-leading:3;line-height:3}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-secondary)}.text-white{color:var(--color-white)}.opacity-0{opacity:0}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-primary\\/50{--tw-shadow-color:color-mix(in oklab,var(--color-primary)50%,transparent)}.ring-primary\\/30{--tw-ring-color:color-mix(in oklab,var(--color-primary)30%,transparent)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\\:text-primary:hover{color:var(--color-primary)}}@media (width>=48rem){.md\\:mx-16{margin-inline:calc(var(--spacing)*16)}.md\\:block{display:block}.md\\:flex{display:flex}.md\\:hidden{display:none}.md\\:aspect-square{aspect-ratio:1}.md\\:h-56{height:calc(var(--spacing)*56)}.md\\:h-full{height:100%}.md\\:w-\\[unset\\]{width:unset}.md\\:flex-1{flex:1}.md\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\\:flex-row-reverse{flex-direction:row-reverse}.md\\:items-start{align-items:flex-start}.md\\:justify-start{justify-content:flex-start}.md\\:justify-items-start{justify-items:start}.md\\:gap-12{gap:calc(var(--spacing)*12)}.md\\:px-16{padding-inline:calc(var(--spacing)*16)}.md\\:pr-\\[30vw\\]{padding-right:30vw}.md\\:text-left{text-align:left}}@media (width>=64rem){.lg\\:aspect-\\[4\\/3\\]{aspect-ratio:4/3}.lg\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false;initial-value:rotateX(0)}@property --tw-rotate-y{syntax:"*";inherits:false;initial-value:rotateY(0)}@property --tw-rotate-z{syntax:"*";inherits:false;initial-value:rotateZ(0)}@property --tw-skew-x{syntax:"*";inherits:false;initial-value:skewX(0)}@property --tw-skew-y{syntax:"*";inherits:false;initial-value:skewY(0)}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@font-face{font-family: "custom-font";font-display: swap;font-weight: 500;src: url("fonts/custom-font.otf") format("opentype");}`; - const actual = splitShadowRootCss(css); + const actual = splitShadowRootCss(CSS); expect(actual).toMatchSnapshot(); }); it('should maintain the same rule ordering and spacing as the original', () => { - const css = ` + const CSS = ` .one { color: blue; } @@ -35,8 +35,9 @@ describe('Shadow Root Utils', () => { font-weight: 500; src: url("~~/assets/fonts/custom-font.otf") format("opentype"); } - `.trim(); - const expectedShadowCss = ` +`.trim(); + + const EXPECTED_SHADOW_CSS = ` .one { color: blue; } @@ -45,7 +46,8 @@ describe('Shadow Root Utils', () => { color: red; } `.trim(); - const expectedDocumentCss = ` + + const EXPECTED_DOCUMENT_CSS = ` @property --test-1 { initial-value: 0; } @@ -62,11 +64,11 @@ describe('Shadow Root Utils', () => { } `.trim(); - const actual = splitShadowRootCss(css); + const actual = splitShadowRootCss(CSS); expect(actual).toEqual({ - shadowCss: expectedShadowCss, - documentCss: expectedDocumentCss, + shadowCss: EXPECTED_SHADOW_CSS, + documentCss: EXPECTED_DOCUMENT_CSS, }); }); }); diff --git a/packages/wxt/src/utils/content-script-context.ts b/packages/wxt/src/utils/content-script-context.ts index fdcbe857d..7bcc9daa4 100644 --- a/packages/wxt/src/utils/content-script-context.ts +++ b/packages/wxt/src/utils/content-script-context.ts @@ -1,7 +1,7 @@ /** @module wxt/utils/content-script-context */ import { ContentScriptDefinition } from '../types'; import { browser } from 'wxt/browser'; -import { logger } from '../utils/internal/logger'; +import { logger } from './internal/logger'; import { WxtLocationChangeEvent, getUniqueEventName, @@ -74,6 +74,7 @@ export class ContentScriptContext implements AbortController { if (browser.runtime.id == null) { this.notifyInvalidated(); // Sets `signal.aborted` to true } + return this.signal.aborted; } @@ -96,6 +97,7 @@ export class ContentScriptContext implements AbortController { */ onInvalidated(cb: () => void): () => void { this.signal.addEventListener('abort', cb); + return () => this.signal.removeEventListener('abort', cb); } @@ -142,6 +144,7 @@ export class ContentScriptContext implements AbortController { return id; } + // TODO: IT SEEMS UNUSED, IT'S SHARED VIA API FOR DEVS? /** * Wrapper around `window.requestAnimationFrame` that automatically cancels the request when * invalidated. @@ -237,6 +240,7 @@ export class ContentScriptContext implements AbortController { */ notifyInvalidated() { this.abort('Content script context invalidated'); + logger.debug( `Content script "${this.contentScriptName}" context invalidated`, ); @@ -257,9 +261,12 @@ export class ContentScriptContext implements AbortController { verifyScriptStartedEvent(event: MessageEvent) { const isScriptStartedEvent = event.data?.type === ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE; + const isSameContentScript = event.data?.contentScriptName === this.contentScriptName; + const isNotDuplicate = !this.receivedMessageIds.has(event.data?.messageId); + return isScriptStartedEvent && isSameContentScript && isNotDuplicate; } @@ -272,6 +279,7 @@ export class ContentScriptContext implements AbortController { const wasFirst = isFirst; isFirst = false; + if (wasFirst && options?.ignoreFirstEvent) return; this.notifyInvalidated(); diff --git a/packages/wxt/src/utils/content-script-ui/__tests__/index.test.ts b/packages/wxt/src/utils/content-script-ui/__tests__/index.test.ts index 1e349ef86..b50c7460f 100644 --- a/packages/wxt/src/utils/content-script-ui/__tests__/index.test.ts +++ b/packages/wxt/src/utils/content-script-ui/__tests__/index.test.ts @@ -10,7 +10,7 @@ import { ContentScriptUi } from '../types'; * Util for floating promise. */ async function runMicrotasks() { - return await new Promise((resolve) => setTimeout(resolve, 0)); + return new Promise((resolve) => setTimeout(resolve, 0)); } function appendTestApp(container: HTMLElement) { @@ -25,14 +25,17 @@ function appendTestElement({ id: string; }) { const parent = document.querySelector('#parent'); + if (!parent) { throw Error( 'Parent element not found. Please check the testing environment DOM', ); } + const element = document.createElement(tagName); element.id = id; parent.append(element); + return element; } @@ -148,6 +151,7 @@ describe('Content Script UIs', () => { position: 'overlay', page: '/page.html', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -161,6 +165,7 @@ describe('Content Script UIs', () => { page: '/page.html', alignment: 'top-left', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -174,6 +179,7 @@ describe('Content Script UIs', () => { page: '/page.html', alignment: 'top-right', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -187,6 +193,7 @@ describe('Content Script UIs', () => { page: '/page.html', alignment: 'bottom-right', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -200,6 +207,7 @@ describe('Content Script UIs', () => { page: '/page.html', alignment: 'bottom-left', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -208,15 +216,17 @@ describe('Content Script UIs', () => { }); it('should respect the provided zIndex', () => { - const zIndex = 123; + const Z_INDEX = 123; + const ui = createIframeUi(ctx, { position: 'overlay', page: '/page.html', - zIndex, + zIndex: Z_INDEX, }); + ui.mount(); - expect(ui.wrapper.style.zIndex).toBe(String(zIndex)); + expect(ui.wrapper.style.zIndex).toBe(String(Z_INDEX)); }); }); @@ -226,6 +236,7 @@ describe('Content Script UIs', () => { position: 'modal', page: '/page.html', }); + ui.mount(); expect(ui.wrapper.outerHTML).toMatchInlineSnapshot( @@ -234,15 +245,17 @@ describe('Content Script UIs', () => { }); it('should respect the provided zIndex', () => { - const zIndex = 123; + const Z_INDEX = 123; + const ui = createIframeUi(ctx, { position: 'modal', page: '/page.html', - zIndex, + zIndex: Z_INDEX, }); + ui.mount(); - expect(ui.wrapper.style.zIndex).toBe(String(zIndex)); + expect(ui.wrapper.style.zIndex).toBe(String(Z_INDEX)); }); }); }); @@ -254,6 +267,7 @@ describe('Content Script UIs', () => { position: 'inline', onMount: appendTestApp, }); + ui.mount(); expect(document.body.children).toContain(ui.wrapper); @@ -267,6 +281,7 @@ describe('Content Script UIs', () => { onMount: appendTestApp, anchor: '#parent', }); + ui.mount(); expect(document.querySelector('#parent')!.children).toContain( @@ -285,6 +300,7 @@ describe('Content Script UIs', () => { onMount: appendTestApp, anchor: '//p[@id="three"]', }); + ui.mount(); expect(document.querySelector('#three')!.children[0]).toBe(ui.wrapper); @@ -298,6 +314,7 @@ describe('Content Script UIs', () => { onMount: appendTestApp, anchor: document.getElementById('parent'), }); + ui.mount(); expect(document.querySelector('#parent')!.children).toContain( @@ -313,6 +330,7 @@ describe('Content Script UIs', () => { onMount: appendTestApp, anchor: () => document.getElementById('parent'), }); + ui.mount(); expect(document.querySelector('#parent')!.children).toContain( @@ -341,6 +359,7 @@ describe('Content Script UIs', () => { append, onMount: appendTestApp, }); + ui.mount(); expect( @@ -357,6 +376,7 @@ describe('Content Script UIs', () => { append: 'first', onMount: appendTestApp, }); + ui.mount(); expect( @@ -373,6 +393,7 @@ describe('Content Script UIs', () => { append: 'replace', onMount: appendTestApp, }); + ui.mount(); expect(document.body.children).toContain(ui.wrapper); @@ -388,6 +409,7 @@ describe('Content Script UIs', () => { append: 'before', onMount: appendTestApp, }); + ui.mount(); expect(document.querySelector('#parent')!.firstElementChild).toBe( @@ -404,6 +426,7 @@ describe('Content Script UIs', () => { append: 'after', onMount: appendTestApp, }); + ui.mount(); expect(document.querySelector('#parent')!.lastElementChild).toBe( @@ -422,10 +445,10 @@ describe('Content Script UIs', () => { }, onMount: appendTestApp, }); + ui.mount(); expect(document.body.children).toContain(ui.wrapper); - expect(document.querySelector('#parent')).toBeNull(); }); }); @@ -434,16 +457,17 @@ describe('Content Script UIs', () => { describe('mounted value', () => { describe('integrated', () => { it('should set the mounted value based on the onMounted return value', () => { - const expected = Symbol(); + const EXPECTED = Symbol(); const ui = createIntegratedUi(new ContentScriptContext('test'), { position: 'inline', - onMount: () => expected, + onMount: () => EXPECTED, }); + expect(ui.mounted).toBeUndefined(); ui.mount(); - expect(ui.mounted).toBe(expected); + expect(ui.mounted).toBe(EXPECTED); ui.remove(); expect(ui.mounted).toBeUndefined(); @@ -452,17 +476,18 @@ describe('Content Script UIs', () => { describe('iframe', () => { it('should set the mounted value based on the onMounted return value', async () => { - const expected = Symbol(); + const EXPECTED = Symbol(); const ui = createIframeUi(new ContentScriptContext('test'), { page: '', position: 'inline', - onMount: () => expected, + onMount: () => EXPECTED, }); + expect(ui.mounted).toBeUndefined(); ui.mount(); - expect(ui.mounted).toBe(expected); + expect(ui.mounted).toBe(EXPECTED); ui.remove(); expect(ui.mounted).toBeUndefined(); @@ -471,17 +496,18 @@ describe('Content Script UIs', () => { describe('shadow-root', () => { it('should set the mounted value based on the onMounted return value', async () => { - const expected = Symbol(); + const EXPECTED = Symbol(); const ui = await createShadowRootUi(new ContentScriptContext('test'), { name: 'test-component', position: 'inline', - onMount: () => expected, + onMount: () => EXPECTED, }); + expect(ui.mounted).toBeUndefined(); ui.mount(); - expect(ui.mounted).toBe(expected); + expect(ui.mounted).toBe(EXPECTED); ui.remove(); expect(ui.mounted).toBeUndefined(); @@ -495,6 +521,7 @@ describe('Content Script UIs', () => { describe('auto mount', () => { const DYNAMIC_CHILD_ID = 'dynamic-child'; let ui: ContentScriptUi; + beforeEach(async () => { ui?.remove(); await runMicrotasks(); @@ -550,7 +577,10 @@ describe('Content Script UIs', () => { }); it('should mount when an anchor is dynamically added and unmount when an anchor is removed', async () => { + let dynamicEl; + const onRemove = vi.fn(); + ui = await createUiFunction(ctx, { position: 'inline', onMount, @@ -559,7 +589,7 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); - let dynamicEl; + ui.autoMount(); await runMicrotasks(); @@ -570,6 +600,7 @@ describe('Content Script UIs', () => { dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID }); await runMicrotasks(); + await expect .poll(() => document.querySelector(uiSelector)) .not.toBeNull(); @@ -584,7 +615,9 @@ describe('Content Script UIs', () => { describe('options', () => { it('should auto-mount only once mount and remove when the `once` option is true', async () => { + let dynamicEl; const onRemove = vi.fn(); + ui = await createUiFunction(ctx, { position: 'inline', onMount, @@ -593,32 +626,35 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); - let dynamicEl; + ui.autoMount({ once: true }); await runMicrotasks(); + await expect .poll(() => document.querySelector(uiSelector)) .toBeNull(); dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID }); + await runMicrotasks(); + await expect .poll(() => document.querySelector(uiSelector)) .not.toBeNull(); dynamicEl.remove(); await runMicrotasks(); + expect(onMount).toHaveBeenCalledTimes(1); expect(onRemove).toHaveBeenCalledTimes(1); - // re-append after once cycle - dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID }); await runMicrotasks(); // expect stop automount await expect .poll(() => document.querySelector(uiSelector)) .toBeNull(); + expect(onMount).toHaveBeenCalledTimes(1); expect(onRemove).toHaveBeenCalledTimes(1); }); @@ -633,6 +669,7 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); + expect(() => ui.autoMount()).toThrowError( 'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.', ); @@ -646,6 +683,7 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); + expect(() => ui.autoMount()).toThrowError( 'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.', ); @@ -654,7 +692,9 @@ describe('Content Script UIs', () => { describe('StopAutoMount', () => { it('should stop auto-mounting and remove ui when `ui.remove` is called', async () => { + let dynamicEl; const onRemove = vi.fn(); + ui = await createUiFunction(ctx, { position: 'inline', onMount, @@ -663,18 +703,20 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); - let dynamicEl; + ui.autoMount(); await runMicrotasks(); dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID }); await runMicrotasks(); + await expect .poll(() => document.querySelector(uiSelector)) .not.toBeNull(); dynamicEl.remove(); await runMicrotasks(); + expect(onMount).toHaveBeenCalledTimes(1); expect(onRemove).toHaveBeenCalledTimes(1); @@ -683,6 +725,7 @@ describe('Content Script UIs', () => { dynamicEl = appendTestElement({ id: DYNAMIC_CHILD_ID }); dynamicEl.remove(); await runMicrotasks(); + expect(onMount).toHaveBeenCalledTimes(1); expect(onRemove).toHaveBeenCalledTimes(2); }); @@ -690,6 +733,7 @@ describe('Content Script UIs', () => { it('should call internal StopAutoMount when `ui.remove` is called', async () => { const onRemove = vi.fn(); const onStop = vi.fn(); + ui = await createUiFunction(ctx, { position: 'inline', onMount, @@ -698,8 +742,10 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); + ui.autoMount({ onStop }); ui.remove(); + expect(onStop).toHaveBeenCalledTimes(1); expect(onRemove).toHaveBeenCalledTimes(1); }); @@ -712,7 +758,9 @@ describe('Content Script UIs', () => { page: name === 'iframe' ? '/page.html' : undefined, name: 'test-component', }); + const onStop = vi.fn(); + ui.autoMount({ onStop }); ui.autoMount({ onStop }); @@ -720,7 +768,6 @@ describe('Content Script UIs', () => { expect(onStop).toBeCalledTimes(1); ui.autoMount({ onStop }); - ui.remove(); expect(onStop).toBeCalledTimes(2); }); diff --git a/packages/wxt/src/utils/content-script-ui/iframe.ts b/packages/wxt/src/utils/content-script-ui/iframe.ts index aa968f9bd..447e69f28 100644 --- a/packages/wxt/src/utils/content-script-ui/iframe.ts +++ b/packages/wxt/src/utils/content-script-ui/iframe.ts @@ -15,17 +15,19 @@ export function createIframeUi( ): IframeContentScriptUi { const wrapper = document.createElement('div'); const iframe = document.createElement('iframe'); + // TODO: MAYBE REDEFINE IT OR IMPORT IN SOME WAY? // @ts-expect-error: getURL is defined per-project, but not inside the package iframe.src = browser.runtime.getURL(options.page); wrapper.appendChild(iframe); - let mounted: TMounted | undefined = undefined; + let mounted: TMounted | undefined; const mount = () => { applyPosition(wrapper, iframe, options); options.onBeforeMount?.(wrapper, iframe); mountUi(wrapper, options); mounted = options.onMount?.(wrapper, iframe); }; + const remove = () => { options.onRemove?.(mounted); wrapper.remove(); @@ -65,6 +67,7 @@ export type IframeContentScriptUiOptions = * The path to the HTML page that will be shown in the iframe. This string is passed into * `browser.runtime.getURL`. */ + // TODO: MAYBE REDEFINE IT OR IMPORT IN SOME WAY? // @ts-expect-error: HtmlPublicPath is generated per-project page: import('wxt/browser').HtmlPublicPath; /** diff --git a/packages/wxt/src/utils/content-script-ui/integrated.ts b/packages/wxt/src/utils/content-script-ui/integrated.ts index 37689ec67..f81e5f29c 100644 --- a/packages/wxt/src/utils/content-script-ui/integrated.ts +++ b/packages/wxt/src/utils/content-script-ui/integrated.ts @@ -12,14 +12,15 @@ export function createIntegratedUi( ctx: ContentScriptContext, options: IntegratedContentScriptUiOptions, ): IntegratedContentScriptUi { + let mounted: TMounted | undefined; const wrapper = document.createElement(options.tag || 'div'); - let mounted: TMounted | undefined = undefined; const mount = () => { applyPosition(wrapper, undefined, options); mountUi(wrapper, options); mounted = options.onMount?.(wrapper); }; + const remove = () => { options.onRemove?.(mounted); wrapper.replaceChildren(); diff --git a/packages/wxt/src/utils/content-script-ui/shadow-root.ts b/packages/wxt/src/utils/content-script-ui/shadow-root.ts index 8d1c37d86..114c3fd22 100644 --- a/packages/wxt/src/utils/content-script-ui/shadow-root.ts +++ b/packages/wxt/src/utils/content-script-ui/shadow-root.ts @@ -24,9 +24,11 @@ export async function createShadowRootUi( if (!options.inheritStyles) { css.push(`/* WXT Shadow Root Reset */ :host{all:initial !important;}`); } + if (options.css) { css.push(options.css); } + if (ctx.options?.cssInjectionMode === 'ui') { const entryCss = await loadCss(); // Replace :root selectors with :host since we're in a shadow root @@ -120,11 +122,13 @@ export async function createShadowRootUi( */ async function loadCss(): Promise { const url = browser.runtime + // TODO: MAYBE REDEFINE IT OR IMPORT IN SOME WAY? // @ts-expect-error: getURL is defined per-project, but not inside the package .getURL(`/content-scripts/${import.meta.env.ENTRYPOINT}.css`); try { const res = await fetch(url); - return await res.text(); + + return res.text(); } catch (err) { logger.warn( `Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`, diff --git a/packages/wxt/src/utils/content-script-ui/shared.ts b/packages/wxt/src/utils/content-script-ui/shared.ts index c23b52eb9..1a671b26d 100644 --- a/packages/wxt/src/utils/content-script-ui/shared.ts +++ b/packages/wxt/src/utils/content-script-ui/shared.ts @@ -12,7 +12,7 @@ import { isExist as mountDetector, isNotExist as removeDetector, } from '@1natsu/wait-element/detectors'; -import { logger } from '../../utils/internal/logger'; +import { logger } from '../internal/logger'; export function applyPosition( root: HTMLElement, @@ -33,13 +33,18 @@ export function applyPosition( if (positionedElement) { if (options.position === 'overlay') { positionedElement.style.position = 'absolute'; - if (options.alignment?.startsWith('bottom-')) + + if (options.alignment?.startsWith('bottom-')) { positionedElement.style.bottom = '0'; - else positionedElement.style.top = '0'; + } else { + positionedElement.style.top = '0'; + } - if (options.alignment?.endsWith('-right')) + if (options.alignment?.endsWith('-right')) { positionedElement.style.right = '0'; - else positionedElement.style.left = '0'; + } else { + positionedElement.style.left = '0'; + } } else { positionedElement.style.position = 'fixed'; positionedElement.style.top = '0'; @@ -69,6 +74,7 @@ export function getAnchor( XPathResult.FIRST_ORDERED_NODE_TYPE, null, ); + return (result.singleNodeValue as Element) ?? undefined; } else { // If the string is a CSS selector, query the document and return the element @@ -84,6 +90,7 @@ export function mountUi( options: ContentScriptAnchoredOptions, ): void { const anchor = getAnchor(options); + if (anchor == null) throw Error( 'Failed to mount content script UI: could not find anchor element', @@ -108,7 +115,6 @@ export function mountUi( break; default: options.append(anchor, root); - break; } } @@ -116,7 +122,7 @@ export function createMountFunctions( baseFunctions: BaseMountFunctions, options: ContentScriptUiOptions, ): MountFunctions { - let autoMountInstance: AutoMount | undefined = undefined; + let autoMountInstance: AutoMount | undefined; const stopAutoMount = () => { autoMountInstance?.stopAutoMount(); @@ -171,6 +177,7 @@ function autoMountUi( let resolvedAnchor = typeof options.anchor === 'function' ? options.anchor() : options.anchor; + if (resolvedAnchor instanceof Element) { throw Error( 'autoMount and Element anchor option cannot be combined. Avoid passing `Element` directly or `() => Element` to the anchor.', @@ -193,10 +200,12 @@ function autoMountUi( signal: abortController.signal, }); isAnchorExist = !!changedAnchor; + if (isAnchorExist) { uiCallbacks.mount(); } else { uiCallbacks.unmount(); + if (options.once) { uiCallbacks.stopAutoMount(); } @@ -213,6 +222,8 @@ function autoMountUi( } } } + + // TODO: MISSING AWAIT observeElement(resolvedAnchor); return { stopAutoMount: _stopAutoMount }; diff --git a/packages/wxt/src/utils/define-background.ts b/packages/wxt/src/utils/define-background.ts index 18a8c92ee..243ad4350 100644 --- a/packages/wxt/src/utils/define-background.ts +++ b/packages/wxt/src/utils/define-background.ts @@ -2,12 +2,15 @@ import type { BackgroundDefinition } from '../types'; export function defineBackground(main: () => void): BackgroundDefinition; + export function defineBackground( definition: BackgroundDefinition, ): BackgroundDefinition; + export function defineBackground( arg: (() => void) | BackgroundDefinition, ): BackgroundDefinition { if (arg == null || typeof arg === 'function') return { main: arg }; + return arg; } diff --git a/packages/wxt/src/utils/define-unlisted-script.ts b/packages/wxt/src/utils/define-unlisted-script.ts index 6ee1dbba4..49bc75acf 100644 --- a/packages/wxt/src/utils/define-unlisted-script.ts +++ b/packages/wxt/src/utils/define-unlisted-script.ts @@ -4,12 +4,15 @@ import type { UnlistedScriptDefinition } from '../types'; export function defineUnlistedScript( main: () => void, ): UnlistedScriptDefinition; + export function defineUnlistedScript( definition: UnlistedScriptDefinition, ): UnlistedScriptDefinition; + export function defineUnlistedScript( arg: (() => void) | UnlistedScriptDefinition, ): UnlistedScriptDefinition { if (arg == null || typeof arg === 'function') return { main: arg }; + return arg; } diff --git a/packages/wxt/src/utils/internal/custom-events.ts b/packages/wxt/src/utils/internal/custom-events.ts index 08ca87a26..3240fefe9 100644 --- a/packages/wxt/src/utils/internal/custom-events.ts +++ b/packages/wxt/src/utils/internal/custom-events.ts @@ -1,13 +1,13 @@ import { browser } from 'wxt/browser'; export class WxtLocationChangeEvent extends Event { - static EVENT_NAME = getUniqueEventName('wxt:locationchange'); + static eventName = getUniqueEventName('wxt:locationchange'); constructor( readonly newUrl: URL, readonly oldUrl: URL, ) { - super(WxtLocationChangeEvent.EVENT_NAME, {}); + super(WxtLocationChangeEvent.eventName, {}); } } diff --git a/packages/wxt/src/utils/internal/dev-server-websocket.ts b/packages/wxt/src/utils/internal/dev-server-websocket.ts index 83a159805..a25205bc8 100644 --- a/packages/wxt/src/utils/internal/dev-server-websocket.ts +++ b/packages/wxt/src/utils/internal/dev-server-websocket.ts @@ -29,9 +29,6 @@ let ws: WxtWebSocket | undefined; /** * Connect to the websocket and listen for messages. - * - * @param onMessage Optional callback that is called when a message is recieved and we've verified - * it's structure is what we expect. */ export function getDevServerWebSocket(): WxtWebSocket { if (import.meta.env.COMMAND !== 'serve') @@ -41,6 +38,7 @@ export function getDevServerWebSocket(): WxtWebSocket { if (ws == null) { const serverUrl = __DEV_SERVER_ORIGIN__; + logger.debug('Connecting to dev server @', serverUrl); ws = new WebSocket(serverUrl, 'vite-hmr') as WxtWebSocket; @@ -61,6 +59,7 @@ export function getDevServerWebSocket(): WxtWebSocket { ws.addEventListener('message', (e) => { try { const message = JSON.parse(e.data) as WebSocketMessage; + if (message.type === 'custom') { ws?.dispatchEvent( new CustomEvent(message.event, { detail: message.data }), diff --git a/packages/wxt/src/utils/internal/location-watcher.ts b/packages/wxt/src/utils/internal/location-watcher.ts index 085844e8d..923414010 100644 --- a/packages/wxt/src/utils/internal/location-watcher.ts +++ b/packages/wxt/src/utils/internal/location-watcher.ts @@ -18,6 +18,7 @@ export function createLocationWatcher(ctx: ContentScriptContext) { if (interval != null) return; oldUrl = new URL(location.href); + interval = ctx.setInterval(() => { let newUrl = new URL(location.href); if (newUrl.href !== oldUrl.href) { diff --git a/packages/wxt/src/version.ts b/packages/wxt/src/version.ts index 526225a09..d456ac9cb 100644 --- a/packages/wxt/src/version.ts +++ b/packages/wxt/src/version.ts @@ -1 +1 @@ -export const version = '{{version}}'; +export const VERSION = '{{version}}'; diff --git a/packages/wxt/src/virtual/background-entrypoint.ts b/packages/wxt/src/virtual/background-entrypoint.ts index 44c4be0ad..00a9d91e8 100644 --- a/packages/wxt/src/virtual/background-entrypoint.ts +++ b/packages/wxt/src/virtual/background-entrypoint.ts @@ -9,6 +9,7 @@ import { reloadContentScript } from './utils/reload-content-scripts'; if (import.meta.env.COMMAND === 'serve') { try { const ws = getDevServerWebSocket(); + ws.addWxtEventListener('wxt:reload-extension', () => { browser.runtime.reload(); }); @@ -41,7 +42,8 @@ let result; try { initPlugins(); result = definition.main(); - // @ts-expect-error: res shouldn't be a promise, but we're checking it anyways + // TODO: WHAT'S THE REASON FOR THIS CHECK? + // @ts-expect-error: Res shouldn't be a promise, but we're checking it anyway if (result instanceof Promise) { console.warn( "The background's main() function return a promise, but it must be synchronous", diff --git a/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts b/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts index 4bd388331..4e6c7c95f 100644 --- a/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts +++ b/packages/wxt/src/virtual/content-script-isolated-world-entrypoint.ts @@ -9,7 +9,7 @@ const result = (async () => { const { main, ...options } = definition; const ctx = new ContentScriptContext(import.meta.env.ENTRYPOINT, options); - return await main(ctx); + return main(ctx); } catch (err) { logger.error( `The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`, diff --git a/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts b/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts index 1241bad64..f52d3821d 100644 --- a/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts +++ b/packages/wxt/src/virtual/content-script-main-world-entrypoint.ts @@ -5,7 +5,7 @@ import { initPlugins } from 'virtual:wxt-plugins'; const result = (async () => { try { initPlugins(); - return await definition.main(); + return definition.main(); } catch (err) { logger.error( `The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`, diff --git a/packages/wxt/src/virtual/reload-html.ts b/packages/wxt/src/virtual/reload-html.ts index 3f3742bb2..eb4c36c1b 100644 --- a/packages/wxt/src/virtual/reload-html.ts +++ b/packages/wxt/src/virtual/reload-html.ts @@ -4,8 +4,8 @@ import { getDevServerWebSocket } from '../utils/internal/dev-server-websocket'; if (import.meta.env.COMMAND === 'serve') { try { const ws = getDevServerWebSocket(); + ws.addWxtEventListener('wxt:reload-page', (event) => { - // "popup.html" === "/popup.html".substring(1) if (event.detail === location.pathname.substring(1)) location.reload(); }); } catch (err) { diff --git a/packages/wxt/src/virtual/utils/reload-content-scripts.ts b/packages/wxt/src/virtual/utils/reload-content-scripts.ts index 5d3b4363e..83e927035 100644 --- a/packages/wxt/src/virtual/utils/reload-content-scripts.ts +++ b/packages/wxt/src/virtual/utils/reload-content-scripts.ts @@ -5,6 +5,7 @@ import type { ReloadContentScriptPayload } from '../../utils/internal/dev-server export function reloadContentScript(payload: ReloadContentScriptPayload) { const manifest = browser.runtime.getManifest(); + if (manifest.manifest_version == 2) { void reloadContentScriptMv2(payload); } else { @@ -29,14 +30,18 @@ export async function reloadManifestContentScriptMv3( contentScript: ContentScript, ) { const id = `wxt:${contentScript.js![0]}`; + logger.log('Reloading content script:', contentScript); + const registered = await browser.scripting.getRegisteredContentScripts(); + logger.debug('Existing scripts:', registered); const existing = registered.find((cs) => cs.id === id); if (existing) { logger.debug('Updating content script', existing); + await browser.scripting.updateContentScripts([ { ...contentScript, @@ -46,6 +51,7 @@ export async function reloadManifestContentScriptMv3( ]); } else { logger.debug('Registering new content script...'); + await browser.scripting.registerContentScripts([ { ...contentScript, @@ -62,12 +68,15 @@ export async function reloadRuntimeContentScriptMv3( contentScript: ContentScript, ) { logger.log('Reloading content script:', contentScript); + const registered = await browser.scripting.getRegisteredContentScripts(); + logger.debug('Existing scripts:', registered); const matches = registered.filter((cs) => { const hasJs = contentScript.js?.find((js) => cs.js?.includes(js)); const hasCss = contentScript.css?.find((css) => cs.css?.includes(css)); + return hasJs || hasCss; }); @@ -90,7 +99,9 @@ async function reloadTabsForContentScript(contentScript: ContentScript) { ); const matchingTabs = allTabs.filter((tab) => { const url = tab.url; + if (!url) return false; + return !!matchPatterns.find((pattern) => pattern.includes(url)); }); await Promise.all( diff --git a/packages/wxt/tsdown.config.ts b/packages/wxt/tsdown.config.ts index 635ef0746..2ea0550c4 100644 --- a/packages/wxt/tsdown.config.ts +++ b/packages/wxt/tsdown.config.ts @@ -55,8 +55,10 @@ async function replaceVars( vars: Record, ): Promise { let text = await readFile(file, 'utf8'); + Object.entries(vars).forEach(([name, value]) => { text = text.replaceAll(`{{${name}}}`, value); }); + await writeFile(file, text, 'utf8'); } diff --git a/packages/wxt/vitest.globalSetup.ts b/packages/wxt/vitest.globalSetup.ts index 3847f26ad..d4a92be7f 100644 --- a/packages/wxt/vitest.globalSetup.ts +++ b/packages/wxt/vitest.globalSetup.ts @@ -1,4 +1,4 @@ -import { exists, rm } from 'fs-extra'; +import { pathExists, rm } from 'fs-extra'; let setupHappened = false; @@ -9,11 +9,11 @@ export async function setup() { setupHappened = true; - // @ts-expect-error globalThis.__ENTRYPOINT__ = 'test'; - const e2eDistPath = './e2e/dist/'; - if (await exists(e2eDistPath)) { - await rm(e2eDistPath, { recursive: true, force: true }); + const E2E_DIST_PATH = './e2e/dist/'; + + if (await pathExists(E2E_DIST_PATH)) { + await rm(E2E_DIST_PATH, { recursive: true, force: true }); } }