Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6d66ea1
test(firefox): spike xpi cache metadata in zip comment
davidmurdoch Apr 1, 2026
f5dba5c
test(firefox): simplify xpi manifest patching
davidmurdoch Apr 1, 2026
8558906
test(firefox): simplify xpi archive walk
davidmurdoch Apr 1, 2026
54a12f1
test(firefox): extract xpi cache helper
davidmurdoch Apr 2, 2026
8e7ab08
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 2, 2026
ce49951
test(firefox): remove xpi benchmark script
davidmurdoch Apr 2, 2026
3a117b0
perf(firefox): compress cached xpi payload
davidmurdoch Apr 2, 2026
701e1c9
build(webpack): add test-only manifest build ids
davidmurdoch Apr 3, 2026
36f7d7b
test(webpack): simplify manifest build id watch test
davidmurdoch Apr 3, 2026
747a4e7
chore(e2e): remove unused xpi helper import
davidmurdoch Apr 4, 2026
0b1269e
fix(e2e): validate cached manifest size
davidmurdoch Apr 4, 2026
ef2c178
fix(e2e): invalidate xpi cache on test rebuilds
davidmurdoch Apr 4, 2026
6296b80
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 4, 2026
2b643c9
docs(e2e): trim xpi helper jsdoc
davidmurdoch Apr 4, 2026
763766f
fix(e2e): close xpi stream before rename
davidmurdoch Apr 5, 2026
b552779
fix(build): serialize manifest watch updates
davidmurdoch Apr 5, 2026
838e6d9
types
davidmurdoch Apr 5, 2026
be54697
fix(e2e): rebuild xpi when build changes
davidmurdoch Apr 6, 2026
65b6045
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 6, 2026
46760b6
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 7, 2026
5f360d8
clarify
davidmurdoch Apr 7, 2026
2b96682
test(webpack): fail watch helper on build errors
davidmurdoch Apr 8, 2026
1034418
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 8, 2026
ddeadaf
chore(build): ignore manifest watch events
davidmurdoch Apr 8, 2026
d9bb0f2
refactor(webpack): simplify manifest build id emit
davidmurdoch Apr 8, 2026
f1cf4a7
Merge branch 'main' into codex/firefox-xpi-comment-metadata-spike
davidmurdoch Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 61 additions & 26 deletions development/build/manifest.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const nodeCrypto = require('node:crypto');
const { promises: fs } = require('fs');
const path = require('path');
const childProcess = require('child_process');
const childProcess = require('node:child_process');
const watch = require('gulp-watch');
const { mergeWith, cloneDeep } = require('lodash');
const { isManifestV3 } = require('../../shared/lib/mv3.utils');

Expand Down Expand Up @@ -105,30 +107,36 @@ function createManifestTasks({
});

// testDev: add perms
const envTestDev = createTaskForModifyManifestForEnvironment((manifest) => {
manifest.permissions = [
...new Set([
...manifest.permissions,
'webRequestBlocking',
'http://localhost/*',
'tabs', // test builds need tabs permission for switchToWindowWithTitle
]),
];
loadManifestKey(manifest);
});
const envTestDev = createTaskForModifyManifestForEnvironment(
(manifest) => {
manifest.permissions = [
...new Set([
...manifest.permissions,
'webRequestBlocking',
'http://localhost/*',
'tabs', // test builds need tabs permission for switchToWindowWithTitle
]),
];
loadManifestKey(manifest);
},
{ setBuildId: true, watch: true },
);

// test: add permissions
const envTest = createTaskForModifyManifestForEnvironment((manifest) => {
manifest.permissions = [
...new Set([
...manifest.permissions,
'webRequestBlocking',
'http://localhost/*',
'tabs', // test builds need tabs permission for switchToWindowWithTitle
]),
];
loadManifestKey(manifest);
});
const envTest = createTaskForModifyManifestForEnvironment(
(manifest) => {
manifest.permissions = [
...new Set([
...manifest.permissions,
'webRequestBlocking',
'http://localhost/*',
'tabs', // test builds need tabs permission for switchToWindowWithTitle
]),
];
loadManifestKey(manifest);
},
{ setBuildId: true },
);

