From 4146a3429a82673f6f647e19631bba3112101208 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:20:39 +0000 Subject: [PATCH] fix: Generate high resolution sourcemaps when injecting auto-imports `unimport/unplugin` calls `MagicString#generateMap()` with no options, which produces a line-level sourcemap with no `source`. Since auto-imports are injected at the top of the file, tools that rely on columns - like V8 code coverage - see the whole module as a single statement. The result is that any module referencing an auto-imported variable (`browser`, `storage`, ...) silently drops out of coverage reports, which inflates the reported percentage and lets thresholds pass when they shouldn't. This was fixed once in #381, but #1412 replaced the `wxt:unimport` plugin with `UnimportPlugin.vite()` in both the build path and `WxtVitest()`, dropping the `hires` option along the way. Restore the `wxt:unimport` plugin with the sourcemap options from #381, keeping the `enforce: 'post'` ordering and the include/exclude filter that #1412 relied on. The defaults are imported from `unimport/unplugin`, so they can't drift. Both the build path and `WxtVitest()` share the plugin. Fixes #2604 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011PEx8LKp7AtQkR3tJfyFrY --- .../__tests__/unimport.test.ts | 97 +++++++++++++++++++ packages/wxt/src/builtin-modules/unimport.ts | 55 ++++++++++- packages/wxt/src/testing/wxt-vitest-plugin.ts | 4 +- 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/wxt/src/builtin-modules/__tests__/unimport.test.ts 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; }