shakapacker 9.2.0 → 9.3.0.beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +6 -9
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +6 -8
  4. data/.github/workflows/claude-code-review.yml +4 -5
  5. data/.github/workflows/claude.yml +1 -2
  6. data/.github/workflows/dummy.yml +4 -4
  7. data/.github/workflows/generator.yml +9 -9
  8. data/.github/workflows/node.yml +11 -2
  9. data/.github/workflows/ruby.yml +16 -16
  10. data/.github/workflows/test-bundlers.yml +9 -9
  11. data/.gitignore +4 -0
  12. data/CHANGELOG.md +19 -4
  13. data/CLAUDE.md +6 -1
  14. data/CONTRIBUTING.md +0 -1
  15. data/Gemfile.lock +1 -1
  16. data/README.md +14 -14
  17. data/TODO.md +10 -2
  18. data/TODO_v9.md +13 -3
  19. data/bin/export-bundler-config +1 -1
  20. data/conductor-setup.sh +1 -1
  21. data/conductor.json +1 -1
  22. data/docs/cdn_setup.md +13 -8
  23. data/docs/common-upgrades.md +2 -1
  24. data/docs/configuration.md +630 -0
  25. data/docs/css-modules-export-mode.md +120 -100
  26. data/docs/customizing_babel_config.md +16 -16
  27. data/docs/deployment.md +18 -0
  28. data/docs/developing_shakapacker.md +6 -0
  29. data/docs/optional-peer-dependencies.md +9 -4
  30. data/docs/peer-dependencies.md +17 -6
  31. data/docs/precompile_hook.md +342 -0
  32. data/docs/react.md +57 -47
  33. data/docs/releasing.md +0 -2
  34. data/docs/rspack.md +25 -21
  35. data/docs/rspack_migration_guide.md +335 -8
  36. data/docs/sprockets.md +1 -0
  37. data/docs/style_loader_vs_mini_css.md +12 -12
  38. data/docs/subresource_integrity.md +13 -7
  39. data/docs/transpiler-performance.md +40 -19
  40. data/docs/troubleshooting.md +0 -2
  41. data/docs/typescript-migration.md +48 -39
  42. data/docs/typescript.md +12 -8
  43. data/docs/using_esbuild_loader.md +10 -10
  44. data/docs/v6_upgrade.md +33 -20
  45. data/docs/v7_upgrade.md +8 -6
  46. data/docs/v8_upgrade.md +13 -12
  47. data/docs/v9_upgrade.md +2 -1
  48. data/eslint.config.fast.js +134 -0
  49. data/eslint.config.js +140 -0
  50. data/knip.ts +54 -0
  51. data/lib/install/bin/export-bundler-config +1 -1
  52. data/lib/install/config/shakapacker.yml +16 -5
  53. data/lib/shakapacker/compiler.rb +80 -0
  54. data/lib/shakapacker/configuration.rb +33 -5
  55. data/lib/shakapacker/dev_server_runner.rb +140 -1
  56. data/lib/shakapacker/doctor.rb +294 -65
  57. data/lib/shakapacker/instance.rb +8 -3
  58. data/lib/shakapacker/runner.rb +244 -8
  59. data/lib/shakapacker/version.rb +1 -1
  60. data/lib/tasks/shakapacker/doctor.rake +42 -2
  61. data/package/babel/preset.ts +7 -4
  62. data/package/config.ts +42 -30
  63. data/package/configExporter/cli.ts +799 -208
  64. data/package/configExporter/configFile.ts +520 -0
  65. data/package/configExporter/fileWriter.ts +12 -8
  66. data/package/configExporter/index.ts +9 -1
  67. data/package/configExporter/types.ts +36 -2
  68. data/package/configExporter/yamlSerializer.ts +22 -8
  69. data/package/dev_server.ts +1 -1
  70. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +11 -5
  71. data/package/environments/base.ts +18 -13
  72. data/package/environments/development.ts +1 -1
  73. data/package/environments/production.ts +4 -1
  74. data/package/index.d.ts +50 -3
  75. data/package/index.d.ts.template +50 -0
  76. data/package/index.ts +7 -7
  77. data/package/loaders.d.ts +2 -2
  78. data/package/optimization/rspack.ts +1 -1
  79. data/package/plugins/rspack.ts +15 -4
  80. data/package/plugins/webpack.ts +7 -3
  81. data/package/rspack/index.ts +10 -2
  82. data/package/rules/raw.ts +3 -2
  83. data/package/rules/sass.ts +1 -1
  84. data/package/types/README.md +15 -13
  85. data/package/types/index.ts +5 -5
  86. data/package/types.ts +0 -1
  87. data/package/utils/defaultConfigPath.ts +4 -1
  88. data/package/utils/errorCodes.ts +129 -100
  89. data/package/utils/errorHelpers.ts +34 -29
  90. data/package/utils/getStyleRule.ts +5 -2
  91. data/package/utils/helpers.ts +21 -11
  92. data/package/utils/pathValidation.ts +43 -35
  93. data/package/utils/requireOrError.ts +1 -1
  94. data/package/utils/snakeToCamelCase.ts +1 -1
  95. data/package/utils/typeGuards.ts +132 -83
  96. data/package/utils/validateDependencies.ts +1 -1
  97. data/package/webpack-types.d.ts +3 -3
  98. data/package/webpackDevServerConfig.ts +22 -10
  99. data/package-lock.json +2 -2
  100. data/package.json +36 -28
  101. data/scripts/type-check-no-emit.js +1 -1
  102. data/test/configExporter/configFile.test.js +392 -0
  103. data/test/configExporter/integration.test.js +275 -0
  104. data/test/helpers.js +1 -1
  105. data/test/package/configExporter.test.js +154 -0
  106. data/test/package/helpers.test.js +2 -2
  107. data/test/package/rules/sass-version-parsing.test.js +71 -0
  108. data/test/package/rules/sass.test.js +2 -4
  109. data/test/package/rules/sass1.test.js +1 -3
  110. data/test/package/rules/sass16.test.js +23 -0
  111. data/tools/README.md +15 -5
  112. data/tsconfig.eslint.json +2 -9
  113. data/yarn.lock +1894 -1492
  114. metadata +19 -3
  115. data/.eslintignore +0 -5