const envScriptDist = createTaskForModifyManifestForEnvironment(
(manifest) => {
Expand Down Expand Up @@ -163,9 +171,16 @@ function createManifestTasks({
return { prod, dev, testDev, test, scriptDist };

// helper for modifying each platform's manifest.json in place
function createTaskForModifyManifestForEnvironment(transformFn) {
return () => {
return Promise.all(
function createTaskForModifyManifestForEnvironment(
transformFn,
{ setBuildId = false, watch: shouldWatch = false } = {},
) {
let updateQueue = Promise.resolve();

const updateManifests = async () => {
const buildId = setBuildId ? nodeCrypto.randomUUID() : undefined;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

webpack uses a hash of the compilation, but that would be very too slow and complicated here in browserify, so I just went with a random ID for every non-manifest file change.


await Promise.all(
browserPlatforms.map(async (platform) => {
const manifestPath = path.join(
'.',
Expand All @@ -175,11 +190,31 @@ function createManifestTasks({
);
const manifest = await readJson(manifestPath);
transformFn(manifest);
if (buildId) {
manifest.build_id = buildId;
}

await writeJson(manifest, manifestPath);
}),
);
};

const queueManifestUpdate = () =>
(updateQueue = updateQueue.catch(() => undefined).then(updateManifests));

return async () => {
await queueManifestUpdate();

if (!shouldWatch) {
return;
}

watch(
'./dist/*/**/*',
{ ignoreInitial: true, ignored: '**/manifest.json' },
() => queueManifestUpdate().catch(console.error),
);
};
}

// For non-production builds only, modify the extension's name and description
Expand Down
1 change: 1 addition & 0 deletions development/webpack/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function mockWebpack(
const entries: Record<string, EntryDescriptionNormalized> = {};
const compiler = {
context: '',
modifiedFiles: undefined,
hooks: {
entryOption: {
tap(
Expand Down
49 changes: 39 additions & 10 deletions development/webpack/test/plugins.ManifestPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,34 @@ describe('ManifestPlugin', () => {
assert.strictEqual(zipEntries.has('filename.js.map'), false);
}
});

it('sets build_id in the emitted manifest when setBuildId is enabled', async () => {
const { compiler, compilation, promise } = mockWebpack([], [], []);
compiler.context = join(__dirname, 'fixtures/ManifestPlugin/empty');
compilation.fullHash = 'test-full-hash';

const manifestPlugin = new ManifestPlugin({
browsers: ['chrome', 'firefox'],
manifest_version: 3,
version: '1.0.0.0',
versionName: '1.0.0',
description: null,
buildType: 'main',
zip: false,
setBuildId: true,
});

manifestPlugin.apply(compiler);
await promise;

for (const browser of ['chrome', 'firefox'] as const) {
const manifest = JSON.parse(
compilation.assets[`${browser}/manifest.json`].source().toString(),
);

assert.strictEqual(manifest.build_id, 'test-full-hash');
}
});
});

describe('zip helpers', () => {
Expand Down Expand Up @@ -775,6 +803,11 @@ describe('ManifestPlugin', () => {
'overridden_by_beta',
'should override base property with beta value',
);
assert.strictEqual(
json.build_id,
undefined,
'should not emit build_id unless setBuildId is enabled',
);
});

it('should apply build type browser manifest overrides on top of all previous layers', async () => {
Expand Down Expand Up @@ -1562,19 +1595,17 @@ describe('ManifestPlugin', () => {
);

// Simulate a watch rebuild where no watched files were modified
(compiler as unknown as Record<string, unknown>).modifiedFiles = new Set([
'/some/unrelated/file.ts',
]);
compiler.modifiedFiles = new Set(['/some/unrelated/file.ts']);

// Trigger a second compilation so resolveEntrypoints runs again
const { compilation: compilation2, promise: promise2 } = mockWebpack(
[],
[],
[],
);
(compilation2 as unknown as Record<string, unknown>).compiler = compiler;
compilation2.compiler = compiler;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i fixed the types i guess, as these casts weren't needed anymore.

// eslint-disable-next-line dot-notation
plugin['hookIntoPipelines'](compilation2 as unknown as Compilation);
plugin['hookIntoPipelines'](compilation2);
await promise2;

// Manifest reference should be the same since prepareManifests was NOT called
Expand Down Expand Up @@ -1615,20 +1646,18 @@ describe('ManifestPlugin', () => {

// Simulate a watch rebuild where the base manifest was modified.
// resolveEntrypoints checks compiler.modifiedFiles.
(compiler as unknown as Record<string, unknown>).modifiedFiles = new Set([
baseManifestDep,
]);
compiler.modifiedFiles = new Set([baseManifestDep]);

// Run apply again to trigger a new compilation with modifiedFiles set
const { compilation: compilation2, promise: promise2 } = mockWebpack(
[],
[],
[],
);
(compilation2 as unknown as Record<string, unknown>).compiler = compiler;
compilation2.compiler = compiler;
// hookIntoPipelines registers processAssets which calls resolveEntrypoints
// eslint-disable-next-line dot-notation
plugin['hookIntoPipelines'](compilation2 as unknown as Compilation);
plugin['hookIntoPipelines'](compilation2);
await promise2;

// Should still produce valid output (manifests were re-read from disk)
Expand Down
131 changes: 130 additions & 1 deletion development/webpack/test/webpack.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Compiler,
WebpackPluginInstance,
} from 'webpack';
import { noop } from '../utils/helpers';
import { noop, type Manifest } from '../utils/helpers';
import { ManifestPlugin } from '../utils/plugins/ManifestPlugin';
import { getLatestCommit } from '../utils/git';
import { ManifestPluginOptions } from '../utils/plugins/ManifestPlugin/types';
Expand All @@ -25,6 +25,55 @@ function getWebpackInstance(config: Configuration) {
return webpack(config);
}

async function withWatching<T>(
config: Configuration,
callback: (
waitForBuild: (trigger?: () => void) => Promise<void>,
) => Promise<T>,
) {
const compiler = webpack(config);
// @ts-expect-error - Node types need to be updated.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a PR i'm working on already for this. Since it'll be fixed at that time, the ts-expect-error itself will be start being an error and i'll be sure to remove this line then.

let build = Promise.withResolvers<void>();
const watchHandle = compiler.watch({}, (error, stats) => {
if (error) {
build.reject(error);
return;
}
if (!stats) {
build.reject(
new Error('Webpack finished watch build without returning stats.'),
);
return;
}
if (stats.hasErrors()) {
build.reject(new Error('Webpack watch build failed.'));
return;
}
build.resolve();
});
assert(watchHandle, 'Webpack did not return a watch handle.');
const watching = watchHandle;

const waitForBuild = (trigger?: () => void) => {
if (!trigger) {
return build.promise;
}
// @ts-expect-error - Node types need to be updated.
build = Promise.withResolvers<void>();
trigger();
watching.invalidate();
return build.promise;
};

try {
return await callback(waitForBuild);
} finally {
await new Promise<void>((resolveClose, rejectClose) =>
watching.close((error) => (error ? rejectClose(error) : resolveClose())),
);
}
}

/**
* These tests are aimed at testing conditional branches in webpack.config.ts.
* These tests do *not* test the actual webpack build process itself, or that
Expand Down Expand Up @@ -166,6 +215,7 @@ ${Object.entries(env)
],
key: CHROME_MANIFEST_KEY_NON_PRODUCTION,
});
assert.strictEqual(manifestPlugin.options.setBuildId, false);
assert.strictEqual(manifestPlugin.options.zip, false);
const manifestOpts = manifestPlugin.options as ManifestPluginOptions<true>;
assert.strictEqual(manifestOpts.zipOptions, undefined);
Expand Down Expand Up @@ -256,6 +306,85 @@ ${Object.entries(env)
assert.strictEqual(instance.options.devtool, false);
});

it('enables manifest build IDs for test builds', () => {
const config: Configuration = getWebpackConfig(['--test']);
const instance = getWebpackInstance(config);
const manifestPlugin = instance.options.plugins.find(
(plugin) => plugin && plugin.constructor.name === 'ManifestPlugin',
) as ManifestPlugin<boolean>;

assert(manifestPlugin, 'Manifest plugin should be present');
assert.strictEqual(manifestPlugin.options.setBuildId, true);
});

it('keeps build_id stable for no-op watch rebuilds and changes it for real edits', async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit complicated, but I couldn't figure out how to make it any less so :-(

using tempDirectory = fs.mkdtempDisposableSync(
join(tmpdir(), 'manifest-plugin-watch-test-'),
);
const manifestDirectory = join(tempDirectory.path, 'manifest', 'v3');
const sourceFilePath = join(tempDirectory.path, 'index.js');
const outputPath = join(tempDirectory.path, 'dist');
const manifestPath = join(outputPath, 'chrome', 'manifest.json');

const readBuildId = () =>
(
JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest & {
build_id?: string;
}
).build_id;

const manifest = { manifest_version: 3, name: 'test', version: '1.0.0' };
const baseManifestPath = join(manifestDirectory, '_base.json');
const writeSource = (source: string | NodeJS.ArrayBufferView) =>
fs.writeFileSync(sourceFilePath, source);
fs.mkdirSync(manifestDirectory, { recursive: true });
fs.writeFileSync(baseManifestPath, JSON.stringify(manifest));
writeSource('console.log("v1");\n');

await withWatching(
{
mode: 'development',
context: tempDirectory.path,
entry: { app: sourceFilePath },
output: { path: outputPath },
plugins: [
new ManifestPlugin({
browsers: ['chrome'],
manifest_version: 3,
version: '1.0.0.0',
versionName: '1.0.0',
description: null,
buildType: 'main',
zip: false,
setBuildId: true,
}),
],
},
async (waitForBuild) => {
await waitForBuild();
const firstBuildId = readBuildId();
assert.ok(firstBuildId, 'expected initial build_id');

await waitForBuild(() => writeSource(fs.readFileSync(sourceFilePath)));
const secondBuildId = readBuildId();

await waitForBuild(() => writeSource('console.log("v2");\n'));
const thirdBuildId = readBuildId();

assert.strictEqual(
secondBuildId,
firstBuildId,
'expected no-op watch rebuild to keep the same build_id',
);
assert.notStrictEqual(
thirdBuildId,
secondBuildId,
'expected real file changes to produce a new build_id',
);
},
);
});

it('keeps the MYX provider ignore only while the warning still exists', async () => {
const config: Configuration = getWebpackConfig();
const myxWarningPattern = /MYXProvider\.mjs/u;
Expand Down
Loading
Loading