Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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
194 changes: 193 additions & 1 deletion development/webpack/test/webpack.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
webpack,
Compiler,
WebpackPluginInstance,
type Stats,
} 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 +26,88 @@ function getWebpackInstance(config: Configuration) {
return webpack(config);
}

function createDeferred<T>() {
let resolveDeferred: ((value: T | PromiseLike<T>) => void) | undefined;
let rejectDeferred: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolveDeferred = nextResolve;
rejectDeferred = nextReject;
});

return {
promise,
resolve(value: T | PromiseLike<T>) {
if (!resolveDeferred) {
throw new Error('Deferred promise was not initialized.');
}
resolveDeferred(value);
},
reject(reason?: unknown) {
if (!rejectDeferred) {
throw new Error('Deferred promise was not initialized.');
}
rejectDeferred(reason);
},
};
}

async function withWatching<T>(
config: Configuration,
callback: (context: {
waitForNextBuild: (trigger?: () => void) => Promise<Stats>;
invalidateAfter: (trigger: () => void) => Promise<Stats>;
}) => Promise<T>,
) {
const compiler = webpack(config);
let deferredBuild = createDeferred<Stats>();
const watchHandle = compiler.watch({}, (error, stats) => {
if (error) {
deferredBuild.reject(error);
return;
}
if (!stats) {
deferredBuild.reject(
new Error('Webpack finished watch build without returning stats.'),
);
return;
}
deferredBuild.resolve(stats);
});
if (!watchHandle) {
throw new Error('Webpack did not return a watch handle.');
}
const watching = watchHandle;

const waitForNextBuild = async (trigger?: () => void) => {
const currentBuild = deferredBuild;
trigger?.();
const stats = await currentBuild.promise;
deferredBuild = createDeferred<Stats>();
return stats;
};

try {
return await callback({
waitForNextBuild,
invalidateAfter: async (trigger) =>
await waitForNextBuild(() => {
trigger();
watching.invalidate();
}),
});
} finally {
await new Promise<void>((resolveClose, rejectClose) => {
watching.close((error) => {
if (error) {
rejectClose(error);
return;
}
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 +249,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 +340,114 @@ ${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 :-(

const tempDirectory = fs.mkdtempSync(
join(tmpdir(), 'manifest-plugin-watch-test-'),
);
const manifestDirectory = join(tempDirectory, 'manifest', 'v3');
const sourceFilePath = join(tempDirectory, 'index.js');
const outputPath = join(tempDirectory, 'dist');

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

try {
fs.mkdirSync(manifestDirectory, { recursive: true });
fs.writeFileSync(
join(manifestDirectory, '_base.json'),
JSON.stringify(
{
manifest_version: 3,
name: 'watch-test',
version: '1.0.0',
},
null,
2,
),
);
fs.writeFileSync(sourceFilePath, 'console.log("v1");\n');

await withWatching(
{
mode: 'development',
context: tempDirectory,
cache: false,
devtool: false,
entry: {
app: {
import: [sourceFilePath],
filename: 'scripts/app.js',
},
},
output: {
path: outputPath,
clean: true,
filename: '[name].js',
},
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,
}),
],
infrastructureLogging: {
level: 'error',
},
},
async ({ waitForNextBuild, invalidateAfter }) => {
await waitForNextBuild();
const firstBuildId = readBuildId();
assert.ok(firstBuildId, 'expected initial build_id');

await invalidateAfter(() => {
const current = fs.readFileSync(sourceFilePath, 'utf8');
fs.writeFileSync(sourceFilePath, current);
});
const secondBuildId = readBuildId();

await invalidateAfter(() => {
fs.writeFileSync(sourceFilePath, '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',
);
},
);
} finally {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
});

it('keeps the MYX provider ignore only while the warning still exists', async () => {
const config: Configuration = getWebpackConfig();
const myxWarningPattern = /MYXProvider\.mjs/u;
Expand Down
22 changes: 17 additions & 5 deletions development/webpack/utils/plugins/ManifestPlugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,11 +632,23 @@ export class ManifestPlugin<Z extends boolean> {
}
}

// cache the resolved manifests as RawSource
this.manifestSources.set(
browser,
new RawSource(JSON.stringify(manifest, null, 2)),
);
if (this.options.setBuildId) {
// if we edit the real `manifest` we change the compilation hash
const manifestForEmit = structuredClone(manifest);
manifestForEmit.build_id = compilation.fullHash;

// cache the resolved manifests as RawSource
this.manifestSources.set(
browser,
new RawSource(JSON.stringify(manifestForEmit, null, 2)),
);
} else {
// cache the resolved manifests as RawSource
this.manifestSources.set(
browser,
new RawSource(JSON.stringify(manifest, null, 2)),
);
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions development/webpack/utils/plugins/ManifestPlugin/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export const schema = {
description: 'The build type to create.',
type: 'string',
},
setBuildId: {
description:
'Whether to set a build ID in the emitted manifest. The build ID is a hash of the build contents that can be used to identify the build and detect when it has changed.',
type: 'boolean',
},
},
additionalProperties: false,
if: {
Expand Down
7 changes: 7 additions & 0 deletions development/webpack/utils/plugins/ManifestPlugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export type BaseManifestPluginOptions<Zip extends boolean> = {
* The build type of the build being created.
*/
buildType: string;

/**
* Whether to set a build ID in the emitted manifest. The build ID is a hash
* of the build contents that can be used to identify the build and detect
* when it has changed.
*/
setBuildId?: boolean;
};

export type ZipOptions = {
Expand Down
4 changes: 4 additions & 0 deletions development/webpack/webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ const manifestPlugin = new ManifestPlugin({
}
: {}),
buildType: args.type,
// We want to set a build ID for test builds to make it easier for tooling to
// know if the build contents have changed. Can be useful during testing or
// development.
setBuildId: args.test,
});

const plugins: WebpackPluginInstance[] = [
Expand Down
Loading
Loading