Compare commits

..

9 Commits

Author SHA1 Message Date
GitHub Actions c298d8552b chore(release): v0.14.6 2024-01-24 06:34:25 +00:00
Aaron 87267347f3 feat: Add basic validation for entrypoint options (#368) 2024-01-24 00:31:05 -06:00
Aaron 326e84b667 feat: Restart dev mode when saving config (#365) 2024-01-19 22:58:49 -06:00
Aaron Klinker 4b005b450e ci: Check for type errors in demo before building 2024-01-19 22:57:06 -06:00
Aaron 3bf40fc4ed fix: Sort build output files naturally (#364) 2024-01-19 11:35:09 -06:00
Aaron 9ace395e11 fix: Add subdependency bin directory so wxt build --analyze works with PNPM (#363) 2024-01-19 11:32:18 -06:00
GitHub Actions 64f42064ae chore(release): v0.14.5 2024-01-19 14:36:07 +00:00
Aaron a525d786f1 feat: Add dev.reloadCommand config (#362) 2024-01-19 08:33:03 -06:00
Aaron 948ee810da fix: Disable reload dev command when 4 commands are already registered (#361) 2024-01-19 07:47:04 -06:00
23 changed files with 772 additions and 121 deletions
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm build:all
working-directory: demo
- run: pnpm tsc --noEmit
working-directory: demo
- run: pnpm build:all
working-directory: demo
- run: pnpm wxt zip
working-directory: demo
- run: pnpm vitest run
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## v0.14.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.5...v0.14.6)
### 🚀 Enhancements
- Restart dev mode when saving config ([#365](https://github.com/wxt-dev/wxt/pull/365))
- Add basic validation for entrypoint options ([#368](https://github.com/wxt-dev/wxt/pull/368))
### 🩹 Fixes
- Add subdependency bin directory so `wxt build --analyze` works with PNPM ([#363](https://github.com/wxt-dev/wxt/pull/363))
- Sort build output files naturally ([#364](https://github.com/wxt-dev/wxt/pull/364))
### 🤖 CI
- Check for type errors in demo before building ([4b005b4](https://github.com/wxt-dev/wxt/commit/4b005b4))
## v0.14.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.4...v0.14.5)
### 🚀 Enhancements
- Add `dev.reloadCommand` config ([#362](https://github.com/wxt-dev/wxt/pull/362))
### 🩹 Fixes
- Disable reload dev command when 4 commands are already registered ([#361](https://github.com/wxt-dev/wxt/pull/361))
## v0.14.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.3...v0.14.4)
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.14.4",
"version": "0.14.6",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -125,7 +125,9 @@
"jiti": "^1.21.0",
"json5": "^2.2.3",
"linkedom": "^0.16.1",
"manage-path": "^2.0.0",
"minimatch": "^9.0.3",
"natural-compare": "^1.4.0",
"normalize-path": "^3.0.0",
"ora": "^7.0.1",
"picocolors": "^1.0.0",
@@ -141,6 +143,7 @@
"@faker-js/faker": "^8.3.1",
"@types/fs-extra": "^11.0.4",
"@types/lodash.merge": "^4.6.9",
"@types/natural-compare": "^1.4.3",
"@types/node": "^20.10.3",
"@types/normalize-path": "^3.0.2",
"@types/prompts": "^2.4.9",
+21
View File
@@ -74,9 +74,15 @@ importers:
linkedom:
specifier: ^0.16.1
version: 0.16.1
manage-path:
specifier: ^2.0.0
version: 2.0.0
minimatch:
specifier: ^9.0.3
version: 9.0.3
natural-compare:
specifier: ^1.4.0
version: 1.4.0
normalize-path:
specifier: ^3.0.0
version: 3.0.0
@@ -117,6 +123,9 @@ importers:
'@types/lodash.merge':
specifier: ^4.6.9
version: 4.6.9
'@types/natural-compare':
specifier: ^1.4.3
version: 1.4.3
'@types/node':
specifier: ^20.10.3
version: 20.10.3
@@ -1106,6 +1115,10 @@ packages:
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
dev: false
/@types/natural-compare@1.4.3:
resolution: {integrity: sha512-XCAxy+Gg6+S6VagwzcknnvCKujj/bVv1q+GFuCrFEelqaZPqJoC+FeXLwc2dp+oLP7qDZQ4ZfQiTJQ9sIUmlLw==}
dev: true
/@types/node@20.10.3:
resolution: {integrity: sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==}
dependencies:
@@ -3352,6 +3365,10 @@ packages:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: false
/manage-path@2.0.0:
resolution: {integrity: sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==}
dev: false
/mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
dev: true
@@ -3533,6 +3550,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false
/ncp@2.0.0:
resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==}
hasBin: true
+6 -1
View File
@@ -4,6 +4,7 @@ import { build, clean, createServer, initialize, prepare, zip } from '~/core';
import consola, { LogLevels } from 'consola';
import { printHeader } from '~/core/utils/log';
import { formatDuration } from '~/core/utils/time';
import { ValidationError } from './core/utils/validation';
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
process.env.VITE_CJS_IGNORE_WARNING = 'true';
@@ -174,7 +175,11 @@ function wrapAction(
consola.fail(
`Command failed after ${formatDuration(Date.now() - startTime)}`,
);
consola.error(err);
if (err instanceof ValidationError) {
// Don't log these errors, they've already been logged
} else {
consola.error(err);
}
process.exit(1);
}
};
+3
View File
@@ -230,6 +230,9 @@ export async function createViteBuilder(
async listen() {
await viteServer.listen(info.port);
},
async close() {
await viteServer.close();
},
transformHtml(...args) {
return viteServer.transformIndexHtml(...args);
},
+65 -16
View File
@@ -49,22 +49,46 @@ export async function createServer(
origin,
};
const buildAndOpenBrowser = async () => {
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild(config);
// Open browser after everything is ready to go.
await runner.openBrowser(config);
};
/**
* Stops the previous runner, grabs the latest config, and recreates the runner.
*/
const closeAndRecreateRunner = async () => {
await runner.closeBrowser();
config = await getLatestConfig();
runner = await createExtensionRunner(config);
};
// Server instance must be created first so its reference can be added to the internal config used
// to pre-render entrypoints
const server: WxtDevServer = {
...serverInfo,
watcher: undefined as any, // Filled out later down below
ws: undefined as any, // Filled out later down below
currentOutput: undefined as any, // Filled out later down below
get watcher() {
return builderServer.watcher;
},
get ws() {
return builderServer.ws;
},
currentOutput: undefined,
async start() {
await builderServer.listen();
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
// Build after starting the dev server so it can be used to transform HTML files
server.currentOutput = await internalBuild(config);
// Open browser after everything is ready to go.
await runner.openBrowser(config);
await buildAndOpenBrowser();
},
async stop() {
await runner.closeBrowser();
await builderServer.close();
},
async restart() {
await closeAndRecreateRunner();
await buildAndOpenBrowser();
},
transformHtml(url, html, originalUrl) {
return builderServer.transformHtml(url, html, originalUrl);
@@ -78,23 +102,25 @@ export async function createServer(
reloadExtension() {
server.ws.send('wxt:reload-extension');
},
async restartBrowser() {
await closeAndRecreateRunner();
await runner.openBrowser(config);
},
};
const getLatestConfig = () =>
getInternalConfig(inlineConfig ?? {}, 'serve', server);
let config = await getLatestConfig();
const [runner, builderServer] = await Promise.all([
let [runner, builderServer] = await Promise.all([
createExtensionRunner(config),
config.builder.createServer(server),
]);
server.watcher = builderServer.watcher;
server.ws = builderServer.ws;
// Register content scripts for the first time after the background starts up since they're not
// listed in the manifest
server.ws.on('wxt:background-initialized', () => {
if (server.currentOutput == null) return;
reloadContentScripts(server.currentOutput.steps, config, server);
});
@@ -138,18 +164,39 @@ function createFileReloader(options: {
changeQueue.push([event, path]);
await fileChangedMutex.runExclusive(async () => {
const fileChanges = changeQueue.splice(0, changeQueue.length);
if (server.currentOutput == null) return;
const fileChanges = changeQueue
.splice(0, changeQueue.length)
.map(([_, file]) => file);
if (fileChanges.length === 0) return;
const changes = detectDevChanges(fileChanges, server.currentOutput);
const changes = detectDevChanges(
config,
fileChanges,
server.currentOutput,
);
if (changes.type === 'no-change') return;
if (changes.type === 'full-restart') {
config.logger.info('Config changed, restarting server...');
server.restart();
return;
}
if (changes.type === 'browser-restart') {
config.logger.info('Runner config changed, restarting browser...');
server.restartBrowser();
return;
}
// Log the entrypoints that were effected
config.logger.info(
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
`Changed: ${Array.from(new Set(fileChanges))
.map((file) => pc.dim(relative(config.root, file)))
.join(', ')}`,
);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => {
@@ -197,6 +244,8 @@ function reloadContentScripts(
) {
if (config.manifestVersion === 3) {
steps.forEach((step) => {
if (server.currentOutput == null) return;
const entry = step.entrypoints;
if (Array.isArray(entry) || entry.type !== 'content-script') return;
+5 -3
View File
@@ -1,5 +1,6 @@
import type { WebExtRunInstance } from 'web-ext-run';
import { ExtensionRunner } from '~/types';
import { formatDuration } from '../utils/time';
/**
* Create an `ExtensionRunner` backed by `web-ext`.
@@ -9,7 +10,7 @@ export function createWebExtRunner(): ExtensionRunner {
return {
async openBrowser(config) {
config.logger.info('Opening browser...');
const startTime = Date.now();
if (config.browser === 'firefox' && config.manifestVersion === 3) {
throw Error(
@@ -17,7 +18,7 @@ export function createWebExtRunner(): ExtensionRunner {
);
}
// Use the plugin's logger instead of web-ext's built-in one.
// Use WXT's logger instead of web-ext's built-in one.
const webExtLogger = await import('web-ext-run/util/logger');
webExtLogger.consoleStream.write = ({ level, msg, name }) => {
if (level >= ERROR_LOG_LEVEL) config.logger.error(name, msg);
@@ -61,7 +62,8 @@ export function createWebExtRunner(): ExtensionRunner {
const webExt = await import('web-ext-run');
runner = await webExt.default.cmd.run(finalConfig, options);
config.logger.success('Opened!');
const duration = Date.now() - startTime;
config.logger.success(`Opened browser in ${formatDuration(duration)}`);
},
async closeBrowser() {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { every } from '~/core/utils/arrays';
import { every, some } from '~/core/utils/arrays';
describe('Array Utils', () => {
describe('every', () => {
@@ -15,4 +15,20 @@ describe('Array Utils', () => {
expect(every([1, 2, 1], (item) => item === 1)).toBe(false);
});
});
describe('some', () => {
it('should return true if one value returns true', () => {
const array = [1, 2, 3];
const predicate = (item: number) => item === 2;
expect(some(array, predicate)).toBe(true);
});
it('should return false if no values match', () => {
const array = [1, 2, 3];
const predicate = (item: number) => item === 4;
expect(some(array, predicate)).toBe(false);
});
});
});
+171 -37
View File
@@ -6,6 +6,7 @@ import {
fakeBuildOutput,
fakeEntrypoint,
fakeInternalConfig,
fakeManifestCommand,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
} from '../testing/fake-objects';
@@ -52,7 +53,11 @@ describe('Manifest Utils', () => {
},
};
const actual = await generateManifest([popup], buildOutput, config);
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -79,7 +84,11 @@ describe('Manifest Utils', () => {
default_popup: 'popup.html',
};
const actual = await generateManifest([popup], buildOutput, config);
const { manifest: actual } = await generateManifest(
[popup],
buildOutput,
config,
);
expect(actual[expectedType]).toEqual(expected);
},
@@ -102,7 +111,11 @@ describe('Manifest Utils', () => {
action: config.manifest.action,
};
const actual = await generateManifest([], buildOutput, config);
const { manifest: actual } = await generateManifest(
[],
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -131,7 +144,11 @@ describe('Manifest Utils', () => {
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
const { manifest: actual } = await generateManifest(
[options],
buildOutput,
config,
);
expect(actual.options_ui).toEqual(expected);
});
@@ -149,7 +166,11 @@ describe('Manifest Utils', () => {
page: 'options.html',
};
const actual = await generateManifest([options], buildOutput, config);
const { manifest: actual } = await generateManifest(
[options],
buildOutput,
config,
);
expect(actual.options_ui).toEqual(expected);
});
@@ -179,7 +200,7 @@ describe('Manifest Utils', () => {
service_worker: 'background.js',
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -201,7 +222,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -226,7 +247,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -248,7 +269,7 @@ describe('Manifest Utils', () => {
scripts: ['background.js'],
};
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
[background],
buildOutput,
config,
@@ -274,7 +295,11 @@ describe('Manifest Utils', () => {
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toEqual({
16: 'icon-16.png',
@@ -296,7 +321,11 @@ describe('Manifest Utils', () => {
});
const config = fakeInternalConfig();
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toBeUndefined();
});
@@ -323,7 +352,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.icons).toEqual(expected);
});
@@ -423,7 +456,11 @@ describe('Manifest Utils', () => {
],
};
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
@@ -480,7 +517,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.content_scripts).toContainEqual(userContentScript);
expect(actual.content_scripts).toContainEqual(generatedContentScript);
@@ -516,7 +557,7 @@ describe('Manifest Utils', () => {
command: 'build',
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -561,7 +602,7 @@ describe('Manifest Utils', () => {
command: 'build',
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -604,7 +645,7 @@ describe('Manifest Utils', () => {
manifestVersion: 3,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -646,7 +687,7 @@ describe('Manifest Utils', () => {
manifestVersion: 2,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -685,7 +726,7 @@ describe('Manifest Utils', () => {
manifestVersion: 3,
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -735,7 +776,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
{ resources: ['one.png'], matches: ['*://one.com/*'] },
@@ -777,7 +822,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.web_accessible_resources).toEqual([
'one.png',
@@ -800,7 +849,11 @@ describe('Manifest Utils', () => {
author: newAuthor,
};
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual).toMatchObject(expected);
});
@@ -822,7 +875,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -848,7 +901,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -873,7 +926,7 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
@@ -894,7 +947,11 @@ describe('Manifest Utils', () => {
},
});
const actual = await generateManifest(entrypoints, buildOutput, config);
const { manifest: actual } = await generateManifest(
entrypoints,
buildOutput,
config,
);
expect(actual.version).toBe('0.0.0');
expect(actual.version_name).toBeUndefined();
@@ -908,6 +965,7 @@ describe('Manifest Utils', () => {
describe('commands', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
description: expect.any(String),
suggested_key: {
default: 'Alt+R',
},
@@ -918,21 +976,65 @@ describe('Manifest Utils', () => {
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toMatchObject({
expect(actual.commands).toEqual({
[reloadCommandName]: reloadCommand,
});
});
it('should customize the reload commands key binding if passing a custom command', async () => {
const config = fakeInternalConfig({
command: 'serve',
dev: {
reloadCommand: 'Ctrl+E',
},
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toEqual({
[reloadCommandName]: {
...reloadCommand,
suggested_key: {
default: 'Ctrl+E',
},
},
});
});
it("should not include the reload command when it's been disabled", async () => {
const config = fakeInternalConfig({
command: 'serve',
dev: {
reloadCommand: false,
},
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toBeUndefined();
});
it('should not override any existing commands when adding the one to reload the extension', async () => {
const customCommandName = 'custom-command';
const customCommand = {
description: 'Some other command',
suggested_key: {
default: 'Ctrl+H',
},
};
const customCommand = fakeManifestCommand();
const config = fakeInternalConfig({
command: 'serve',
manifest: {
@@ -944,20 +1046,52 @@ describe('Manifest Utils', () => {
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toMatchObject({
expect(actual.commands).toEqual({
[reloadCommandName]: reloadCommand,
[customCommandName]: customCommand,
});
});
it('should not include the command if there are already 4 others (the max)', async () => {
const commands = {
command1: fakeManifestCommand(),
command2: fakeManifestCommand(),
command3: fakeManifestCommand(),
command4: fakeManifestCommand(),
};
const config = fakeInternalConfig({
command: 'serve',
manifest: { commands },
});
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const { manifest: actual, warnings } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toEqual(commands);
expect(warnings).toHaveLength(1);
});
it('should not include the command when building an extension', async () => {
const config = fakeInternalConfig({ command: 'build' });
const output = fakeBuildOutput();
const entrypoints = fakeArray(fakeEntrypoint);
const actual = await generateManifest(entrypoints, output, config);
const { manifest: actual } = await generateManifest(
entrypoints,
output,
config,
);
expect(actual.commands).toBeUndefined();
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import {
fakeArray,
fakeContentScriptEntrypoint,
fakeEntrypoint,
fakeGenericEntrypoint,
} from '../testing/fake-objects';
import { validateEntrypoints } from '../validation';
describe('Validation Utils', () => {
describe('validateEntrypoints', () => {
it('should return no errors when there are no errors', () => {
const entrypoints = fakeArray(fakeEntrypoint);
const expected = {
errors: [],
errorCount: 0,
warningCount: 0,
};
const actual = validateEntrypoints(entrypoints);
expect(actual).toEqual(expected);
});
it('should return an error when exclude is not an array', () => {
const entrypoint = fakeGenericEntrypoint({
options: {
// @ts-expect-error
exclude: 0,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`exclude` must be an array of browser names',
value: 0,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
it('should return an error when include is not an array', () => {
const entrypoint = fakeGenericEntrypoint({
options: {
// @ts-expect-error
include: 0,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`include` must be an array of browser names',
value: 0,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
it("should return an error when content scripts don't have a matches", () => {
const entrypoint = fakeContentScriptEntrypoint({
options: {
// @ts-expect-error
matches: null,
},
});
const expected = {
errors: [
{
type: 'error',
message: '`matches` is required',
value: null,
entrypoint,
},
],
errorCount: 1,
warningCount: 0,
};
const actual = validateEntrypoints([entrypoint]);
expect(actual).toEqual(expected);
});
});
});
+12
View File
@@ -9,3 +9,15 @@ export function every<T>(
if (!predicate(array[i], i)) return false;
return true;
}
/**
* Returns true when any of the predicates return true;
*/
export function some<T>(
array: T[],
predicate: (item: T, index: number) => boolean,
): boolean {
for (let i = 0; i < array.length; i++)
if (predicate(array[i], i)) return true;
return false;
}
@@ -10,22 +10,15 @@ import {
fakePopupEntrypoint,
fakeOutputAsset,
fakeOutputChunk,
fakeInternalConfig,
} from '~/core/utils/testing/fake-objects';
import { BuildOutput, BuildStepOutput } from '~/types';
describe('Detect Dev Changes', () => {
describe('No changes', () => {
it("should return 'no-change' when a build hasn't finished", () => {
const actual = detectDevChanges(
[['unknown', '/path/to/file.ts']],
undefined,
);
expect(actual).toEqual({ type: 'no-change' });
});
it("should return 'no-change' when the changed file isn't used by any of the entrypoints", () => {
const change: [string, string] = ['unknown', '/some/path.ts'];
const config = fakeInternalConfig();
const changes = ['/some/path.ts'];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
@@ -41,18 +34,64 @@ describe('Detect Dev Changes', () => {
],
};
const actual = detectDevChanges([change], currentOutput);
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual({ type: 'no-change' });
});
});
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 = fakeInternalConfig({
userConfigMetadata: {
configFile,
},
});
const changes = ['/root/src/public/image.svg', configFile];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [],
};
const expected: DevModeChange = {
type: 'full-restart',
};
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
});
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 config = fakeInternalConfig({
runnerConfig: {
configFile: runnerFile,
},
});
const changes = ['/root/src/public/image.svg', runnerFile];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [],
};
const expected: DevModeChange = {
type: 'browser-restart',
};
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
});
describe('Public Assets', () => {
it("should return 'extension-reload' without any groups to rebuild when the changed file is a public asset", () => {
const change: [string, string] = [
'unknown',
'/root/src/public/image.svg',
];
const config = fakeInternalConfig();
const changes = ['/root/src/public/image.svg'];
const asset1 = fakeOutputAsset({
fileName: 'image.svg',
});
@@ -73,7 +112,7 @@ describe('Detect Dev Changes', () => {
},
};
const actual = detectDevChanges([change], currentOutput);
const actual = detectDevChanges(config, changes, currentOutput);
expect(actual).toEqual(expected);
});
@@ -81,6 +120,7 @@ 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 config = fakeInternalConfig();
const changedPath = '/root/utils/shared.ts';
const contentScript = fakeContentScriptEntrypoint({
inputPath: '/root/overlay.content.ts',
@@ -120,10 +160,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [background],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
@@ -131,6 +168,7 @@ describe('Detect Dev Changes', () => {
describe('HTML Pages', () => {
it('should rebuild then reload only the effected pages', async () => {
const config = fakeInternalConfig();
const changedPath = '/root/page1/index.html';
const htmlPage1 = fakePopupEntrypoint({
inputPath: changedPath,
@@ -174,10 +212,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [[htmlPage1, htmlPage2]],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
@@ -185,6 +220,7 @@ describe('Detect Dev Changes', () => {
describe('Content Scripts', () => {
it('should rebuild then reload only the effected content scripts', async () => {
const config = fakeInternalConfig();
const changedPath = '/root/utils/shared.ts';
const script1 = fakeContentScriptEntrypoint({
inputPath: '/root/overlay1.content/index.ts',
@@ -236,10 +272,7 @@ describe('Detect Dev Changes', () => {
rebuildGroups: [script1, script3],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
const actual = detectDevChanges(config, [changedPath], currentOutput);
expect(actual).toEqual(expected);
});
+30 -9
View File
@@ -2,10 +2,11 @@ import {
BuildOutput,
BuildStepOutput,
EntrypointGroup,
InternalConfig,
OutputAsset,
OutputFile,
} from '~/types';
import { every } from '~/core/utils/arrays';
import { every, some } from '~/core/utils/arrays';
import { normalizePath } from '~/core/utils/paths';
/**
@@ -29,10 +30,21 @@ import { normalizePath } from '~/core/utils/paths';
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
*/
export function detectDevChanges(
changedFiles: [event: string, path: string][],
currentOutput: BuildOutput | undefined,
config: InternalConfig,
changedFiles: string[],
currentOutput: BuildOutput,
): DevModeChange {
if (currentOutput == null) return { type: 'no-change' };
const isConfigChange = some(
changedFiles,
(file) => file === config.userConfigMetadata.configFile,
);
if (isConfigChange) return { type: 'full-restart' };
const isRunnerChange = some(
changedFiles,
(file) => file === config.runnerConfig.configFile,
);
if (isRunnerChange) return { type: 'browser-restart' };
const changedSteps = new Set(
changedFiles.flatMap((changedFile) =>
@@ -69,7 +81,7 @@ export function detectDevChanges(
const isOnlyHtmlChanges =
changedFiles.length > 0 &&
every(changedFiles, ([_, file]) => file.endsWith('.html'));
every(changedFiles, (file) => file.endsWith('.html'));
if (isOnlyHtmlChanges) {
return {
type: 'html-reload',
@@ -104,11 +116,11 @@ export function detectDevChanges(
* For a single change, return all the step of the build output that were effected by it.
*/
function findEffectedSteps(
changedFile: [event: string, path: string],
changedFile: string,
currentOutput: BuildOutput,
): DetectedChange[] {
const changes: DetectedChange[] = [];
const changedPath = normalizePath(changedFile[1]);
const changedPath = normalizePath(changedFile);
const isChunkEffected = (chunk: OutputFile): boolean =>
// If it's an HTML file with the same path, is is effected because HTML files need to be pre-rendered
@@ -139,8 +151,9 @@ export type DevModeChange =
| NoChange
| HtmlReload
| ExtensionReload
| ContentScriptReload;
// | BrowserRestart
| ContentScriptReload
| FullRestart
| BrowserRestart;
interface NoChange {
type: 'no-change';
@@ -157,6 +170,14 @@ interface RebuildChange {
cachedOutput: BuildOutput;
}
interface FullRestart {
type: 'full-restart';
}
interface BrowserRestart {
type: 'browser-restart';
}
interface HtmlReload extends RebuildChange {
type: 'html-reload';
}
@@ -79,6 +79,7 @@ export async function getInternalConfig(
const typesDir = path.resolve(wxtDir, 'types');
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
const reloadCommand = mergedConfig.dev?.reloadCommand ?? 'Alt+R';
const runnerConfig = await loadConfig<ExtensionRunnerConfig>({
name: 'web-ext',
@@ -136,6 +137,9 @@ export async function getInternalConfig(
mergedConfig.experimental?.includeBrowserPolyfill ?? true,
},
server,
dev: {
reloadCommand,
},
};
const builder = await createViteBuilder(
@@ -221,6 +225,10 @@ function mergeInlineConfig(
},
vite: undefined,
transformManifest: undefined,
dev: {
...userConfig.dev,
...inlineConfig.dev,
},
};
}
+65 -2
View File
@@ -1,5 +1,5 @@
import { findEntrypoints } from './find-entrypoints';
import { InternalConfig, BuildOutput } from '~/types';
import { InternalConfig, BuildOutput, Entrypoint } from '~/types';
import pc from 'picocolors';
import fs from 'fs-extra';
import { groupEntrypoints } from './group-entrypoints';
@@ -8,6 +8,15 @@ import { printBuildSummary } from '~/core/utils/log';
import glob from 'fast-glob';
import { unnormalizePath } from '~/core/utils/paths';
import { rebuild } from './rebuild';
import managePath from 'manage-path';
import { resolve, relative } from 'node:path';
import {
ValidationError,
ValidationResult,
ValidationResults,
validateEntrypoints,
} from '../validation';
import consola from 'consola';
/**
* Builds the extension based on an internal config. No more config discovery is performed, the
@@ -37,8 +46,24 @@ export async function internalBuild(
const entrypoints = await findEntrypoints(config);
config.logger.debug('Detected entrypoints:', entrypoints);
const validationResults = validateEntrypoints(entrypoints);
if (validationResults.errorCount + validationResults.warningCount > 0) {
printValidationResults(config, validationResults);
}
if (validationResults.errorCount > 0) {
throw new ValidationError(`Entrypoint validation failed`, {
cause: validationResults,
});
}
const groups = groupEntrypoints(entrypoints);
const { output } = await rebuild(config, entrypoints, groups, undefined);
const { output, warnings } = await rebuild(
config,
entrypoints,
groups,
undefined,
);
// Post-build
await printBuildSummary(
@@ -48,6 +73,10 @@ export async function internalBuild(
config,
);
for (const warning of warnings) {
config.logger.warn(...warning);
}
if (config.analysis.enabled) {
await combineAnalysisStats(config);
config.logger.info(
@@ -66,6 +95,10 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
});
const absolutePaths = unixFiles.map(unnormalizePath);
const alterPath = managePath(process.env);
// Add subdependency path for PNPM shamefully-hoist=false
alterPath.push(resolve(config.root, 'node_modules/wxt/node_modules/.bin'));
await execaCommand(
`rollup-plugin-visualizer ${absolutePaths.join(' ')} --template ${
config.analysis.template
@@ -73,3 +106,33 @@ async function combineAnalysisStats(config: InternalConfig): Promise<void> {
{ cwd: config.root, stdio: 'inherit' },
);
}
function printValidationResults(
config: InternalConfig,
{ errorCount, errors, warningCount }: ValidationResults,
) {
(errorCount > 0 ? config.logger.error : config.logger.warn)(
`Entrypoint validation failed: ${errorCount} error${
errorCount === 1 ? '' : 's'
}, ${warningCount} warning${warningCount === 1 ? '' : 's'}`,
);
const cwd = process.cwd();
const entrypointErrors = errors.reduce((map, error) => {
const entryErrors = map.get(error.entrypoint) ?? [];
entryErrors.push(error);
map.set(error.entrypoint, entryErrors);
return map;
}, new Map<Entrypoint, ValidationResult[]>());
Array.from(entrypointErrors.entries()).forEach(([entrypoint, errors]) => {
consola.log(relative(cwd, entrypoint.inputPath));
console.log();
errors.forEach((err) => {
const type = err.type === 'error' ? pc.red('ERROR') : pc.yellow('WARN');
const recieved = pc.dim(`(recieved: ${JSON.stringify(err.value)})`);
consola.log(` - ${type} ${err.message} ${recieved}`);
});
console.log();
});
}
+8 -6
View File
@@ -33,7 +33,11 @@ export async function rebuild(
steps: [],
publicAssets: [],
},
): Promise<{ output: BuildOutput; manifest: Manifest.WebExtensionManifest }> {
): Promise<{
output: BuildOutput;
manifest: Manifest.WebExtensionManifest;
warnings: any[][];
}> {
const { default: ora } = await import('ora');
const spinner = ora(`Preparing...`).start();
@@ -51,11 +55,8 @@ export async function rebuild(
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
};
const newManifest = await generateManifest(
allEntrypoints,
mergedOutput,
config,
);
const { manifest: newManifest, warnings: manifestWarnings } =
await generateManifest(allEntrypoints, mergedOutput, config);
const finalOutput: BuildOutput = {
manifest: newManifest,
...newOutput,
@@ -77,5 +78,6 @@ export async function rebuild(
],
},
manifest: newManifest,
warnings: manifestWarnings,
};
}
+23 -12
View File
@@ -54,7 +54,8 @@ export async function generateManifest(
entrypoints: Entrypoint[],
buildOutput: Omit<BuildOutput, 'manifest'>,
config: InternalConfig,
): Promise<Manifest.WebExtensionManifest> {
): Promise<{ manifest: Manifest.WebExtensionManifest; warnings: any[][] }> {
const warnings: any[][] = [];
const pkg = await getPackageJson(config);
let versionName =
@@ -75,16 +76,6 @@ export async function generateManifest(
short_name: pkg?.shortName,
icons: discoverIcons(buildOutput),
};
if (config.command === 'serve') {
baseManifest.commands = {
'wxt:reload-extension': {
description: 'Reload the extension during development',
suggested_key: {
default: 'Alt+R',
},
},
};
}
const userManifest = config.manifest;
const manifest = defu(
@@ -92,6 +83,23 @@ export async function generateManifest(
baseManifest,
) as Manifest.WebExtensionManifest;
// Add reload command in dev mode
if (config.command === 'serve' && config.dev.reloadCommand) {
if (manifest.commands && Object.keys(manifest.commands).length >= 4) {
warnings.push([
"Extension already has 4 registered commands, WXT's reload command is disabled",
]);
} else {
manifest.commands ??= {};
manifest.commands['wxt:reload-extension'] = {
description: 'Reload the extension during development',
suggested_key: {
default: config.dev.reloadCommand,
},
};
}
}
// Apply the final version fields after merging the user manifest
manifest.version = version;
manifest.version_name =
@@ -117,7 +125,10 @@ export async function generateManifest(
);
}
return finalManifest;
return {
manifest: finalManifest,
warnings,
};
}
/**
+12 -1
View File
@@ -4,7 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import type { Manifest } from '~/browser';
import { Commands, type Manifest } from '~/browser';
import {
FsCache,
InternalConfig,
@@ -244,6 +244,9 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
includeBrowserPolyfill: true,
},
builder: mock(),
dev: {
reloadCommand: 'Alt+R',
},
};
});
@@ -257,3 +260,11 @@ export const fakeBuildStepOutput = fakeObjectCreator<BuildStepOutput>(() => ({
chunks: fakeArray(fakeOutputChunk),
entrypoints: fakeArray(fakeEntrypoint),
}));
export const fakeManifestCommand = fakeObjectCreator<Commands.Command>(() => ({
description: faker.string.sample(),
shortcut: `${faker.helpers.arrayElement(['ctrl', 'alt'])}+${faker.number.int({
min: 0,
max: 9,
})}`,
}));
+86
View File
@@ -0,0 +1,86 @@
import { ContentScriptEntrypoint, Entrypoint } from '~/types';
export function validateEntrypoints(
entrypoints: Entrypoint[],
): ValidationResults {
const errors = entrypoints.flatMap((entrypoint) => {
switch (entrypoint.type) {
case 'content-script':
return validateContentScriptEntrypoint(entrypoint);
default:
return validateBaseEntrypoint(entrypoint);
}
});
let errorCount = 0;
let warningCount = 0;
for (const err of errors) {
if (err.type === 'warning') warningCount++;
else errorCount++;
}
return {
errors,
errorCount,
warningCount,
};
}
function validateContentScriptEntrypoint(
definition: ContentScriptEntrypoint,
): ValidationResult[] {
const errors = validateBaseEntrypoint(definition);
if (definition.options.matches == null) {
errors.push({
type: 'error',
message: '`matches` is required',
value: definition.options.matches,
entrypoint: definition,
});
}
return errors;
}
function validateBaseEntrypoint(definition: Entrypoint): ValidationResult[] {
const errors: ValidationResult[] = [];
if (
definition.options.exclude != null &&
!Array.isArray(definition.options.exclude)
) {
errors.push({
type: 'error',
message: '`exclude` must be an array of browser names',
value: definition.options.exclude,
entrypoint: definition,
});
}
if (
definition.options.include != null &&
!Array.isArray(definition.options.include)
) {
errors.push({
type: 'error',
message: '`include` must be an array of browser names',
value: definition.options.include,
entrypoint: definition,
});
}
return errors;
}
export interface ValidationResult {
type: 'warning' | 'error';
message: string;
entrypoint: Entrypoint;
value: any;
}
export interface ValidationResults {
errors: ValidationResult[];
errorCount: number;
warningCount: number;
}
export class ValidationError extends Error {}
+36 -2
View File
@@ -229,6 +229,24 @@ export interface InlineConfig {
*/
includeBrowserPolyfill?: boolean;
};
/**
* Config effecting dev mode only.
*/
dev?: {
/**
* Controls whether a custom keyboard shortcut command, `Alt+R`, is added during dev mode to
* quickly reload the extension.
*
* If false, the shortcut is not added during development.
*
* If set to a custom string, you can override the key combo used. See
* [Chrome's command docs](https://developer.chrome.com/docs/extensions/reference/api/commands)
* for available options.
*
* @default "Alt+R"
*/
reloadCommand?: string | false;
};
}
// TODO: Extract to @wxt/vite-builder and use module augmentation to include the vite field
@@ -290,16 +308,24 @@ export interface BuildStepOutput {
}
export interface WxtDevServer
extends Omit<WxtBuilderServer, 'listen'>,
extends Omit<WxtBuilderServer, 'listen' | 'close'>,
ServerInfo {
/**
* Stores the current build output of the server.
*/
currentOutput: BuildOutput;
currentOutput: BuildOutput | undefined;
/**
* Start the server.
*/
start(): Promise<void>;
/**
* Stop the server.
*/
stop(): Promise<void>;
/**
* Close the browser, stop the server, rebuild the entire extension, and start the server again.
*/
restart(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
@@ -332,6 +358,10 @@ export interface WxtDevServer
reloadContentScript: (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => void;
/**
* Grab the latest runner config and restart the browser.
*/
restartBrowser: () => void;
}
export type TargetBrowser = string;
@@ -674,6 +704,10 @@ export interface WxtBuilderServer {
* Start the server.
*/
listen(): Promise<void>;
/**
* Stop the server.
*/
close(): Promise<void>;
/**
* Transform the HTML for dev mode.
*/
+3
View File
@@ -57,6 +57,9 @@ export interface InternalConfig {
includeBrowserPolyfill: boolean;
};
builder: WxtBuilder;
dev: {
reloadCommand: string | false;
};
}
export interface FsCache {
+4
View File
@@ -50,6 +50,10 @@ declare module 'web-ext-run/util/logger' {
export const consoleStream: IConsoleStream;
}
declare module 'manage-path' {
export default function managePath(env: object): string[];
}
declare module 'wxt/browser' {
// Overridden when types are generated per project
export type PublicPath = string;