data/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "shakapacker",
3
- "version": "9.2.0",
3
+ "version": "9.3.0-beta.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "shakapacker",
9
- "version": "9.2.0",
9
+ "version": "9.3.0-beta.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "js-yaml": "^4.1.0",
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakapacker",
3
- "version": "9.2.0",
3
+ "version": "9.3.0-beta.0",
4
4
  "description": "Use webpack to manage app-like JavaScript modules in Rails",
5
5
  "homepage": "https://github.com/shakacode/shakapacker",
6
6
  "bugs": {
@@ -32,10 +32,12 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "clean:ts": "find package -name '*.ts' -not -name '*.d.ts' | sed 's/\\.ts$//' | xargs -I {} rm -f {}.js {}.d.ts {}.d.ts.map {}.js.map",
35
- "build": "tsc && node scripts/remove-use-strict.js && yarn prettier --write 'package/**/*.js'",
35
+ "build": "tsc && cp package/index.d.ts.template package/index.d.ts && node scripts/remove-use-strict.js && yarn prettier --write 'package/**/*.js'",
36
36
  "build:types": "tsc",
37
- "lint": "eslint .",
38
- "lint:fast": "eslint . --ext .js,.jsx,.ts,.tsx --config .eslintrc.fast.js",
37
+ "knip": "knip",
38
+ "knip:production": "knip --production",
39
+ "lint": "eslint . --cache",
40
+ "lint:fast": "eslint . --config eslint.config.fast.js --cache",
39
41
  "test": "jest",
40
42
  "type-check": "tsc --noEmit",
41
43
  "prepublishOnly": "yarn build && yarn type-check"
@@ -43,12 +45,27 @@
43
45
  "dependencies": {
44
46
  "js-yaml": "^4.1.0",
45
47
  "path-complete-extname": "^1.0.0",
46
- "webpack-merge": "^5.8.0"
48
+ "webpack-merge": "^5.8.0",
49
+ "yargs": "^17.7.2"
47
50
  },
48
51
  "devDependencies": {
49
- "@rspack/cli": "^1.4.11",
50
- "@rspack/core": "^1.4.11",
52
+ "@rspack/cli": "^1.5.8",
53
+ "@rspack/core": "^1.5.8",
51
54
  "@swc/core": "^1.3.0",
55
+ "babel-loader": "^8.2.4",
56
+ "compression-webpack-plugin": "^9.0.0",
57
+ "css-loader": "^7.1.2",
58
+ "esbuild-loader": "^2.18.0",
59
+ "mini-css-extract-plugin": "^2.9.4",
60
+ "rspack-manifest-plugin": "^5.0.3",
61
+ "sass-loader": "^16.0.5",
62
+ "swc-loader": "^0.1.15",
63
+ "webpack": "5.93.0",
64
+ "webpack-assets-manifest": "^5.0.6",
65
+ "webpack-cli": "^6.0.0",
66
+ "webpack-subresource-integrity": "^5.1.0",
67
+ "@eslint/eslintrc": "^3.2.0",
68
+ "@eslint/js": "^9.37.0",
52
69
  "@types/babel__core": "^7.20.5",
53
70
  "@types/js-yaml": "^4.0.9",
54
71
  "@types/node": "^24.5.2",
@@ -56,35 +73,26 @@
56
73
  "@types/webpack": "^5.28.5",
57
74
  "@types/webpack-dev-server": "^4.7.2",
58
75
  "@types/webpack-merge": "^5.0.0",
59
- "@typescript-eslint/eslint-plugin": "^8.45.0",
60
- "@typescript-eslint/parser": "^8.45.0",
61
- "babel-loader": "^8.2.4",
62
- "compression-webpack-plugin": "^9.0.0",
63
- "css-loader": "^7.1.2",
64
- "esbuild-loader": "^2.18.0",
65
- "eslint": "^8.0.0",
76
+ "@types/yargs": "^17.0.33",
77
+ "@typescript-eslint/eslint-plugin": "^8.46.0",
78
+ "@typescript-eslint/parser": "^8.46.0",
79
+ "eslint": "^9.37.0",
66
80
  "eslint-config-airbnb": "^19.0.4",
67
- "eslint-config-prettier": "^9.0.0",
68
- "eslint-plugin-import": "^2.31.0",
69
- "eslint-plugin-jest": "^27.9.0",
81
+ "eslint-config-prettier": "^10.1.8",
82
+ "eslint-plugin-import": "^2.32.0",
83
+ "eslint-plugin-jest": "^29.0.1",
70
84
  "eslint-plugin-jsx-a11y": "^6.10.2",
71
- "eslint-plugin-prettier": "^5.2.6",
85
+ "eslint-plugin-prettier": "^5.5.4",
72
86
  "eslint-plugin-react": "^7.37.5",
73
- "eslint-plugin-react-hooks": "^4.6.0",
87
+ "eslint-plugin-react-hooks": "^7.0.0",
74
88
  "husky": "^9.1.7",
75
89
  "jest": "^29.7.0",
90
+ "knip": "^5.64.2",
76
91
  "lint-staged": "^15.2.10",
77
92
  "memory-fs": "^0.5.0",
78
- "mini-css-extract-plugin": "^2.9.4",
79
93
  "prettier": "^3.2.5",
80
- "rspack-manifest-plugin": "^5.0.3",
81
- "sass-loader": "^16.0.5",
82
- "swc-loader": "^0.1.15",
83
94
  "thenify": "^3.3.1",
84
- "typescript": "^5.9.2",
85
- "webpack": "5.93.0",
86
- "webpack-assets-manifest": "^5.0.6",
87
- "webpack-subresource-integrity": "^5.1.0"
95
+ "typescript": "^5.9.2"
88
96
  },
89
97
  "peerDependencies": {
90
98
  "@babel/core": "^7.17.9",
@@ -210,7 +218,7 @@
210
218
  ]
211
219
  },
