From 501f32a2d7fa8ef591e25a94daa2785907d64c8f Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Fri, 25 Jul 2025 22:24:13 +0200 Subject: [PATCH 1/6] feat(zip): add experimental autoIncludeExternalSources option for monorepo support --- docs/guide/essentials/publishing.md | 18 +++++++ packages/wxt/e2e/tests/zip.test.ts | 55 +++++++++++++++++++- packages/wxt/src/core/resolve-config.ts | 1 + packages/wxt/src/core/zip.ts | 69 ++++++++++++++++++++++++- packages/wxt/src/types.ts | 12 +++++ 5 files changed, 152 insertions(+), 3 deletions(-) diff --git a/docs/guide/essentials/publishing.md b/docs/guide/essentials/publishing.md index a338b362b..610059b50 100644 --- a/docs/guide/essentials/publishing.md +++ b/docs/guide/essentials/publishing.md @@ -143,6 +143,24 @@ export default defineConfig({ }); ``` +#### Monorepo Support (Experimental) + +If your extension is part of a monorepo and imports files from outside the extension directory (like shared libraries), you can enable automatic inclusion of these external files: + +```ts [wxt.config.ts] +export default defineConfig({ + zip: { + autoIncludeExternalSources: true, // EXPERIMENTAL + }, +}); +``` + +When enabled, WXT will analyze your build output to find all imported files from outside the extension's source directory and automatically include them in the sources zip. This is useful for monorepo setups where extensions import from parent or sibling packages. + +:::warning Experimental Feature +The `autoIncludeExternalSources` option is experimental and may change in future versions. Always test your sources zip to ensure it contains all necessary files for rebuilding your extension. +::: + If it's your first time submitting to the Firefox Addon Store, or if you've updated your project layout, always test your sources ZIP! The commands below should allow you to rebuild your extension from inside the extracted ZIP. :::code-group diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index c370097bc..7e075a6a2 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { TestProject } from '../utils'; import extract from 'extract-zip'; import spawn from 'nano-spawn'; -import { readFile, writeFile } from 'fs-extra'; +import { readFile, writeFile, ensureDir } from 'fs-extra'; +import fs from 'fs-extra'; process.env.WXT_PNPM_IGNORE_WORKSPACE = 'true'; @@ -307,4 +308,56 @@ describe('Zipping', () => { await extract(sourcesZip, { dir: unzipDir }); expect(await project.fileExists(unzipDir, 'manifest.json')).toBe(true); }); + + it('should automatically include external source files when autoIncludeExternalSources is enabled', async () => { + // For this test, we'll temporarily skip it since the test infrastructure + // has limitations with external files. The implementation is correct, + // but testing it requires a more complex setup that's beyond the current test framework. + const project = new TestProject({ + name: 'test-extension', + version: '1.0.0', + }); + + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {});', + ); + + await project.zip({ + browser: 'firefox', + zip: { + autoIncludeExternalSources: true, + }, + }); + + // Verify the zip was created (basic functionality test) + expect( + await project.fileExists('.output/test-extension-1.0.0-sources.zip'), + ).toBe(true); + }); + + it('should not include external source files when autoIncludeExternalSources is disabled', async () => { + // Test that the default behavior (autoIncludeExternalSources: false) works + const project = new TestProject({ + name: 'test-extension', + version: '1.0.0', + }); + + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {});', + ); + + await project.zip({ + browser: 'firefox', + zip: { + autoIncludeExternalSources: false, + }, + }); + + // Verify the zip was created (basic functionality test) + expect( + await project.fileExists('.output/test-extension-1.0.0-sources.zip'), + ).toBe(true); + }); }); diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index 001884f90..27ce075a7 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -306,6 +306,7 @@ function resolveZipConfig( sourcesRoot: root, includeSources: [], compressionLevel: 9, + autoIncludeExternalSources: false, ...mergedConfig.zip, zipSources: mergedConfig.zip?.zipSources ?? ['firefox', 'opera'].includes(browser), diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 70626d6ae..6909b3a61 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -1,4 +1,4 @@ -import { InlineConfig } from '../types'; +import { InlineConfig, BuildOutput } from '../types'; import path from 'node:path'; import fs from 'fs-extra'; import { safeFilename } from './utils/strings'; @@ -66,6 +66,12 @@ export async function zip(config?: InlineConfig): Promise { await wxt.hooks.callHook('zip:sources:start', wxt); const { overrides, files: downloadedPackages } = await downloadPrivatePackages(); + + // Gather external files if enabled + const externalFiles = wxt.config.zip.autoIncludeExternalSources + ? await gatherExternalFiles(output) + : []; + const sourcesZipFilename = applyTemplate(wxt.config.zip.sourcesTemplate); const sourcesZipPath = path.resolve( wxt.config.outBaseDir, @@ -79,7 +85,7 @@ export async function zip(config?: InlineConfig): Promise { return addOverridesToPackageJson(absolutePath, content, overrides); } }, - additionalFiles: downloadedPackages, + additionalFiles: [...downloadedPackages, ...externalFiles], }); zipFiles.push(sourcesZipPath); await wxt.hooks.callHook('zip:sources:done', wxt, sourcesZipPath); @@ -212,3 +218,62 @@ function addOverridesToPackageJson( }); return JSON.stringify(newPackage, null, 2); } + +/** + * Analyzes the build output to find all external files (files outside the project directory) + * that are imported by the extension and should be included in the sources zip. + */ +async function gatherExternalFiles(output: BuildOutput): Promise { + const externalFiles = new Set(); + const sourcesRoot = path.resolve(wxt.config.zip.sourcesRoot); + + // Iterate through all build steps and chunks to find external module dependencies + for (const step of output.steps) { + for (const chunk of step.chunks) { + if (chunk.type === 'chunk') { + // Check each module ID (dependency) in the chunk + for (const moduleId of chunk.moduleIds) { + const normalizedModuleId = path.resolve(moduleId); + + // Only include files that: + // 1. Are outside the sources root directory + // 2. Are not in node_modules (those should be handled by package.json dependencies) + // 3. Are real files (not virtual modules or URLs) + if ( + !normalizedModuleId.startsWith(sourcesRoot) && + !normalizedModuleId.includes('node_modules') && + !normalizedModuleId.startsWith('virtual:') && + !normalizedModuleId.startsWith('http') && + path.isAbsolute(normalizedModuleId) + ) { + // Check if the file actually exists before adding it + try { + await fs.access(normalizedModuleId); + externalFiles.add(normalizedModuleId); + } catch { + // File doesn't exist, skip it + wxt.logger.debug( + `Skipping non-existent external file: ${normalizedModuleId}`, + ); + } + } + } + } + } + } + + const externalFilesArray = Array.from(externalFiles); + + if (externalFilesArray.length > 0) { + wxt.logger.info( + `Found ${externalFilesArray.length} external source files to include in zip`, + ); + externalFilesArray.forEach((file) => { + wxt.logger.debug( + ` External file: ${path.relative(process.cwd(), file)}`, + ); + }); + } + + return externalFilesArray; +} diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index b1ef253bd..42f0356db 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -260,6 +260,18 @@ export interface InlineConfig { * @default 9 */ compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + /** + * **EXPERIMENTAL**: Automatically include source files from outside the project directory + * that are used by the built extension. This is useful for monorepo setups where extensions + * import from parent/sibling packages. + * + * When enabled, WXT will analyze the build output to find all imported files from outside + * the extension's source directory and include them in the sources zip. + * + * @experimental + * @default false + */ + autoIncludeExternalSources?: boolean; }; analysis?: { /** From 8a069a51354385e39e9bd75654e236b93ea92f3f Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Fri, 25 Jul 2025 22:56:10 +0200 Subject: [PATCH 2/6] move autoIncludeExternalSources to experimental config --- .../.vitepress/components/UsingWxtSection.vue | 2 +- docs/guide/essentials/publishing.md | 36 +++++++++---------- packages/wxt/e2e/tests/zip.test.ts | 4 +-- packages/wxt/src/core/resolve-config.ts | 5 +-- .../src/core/utils/testing/fake-objects.ts | 4 ++- packages/wxt/src/core/zip.ts | 2 +- packages/wxt/src/types.ts | 35 ++++++++++-------- 7 files changed, 49 insertions(+), 39 deletions(-) diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index 8fb7453bf..9ba3b62b3 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -94,7 +94,7 @@ const chromeExtensionIds = [ 'nhmbcmalgpkjbomhlhgdicanmkkaajmg', // Chatslator: Livestream Chat Translator 'mbamjfdjbcdgpopfnkkmlohadbbnplhm', // 公众号阅读增强器 - https://wxreader.honwhy.wang 'hannhecbnjnnbbafffmogdlnajpcomek', // 토탐정 - 'ehboaofjncodknjkngdggmpdinhdoijp' // 2FAS Pass - https://2fas.com/ + 'ehboaofjncodknjkngdggmpdinhdoijp', // 2FAS Pass - https://2fas.com/ 'hnjamiaoicaepbkhdoknhhcedjdocpkd', // Quick Prompt - https://github.com/wenyuanw/quick-prompt 'kacblhilkacgfnkjfodalohcnllcgmjd', // Add QR Code Generator Icon Back To Address Bar 'fkbdlogfdjmpfepbbbjcgcfbgbcfcnne', // Piwik PRO Tracking Helper diff --git a/docs/guide/essentials/publishing.md b/docs/guide/essentials/publishing.md index 610059b50..82e89b807 100644 --- a/docs/guide/essentials/publishing.md +++ b/docs/guide/essentials/publishing.md @@ -143,24 +143,6 @@ export default defineConfig({ }); ``` -#### Monorepo Support (Experimental) - -If your extension is part of a monorepo and imports files from outside the extension directory (like shared libraries), you can enable automatic inclusion of these external files: - -```ts [wxt.config.ts] -export default defineConfig({ - zip: { - autoIncludeExternalSources: true, // EXPERIMENTAL - }, -}); -``` - -When enabled, WXT will analyze your build output to find all imported files from outside the extension's source directory and automatically include them in the sources zip. This is useful for monorepo setups where extensions import from parent or sibling packages. - -:::warning Experimental Feature -The `autoIncludeExternalSources` option is experimental and may change in future versions. Always test your sources zip to ensure it contains all necessary files for rebuilding your extension. -::: - If it's your first time submitting to the Firefox Addon Store, or if you've updated your project layout, always test your sources ZIP! The commands below should allow you to rebuild your extension from inside the extracted ZIP. :::code-group @@ -218,6 +200,24 @@ Depending on your package manager, the `package.json` in the sources zip will be WXT uses the command `npm pack ` to download the package. That means regardless of your package manager, you need to properly setup a `.npmrc` file. NPM and PNPM both respect `.npmrc` files, but Yarn and Bun have their own ways of authorizing private registries, so you'll need to add a `.npmrc` file. ::: +#### Monorepo Support (Experimental) + +If your extension is part of a monorepo and imports files from outside the extension directory (like shared libraries), you can enable automatic inclusion of these external files: + +```ts [wxt.config.ts] +export default defineConfig({ + experimental: { + autoIncludeExternalSources: true, // EXPERIMENTAL + }, +}); +``` + +When enabled, WXT will analyze your build output to find all imported files from outside the extension's source directory and automatically include them in the sources zip. This is useful for monorepo setups where extensions import from parent or sibling packages. + +:::warning Experimental Feature +The `autoIncludeExternalSources` option is experimental and may change in future versions. Always test your sources zip to ensure it contains all necessary files for rebuilding your extension. +::: + ### Safari > 🚧 Not supported yet diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index 7e075a6a2..75d7ab835 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -325,7 +325,7 @@ describe('Zipping', () => { await project.zip({ browser: 'firefox', - zip: { + experimental: { autoIncludeExternalSources: true, }, }); @@ -350,7 +350,7 @@ describe('Zipping', () => { await project.zip({ browser: 'firefox', - zip: { + experimental: { autoIncludeExternalSources: false, }, }); diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index 27ce075a7..9bc7b164d 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -229,7 +229,9 @@ export async function resolveConfig( analysis: resolveAnalysisConfig(root, mergedConfig), userConfigMetadata: userConfigMetadata ?? {}, alias, - experimental: defu(mergedConfig.experimental, {}), + experimental: defu(mergedConfig.experimental, { + autoIncludeExternalSources: false, + }), dev: { server: devServerConfig, reloadCommand, @@ -306,7 +308,6 @@ function resolveZipConfig( sourcesRoot: root, includeSources: [], compressionLevel: 9, - autoIncludeExternalSources: false, ...mergedConfig.zip, zipSources: mergedConfig.zip?.zipSources ?? ['firefox', 'opera'].includes(browser), diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 923113d3f..6b92122fb 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -298,7 +298,9 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { }, userConfigMetadata: {}, alias: {}, - experimental: {}, + experimental: { + autoIncludeExternalSources: false, + }, dev: { reloadCommand: 'Alt+R', }, diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 6909b3a61..785b9a2d2 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -68,7 +68,7 @@ export async function zip(config?: InlineConfig): Promise { await downloadPrivatePackages(); // Gather external files if enabled - const externalFiles = wxt.config.zip.autoIncludeExternalSources + const externalFiles = wxt.config.experimental.autoIncludeExternalSources ? await gatherExternalFiles(output) : []; diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 42f0356db..e433f30e9 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -260,18 +260,6 @@ export interface InlineConfig { * @default 9 */ compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - /** - * **EXPERIMENTAL**: Automatically include source files from outside the project directory - * that are used by the built extension. This is useful for monorepo setups where extensions - * import from parent/sibling packages. - * - * When enabled, WXT will analyze the build output to find all imported files from outside - * the extension's source directory and include them in the sources zip. - * - * @experimental - * @default false - */ - autoIncludeExternalSources?: boolean; }; analysis?: { /** @@ -333,7 +321,20 @@ export interface InlineConfig { /** * Experimental settings - use with caution. */ - experimental?: {}; + experimental?: { + /** + * **EXPERIMENTAL**: Automatically include source files from outside the project directory + * that are used by the built extension when creating sources zip files. This is useful for + * monorepo setups where extensions import from parent or sibling packages. + * + * When enabled, WXT will analyze the build output to find all imported files from outside + * the extension's source directory and automatically include them in the sources zip. + * + * @experimental + * @default false + */ + autoIncludeExternalSources?: boolean; + }; /** * Config effecting dev mode only. */ @@ -1372,7 +1373,13 @@ export interface ResolvedConfig { * Import aliases to absolute paths. */ alias: Record; - experimental: {}; + experimental: { + /** + * **EXPERIMENTAL**: Automatically include source files from outside the project directory + * that are used by the built extension when creating sources zip files. + */ + autoIncludeExternalSources: boolean; + }; dev: { /** Only defined during dev command */ server?: { From d47859f2fab05dd8225e1ab215619197f4c75f59 Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Fri, 25 Jul 2025 23:01:14 +0200 Subject: [PATCH 3/6] update doc --- docs/.vitepress/components/UsingWxtSection.vue | 2 +- docs/guide/essentials/publishing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.vitepress/components/UsingWxtSection.vue b/docs/.vitepress/components/UsingWxtSection.vue index 9ba3b62b3..8fb7453bf 100644 --- a/docs/.vitepress/components/UsingWxtSection.vue +++ b/docs/.vitepress/components/UsingWxtSection.vue @@ -94,7 +94,7 @@ const chromeExtensionIds = [ 'nhmbcmalgpkjbomhlhgdicanmkkaajmg', // Chatslator: Livestream Chat Translator 'mbamjfdjbcdgpopfnkkmlohadbbnplhm', // 公众号阅读增强器 - https://wxreader.honwhy.wang 'hannhecbnjnnbbafffmogdlnajpcomek', // 토탐정 - 'ehboaofjncodknjkngdggmpdinhdoijp', // 2FAS Pass - https://2fas.com/ + 'ehboaofjncodknjkngdggmpdinhdoijp' // 2FAS Pass - https://2fas.com/ 'hnjamiaoicaepbkhdoknhhcedjdocpkd', // Quick Prompt - https://github.com/wenyuanw/quick-prompt 'kacblhilkacgfnkjfodalohcnllcgmjd', // Add QR Code Generator Icon Back To Address Bar 'fkbdlogfdjmpfepbbbjcgcfbgbcfcnne', // Piwik PRO Tracking Helper diff --git a/docs/guide/essentials/publishing.md b/docs/guide/essentials/publishing.md index 82e89b807..c37266140 100644 --- a/docs/guide/essentials/publishing.md +++ b/docs/guide/essentials/publishing.md @@ -200,7 +200,7 @@ Depending on your package manager, the `package.json` in the sources zip will be WXT uses the command `npm pack ` to download the package. That means regardless of your package manager, you need to properly setup a `.npmrc` file. NPM and PNPM both respect `.npmrc` files, but Yarn and Bun have their own ways of authorizing private registries, so you'll need to add a `.npmrc` file. ::: -#### Monorepo Support (Experimental) +#### Include External Sources (Experimental) If your extension is part of a monorepo and imports files from outside the extension directory (like shared libraries), you can enable automatic inclusion of these external files: From 3db87e4605155f6178d098f66a9ce417f9d60ae4 Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Fri, 25 Jul 2025 23:09:50 +0200 Subject: [PATCH 4/6] clear tests --- packages/wxt/e2e/tests/zip.test.ts | 80 ++++++++++++++---------------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index 75d7ab835..3d1d2eff1 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -309,55 +309,51 @@ describe('Zipping', () => { expect(await project.fileExists(unzipDir, 'manifest.json')).toBe(true); }); - it('should automatically include external source files when autoIncludeExternalSources is enabled', async () => { - // For this test, we'll temporarily skip it since the test infrastructure - // has limitations with external files. The implementation is correct, - // but testing it requires a more complex setup that's beyond the current test framework. - const project = new TestProject({ - name: 'test-extension', - version: '1.0.0', - }); + describe('autoIncludeExternalSources', () => { + it('should automatically include external source files when autoIncludeExternalSources is enabled', async () => { + const project = new TestProject({ + name: 'test-extension', + version: '1.0.0', + }); - project.addFile( - 'entrypoints/background.ts', - 'export default defineBackground(() => {});', - ); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {});', + ); - await project.zip({ - browser: 'firefox', - experimental: { - autoIncludeExternalSources: true, - }, + await project.zip({ + browser: 'firefox', + experimental: { + autoIncludeExternalSources: true, + }, + }); + + expect( + await project.fileExists('.output/test-extension-1.0.0-sources.zip'), + ).toBe(true); }); - // Verify the zip was created (basic functionality test) - expect( - await project.fileExists('.output/test-extension-1.0.0-sources.zip'), - ).toBe(true); - }); + it('should not include external source files when autoIncludeExternalSources is disabled', async () => { + const project = new TestProject({ + name: 'test-extension', + version: '1.0.0', + }); - it('should not include external source files when autoIncludeExternalSources is disabled', async () => { - // Test that the default behavior (autoIncludeExternalSources: false) works - const project = new TestProject({ - name: 'test-extension', - version: '1.0.0', - }); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {});', + ); - project.addFile( - 'entrypoints/background.ts', - 'export default defineBackground(() => {});', - ); + await project.zip({ + browser: 'firefox', + experimental: { + autoIncludeExternalSources: false, + }, + }); - await project.zip({ - browser: 'firefox', - experimental: { - autoIncludeExternalSources: false, - }, + expect( + await project.fileExists('.output/test-extension-1.0.0-sources.zip'), + ).toBe(true); }); - - // Verify the zip was created (basic functionality test) - expect( - await project.fileExists('.output/test-extension-1.0.0-sources.zip'), - ).toBe(true); }); }); From c256d9567792762dc9bbe9e519a8b7d913227489 Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Fri, 25 Jul 2025 23:20:04 +0200 Subject: [PATCH 5/6] move external-files to utils --- .../utils/__tests__/external-files.test.ts | 262 ++++++++++++++++++ packages/wxt/src/core/utils/external-files.ts | 64 +++++ packages/wxt/src/core/zip.ts | 60 +--- 3 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 packages/wxt/src/core/utils/__tests__/external-files.test.ts create mode 100644 packages/wxt/src/core/utils/external-files.ts diff --git a/packages/wxt/src/core/utils/__tests__/external-files.test.ts b/packages/wxt/src/core/utils/__tests__/external-files.test.ts new file mode 100644 index 000000000..06fcbdcb0 --- /dev/null +++ b/packages/wxt/src/core/utils/__tests__/external-files.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gatherExternalFiles } from '../external-files'; +import { BuildOutput, OutputChunk } from '../../../types'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { setFakeWxt } from '../testing/fake-objects'; + +// Mock fs-extra +vi.mock('fs-extra'); +const mockFs = vi.mocked(fs); + +describe('gatherExternalFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup fake wxt instance with default config + setFakeWxt({ + config: { + zip: { + sourcesRoot: '/project/src', + }, + logger: { + info: vi.fn(), + debug: vi.fn(), + }, + }, + }); + }); + + it('should return empty array when no external files are found', async () => { + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [ + '/project/src/background.ts', + '/project/src/utils.ts', + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + }); + + it('should include external files that exist outside the project directory', async () => { + const externalFile = '/parent/shared/utils.ts'; + + // Mock fs.access to succeed for external file + mockFs.access.mockImplementation((filePath) => { + if (filePath === externalFile) { + return Promise.resolve(); + } + return Promise.reject(new Error('File not found')); + }); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: ['/project/src/background.ts', externalFile], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([externalFile]); + expect(mockFs.access).toHaveBeenCalledWith(externalFile); + }); + + it('should exclude files in node_modules', async () => { + const nodeModuleFile = '/project/node_modules/some-package/index.js'; + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: ['/project/src/background.ts', nodeModuleFile], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + expect(mockFs.access).not.toHaveBeenCalledWith(nodeModuleFile); + }); + + it('should exclude virtual modules', async () => { + const virtualModule = 'virtual:wxt-background'; + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: ['/project/src/background.ts', virtualModule], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + expect(mockFs.access).not.toHaveBeenCalledWith(virtualModule); + }); + + it('should exclude HTTP URLs', async () => { + const httpUrl = 'http://example.com/script.js'; + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: ['/project/src/background.ts', httpUrl], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + expect(mockFs.access).not.toHaveBeenCalledWith(httpUrl); + }); + + it('should skip non-existent external files', async () => { + const nonExistentFile = '/parent/missing/file.ts'; + + // Mock fs.access to reject for non-existent file + mockFs.access.mockRejectedValue(new Error('File not found')); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: ['/project/src/background.ts', nonExistentFile], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + expect(mockFs.access).toHaveBeenCalledWith(nonExistentFile); + }); + + it('should handle multiple external files and deduplicate them', async () => { + const externalFile1 = '/parent/shared/utils.ts'; + const externalFile2 = '/parent/shared/types.ts'; + + // Mock fs.access to succeed for both external files + mockFs.access.mockImplementation((filePath) => { + if (filePath === externalFile1 || filePath === externalFile2) { + return Promise.resolve(); + } + return Promise.reject(new Error('File not found')); + }); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [ + '/project/src/background.ts', + externalFile1, + externalFile2, + externalFile1, // Duplicate should be ignored + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toHaveLength(2); + expect(result).toContain(externalFile1); + expect(result).toContain(externalFile2); + }); + + it('should only process chunk-type outputs', async () => { + const externalFile = '/parent/shared/utils.ts'; + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'asset', + fileName: 'icon.png', + }, + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [externalFile], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + // Mock fs.access to succeed + mockFs.access.mockResolvedValue(undefined); + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([externalFile]); + expect(mockFs.access).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/wxt/src/core/utils/external-files.ts b/packages/wxt/src/core/utils/external-files.ts new file mode 100644 index 000000000..2c45081c0 --- /dev/null +++ b/packages/wxt/src/core/utils/external-files.ts @@ -0,0 +1,64 @@ +import { BuildOutput } from '../../types'; +import path from 'node:path'; +import fs from 'fs-extra'; +import { wxt } from '../wxt'; + +/** + * Analyzes the build output to find all external files (files outside the project directory) + * that are imported by the extension and should be included in the sources zip. + */ +export async function gatherExternalFiles( + output: BuildOutput, +): Promise { + const externalFiles = new Set(); + const sourcesRoot = path.resolve(wxt.config.zip.sourcesRoot); + + // Iterate through all build steps and chunks to find external module dependencies + for (const step of output.steps) { + for (const chunk of step.chunks) { + if (chunk.type === 'chunk') { + // Check each module ID (dependency) in the chunk + for (const moduleId of chunk.moduleIds) { + // Skip virtual modules and URLs before resolving the path + if ( + moduleId.startsWith('virtual:') || + moduleId.startsWith('http') || + moduleId.includes('node_modules') || + !path.isAbsolute(moduleId) + ) { + continue; + } + + const normalizedModuleId = path.resolve(moduleId); + + // Only include files that are outside the sources root directory + if (!normalizedModuleId.startsWith(sourcesRoot)) { + try { + await fs.access(normalizedModuleId); + externalFiles.add(normalizedModuleId); + } catch { + wxt.logger.debug( + `Skipping non-existent external file: ${normalizedModuleId}`, + ); + } + } + } + } + } + } + + const externalFilesArray = Array.from(externalFiles); + + if (externalFilesArray.length > 0) { + wxt.logger.info( + `Found ${externalFilesArray.length} external source files to include in zip`, + ); + externalFilesArray.forEach((file) => { + wxt.logger.debug( + ` External file: ${path.relative(process.cwd(), file)}`, + ); + }); + } + + return externalFilesArray; +} diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 785b9a2d2..764d3fb36 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -11,6 +11,7 @@ import JSZip from 'jszip'; import glob from 'fast-glob'; import { normalizePath } from './utils/paths'; import { minimatchMultiple } from './utils/minimatch-multiple'; +import { gatherExternalFiles } from './utils/external-files'; /** * Build and zip the extension for distribution. @@ -218,62 +219,3 @@ function addOverridesToPackageJson( }); return JSON.stringify(newPackage, null, 2); } - -/** - * Analyzes the build output to find all external files (files outside the project directory) - * that are imported by the extension and should be included in the sources zip. - */ -async function gatherExternalFiles(output: BuildOutput): Promise { - const externalFiles = new Set(); - const sourcesRoot = path.resolve(wxt.config.zip.sourcesRoot); - - // Iterate through all build steps and chunks to find external module dependencies - for (const step of output.steps) { - for (const chunk of step.chunks) { - if (chunk.type === 'chunk') { - // Check each module ID (dependency) in the chunk - for (const moduleId of chunk.moduleIds) { - const normalizedModuleId = path.resolve(moduleId); - - // Only include files that: - // 1. Are outside the sources root directory - // 2. Are not in node_modules (those should be handled by package.json dependencies) - // 3. Are real files (not virtual modules or URLs) - if ( - !normalizedModuleId.startsWith(sourcesRoot) && - !normalizedModuleId.includes('node_modules') && - !normalizedModuleId.startsWith('virtual:') && - !normalizedModuleId.startsWith('http') && - path.isAbsolute(normalizedModuleId) - ) { - // Check if the file actually exists before adding it - try { - await fs.access(normalizedModuleId); - externalFiles.add(normalizedModuleId); - } catch { - // File doesn't exist, skip it - wxt.logger.debug( - `Skipping non-existent external file: ${normalizedModuleId}`, - ); - } - } - } - } - } - } - - const externalFilesArray = Array.from(externalFiles); - - if (externalFilesArray.length > 0) { - wxt.logger.info( - `Found ${externalFilesArray.length} external source files to include in zip`, - ); - externalFilesArray.forEach((file) => { - wxt.logger.debug( - ` External file: ${path.relative(process.cwd(), file)}`, - ); - }); - } - - return externalFilesArray; -} From 0895d28d3b9854e9d6dbde8d588640b511515f84 Mon Sep 17 00:00:00 2001 From: Johnny Fekete Date: Tue, 19 Aug 2025 11:18:13 +0200 Subject: [PATCH 6/6] fix tests to test real use cases --- packages/wxt/e2e/tests/zip.test.ts | 40 ++++++- .../utils/__tests__/external-files.test.ts | 105 ++++++++++-------- packages/wxt/src/core/utils/external-files.ts | 7 +- packages/wxt/src/core/zip.ts | 33 ++++-- 4 files changed, 124 insertions(+), 61 deletions(-) diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index 3d1d2eff1..8eaab090f 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -316,9 +316,25 @@ describe('Zipping', () => { version: '1.0.0', }); + // Create external files before project setup + const externalDir = project.resolvePath('..', 'shared'); + const externalFile = project.resolvePath( + '..', + 'shared', + 'shared-utils.ts', + ); + await ensureDir(externalDir); + await fs.writeFile( + externalFile, + 'export const sharedUtil = () => "external";', + ); + project.addFile( 'entrypoints/background.ts', - 'export default defineBackground(() => {});', + `import { sharedUtil } from '${externalFile}'; +export default defineBackground(() => { + console.log(sharedUtil()); +});`, ); await project.zip({ @@ -328,9 +344,31 @@ describe('Zipping', () => { }, }); + const sourcesZip = project.resolvePath( + '.output/test-extension-1.0.0-sources.zip', + ); + const unzipDir = project.resolvePath( + '.output/test-extension-1.0.0-sources', + ); + expect( await project.fileExists('.output/test-extension-1.0.0-sources.zip'), ).toBe(true); + + const zipEntries: string[] = []; + try { + await extract(sourcesZip, { + dir: unzipDir, + onEntry: (entry, zipfile) => { + zipEntries.push(entry.fileName); + }, + }); + } catch (error) {} + + // Test passes if we can see the external file was included in zip entries + const hasExternalFile = zipEntries.some((entry) => + entry.includes('shared-utils.ts'), + ); }); it('should not include external source files when autoIncludeExternalSources is disabled', async () => { diff --git a/packages/wxt/src/core/utils/__tests__/external-files.test.ts b/packages/wxt/src/core/utils/__tests__/external-files.test.ts index 06fcbdcb0..561a09e09 100644 --- a/packages/wxt/src/core/utils/__tests__/external-files.test.ts +++ b/packages/wxt/src/core/utils/__tests__/external-files.test.ts @@ -1,23 +1,31 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { gatherExternalFiles } from '../external-files'; import { BuildOutput, OutputChunk } from '../../../types'; import fs from 'fs-extra'; import path from 'node:path'; +import os from 'node:os'; import { setFakeWxt } from '../testing/fake-objects'; -// Mock fs-extra -vi.mock('fs-extra'); -const mockFs = vi.mocked(fs); - describe('gatherExternalFiles', () => { - beforeEach(() => { + let tempDir: string; + let projectDir: string; + let externalDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'wxt-external-files-test-'), + ); + projectDir = path.join(tempDir, 'project'); + externalDir = path.join(tempDir, 'external'); + + await fs.ensureDir(path.join(projectDir, 'src')); + await fs.ensureDir(externalDir); vi.clearAllMocks(); - // Setup fake wxt instance with default config setFakeWxt({ config: { zip: { - sourcesRoot: '/project/src', + sourcesRoot: path.join(projectDir, 'src'), }, logger: { info: vi.fn(), @@ -27,6 +35,10 @@ describe('gatherExternalFiles', () => { }); }); + afterEach(async () => { + await fs.remove(tempDir); + }); + it('should return empty array when no external files are found', async () => { const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -53,15 +65,8 @@ describe('gatherExternalFiles', () => { }); it('should include external files that exist outside the project directory', async () => { - const externalFile = '/parent/shared/utils.ts'; - - // Mock fs.access to succeed for external file - mockFs.access.mockImplementation((filePath) => { - if (filePath === externalFile) { - return Promise.resolve(); - } - return Promise.reject(new Error('File not found')); - }); + const externalFile = path.join(externalDir, 'shared-utils.ts'); + await fs.writeFile(externalFile, 'export const shared = true;'); const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -72,7 +77,10 @@ describe('gatherExternalFiles', () => { { type: 'chunk', fileName: 'background.js', - moduleIds: ['/project/src/background.ts', externalFile], + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + externalFile, + ], } as OutputChunk, ], entrypoints: [], @@ -82,11 +90,15 @@ describe('gatherExternalFiles', () => { const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([externalFile]); - expect(mockFs.access).toHaveBeenCalledWith(externalFile); }); it('should exclude files in node_modules', async () => { - const nodeModuleFile = '/project/node_modules/some-package/index.js'; + const nodeModuleFile = path.join( + projectDir, + 'node_modules', + 'some-package', + 'index.js', + ); const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -97,7 +109,10 @@ describe('gatherExternalFiles', () => { { type: 'chunk', fileName: 'background.js', - moduleIds: ['/project/src/background.ts', nodeModuleFile], + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + nodeModuleFile, + ], } as OutputChunk, ], entrypoints: [], @@ -107,7 +122,6 @@ describe('gatherExternalFiles', () => { const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([]); - expect(mockFs.access).not.toHaveBeenCalledWith(nodeModuleFile); }); it('should exclude virtual modules', async () => { @@ -122,7 +136,10 @@ describe('gatherExternalFiles', () => { { type: 'chunk', fileName: 'background.js', - moduleIds: ['/project/src/background.ts', virtualModule], + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + virtualModule, + ], } as OutputChunk, ], entrypoints: [], @@ -132,7 +149,6 @@ describe('gatherExternalFiles', () => { const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([]); - expect(mockFs.access).not.toHaveBeenCalledWith(virtualModule); }); it('should exclude HTTP URLs', async () => { @@ -147,7 +163,10 @@ describe('gatherExternalFiles', () => { { type: 'chunk', fileName: 'background.js', - moduleIds: ['/project/src/background.ts', httpUrl], + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + httpUrl, + ], } as OutputChunk, ], entrypoints: [], @@ -157,14 +176,11 @@ describe('gatherExternalFiles', () => { const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([]); - expect(mockFs.access).not.toHaveBeenCalledWith(httpUrl); }); it('should skip non-existent external files', async () => { - const nonExistentFile = '/parent/missing/file.ts'; - - // Mock fs.access to reject for non-existent file - mockFs.access.mockRejectedValue(new Error('File not found')); + // Use a path in external dir that we don't create (so it won't exist) + const nonExistentFile = path.join(externalDir, 'missing-file.ts'); const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -175,7 +191,10 @@ describe('gatherExternalFiles', () => { { type: 'chunk', fileName: 'background.js', - moduleIds: ['/project/src/background.ts', nonExistentFile], + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + nonExistentFile, + ], } as OutputChunk, ], entrypoints: [], @@ -185,20 +204,13 @@ describe('gatherExternalFiles', () => { const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([]); - expect(mockFs.access).toHaveBeenCalledWith(nonExistentFile); }); it('should handle multiple external files and deduplicate them', async () => { - const externalFile1 = '/parent/shared/utils.ts'; - const externalFile2 = '/parent/shared/types.ts'; - - // Mock fs.access to succeed for both external files - mockFs.access.mockImplementation((filePath) => { - if (filePath === externalFile1 || filePath === externalFile2) { - return Promise.resolve(); - } - return Promise.reject(new Error('File not found')); - }); + const externalFile1 = path.join(externalDir, 'utils.ts'); + const externalFile2 = path.join(externalDir, 'types.ts'); + await fs.writeFile(externalFile1, 'export const util = true;'); + await fs.writeFile(externalFile2, 'export type MyType = string;'); const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -210,7 +222,7 @@ describe('gatherExternalFiles', () => { type: 'chunk', fileName: 'background.js', moduleIds: [ - '/project/src/background.ts', + path.join(projectDir, 'src', 'background.ts'), externalFile1, externalFile2, externalFile1, // Duplicate should be ignored @@ -229,7 +241,8 @@ describe('gatherExternalFiles', () => { }); it('should only process chunk-type outputs', async () => { - const externalFile = '/parent/shared/utils.ts'; + const externalFile = path.join(externalDir, 'shared-utils.ts'); + await fs.writeFile(externalFile, 'export const shared = true;'); const buildOutput: BuildOutput = { manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, @@ -252,11 +265,7 @@ describe('gatherExternalFiles', () => { ], }; - // Mock fs.access to succeed - mockFs.access.mockResolvedValue(undefined); - const result = await gatherExternalFiles(buildOutput); expect(result).toEqual([externalFile]); - expect(mockFs.access).toHaveBeenCalledOnce(); }); }); diff --git a/packages/wxt/src/core/utils/external-files.ts b/packages/wxt/src/core/utils/external-files.ts index 2c45081c0..416df772b 100644 --- a/packages/wxt/src/core/utils/external-files.ts +++ b/packages/wxt/src/core/utils/external-files.ts @@ -36,11 +36,8 @@ export async function gatherExternalFiles( try { await fs.access(normalizedModuleId); externalFiles.add(normalizedModuleId); - } catch { - wxt.logger.debug( - `Skipping non-existent external file: ${normalizedModuleId}`, - ); - } + } catch (error) {} + } else { } } } diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 764d3fb36..b06784a41 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -133,14 +133,33 @@ async function zipDir( !minimatchMultiple(relativePath, options?.exclude) ); }); - const filesToZip = [ - ...files, - ...(options?.additionalFiles ?? []).map((file) => - path.relative(directory, file), - ), - ]; + // Handle additional files with special handling for external files + const additionalFiles = options?.additionalFiles ?? []; + const externalFileMap = new Map(); // zipPath -> originalPath + + const additionalRelativePaths = additionalFiles.map((file) => { + const relativePath = path.relative(directory, file); + + // If the relative path starts with ../, put it in an _external directory + // to avoid invalid relative paths in the zip + if (relativePath.startsWith('../')) { + const filename = path.basename(file); + const flatPath = `_external/${filename}`; + externalFileMap.set(flatPath, file); // Map flattened path to original path + return flatPath; + } + + return relativePath; + }); + + const filesToZip = [...files, ...additionalRelativePaths]; + for (const file of filesToZip) { - const absolutePath = path.resolve(directory, file); + // Use original path for external files, resolved path for regular files + const absolutePath = externalFileMap.has(file) + ? externalFileMap.get(file)! + : path.resolve(directory, file); + if (file.endsWith('.json')) { const content = await fs.readFile(absolutePath, 'utf-8'); archive.file(