Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/cli-plugin-import-interop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-cli": patch
---

fix: `CLIPlugin` dynamic import interop under Bun
5 changes: 4 additions & 1 deletion packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3250,7 +3250,10 @@ class WebpackCLI {
process.exit(2);
}

const { default: CLIPlugin } = (await import("./plugins/cli-plugin.js")).default;
// Node's CJS interop nests the class under `.default.default`; Bun honors
// `__esModule` and unwraps once already, leaving the class one level up.
const cliPluginModule = (await import("./plugins/cli-plugin.js")).default;
const CLIPlugin = cliPluginModule.default ?? cliPluginModule;
Comment on lines +3253 to +3256

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 5727a55: test/api/cli-plugin-interop/ mocks the cli-plugin module to the shape Bun's __esModule-aware interop produces (the class itself, no nested .default) and asserts a build still applies CLIPlugin. Verified it fails with "CLIPlugin is not a constructor" against the previous double-unwrap code.


Generated by Claude Code


// `getArguments()` already returns a name-keyed map of exactly the argument
// metadata `processArguments` consumes, so use it directly (cached) instead
Expand Down
46 changes: 46 additions & 0 deletions test/api/cli-plugin-interop/cli-plugin-interop.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const path = require("node:path");

// Simulate Bun's ESM↔CJS interop: the dynamic import of cli-plugin resolves to
// the class itself instead of Node's `{ __esModule, default }` wrapper. Guards
// the interop-safe unwrap in `webpack-cli.ts` against reintroducing the
// Node-only double-`.default` unwrap (which crashes every build under Bun).
jest.mock(
"../../../packages/webpack-cli/lib/plugins/cli-plugin",
() => jest.requireActual("../../../packages/webpack-cli/lib/plugins/cli-plugin").default,
);

const CLIPlugin = jest.requireActual(
"../../../packages/webpack-cli/lib/plugins/cli-plugin",
).default;

describe("CLIPlugin import interop", () => {
it("applies CLIPlugin when the dynamic import resolves to the class itself", async () => {
process.env.WEBPACK_PACKAGE = path.resolve(__dirname, "./mock-webpack.js");

const WebpackCLI = require("../../../packages/webpack-cli/lib/webpack-cli").default;

const exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
const errors = [];
const errorSpy = jest
.spyOn(console, "error")
.mockImplementation((message) => errors.push(`${message}`));

try {
await new WebpackCLI().run(["node", "webpack", "build"]);
} catch (error) {
throw new Error(`${error.message}\n${errors.join("\n")}`, {
cause: error,
});
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
delete process.env.WEBPACK_PACKAGE;
}

const options = globalThis.__capturedWebpackOptions;

expect(options.plugins[0]).toBeInstanceOf(CLIPlugin);
});
});
14 changes: 14 additions & 0 deletions test/api/cli-plugin-interop/mock-webpack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Minimal webpack stand-in (loaded via WEBPACK_PACKAGE): captures the config
// webpack-cli assembles so tests can assert on the applied plugins.
const realWebpack = require("webpack");

const webpack = (options) => {
globalThis.__capturedWebpackOptions = options;

return { options };
};

webpack.cli = realWebpack.cli;
webpack.version = realWebpack.version;

module.exports = webpack;
Loading