Skip to content

Commit ffbf36f

Browse files
test(css): add named-exports-mangling matrix coverage (#20884)
1 parent 1674988 commit ffbf36f

6 files changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Entry for `parser.namedExports: false` configs. The CSS modules
2+
// expose only `default`, so a default import is the only valid form.
3+
// Mangling does not apply here — there are no JS named exports for
4+
// CSS classes — but concatenation still does.
5+
6+
import asIsDefault from "./style.module.css?as-is";
7+
import camelCaseDefault from "./style.module.css?camel-case";
8+
import camelOnlyDefault from "./style.module.css?camel-case-only";
9+
import dashesDefault from "./style.module.css?dashes";
10+
import dashesOnlyDefault from "./style.module.css?dashes-only";
11+
12+
const matrixTitle = `output.module=${process.env.OUTPUT_MODULE}, namedExports=false`;
13+
14+
it(`should expose the right default-export shape per convention (${matrixTitle})`, () => {
15+
// "as-is": original CSS class names are exported, no aliases
16+
expect(asIsDefault["btn-info_is-disabled"]).toBeDefined();
17+
expect(asIsDefault["btn--info_is-disabled_1"]).toBeDefined();
18+
expect(asIsDefault.foo_bar).toBeDefined();
19+
expect(asIsDefault.simple).toBeDefined();
20+
expect(asIsDefault.class).toBeDefined();
21+
expect(asIsDefault["my-btn-info_is-disabled"]).toBe("value");
22+
expect(asIsDefault.foo).toBe("bar");
23+
expect(asIsDefault.btnInfoIsDisabled).toBeUndefined();
24+
expect(asIsDefault.fooBar).toBeUndefined();
25+
26+
// "camel-case": original AND camelCase aliases share the same value
27+
expect(camelCaseDefault["btn-info_is-disabled"]).toBe(
28+
camelCaseDefault.btnInfoIsDisabled
29+
);
30+
expect(camelCaseDefault["btn--info_is-disabled_1"]).toBe(
31+
camelCaseDefault.btnInfoIsDisabled1
32+
);
33+
expect(camelCaseDefault.foo_bar).toBe(camelCaseDefault.fooBar);
34+
expect(camelCaseDefault.foo).toBe("bar");
35+
expect(camelCaseDefault["my-btn-info_is-disabled"]).toBe("value");
36+
expect(camelCaseDefault.myBtnInfoIsDisabled).toBe("value");
37+
38+
// "camel-case-only": only the camelCase form exists
39+
expect(camelOnlyDefault.btnInfoIsDisabled).toBeDefined();
40+
expect(camelOnlyDefault.btnInfoIsDisabled1).toBeDefined();
41+
expect(camelOnlyDefault.fooBar).toBeDefined();
42+
expect(camelOnlyDefault["btn-info_is-disabled"]).toBeUndefined();
43+
expect(camelOnlyDefault["btn--info_is-disabled_1"]).toBeUndefined();
44+
expect(camelOnlyDefault.foo_bar).toBeUndefined();
45+
expect(camelOnlyDefault.myBtnInfoIsDisabled).toBe("value");
46+
expect(camelOnlyDefault["my-btn-info_is-disabled"]).toBeUndefined();
47+
48+
// "dashes": dashes -> camelCase, underscores preserved, original kept
49+
expect(dashesDefault["btn-info_is-disabled"]).toBe(
50+
dashesDefault.btnInfo_isDisabled
51+
);
52+
expect(dashesDefault.foo_bar).toBeDefined();
53+
expect(dashesDefault["my-btn-info_is-disabled"]).toBe("value");
54+
expect(dashesDefault.myBtnInfo_isDisabled).toBe("value");
55+
56+
// "dashes-only": only the dashes-converted form is exported
57+
expect(dashesOnlyDefault.btnInfo_isDisabled).toBeDefined();
58+
expect(dashesOnlyDefault["btn-info_is-disabled"]).toBeUndefined();
59+
expect(dashesOnlyDefault.myBtnInfo_isDisabled).toBe("value");
60+
expect(dashesOnlyDefault["my-btn-info_is-disabled"]).toBeUndefined();
61+
});
62+
63+
it(`should concatenate every CSS module out of __webpack_modules__ (${matrixTitle})`, () => {
64+
const cssModuleKeys = Object.keys(__webpack_modules__).filter((k) =>
65+
k.startsWith("./style.module.css")
66+
);
67+
expect(cssModuleKeys).toEqual([]);
68+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Entry for `parser.namedExports: true` configs. Pure named imports
2+
// keep every CSS export mangleable — a namespace import would mark
3+
// them all as observed-by-name and disable mangling.
4+
5+
import {
6+
simple as asIsSimple,
7+
foo_bar as asIsFooBar,
8+
foo as asIsFoo
9+
} from "./style.module.css?as-is";
10+
import {
11+
simple as camelCaseSimple,
12+
fooBar as camelCaseFooBar,
13+
btnInfoIsDisabled as camelCaseBtnInfoIsDisabled,
14+
myBtnInfoIsDisabled as camelCaseMyBtnInfoIsDisabled
15+
} from "./style.module.css?camel-case";
16+
import {
17+
simple as camelOnlySimple,
18+
fooBar as camelOnlyFooBar,
19+
btnInfoIsDisabled as camelOnlyBtnInfoIsDisabled,
20+
btnInfoIsDisabled1 as camelOnlyBtnInfoIsDisabled1
21+
} from "./style.module.css?camel-case-only";
22+
import {
23+
simple as dashesSimple,
24+
btnInfo_isDisabled as dashesBtnInfo_isDisabled
25+
} from "./style.module.css?dashes";
26+
import {
27+
simple as dashesOnlySimple,
28+
btnInfo_isDisabled as dashesOnlyBtnInfo_isDisabled
29+
} from "./style.module.css?dashes-only";
30+
31+
const matrixTitle = `output.module=${process.env.OUTPUT_MODULE}, namedExports=true`;
32+
33+
it(`should resolve every named import per convention (${matrixTitle})`, () => {
34+
expect(typeof asIsSimple).toBe("string");
35+
expect(typeof asIsFooBar).toBe("string");
36+
expect(asIsFoo).toBe("bar");
37+
38+
expect(typeof camelCaseSimple).toBe("string");
39+
expect(typeof camelCaseFooBar).toBe("string");
40+
expect(typeof camelCaseBtnInfoIsDisabled).toBe("string");
41+
expect(camelCaseMyBtnInfoIsDisabled).toBe("value");
42+
43+
expect(typeof camelOnlySimple).toBe("string");
44+
expect(typeof camelOnlyFooBar).toBe("string");
45+
expect(typeof camelOnlyBtnInfoIsDisabled).toBe("string");
46+
expect(typeof camelOnlyBtnInfoIsDisabled1).toBe("string");
47+
48+
expect(typeof dashesSimple).toBe("string");
49+
expect(typeof dashesBtnInfo_isDisabled).toBe("string");
50+
51+
expect(typeof dashesOnlySimple).toBe("string");
52+
expect(typeof dashesOnlyBtnInfo_isDisabled).toBe("string");
53+
});
54+
55+
it(`should concatenate every CSS module out of __webpack_modules__ (${matrixTitle})`, () => {
56+
// `concatenateModules: true` plus only-static imports must inline
57+
// every CSS variant into the entry scope, so none of them remain
58+
// as their own runtime modules.
59+
const cssModuleKeys = Object.keys(__webpack_modules__).filter((k) =>
60+
k.startsWith("./style.module.css")
61+
);
62+
expect(cssModuleKeys).toEqual([]);
63+
});
64+
65+
it(`should concatenate CSS modules in the bundle (${matrixTitle})`, () => {
66+
const fs = __non_webpack_require__("fs");
67+
const source = fs.readFileSync(`${__dirname}/bundle${__STATS_I__ === 0 ? "0.js" : "2.mjs"}`, "utf-8");
68+
69+
for (const convention of [
70+
"as-is",
71+
"camel-case",
72+
"camel-case-only",
73+
"dashes",
74+
"dashes-only"
75+
]) {
76+
expect(source).not.toContain(
77+
`__webpack_require__("./style.module.css?${convention}")`
78+
);
79+
}
80+
});
81+
82+
it(`should mangle JS export identifiers in production (${matrixTitle})`, () => {
83+
const fs = __non_webpack_require__("fs");
84+
const source = fs.readFileSync(`${__dirname}/bundle${__STATS_I__ === 0 ? "0.js" : "2.mjs"}`, "utf-8");
85+
86+
// When CSS modules are concatenated, every named export becomes a
87+
// `const <identifier> = <value>;` declaration in the entry scope
88+
// (CssGenerator.js#L472–497). With
89+
// `mangleExports: "deterministic"`, that identifier is the mangled
90+
// used-name, so the original long names must NOT appear as
91+
// const/let/var bindings.
92+
const longCssExportNames = [
93+
"btnInfoIsDisabled",
94+
"btnInfoIsDisabled1",
95+
"btnInfo_isDisabled",
96+
"myBtnInfoIsDisabled",
97+
"fooBar"
98+
];
99+
for (const name of longCssExportNames) {
100+
const declRegex = new RegExp(`(?:const|let|var)\\s+${name}\\b`);
101+
expect(source).not.toMatch(declRegex);
102+
}
103+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.btn-info_is-disabled {
2+
color: blue;
3+
}
4+
5+
.btn--info_is-disabled_1 {
6+
color: red;
7+
}
8+
9+
.foo_bar {
10+
color: green;
11+
}
12+
13+
.simple {
14+
color: yellow;
15+
}
16+
17+
.class {
18+
color: black;
19+
}
20+
21+
:export {
22+
my-btn-info_is-disabled: value;
23+
foo: bar;
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use strict";
2+
3+
module.exports = {
4+
findBundle(i, options) {
5+
const ext =
6+
options.experiments && options.experiments.outputModule ? "mjs" : "js";
7+
// With concatenateModules: true the CSS variants are concatenated
8+
// into the entry, so only the main bundle file exists.
9+
return [`bundle${i}.${ext}`];
10+
}
11+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"use strict";
2+
3+
module.exports = () => process.version.slice(0, 4) !== "v10.";
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use strict";
2+
3+
const webpack = require("../../../../");
4+
5+
/** @typedef {import("../../../../").Configuration} Configuration */
6+
/** @typedef {import("../../../../").ParserOptionsByModuleTypeKnown} ParserOptionsByModuleTypeKnown */
7+
/** @typedef {import("../../../../").GeneratorOptionsByModuleTypeKnown} GeneratorOptionsByModuleTypeKnown */
8+
/** @typedef {NonNullable<GeneratorOptionsByModuleTypeKnown["css/module"]>["exportsConvention"]} ExportsConvention */
9+
/** @typedef {Extract<ExportsConvention, string>} ExportsConventionLiteral */
10+
11+
/** @type {ExportsConventionLiteral[]} */
12+
const conventions = [
13+
"as-is",
14+
"camel-case",
15+
"camel-case-only",
16+
"dashes",
17+
"dashes-only"
18+
];
19+
20+
/**
21+
* @param {boolean} namedExports value of parser.namedExports
22+
* @returns {Configuration["module"]} module config
23+
*/
24+
const makeModule = (namedExports) => ({
25+
rules: [
26+
{
27+
test: /\.module\.css$/,
28+
type: "css/module",
29+
oneOf: conventions.map((convention) => ({
30+
resourceQuery: new RegExp(`\\?${convention}$`),
31+
/** @type {ParserOptionsByModuleTypeKnown["css/module"]} */
32+
parser: { namedExports },
33+
/** @type {GeneratorOptionsByModuleTypeKnown["css/module"]} */
34+
generator: { exportsConvention: convention }
35+
}))
36+
}
37+
]
38+
});
39+
40+
/**
41+
* @param {{ namedExports: boolean, outputModule: boolean }} options matrix cell
42+
* @returns {Configuration} a single config
43+
*/
44+
const makeConfig = ({ namedExports, outputModule }) => {
45+
/** @type {Configuration} */
46+
const config = {
47+
// Two entry files: only one form of static import works per
48+
// namedExports value (named imports vs default import), so we
49+
// switch the entry rather than have one file with imports that
50+
// fail to resolve in the other config.
51+
entry: namedExports ? "./index-named.js" : "./index-default.js",
52+
mode: "production",
53+
// target: node so the test code can `require("fs")` to inspect the
54+
// emitted bundle without needing externals plumbing.
55+
target: "node",
56+
devtool: false,
57+
optimization: {
58+
chunkIds: "named",
59+
moduleIds: "named",
60+
concatenateModules: true
61+
},
62+
module: makeModule(namedExports),
63+
plugins: [
64+
new webpack.DefinePlugin({
65+
"process.env.NAMED_EXPORTS": JSON.stringify(namedExports),
66+
"process.env.OUTPUT_MODULE": JSON.stringify(outputModule)
67+
})
68+
],
69+
experiments: { css: true }
70+
};
71+
if (outputModule) {
72+
config.output = { module: true, chunkFormat: "module" };
73+
/** @type {NonNullable<Configuration["experiments"]>} */
74+
(config.experiments).outputModule = true;
75+
}
76+
return config;
77+
};
78+
79+
module.exports = [
80+
makeConfig({ namedExports: true, outputModule: false }),
81+
makeConfig({ namedExports: false, outputModule: false }),
82+
makeConfig({ namedExports: true, outputModule: true }),
83+
makeConfig({ namedExports: false, outputModule: true })
84+
];

0 commit comments

Comments
 (0)