diff --git a/packages/wxt/src/builtin-modules/__tests__/unimport.test.ts b/packages/wxt/src/builtin-modules/__tests__/unimport.test.ts new file mode 100644 index 000000000..cd5784343 --- /dev/null +++ b/packages/wxt/src/builtin-modules/__tests__/unimport.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { unimportPlugin } from '../unimport'; +import type { WxtResolvedUnimportOptions } from '../../types'; + +const options = (): WxtResolvedUnimportOptions => ({ + disabled: false, + eslintrc: { enabled: false, filePath: '', globalsPropValue: true }, + presets: [{ from: 'wxt/browser', imports: ['browser'] }], +}); + +const transform = ( + plugin: ReturnType, + code: string, + id: string, +) => { + const transform = plugin.transform; + const handler = + typeof transform === 'function' ? transform : transform!.handler; + return handler.call({} as any, code, id) as Promise< + { code: string; map: { mappings: string; sources: string[] } } | undefined + >; +}; + +/** + * Sourcemap `mappings` are grouped by generated line (`;`), then by segment + * (`,`). A line-level sourcemap only has one segment per line, so counting + * segments tells us whether column information was included. + */ +const segmentsPerLine = (mappings: string) => + mappings.split(';').map((line) => (line === '' ? 0 : line.split(',').length)); + +describe('Unimport Module', () => { + describe('unimportPlugin', () => { + const id = '/src/utils/example.ts'; + const code = [ + 'export function getExtensionId() {', + ' const id = browser.runtime.id;', + ' return id.toUpperCase();', + '}', + '', + ].join('\n'); + + it('should inject auto-imports', async () => { + const res = await transform(unimportPlugin(options()), code, id); + + expect(res?.code).toContain("import { browser } from 'wxt/browser';"); + expect(res?.code).toContain('browser.runtime.id'); + }); + + it('should generate a sourcemap with column-level mappings', async () => { + const res = await transform(unimportPlugin(options()), code, id); + + // Without `hires`, every line collapses to a single segment, which makes + // coverage tools treat the entire module as one statement. + // See https://github.com/wxt-dev/wxt/issues/2604 + expect(Math.max(...segmentsPerLine(res!.map.mappings))).toBeGreaterThan( + 1, + ); + }); + + it('should include the module ID as the sourcemap source', async () => { + const res = await transform(unimportPlugin(options()), code, id); + + expect(res!.map.sources).toEqual([id]); + }); + + it('should not transform files that do not use auto-imports', async () => { + const res = await transform( + unimportPlugin(options()), + 'export const one = 1;\n', + id, + ); + + expect(res).toBeUndefined(); + }); + + it('should not transform excluded files', async () => { + const res = await transform( + unimportPlugin(options()), + code, + '/node_modules/example/index.js', + ); + + expect(res).toBeUndefined(); + }); + + it('should not transform non-JS files', async () => { + const res = await transform( + unimportPlugin(options()), + code, + '/src/a.css', + ); + + expect(res).toBeUndefined(); + }); + }); +}); diff --git a/packages/wxt/src/builtin-modules/unimport.ts b/packages/wxt/src/builtin-modules/unimport.ts index 8e9ec12d2..f4ac740be 100644 --- a/packages/wxt/src/builtin-modules/unimport.ts +++ b/packages/wxt/src/builtin-modules/unimport.ts @@ -8,7 +8,8 @@ import type { EslintConfigVersion, } from '../types'; import { type Unimport, createUnimport, toExports } from 'unimport'; -import UnimportPlugin from 'unimport/unplugin'; +import { defaultExcludes, defaultIncludes } from 'unimport/unplugin'; +import { createFilter, type FilterPattern, type Plugin } from 'vite'; export default defineWxtModule({ name: 'wxt:built-in:unimport', @@ -64,11 +65,61 @@ export default defineWxtModule({ // Add vite plugin addViteConfig(wxt, () => ({ - plugins: [UnimportPlugin.vite(wxt.config.imports)], + plugins: [unimportPlugin(wxt.config.imports)], })); }, }); +/** + * Vite plugin that injects auto-imports into modules. + * + * Equivalent to `UnimportPlugin.vite(options)`, but it generates a high + * resolution sourcemap. Unimport's unplugin calls `MagicString#generateMap()` + * with no options, which produces a line-level map with no `source`. Since + * auto-imports are injected at the top of the file, tools that rely on columns + * (like V8 code coverage) then see the entire module as a single statement, + * silently dropping it from coverage reports. + * + * `hires: 'boundary'` keeps columns accurate and is cheaper than `hires: true`. + * + * If unimport passes these options itself, this plugin can be dropped in favor + * of `UnimportPlugin.vite(options)` again. + * + * @see https://github.com/wxt-dev/wxt/issues/2604 + * @see https://github.com/unjs/unimport/issues/562 + */ +export function unimportPlugin(options: WxtResolvedUnimportOptions): Plugin { + // `include`/`exclude` aren't part of `UnimportOptions`, but unimport's + // unplugin accepts them, so keep honoring them. + const { include = defaultIncludes, exclude = defaultExcludes } = options as { + include?: FilterPattern; + exclude?: FilterPattern; + }; + const filter = createFilter(include, exclude); + const unimport = createUnimport(options); + let initialized: Promise | undefined; + const init = () => (initialized ??= unimport.init()); + + return { + name: 'wxt:unimport', + // Run after plugins that compile files to JS, like `@vitejs/plugin-vue`. + enforce: 'post', + buildStart: init, + async transform(code, id) { + if (!filter(id)) return; + + await init(); + const injected = await unimport.injectImports(code, id); + if (!injected.s.hasChanged()) return; + + return { + code: injected.code, + map: injected.s.generateMap({ hires: 'boundary', source: id }), + }; + }, + }; +} + async function getImportsDeclarationEntry( unimport: Unimport, ): Promise { diff --git a/packages/wxt/src/testing/wxt-vitest-plugin.ts b/packages/wxt/src/testing/wxt-vitest-plugin.ts index 6e5eed922..be69ef4d9 100644 --- a/packages/wxt/src/testing/wxt-vitest-plugin.ts +++ b/packages/wxt/src/testing/wxt-vitest-plugin.ts @@ -13,7 +13,7 @@ import { resolveAppConfig, } from '../core/builders/vite/plugins'; import { InlineConfig } from '../types'; -import UnimportPlugin from 'unimport/unplugin'; +import { unimportPlugin } from '../builtin-modules/unimport'; import { registerWxt, wxt } from '../core/wxt'; /** @@ -44,7 +44,7 @@ export async function WxtVitest( resolveAppConfig(wxt.config), extensionApiMock(wxt.config), ]; - plugins.push(UnimportPlugin.vite(wxt.config.imports)); + plugins.push(unimportPlugin(wxt.config.imports)); return plugins; }