212
220
  "engines": {
213
- "node": ">= 14",
221
+ "node": ">= 20",
214
222
  "yarn": ">=1 <5"
215
223
  },
216
224
  "publishConfig": {
@@ -21,7 +21,7 @@ try {
21
21
  cwd: process.cwd()
22
22
  })
23
23
  process.exit(0)
24
- } catch (error) {
24
+ } catch {
25
25
  // Type checking failed
26
26
  process.exit(1)
27
27
  }
@@ -0,0 +1,392 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ const {
3
+ writeFileSync,
4
+ mkdirSync,
5
+ rmSync,
6
+ existsSync,
7
+ symlinkSync
8
+ } = require("fs")
9
+ const { resolve, join } = require("path")
10
+ const { tmpdir } = require("os")
11
+ const {
12
+ ConfigFileLoader,
13
+ generateSampleConfigFile
14
+ } = require("../../package/configExporter")
15
+
16
+ describe("ConfigFileLoader", () => {
17
+ const testDir = resolve(__dirname, "../tmp/config-file-test")
18
+ let configPath
19
+
20
+ beforeEach(() => {
21
+ // Create test directory
22
+ if (!existsSync(testDir)) {
23
+ mkdirSync(testDir, { recursive: true })
24
+ }
25
+ configPath = join(testDir, ".bundler-config.yml")
26
+ })
27
+
28
+ afterEach(() => {
29
+ // Clean up test directory
30
+ if (existsSync(testDir)) {
31
+ rmSync(testDir, { recursive: true, force: true })
32
+ }
33
+ })
34
+
35
+ describe("validateConfigPath", () => {
36
+ it("should reject path traversal attempts with ..", () => {
37
+ // Use a path that's definitely outside the project
38
+ const maliciousPath = "/etc/passwd"
39
+ expect(() => {
40
+ // eslint-disable-next-line no-new
41
+ new ConfigFileLoader(maliciousPath)
42
+ }).toThrow(/Config file must be within project directory/)
43
+ })
44
+
45
+ it("should reject symlink traversal to files outside project", async () => {
46
+ const outsideFile = join(tmpdir(), `test-outside-${Date.now()}.yml`)
47
+ const symlinkPath = join(testDir, "symlink-config.yml")
48
+
49
+ const cleanup = () => {
50
+ try {
51
+ rmSync(symlinkPath, { force: true })
52
+ // eslint-disable-next-line no-empty
53
+ } catch {}
54
+ try {
55
+ rmSync(outsideFile, { force: true })
56
+ // eslint-disable-next-line no-empty
57
+ } catch {}
58
+ }
59
+
60
+ try {
61
+ // Create a real file outside the project (in system temp dir)
62
+ writeFileSync(
63
+ outsideFile,
64
+ "builds:\n test:\n outputs:\n - client\n"
65
+ )
66
+
67
+ // Attempt to create symlink
68
+ try {
69
+ symlinkSync(outsideFile, symlinkPath)
70
+ } catch (error) {
71
+ // Skip test if symlinks aren't supported or require elevated permissions
72
+ const skipCodes = ["EPERM", "ENOSYS", "EACCES"]
73
+ cleanup()
74
+ // eslint-disable-next-line jest/no-conditional-expect
75
+ expect(skipCodes).toContain(error.code)
76
+ return
77
+ }
78
+
79
+ // Verify that loading via symlink is rejected
80
+ expect(() => {
81
+ // eslint-disable-next-line no-new
82
+ new ConfigFileLoader(symlinkPath)
83
+ }).toThrow(/Config file must be within project directory/)
84
+
85
+ cleanup()
86
+ } catch (error) {
87
+ cleanup()
88
+ throw error
89
+ }
90
+ })
91
+
92
+ it("should accept paths within the project directory", () => {
93
+ expect(() => {
94
+ // eslint-disable-next-line no-new
95
+ new ConfigFileLoader(configPath)
96
+ }).not.toThrow()
97
+ })
98
+ })
99
+
100
+ describe("exists", () => {
101
+ it("should return false when config file does not exist", () => {
102
+ const loader = new ConfigFileLoader(configPath)
103
+ expect(loader.exists()).toBe(false)
104
+ })
105
+
106
+ it("should return true when config file exists", () => {
107
+ writeFileSync(configPath, "default_bundler: webpack\nbuilds: {}")
108
+ const loader = new ConfigFileLoader(configPath)
109
+ expect(loader.exists()).toBe(true)
110
+ })
111
+ })
112
+
113
+ describe("load", () => {
114
+ it("should load valid YAML config", () => {
115
+ writeFileSync(
116
+ configPath,
117
+ `
118
+ default_bundler: rspack
119
+ builds:
120
+ dev:
121
+ description: Development build
122
+ environment:
123
+ NODE_ENV: development
124
+ outputs:
125
+ - client
126
+ - server
127
+ `
128
+ )
129
+ const loader = new ConfigFileLoader(configPath)
130
+ const loaded = loader.load()
131
+ expect(loaded.default_bundler).toBe("rspack")
132
+ expect(loaded.builds.dev).toBeDefined()
133
+ expect(loaded.builds.dev.description).toBe("Development build")
134
+ })
135
+
136
+ it("should throw error for malformed YAML", () => {
137
+ writeFileSync(configPath, "invalid: yaml: content:\n - broken")
138
+ const loader = new ConfigFileLoader(configPath)
139
+ expect(() => loader.load()).toThrow(Error)
140
+ })
141
+
142
+ it("should throw error if builds key is missing", () => {
143
+ writeFileSync(configPath, "default_bundler: webpack")
144
+ const loader = new ConfigFileLoader(configPath)
145
+ expect(() => loader.load()).toThrow(/must contain a 'builds'/)
146
+ })
147
+
148
+ it("should throw error if builds is not an object", () => {
149
+ writeFileSync(configPath, "builds: []")
150
+ const loader = new ConfigFileLoader(configPath)
151
+ expect(() => loader.load()).toThrow(/must contain at least one build/)
152
+ })
153
+ })
154
+
155
+ describe("resolveBuild", () => {
156
+ beforeEach(() => {
157
+ writeFileSync(
158
+ configPath,
159
+ `
160
+ default_bundler: rspack
161
+ builds:
162
+ dev:
163
+ description: Development build
164
+ environment:
165
+ NODE_ENV: development
166
+ RAILS_ENV: development
167
+ outputs:
168
+ - client
169
+ - server
170
+ prod:
171
+ description: Production build
172
+ bundler: webpack
173
+ environment:
174
+ NODE_ENV: production
175
+ outputs:
176
+ - client
177
+ `
178
+ )
179
+ })
180
+
181
+ it("should throw error for non-existent build", () => {
182
+ const loader = new ConfigFileLoader(configPath)
183
+ expect(() => {
184
+ loader.resolveBuild("nonexistent", {}, "webpack")
185
+ }).toThrow(/Build 'nonexistent' not found/)
186
+ })
187
+
188
+ it("should resolve build with environment variables", () => {
189
+ const loader = new ConfigFileLoader(configPath)
190
+ const resolved = loader.resolveBuild("dev", {}, "webpack")
191
+ expect(resolved.name).toBe("dev")
192
+ expect(resolved.environment.NODE_ENV).toBe("development")
193
+ expect(resolved.environment.RAILS_ENV).toBe("development")
194
+ expect(resolved.outputs).toStrictEqual(["client", "server"])
195
+ })
196
+
197
+ it("should use build-specific bundler over default", () => {
198
+ const loader = new ConfigFileLoader(configPath)
199
+ const resolved = loader.resolveBuild("prod", {}, "rspack")
200
+ expect(resolved.bundler).toBe("webpack")
201
+ })
202
+
203
+ it("should use CLI bundler option over everything", () => {
204
+ const loader = new ConfigFileLoader(configPath)
205
+ const resolved = loader.resolveBuild(
206
+ "prod",
207
+ { bundler: "rspack" },
208
+ "webpack"
209
+ )
210
+ expect(resolved.bundler).toBe("rspack")
211
+ })
212
+ })
213
+
214
+ describe("edge case validation", () => {
215
+ it("should throw error for empty outputs array", () => {
216
+ writeFileSync(
217
+ configPath,
218
+ `
219
+ builds:
220
+ bad:
221
+ environment:
222
+ NODE_ENV: development
223
+ outputs: []
224
+ `
225
+ )
226
+ const loader = new ConfigFileLoader(configPath)
227
+ expect(() => {
228
+ loader.resolveBuild("bad", {}, "webpack")
229
+ }).toThrow(/empty outputs array/)
230
+ })
231
+
232
+ it("should throw error for duplicate outputs", () => {
233
+ writeFileSync(
234
+ configPath,
235
+ `
236
+ builds:
237
+ bad:
238
+ environment:
239
+ NODE_ENV: development
240
+ outputs:
241
+ - client
242
+ - client
243
+ - server
244
+ `
245
+ )
246
+ const loader = new ConfigFileLoader(configPath)
247
+ expect(() => {
248
+ loader.resolveBuild("bad", {}, "webpack")
249
+ }).toThrow(/duplicate output types/)
250
+ })
251
+
252
+ it("should throw error for invalid config file path with path traversal", () => {
253
+ writeFileSync(
254
+ configPath,
255
+ `
256
+ builds:
257
+ bad:
258
+ environment:
259
+ NODE_ENV: development
260
+ config: ../../../malicious.js
261
+ outputs:
262
+ - client
263
+ `
264
+ )
265
+ const loader = new ConfigFileLoader(configPath)
266
+ expect(() => {
267
+ loader.resolveBuild("bad", {}, "webpack")
268
+ }).toThrow(/Invalid config file path/)
269
+ })
270
+ })
271
+
272
+ describe("environment variable expansion", () => {
273
+ beforeEach(() => {
274
+ process.env.TEST_VAR = "test-value"
275
+ process.env.BUNDLER_VAR = "should-not-be-used"
276
+ })
277
+
278
+ afterEach(() => {
279
+ delete process.env.TEST_VAR
280
+ delete process.env.BUNDLER_VAR
281
+ })
282
+
283
+ it("should expand ${BUNDLER} variable", () => {
284
+ writeFileSync(
285
+ configPath,
286
+ "builds:\n test:\n environment:\n CONFIG_PATH: config/${BUNDLER}/config.js\n outputs:\n - client\n"
287
+ )
288
+ const loader = new ConfigFileLoader(configPath)
289
+ const resolved = loader.resolveBuild("test", {}, "rspack")
290
+ expect(resolved.environment.CONFIG_PATH).toBe("config/rspack/config.js")
291
+ })
292
+
293
+ it("should expand ${VAR} from environment", () => {
294
+ writeFileSync(
295
+ configPath,
296
+ "builds:\n test:\n environment:\n CUSTOM: ${TEST_VAR}\n outputs:\n - client\n"
297
+ )
298
+ const loader = new ConfigFileLoader(configPath)
299
+ const resolved = loader.resolveBuild("test", {}, "webpack")
300
+ expect(resolved.environment.CUSTOM).toBe("test-value")
301
+ })
302
+
303
+ it("should expand ${VAR:-default} with default value", () => {
304
+ writeFileSync(
305
+ configPath,
306
+ "builds:\n test:\n environment:\n WITH_DEFAULT: ${NONEXISTENT:-fallback-value}\n outputs:\n - client\n"
307
+ )
308
+ const loader = new ConfigFileLoader(configPath)
309
+ const resolved = loader.resolveBuild("test", {}, "webpack")
310
+ expect(resolved.environment.WITH_DEFAULT).toBe("fallback-value")
311
+ })
312
+
313
+ it("should use environment value over default in ${VAR:-default}", () => {
314
+ writeFileSync(
315
+ configPath,
316
+ "builds:\n test:\n environment:\n WITH_DEFAULT: ${TEST_VAR:-fallback-value}\n outputs:\n - client\n"
317
+ )
318
+ const loader = new ConfigFileLoader(configPath)
319
+ const resolved = loader.resolveBuild("test", {}, "webpack")
320
+ expect(resolved.environment.WITH_DEFAULT).toBe("test-value")
321
+ })
322
+
323
+ it("should reject invalid environment variable names", () => {
324
+ writeFileSync(
325
+ configPath,
326
+ "builds:\n test:\n environment:\n BAD: ${Invalid-Var-Name}\n outputs:\n - client\n"
327
+ )
328
+ const loader = new ConfigFileLoader(configPath)
329
+ const resolved = loader.resolveBuild("test", {}, "webpack")
330
+ // Should not expand invalid var names (contains hyphen)
331
+ expect(resolved.environment.BAD).toBe("${Invalid-Var-Name}")
332
+ })
333
+ })
334
+
335
+ describe("bundler_env conversion", () => {
336
+ it("should convert bundler_env to CLI arguments", () => {
337
+ writeFileSync(
338
+ configPath,
339
+ `
340
+ builds:
341
+ test:
342
+ environment:
343
+ NODE_ENV: production
344
+ bundler_env:
345
+ target: modern
346
+ instrumented: true
347
+ disabled: false
348
+ outputs:
349
+ - client
350
+ `
351
+ )
352
+ const loader = new ConfigFileLoader(configPath)
353
+ const resolved = loader.resolveBuild("test", {}, "webpack")
354
+
355
+ // YAML parses booleans as true/false, or as strings "true"/"false"
356
+ // The code handles both cases: true or "true" becomes a flag, false/"false" is ignored
357
+ // Expected format: ['--env', 'target=modern', '--env', 'instrumented']
358
+ expect(resolved.bundlerEnvArgs).toContain("--env")
359
+ expect(resolved.bundlerEnvArgs).toContain("target=modern")
360
+
361
+ // Boolean true becomes a flag (--env key), false is ignored
362
+ const argsString = resolved.bundlerEnvArgs.join(" ")
363
+ expect(argsString).toContain("--env instrumented")
364
+ expect(argsString).not.toContain("disabled")
365
+ })
366
+ })
367
+ })
368
+
369
+ describe("generateSampleConfigFile", () => {
370
+ it("should generate valid YAML string", () => {
371
+ const content = generateSampleConfigFile()
372
+ expect(content).toContain("default_bundler:")
373
+ expect(content).toContain("builds:")
374
+ expect(content).toContain("dev-hmr:")
375
+ expect(content).toContain("dev:")
376
+ expect(content).toContain("prod:")
377
+ })
378
+
379
+ it("should include documentation comments", () => {
380
+ const content = generateSampleConfigFile()
381
+ expect(content).toContain("# Bundler Build Configurations")
382
+ expect(content).toContain("HMR")
383
+ expect(content).toContain("production")
384
+ })
385
+
386
+ it("should escape template literal variables correctly", () => {
387
+ const content = generateSampleConfigFile()
388
+ // Should have ${BUNDLER} not actual 'webpack' or 'rspack'
389
+ expect(content).toContain("${BUNDLER}")
390
+ expect(content).toContain("${RAILS_ENV:-staging}")
391
+ })
392
+ })