shakapacker 9.3.0.beta.0 → 9.3.0.beta.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/Gemfile.lock +1 -1
- data/docs/troubleshooting.md +141 -1
- data/jest.config.js +8 -1
- data/lib/shakapacker/version.rb +1 -1
- data/package/configExporter/buildValidator.ts +883 -0
- data/package/configExporter/cli.ts +183 -12
- data/package/configExporter/index.ts +3 -1
- data/package/configExporter/types.ts +18 -0
- data/package-lock.json +2 -2
- data/package.json +17 -16
- data/test/configExporter/buildValidator.test.js +1292 -0
- data/test/package/environments/base.test.js +6 -3
- data/test/package/rules/babel.test.js +61 -51
- data/test/package/rules/esbuild.test.js +12 -3
- data/test/package/rules/file.test.js +3 -1
- data/test/package/rules/sass.test.js +9 -2
- data/test/package/rules/sass1.test.js +3 -2
- data/test/package/rules/sass16.test.js +3 -2
- data/test/package/rules/swc.test.js +48 -38
- data/yarn.lock +62 -3
- metadata +5 -2
@@ -0,0 +1,1292 @@
|
|
1
|
+
const { existsSync, writeFileSync, mkdirSync, rmSync } = require("fs")
|
2
|
+
const { resolve, join } = require("path")
|
3
|
+
|
4
|
+
// Mock child_process.spawn before importing BuildValidator
|
5
|
+
const mockSpawn = jest.fn()
|
6
|
+
jest.mock("child_process", () => ({
|
7
|
+
spawn: mockSpawn
|
8
|
+
}))
|
9
|
+
|
10
|
+
const { BuildValidator } = require("../../package/configExporter")
|
11
|
+
|
12
|
+
// Helper to wait for async operations to start
|
13
|
+
const waitForAsync = () =>
|
14
|
+
new Promise((r) => {
|
15
|
+
setImmediate(r)
|
16
|
+
})
|
17
|
+
|
18
|
+
describe("BuildValidator", () => {
|
19
|
+
const testDir = resolve(__dirname, "../tmp/build-validator-test")
|
20
|
+
let validator
|
21
|
+
|
22
|
+
beforeEach(() => {
|
23
|
+
jest.clearAllMocks()
|
24
|
+
mockSpawn.mockClear()
|
25
|
+
if (!existsSync(testDir)) {
|
26
|
+
mkdirSync(testDir, { recursive: true })
|
27
|
+
}
|
28
|
+
validator = new BuildValidator({ verbose: false, timeout: 5000 })
|
29
|
+
|
30
|
+
// Mock findBinary to return a path (prevents early return in validateHMRBuild)
|
31
|
+
// This will be overridden by tests that specifically test findBinary behavior
|
32
|
+
jest
|
33
|
+
.spyOn(BuildValidator.prototype, "findBinary")
|
34
|
+
.mockReturnValue("/usr/local/bin/webpack")
|
35
|
+
})
|
36
|
+
|
37
|
+
afterEach(() => {
|
38
|
+
// Restore all mocks to prevent test pollution
|
39
|
+
jest.restoreAllMocks()
|
40
|
+
|
41
|
+
if (existsSync(testDir)) {
|
42
|
+
rmSync(testDir, { recursive: true, force: true })
|
43
|
+
}
|
44
|
+
})
|
45
|
+
|
46
|
+
describe("constructor", () => {
|
47
|
+
it("should accept verbose and timeout options", () => {
|
48
|
+
const v = new BuildValidator({ verbose: true, timeout: 10000 })
|
49
|
+
expect(v).toBeDefined()
|
50
|
+
})
|
51
|
+
|
52
|
+
it("should use default timeout of 120000ms if not specified", () => {
|
53
|
+
const v = new BuildValidator({ verbose: false })
|
54
|
+
expect(v).toBeDefined()
|
55
|
+
})
|
56
|
+
})
|
57
|
+
|
58
|
+
describe("environment variable filtering", () => {
|
59
|
+
it("should only include whitelisted environment variables", async () => {
|
60
|
+
const configPath = join(testDir, "webpack.config.js")
|
61
|
+
writeFileSync(configPath, "module.exports = {}")
|
62
|
+
|
63
|
+
const mockChild = {
|
64
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
65
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
66
|
+
on: jest.fn(),
|
67
|
+
once: jest.fn(),
|
68
|
+
kill: jest.fn(),
|
69
|
+
removeAllListeners: jest.fn()
|
70
|
+
}
|
71
|
+
|
72
|
+
mockSpawn.mockReturnValue(mockChild)
|
73
|
+
|
74
|
+
const build = {
|
75
|
+
name: "test",
|
76
|
+
bundler: "webpack",
|
77
|
+
environment: {
|
78
|
+
NODE_ENV: "production",
|
79
|
+
PATH: "/usr/bin",
|
80
|
+
MALICIOUS_VAR: "should-be-filtered"
|
81
|
+
},
|
82
|
+
configFile: configPath,
|
83
|
+
outputs: ["client"]
|
84
|
+
}
|
85
|
+
|
86
|
+
// Start the validation
|
87
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
88
|
+
|
89
|
+
// Simulate exit
|
90
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
91
|
+
([event]) => event === "exit"
|
92
|
+
)[1]
|
93
|
+
exitHandler(0)
|
94
|
+
|
95
|
+
await validationPromise
|
96
|
+
|
97
|
+
// Verify spawn was called with filtered environment
|
98
|
+
const spawnCall = mockSpawn.mock.calls[0]
|
99
|
+
const { env } = spawnCall[2]
|
100
|
+
expect(env.NODE_ENV).toBe("production")
|
101
|
+
expect(env.PATH).toBe("/usr/bin")
|
102
|
+
expect(env.MALICIOUS_VAR).toBeUndefined()
|
103
|
+
})
|
104
|
+
|
105
|
+
it("should warn about suspicious patterns in environment variables", async () => {
|
106
|
+
const configPath = join(testDir, "webpack.config.js")
|
107
|
+
writeFileSync(configPath, "module.exports = {}")
|
108
|
+
|
109
|
+
const mockChild = {
|
110
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
111
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
112
|
+
on: jest.fn(),
|
113
|
+
once: jest.fn(),
|
114
|
+
kill: jest.fn(),
|
115
|
+
removeAllListeners: jest.fn()
|
116
|
+
}
|
117
|
+
|
118
|
+
mockSpawn.mockReturnValue(mockChild)
|
119
|
+
|
120
|
+
// Use verbose validator to see security warnings
|
121
|
+
const verboseValidator = new BuildValidator({ verbose: true })
|
122
|
+
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation()
|
123
|
+
|
124
|
+
const build = {
|
125
|
+
name: "test",
|
126
|
+
bundler: "webpack",
|
127
|
+
environment: {
|
128
|
+
NODE_ENV: "production; echo hacked",
|
129
|
+
PATH: "/usr/bin"
|
130
|
+
},
|
131
|
+
configFile: configPath,
|
132
|
+
outputs: ["client"]
|
133
|
+
}
|
134
|
+
|
135
|
+
const validationPromise = verboseValidator.validateBuild(build, testDir)
|
136
|
+
|
137
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
138
|
+
([event]) => event === "exit"
|
139
|
+
)[1]
|
140
|
+
exitHandler(0)
|
141
|
+
|
142
|
+
await validationPromise
|
143
|
+
|
144
|
+
// Should have warned about suspicious pattern
|
145
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
146
|
+
expect.stringContaining("Suspicious pattern")
|
147
|
+
)
|
148
|
+
|
149
|
+
consoleWarnSpy.mockRestore()
|
150
|
+
})
|
151
|
+
|
152
|
+
it("should include PATH from process.env for binary resolution", async () => {
|
153
|
+
const configPath = join(testDir, "webpack.config.js")
|
154
|
+
writeFileSync(configPath, "module.exports = {}")
|
155
|
+
|
156
|
+
const originalPath = process.env.PATH
|
157
|
+
process.env.PATH = "/test/path"
|
158
|
+
|
159
|
+
const mockChild = {
|
160
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
161
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
162
|
+
on: jest.fn(),
|
163
|
+
once: jest.fn(),
|
164
|
+
kill: jest.fn(),
|
165
|
+
removeAllListeners: jest.fn()
|
166
|
+
}
|
167
|
+
|
168
|
+
mockSpawn.mockReturnValue(mockChild)
|
169
|
+
|
170
|
+
const build = {
|
171
|
+
name: "test",
|
172
|
+
bundler: "webpack",
|
173
|
+
environment: { NODE_ENV: "production" },
|
174
|
+
configFile: configPath,
|
175
|
+
outputs: ["client"]
|
176
|
+
}
|
177
|
+
|
178
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
179
|
+
|
180
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
181
|
+
([event]) => event === "exit"
|
182
|
+
)[1]
|
183
|
+
exitHandler(0)
|
184
|
+
|
185
|
+
await validationPromise
|
186
|
+
|
187
|
+
const spawnCall = mockSpawn.mock.calls[0]
|
188
|
+
const { env } = spawnCall[2]
|
189
|
+
expect(env.PATH).toBe("/test/path")
|
190
|
+
|
191
|
+
process.env.PATH = originalPath
|
192
|
+
})
|
193
|
+
})
|
194
|
+
|
195
|
+
describe("validateBuild - static builds", () => {
|
196
|
+
it("should successfully validate a static build with JSON output", async () => {
|
197
|
+
const configPath = join(testDir, "webpack.config.js")
|
198
|
+
writeFileSync(configPath, "module.exports = {}")
|
199
|
+
|
200
|
+
const mockChild = {
|
201
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
202
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
203
|
+
on: jest.fn(),
|
204
|
+
once: jest.fn(),
|
205
|
+
kill: jest.fn(),
|
206
|
+
removeAllListeners: jest.fn()
|
207
|
+
}
|
208
|
+
|
209
|
+
mockSpawn.mockReturnValue(mockChild)
|
210
|
+
|
211
|
+
const build = {
|
212
|
+
name: "prod",
|
213
|
+
bundler: "webpack",
|
214
|
+
environment: { NODE_ENV: "production" },
|
215
|
+
configFile: configPath,
|
216
|
+
outputs: ["client"]
|
217
|
+
}
|
218
|
+
|
219
|
+
// Start validation (don't await yet, we need to trigger events)
|
220
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
221
|
+
|
222
|
+
// Simulate stdout data with valid JSON
|
223
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
224
|
+
([event]) => event === "data"
|
225
|
+
)[1]
|
226
|
+
stdoutHandler(Buffer.from(JSON.stringify({ hash: "abc123", errors: [] })))
|
227
|
+
|
228
|
+
// Simulate successful exit
|
229
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
230
|
+
([event]) => event === "exit"
|
231
|
+
)[1]
|
232
|
+
exitHandler(0)
|
233
|
+
|
234
|
+
// Now await the result
|
235
|
+
const result = await validationPromise
|
236
|
+
|
237
|
+
expect(result.success).toBe(true)
|
238
|
+
expect(result.buildName).toBe("prod")
|
239
|
+
expect(result.errors).toHaveLength(0)
|
240
|
+
})
|
241
|
+
|
242
|
+
it("should capture errors from webpack JSON output", async () => {
|
243
|
+
const configPath = join(testDir, "webpack.config.js")
|
244
|
+
writeFileSync(configPath, "module.exports = {}")
|
245
|
+
|
246
|
+
const mockChild = {
|
247
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
248
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
249
|
+
on: jest.fn(),
|
250
|
+
once: jest.fn(),
|
251
|
+
kill: jest.fn(),
|
252
|
+
removeAllListeners: jest.fn()
|
253
|
+
}
|
254
|
+
|
255
|
+
mockSpawn.mockReturnValue(mockChild)
|
256
|
+
|
257
|
+
const build = {
|
258
|
+
name: "prod",
|
259
|
+
bundler: "webpack",
|
260
|
+
environment: { NODE_ENV: "production" },
|
261
|
+
configFile: configPath,
|
262
|
+
outputs: ["client"]
|
263
|
+
}
|
264
|
+
|
265
|
+
// Start validation (don't await yet, we need to trigger events)
|
266
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
267
|
+
|
268
|
+
// Simulate stdout with errors
|
269
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
270
|
+
([event]) => event === "data"
|
271
|
+
)[1]
|
272
|
+
const errorOutput = JSON.stringify({
|
273
|
+
errors: [
|
274
|
+
{ message: "Module not found: Error: Can't resolve './missing'" },
|
275
|
+
"SyntaxError: Unexpected token"
|
276
|
+
]
|
277
|
+
})
|
278
|
+
stdoutHandler(Buffer.from(errorOutput))
|
279
|
+
|
280
|
+
// Simulate exit with error code
|
281
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
282
|
+
([event]) => event === "exit"
|
283
|
+
)[1]
|
284
|
+
exitHandler(1)
|
285
|
+
|
286
|
+
const result = await validationPromise
|
287
|
+
|
288
|
+
expect(result.success).toBe(false)
|
289
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
290
|
+
expect(result.errors[0]).toContain("Module not found")
|
291
|
+
expect(result.errors[1]).toBe("SyntaxError: Unexpected token")
|
292
|
+
})
|
293
|
+
|
294
|
+
it("should handle timeout for static builds", async () => {
|
295
|
+
const configPath = join(testDir, "webpack.config.js")
|
296
|
+
writeFileSync(configPath, "module.exports = {}")
|
297
|
+
|
298
|
+
const mockChild = {
|
299
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
300
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
301
|
+
on: jest.fn(),
|
302
|
+
once: jest.fn(),
|
303
|
+
kill: jest.fn(),
|
304
|
+
removeAllListeners: jest.fn()
|
305
|
+
}
|
306
|
+
|
307
|
+
mockSpawn.mockReturnValue(mockChild)
|
308
|
+
|
309
|
+
const build = {
|
310
|
+
name: "prod",
|
311
|
+
bundler: "webpack",
|
312
|
+
environment: { NODE_ENV: "production" },
|
313
|
+
configFile: configPath,
|
314
|
+
outputs: ["client"]
|
315
|
+
}
|
316
|
+
|
317
|
+
const shortTimeoutValidator = new BuildValidator({
|
318
|
+
verbose: false,
|
319
|
+
timeout: 100
|
320
|
+
})
|
321
|
+
|
322
|
+
const validationPromise = shortTimeoutValidator.validateBuild(
|
323
|
+
build,
|
324
|
+
testDir
|
325
|
+
)
|
326
|
+
|
327
|
+
// Wait for timeout to trigger
|
328
|
+
await new Promise((r) => {
|
329
|
+
setTimeout(r, 150)
|
330
|
+
})
|
331
|
+
|
332
|
+
// Timeout should kill the child
|
333
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM")
|
334
|
+
|
335
|
+
// Simulate exit after kill
|
336
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
337
|
+
([event]) => event === "exit"
|
338
|
+
)[1]
|
339
|
+
exitHandler(143) // SIGTERM exit code
|
340
|
+
|
341
|
+
const result = await validationPromise
|
342
|
+
|
343
|
+
expect(result.success).toBe(false)
|
344
|
+
expect(result.errors.some((e) => e.includes("Timeout"))).toBe(true)
|
345
|
+
})
|
346
|
+
|
347
|
+
it("should handle buffer overflow with warning", async () => {
|
348
|
+
const configPath = join(testDir, "webpack.config.js")
|
349
|
+
writeFileSync(configPath, "module.exports = {}")
|
350
|
+
|
351
|
+
const mockChild = {
|
352
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
353
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
354
|
+
on: jest.fn(),
|
355
|
+
once: jest.fn(),
|
356
|
+
kill: jest.fn(),
|
357
|
+
removeAllListeners: jest.fn()
|
358
|
+
}
|
359
|
+
|
360
|
+
mockSpawn.mockReturnValue(mockChild)
|
361
|
+
|
362
|
+
const build = {
|
363
|
+
name: "prod",
|
364
|
+
bundler: "webpack",
|
365
|
+
environment: { NODE_ENV: "production" },
|
366
|
+
configFile: configPath,
|
367
|
+
outputs: ["client"]
|
368
|
+
}
|
369
|
+
|
370
|
+
// Start validation (don't await yet)
|
371
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
372
|
+
|
373
|
+
// Simulate large stdout data (11MB exceeds 10MB limit)
|
374
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
375
|
+
([event]) => event === "data"
|
376
|
+
)[1]
|
377
|
+
const largeBuffer = Buffer.alloc(11 * 1024 * 1024, "a")
|
378
|
+
stdoutHandler(largeBuffer)
|
379
|
+
|
380
|
+
// Simulate exit
|
381
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
382
|
+
([event]) => event === "exit"
|
383
|
+
)[1]
|
384
|
+
exitHandler(0)
|
385
|
+
|
386
|
+
const result = await validationPromise
|
387
|
+
|
388
|
+
expect(result.warnings.length).toBeGreaterThan(0)
|
389
|
+
expect(
|
390
|
+
result.warnings.some((w) => w.includes("buffer limit exceeded"))
|
391
|
+
).toBe(true)
|
392
|
+
})
|
393
|
+
|
394
|
+
it("should fallback to stderr parsing when JSON parsing fails", async () => {
|
395
|
+
const configPath = join(testDir, "webpack.config.js")
|
396
|
+
writeFileSync(configPath, "module.exports = {}")
|
397
|
+
|
398
|
+
const mockChild = {
|
399
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
400
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
401
|
+
on: jest.fn(),
|
402
|
+
once: jest.fn(),
|
403
|
+
kill: jest.fn(),
|
404
|
+
removeAllListeners: jest.fn()
|
405
|
+
}
|
406
|
+
|
407
|
+
mockSpawn.mockReturnValue(mockChild)
|
408
|
+
|
409
|
+
const build = {
|
410
|
+
name: "prod",
|
411
|
+
bundler: "webpack",
|
412
|
+
environment: { NODE_ENV: "production" },
|
413
|
+
configFile: configPath,
|
414
|
+
outputs: ["client"]
|
415
|
+
}
|
416
|
+
|
417
|
+
// Start validation (don't await yet)
|
418
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
419
|
+
|
420
|
+
// Simulate invalid JSON in stdout
|
421
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
422
|
+
([event]) => event === "data"
|
423
|
+
)[1]
|
424
|
+
stdoutHandler(Buffer.from("invalid json output"))
|
425
|
+
|
426
|
+
// Simulate stderr with error
|
427
|
+
const stderrHandler = mockChild.stderr.on.mock.calls.find(
|
428
|
+
([event]) => event === "data"
|
429
|
+
)[1]
|
430
|
+
stderrHandler(Buffer.from("ERROR: Module build failed"))
|
431
|
+
|
432
|
+
// Simulate exit with error code
|
433
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
434
|
+
([event]) => event === "exit"
|
435
|
+
)[1]
|
436
|
+
exitHandler(1)
|
437
|
+
|
438
|
+
const result = await validationPromise
|
439
|
+
|
440
|
+
expect(result.success).toBe(false)
|
441
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
442
|
+
expect(result.errors.some((e) => e.includes("ERROR"))).toBe(true)
|
443
|
+
})
|
444
|
+
|
445
|
+
it("should return error if config file does not exist", async () => {
|
446
|
+
const nonExistentPath = join(testDir, "nonexistent.config.js")
|
447
|
+
|
448
|
+
const build = {
|
449
|
+
name: "prod",
|
450
|
+
bundler: "webpack",
|
451
|
+
environment: { NODE_ENV: "production" },
|
452
|
+
configFile: nonExistentPath,
|
453
|
+
outputs: ["client"]
|
454
|
+
}
|
455
|
+
|
456
|
+
const result = await validator.validateBuild(build, testDir)
|
457
|
+
|
458
|
+
expect(result.success).toBe(false)
|
459
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
460
|
+
expect(result.errors[0]).toContain("Config file not found")
|
461
|
+
})
|
462
|
+
|
463
|
+
it("should reject path traversal attacks in config path", async () => {
|
464
|
+
// Attempt to access a file outside the appRoot using path traversal
|
465
|
+
const maliciousPath = "../../../etc/passwd"
|
466
|
+
|
467
|
+
const build = {
|
468
|
+
name: "malicious",
|
469
|
+
bundler: "webpack",
|
470
|
+
environment: { NODE_ENV: "production" },
|
471
|
+
configFile: maliciousPath,
|
472
|
+
outputs: ["client"]
|
473
|
+
}
|
474
|
+
|
475
|
+
const result = await validator.validateBuild(build, testDir)
|
476
|
+
|
477
|
+
expect(result.success).toBe(false)
|
478
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
479
|
+
expect(result.errors[0]).toContain(
|
480
|
+
"Path must be within project directory"
|
481
|
+
)
|
482
|
+
})
|
483
|
+
})
|
484
|
+
|
485
|
+
describe("validateBuild - HMR builds", () => {
|
486
|
+
it("should successfully validate an HMR build", async () => {
|
487
|
+
const configPath = join(testDir, "webpack.config.js")
|
488
|
+
writeFileSync(configPath, "module.exports = {}")
|
489
|
+
|
490
|
+
const mockChild = {
|
491
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
492
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
493
|
+
on: jest.fn(),
|
494
|
+
once: jest.fn(),
|
495
|
+
kill: jest.fn(),
|
496
|
+
removeAllListeners: jest.fn()
|
497
|
+
}
|
498
|
+
|
499
|
+
mockSpawn.mockReturnValue(mockChild)
|
500
|
+
|
501
|
+
const build = {
|
502
|
+
name: "dev-hmr",
|
503
|
+
bundler: "webpack",
|
504
|
+
environment: {
|
505
|
+
NODE_ENV: "development",
|
506
|
+
WEBPACK_SERVE: "true"
|
507
|
+
},
|
508
|
+
configFile: configPath,
|
509
|
+
outputs: ["client"]
|
510
|
+
}
|
511
|
+
|
512
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
513
|
+
|
514
|
+
// Wait for spawn to be called
|
515
|
+
await waitForAsync()
|
516
|
+
|
517
|
+
// Simulate stdout with success pattern
|
518
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
519
|
+
([event]) => event === "data"
|
520
|
+
)[1]
|
521
|
+
stdoutHandler(Buffer.from("webpack compiled successfully\n"))
|
522
|
+
|
523
|
+
// Wait for the success handler to process and kill the child
|
524
|
+
await new Promise((r) => {
|
525
|
+
setTimeout(r, 50)
|
526
|
+
})
|
527
|
+
|
528
|
+
// Verify kill was called
|
529
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM")
|
530
|
+
|
531
|
+
// Now simulate exit event (which the success handler triggers via kill)
|
532
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
533
|
+
([event]) => event === "exit"
|
534
|
+
)[1]
|
535
|
+
exitHandler(143) // SIGTERM exit code
|
536
|
+
|
537
|
+
const result = await validationPromise
|
538
|
+
|
539
|
+
expect(result.success).toBe(true)
|
540
|
+
expect(result.buildName).toBe("dev-hmr")
|
541
|
+
})
|
542
|
+
|
543
|
+
it("should detect HMR from HMR environment variable", async () => {
|
544
|
+
const configPath = join(testDir, "webpack.config.js")
|
545
|
+
writeFileSync(configPath, "module.exports = {}")
|
546
|
+
|
547
|
+
const mockChild = {
|
548
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
549
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
550
|
+
on: jest.fn(),
|
551
|
+
once: jest.fn(),
|
552
|
+
kill: jest.fn(),
|
553
|
+
removeAllListeners: jest.fn()
|
554
|
+
}
|
555
|
+
|
556
|
+
mockSpawn.mockReturnValue(mockChild)
|
557
|
+
|
558
|
+
const build = {
|
559
|
+
name: "dev-hmr",
|
560
|
+
bundler: "webpack",
|
561
|
+
environment: {
|
562
|
+
NODE_ENV: "development",
|
563
|
+
HMR: "true"
|
564
|
+
},
|
565
|
+
configFile: configPath,
|
566
|
+
outputs: ["client"]
|
567
|
+
}
|
568
|
+
|
569
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
570
|
+
|
571
|
+
// Wait for spawn to be called
|
572
|
+
await waitForAsync()
|
573
|
+
|
574
|
+
// Simulate success
|
575
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
576
|
+
([event]) => event === "data"
|
577
|
+
)[1]
|
578
|
+
stdoutHandler(Buffer.from("Compiled successfully\n"))
|
579
|
+
|
580
|
+
await new Promise((r) => {
|
581
|
+
setTimeout(r, 50)
|
582
|
+
})
|
583
|
+
|
584
|
+
// Simulate exit event after kill
|
585
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
586
|
+
([event]) => event === "exit"
|
587
|
+
)[1]
|
588
|
+
exitHandler(143) // SIGTERM exit code
|
589
|
+
|
590
|
+
const result = await validationPromise
|
591
|
+
|
592
|
+
expect(result.success).toBe(true)
|
593
|
+
})
|
594
|
+
|
595
|
+
it("should capture errors in HMR builds", async () => {
|
596
|
+
const configPath = join(testDir, "webpack.config.js")
|
597
|
+
writeFileSync(configPath, "module.exports = {}")
|
598
|
+
|
599
|
+
const mockChild = {
|
600
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
601
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
602
|
+
on: jest.fn(),
|
603
|
+
once: jest.fn(),
|
604
|
+
kill: jest.fn(),
|
605
|
+
removeAllListeners: jest.fn()
|
606
|
+
}
|
607
|
+
|
608
|
+
mockSpawn.mockReturnValue(mockChild)
|
609
|
+
|
610
|
+
const build = {
|
611
|
+
name: "dev-hmr",
|
612
|
+
bundler: "webpack",
|
613
|
+
environment: {
|
614
|
+
NODE_ENV: "development",
|
615
|
+
WEBPACK_SERVE: "true"
|
616
|
+
},
|
617
|
+
configFile: configPath,
|
618
|
+
outputs: ["client"]
|
619
|
+
}
|
620
|
+
|
621
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
622
|
+
|
623
|
+
// Wait for spawn to be called
|
624
|
+
await waitForAsync()
|
625
|
+
|
626
|
+
// Simulate error output
|
627
|
+
const stderrHandler = mockChild.stderr.on.mock.calls.find(
|
628
|
+
([event]) => event === "data"
|
629
|
+
)[1]
|
630
|
+
stderrHandler(Buffer.from("ERROR: Failed to compile\n"))
|
631
|
+
stderrHandler(
|
632
|
+
Buffer.from("Module not found: Can't resolve './component'\n")
|
633
|
+
)
|
634
|
+
|
635
|
+
// Simulate exit with error
|
636
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
637
|
+
([event]) => event === "exit"
|
638
|
+
)[1]
|
639
|
+
exitHandler(1)
|
640
|
+
|
641
|
+
const result = await validationPromise
|
642
|
+
|
643
|
+
expect(result.success).toBe(false)
|
644
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
645
|
+
expect(result.errors.some((e) => e.includes("Failed to compile"))).toBe(
|
646
|
+
true
|
647
|
+
)
|
648
|
+
})
|
649
|
+
|
650
|
+
it("should handle timeout for HMR builds", async () => {
|
651
|
+
const configPath = join(testDir, "webpack.config.js")
|
652
|
+
writeFileSync(configPath, "module.exports = {}")
|
653
|
+
|
654
|
+
const mockChild = {
|
655
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
656
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
657
|
+
on: jest.fn(),
|
658
|
+
once: jest.fn(),
|
659
|
+
kill: jest.fn(),
|
660
|
+
removeAllListeners: jest.fn()
|
661
|
+
}
|
662
|
+
|
663
|
+
mockSpawn.mockReturnValue(mockChild)
|
664
|
+
|
665
|
+
const build = {
|
666
|
+
name: "dev-hmr",
|
667
|
+
bundler: "webpack",
|
668
|
+
environment: {
|
669
|
+
NODE_ENV: "development",
|
670
|
+
WEBPACK_SERVE: "true"
|
671
|
+
},
|
672
|
+
configFile: configPath,
|
673
|
+
outputs: ["client"]
|
674
|
+
}
|
675
|
+
|
676
|
+
const shortTimeoutValidator = new BuildValidator({
|
677
|
+
verbose: false,
|
678
|
+
timeout: 100
|
679
|
+
})
|
680
|
+
|
681
|
+
const validationPromise = shortTimeoutValidator.validateBuild(
|
682
|
+
build,
|
683
|
+
testDir
|
684
|
+
)
|
685
|
+
|
686
|
+
// Wait for timeout
|
687
|
+
await new Promise((r) => {
|
688
|
+
setTimeout(r, 150)
|
689
|
+
})
|
690
|
+
|
691
|
+
const result = await validationPromise
|
692
|
+
|
693
|
+
expect(result.success).toBe(false)
|
694
|
+
expect(result.errors.some((e) => e.includes("Timeout"))).toBe(true)
|
695
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM")
|
696
|
+
expect(mockChild.stdout.removeAllListeners).toHaveBeenCalledWith()
|
697
|
+
expect(mockChild.stderr.removeAllListeners).toHaveBeenCalledWith()
|
698
|
+
expect(mockChild.removeAllListeners).toHaveBeenCalledWith()
|
699
|
+
})
|
700
|
+
|
701
|
+
it("should cleanup listeners properly after success", async () => {
|
702
|
+
const configPath = join(testDir, "webpack.config.js")
|
703
|
+
writeFileSync(configPath, "module.exports = {}")
|
704
|
+
|
705
|
+
const mockChild = {
|
706
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
707
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
708
|
+
on: jest.fn(),
|
709
|
+
once: jest.fn(),
|
710
|
+
kill: jest.fn(),
|
711
|
+
removeAllListeners: jest.fn()
|
712
|
+
}
|
713
|
+
|
714
|
+
mockSpawn.mockReturnValue(mockChild)
|
715
|
+
|
716
|
+
const build = {
|
717
|
+
name: "dev-hmr",
|
718
|
+
bundler: "webpack",
|
719
|
+
environment: {
|
720
|
+
NODE_ENV: "development",
|
721
|
+
WEBPACK_SERVE: "true"
|
722
|
+
},
|
723
|
+
configFile: configPath,
|
724
|
+
outputs: ["client"]
|
725
|
+
}
|
726
|
+
|
727
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
728
|
+
|
729
|
+
// Simulate success
|
730
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
731
|
+
([event]) => event === "data"
|
732
|
+
)[1]
|
733
|
+
stdoutHandler(Buffer.from("webpack compiled successfully\n"))
|
734
|
+
|
735
|
+
// Wait for cleanup
|
736
|
+
await new Promise((r) => {
|
737
|
+
setTimeout(r, 50)
|
738
|
+
})
|
739
|
+
|
740
|
+
// Simulate exit event after kill
|
741
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
742
|
+
([event]) => event === "exit"
|
743
|
+
)[1]
|
744
|
+
exitHandler(143) // SIGTERM exit code
|
745
|
+
|
746
|
+
await validationPromise
|
747
|
+
|
748
|
+
// Verify cleanup was called after exit
|
749
|
+
expect(mockChild.stdout.removeAllListeners).toHaveBeenCalledWith()
|
750
|
+
expect(mockChild.stderr.removeAllListeners).toHaveBeenCalledWith()
|
751
|
+
expect(mockChild.removeAllListeners).toHaveBeenCalledWith()
|
752
|
+
})
|
753
|
+
|
754
|
+
it("should handle delayed exit after SIGTERM gracefully", async () => {
|
755
|
+
const configPath = join(testDir, "webpack.config.js")
|
756
|
+
writeFileSync(configPath, "module.exports = {}")
|
757
|
+
|
758
|
+
const mockChild = {
|
759
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
760
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
761
|
+
on: jest.fn(),
|
762
|
+
once: jest.fn(),
|
763
|
+
kill: jest.fn(),
|
764
|
+
removeAllListeners: jest.fn()
|
765
|
+
}
|
766
|
+
|
767
|
+
mockSpawn.mockReturnValue(mockChild)
|
768
|
+
|
769
|
+
const build = {
|
770
|
+
name: "dev-hmr",
|
771
|
+
bundler: "webpack",
|
772
|
+
environment: {
|
773
|
+
NODE_ENV: "development",
|
774
|
+
WEBPACK_SERVE: "true"
|
775
|
+
},
|
776
|
+
configFile: configPath,
|
777
|
+
outputs: ["client"]
|
778
|
+
}
|
779
|
+
|
780
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
781
|
+
|
782
|
+
// Simulate success pattern
|
783
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
784
|
+
([event]) => event === "data"
|
785
|
+
)[1]
|
786
|
+
stdoutHandler(Buffer.from("webpack compiled successfully\n"))
|
787
|
+
|
788
|
+
// Wait for kill to be called
|
789
|
+
await new Promise((r) => {
|
790
|
+
setTimeout(r, 50)
|
791
|
+
})
|
792
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM")
|
793
|
+
|
794
|
+
// Simulate delayed exit (process takes time to clean up)
|
795
|
+
await new Promise((r) => {
|
796
|
+
setTimeout(r, 200)
|
797
|
+
})
|
798
|
+
|
799
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
800
|
+
([event]) => event === "exit"
|
801
|
+
)[1]
|
802
|
+
exitHandler(143) // SIGTERM exit code
|
803
|
+
|
804
|
+
const result = await validationPromise
|
805
|
+
|
806
|
+
// Should still successfully complete
|
807
|
+
expect(result.success).toBe(true)
|
808
|
+
expect(result.buildName).toBe("dev-hmr")
|
809
|
+
})
|
810
|
+
|
811
|
+
it("should handle multiple rapid success patterns without duplicate resolution", async () => {
|
812
|
+
const configPath = join(testDir, "webpack.config.js")
|
813
|
+
writeFileSync(configPath, "module.exports = {}")
|
814
|
+
|
815
|
+
const mockChild = {
|
816
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
817
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
818
|
+
on: jest.fn(),
|
819
|
+
once: jest.fn(),
|
820
|
+
kill: jest.fn(),
|
821
|
+
removeAllListeners: jest.fn()
|
822
|
+
}
|
823
|
+
|
824
|
+
mockSpawn.mockReturnValue(mockChild)
|
825
|
+
|
826
|
+
const build = {
|
827
|
+
name: "dev-hmr",
|
828
|
+
bundler: "webpack",
|
829
|
+
environment: {
|
830
|
+
NODE_ENV: "development",
|
831
|
+
WEBPACK_SERVE: "true"
|
832
|
+
},
|
833
|
+
configFile: configPath,
|
834
|
+
outputs: ["client"]
|
835
|
+
}
|
836
|
+
|
837
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
838
|
+
|
839
|
+
// Simulate multiple success patterns in rapid succession
|
840
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
841
|
+
([event]) => event === "data"
|
842
|
+
)[1]
|
843
|
+
stdoutHandler(Buffer.from("webpack compiled successfully\n"))
|
844
|
+
stdoutHandler(Buffer.from("Compiled successfully\n"))
|
845
|
+
stdoutHandler(Buffer.from("webpack: Compiled successfully\n"))
|
846
|
+
|
847
|
+
await new Promise((r) => {
|
848
|
+
setTimeout(r, 50)
|
849
|
+
})
|
850
|
+
|
851
|
+
// Should only kill once
|
852
|
+
expect(mockChild.kill).toHaveBeenCalledTimes(1)
|
853
|
+
|
854
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
855
|
+
([event]) => event === "exit"
|
856
|
+
)[1]
|
857
|
+
exitHandler(143)
|
858
|
+
|
859
|
+
const result = await validationPromise
|
860
|
+
expect(result.success).toBe(true)
|
861
|
+
})
|
862
|
+
|
863
|
+
it("should return error if webpack-dev-server binary not found", async () => {
|
864
|
+
// Create a validator that will fail to find binary
|
865
|
+
const build = {
|
866
|
+
name: "dev-hmr",
|
867
|
+
bundler: "webpack",
|
868
|
+
environment: {
|
869
|
+
NODE_ENV: "development",
|
870
|
+
WEBPACK_SERVE: "true"
|
871
|
+
},
|
872
|
+
configFile: join(testDir, "webpack.config.js"),
|
873
|
+
outputs: ["client"]
|
874
|
+
}
|
875
|
+
|
876
|
+
// Override findBinary to return null
|
877
|
+
const findBinarySpy = jest
|
878
|
+
.spyOn(BuildValidator.prototype, "findBinary")
|
879
|
+
.mockImplementation()
|
880
|
+
.mockReturnValue(null)
|
881
|
+
|
882
|
+
try {
|
883
|
+
const result = await validator.validateBuild(build, testDir)
|
884
|
+
|
885
|
+
expect(result.success).toBe(false)
|
886
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
887
|
+
expect(result.errors[0]).toContain("Could not find")
|
888
|
+
expect(result.errors[0]).toContain("webpack-dev-server")
|
889
|
+
} finally {
|
890
|
+
// Always restore the spy
|
891
|
+
findBinarySpy.mockRestore()
|
892
|
+
}
|
893
|
+
})
|
894
|
+
|
895
|
+
it("should use strict binary resolution in CI environments", async () => {
|
896
|
+
// Save original env
|
897
|
+
const originalCI = process.env.CI
|
898
|
+
|
899
|
+
// Set CI=true
|
900
|
+
process.env.CI = "true"
|
901
|
+
|
902
|
+
// Mock findBinary to simulate not finding binary locally
|
903
|
+
const findBinarySpy = jest
|
904
|
+
.spyOn(BuildValidator.prototype, "findBinary")
|
905
|
+
.mockImplementation()
|
906
|
+
.mockReturnValue(null)
|
907
|
+
|
908
|
+
try {
|
909
|
+
// Create validator (should auto-enable strict mode)
|
910
|
+
const ciValidator = new BuildValidator({ verbose: false })
|
911
|
+
|
912
|
+
const build = {
|
913
|
+
name: "dev-hmr",
|
914
|
+
bundler: "webpack",
|
915
|
+
environment: {
|
916
|
+
NODE_ENV: "development",
|
917
|
+
WEBPACK_SERVE: "true"
|
918
|
+
},
|
919
|
+
configFile: join(testDir, "webpack.config.js"),
|
920
|
+
outputs: ["client"]
|
921
|
+
}
|
922
|
+
|
923
|
+
const result = await ciValidator.validateBuild(build, testDir)
|
924
|
+
|
925
|
+
expect(result.success).toBe(false)
|
926
|
+
expect(result.errors[0]).toContain("Could not find")
|
927
|
+
} finally {
|
928
|
+
// Always restore the spy and env var
|
929
|
+
findBinarySpy.mockRestore()
|
930
|
+
process.env.CI = originalCI
|
931
|
+
}
|
932
|
+
})
|
933
|
+
})
|
934
|
+
|
935
|
+
describe("formatResults", () => {
|
936
|
+
it("should format successful results correctly", () => {
|
937
|
+
const results = [
|
938
|
+
{
|
939
|
+
buildName: "prod",
|
940
|
+
success: true,
|
941
|
+
errors: [],
|
942
|
+
warnings: [],
|
943
|
+
output: []
|
944
|
+
},
|
945
|
+
{
|
946
|
+
buildName: "dev",
|
947
|
+
success: true,
|
948
|
+
errors: [],
|
949
|
+
warnings: ["Deprecation warning"],
|
950
|
+
output: []
|
951
|
+
}
|
952
|
+
]
|
953
|
+
|
954
|
+
const formatted = validator.formatResults(results)
|
955
|
+
|
956
|
+
expect(formatted).toContain("Build Validation Results")
|
957
|
+
expect(formatted).toContain("✅ Build: prod")
|
958
|
+
expect(formatted).toContain("✅ Build: dev")
|
959
|
+
expect(formatted).toContain("2/2 builds passed")
|
960
|
+
expect(formatted).toContain("1 warning(s)")
|
961
|
+
})
|
962
|
+
|
963
|
+
it("should format failed results correctly", () => {
|
964
|
+
const results = [
|
965
|
+
{
|
966
|
+
buildName: "prod",
|
967
|
+
success: false,
|
968
|
+
errors: ["Module not found", "Syntax error"],
|
969
|
+
warnings: [],
|
970
|
+
output: ["error line 1", "error line 2"]
|
971
|
+
}
|
972
|
+
]
|
973
|
+
|
974
|
+
const formatted = validator.formatResults(results)
|
975
|
+
|
976
|
+
expect(formatted).toContain("❌ Build: prod")
|
977
|
+
expect(formatted).toContain("2 error(s)")
|
978
|
+
expect(formatted).toContain("Module not found")
|
979
|
+
expect(formatted).toContain("Syntax error")
|
980
|
+
expect(formatted).toContain("0/1 builds passed, 1 failed")
|
981
|
+
})
|
982
|
+
|
983
|
+
it("should show output section for errors", () => {
|
984
|
+
const results = [
|
985
|
+
{
|
986
|
+
buildName: "prod",
|
987
|
+
success: false,
|
988
|
+
errors: ["Build failed"],
|
989
|
+
warnings: [],
|
990
|
+
output: ["detailed error output"]
|
991
|
+
}
|
992
|
+
]
|
993
|
+
|
994
|
+
const formatted = validator.formatResults(results)
|
995
|
+
|
996
|
+
expect(formatted).toContain("Full Output:")
|
997
|
+
expect(formatted).toContain("detailed error output")
|
998
|
+
})
|
999
|
+
|
1000
|
+
it("should handle mixed success and failure", () => {
|
1001
|
+
const results = [
|
1002
|
+
{
|
1003
|
+
buildName: "prod",
|
1004
|
+
success: true,
|
1005
|
+
errors: [],
|
1006
|
+
warnings: [],
|
1007
|
+
output: []
|
1008
|
+
},
|
1009
|
+
{
|
1010
|
+
buildName: "dev",
|
1011
|
+
success: false,
|
1012
|
+
errors: ["Failed"],
|
1013
|
+
warnings: [],
|
1014
|
+
output: []
|
1015
|
+
}
|
1016
|
+
]
|
1017
|
+
|
1018
|
+
const formatted = validator.formatResults(results)
|
1019
|
+
|
1020
|
+
expect(formatted).toContain("✅ Build: prod")
|
1021
|
+
expect(formatted).toContain("❌ Build: dev")
|
1022
|
+
expect(formatted).toContain("1/2 builds passed, 1 failed")
|
1023
|
+
})
|
1024
|
+
})
|
1025
|
+
|
1026
|
+
describe("success pattern detection", () => {
|
1027
|
+
it("should not false-positive on success patterns in error messages", async () => {
|
1028
|
+
const configPath = join(testDir, "webpack.config.js")
|
1029
|
+
writeFileSync(configPath, "module.exports = {}")
|
1030
|
+
|
1031
|
+
const mockChild = {
|
1032
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1033
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1034
|
+
on: jest.fn(),
|
1035
|
+
once: jest.fn(),
|
1036
|
+
kill: jest.fn(),
|
1037
|
+
removeAllListeners: jest.fn()
|
1038
|
+
}
|
1039
|
+
|
1040
|
+
mockSpawn.mockReturnValue(mockChild)
|
1041
|
+
|
1042
|
+
const build = {
|
1043
|
+
name: "dev-hmr",
|
1044
|
+
bundler: "webpack",
|
1045
|
+
environment: {
|
1046
|
+
NODE_ENV: "development",
|
1047
|
+
WEBPACK_SERVE: "true"
|
1048
|
+
},
|
1049
|
+
configFile: configPath,
|
1050
|
+
outputs: ["client"]
|
1051
|
+
}
|
1052
|
+
|
1053
|
+
// Start validation (don't await yet)
|
1054
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
1055
|
+
|
1056
|
+
// Wait for spawn to be called
|
1057
|
+
await waitForAsync()
|
1058
|
+
|
1059
|
+
// Simulate output with success pattern in error context
|
1060
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
1061
|
+
([event]) => event === "data"
|
1062
|
+
)[1]
|
1063
|
+
stdoutHandler(
|
1064
|
+
Buffer.from(
|
1065
|
+
"ERROR: Expected 'Built at:' timestamp but found invalid format\n"
|
1066
|
+
)
|
1067
|
+
)
|
1068
|
+
|
1069
|
+
// Simulate actual error exit
|
1070
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
1071
|
+
([event]) => event === "exit"
|
1072
|
+
)[1]
|
1073
|
+
exitHandler(1)
|
1074
|
+
|
1075
|
+
const result = await validationPromise
|
1076
|
+
|
1077
|
+
// Should recognize as error, not success
|
1078
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
1079
|
+
})
|
1080
|
+
|
1081
|
+
it("should detect modern webpack 5.x compiled messages", async () => {
|
1082
|
+
const configPath = join(testDir, "webpack.config.js")
|
1083
|
+
writeFileSync(configPath, "module.exports = {}")
|
1084
|
+
|
1085
|
+
const mockChild = {
|
1086
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1087
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1088
|
+
on: jest.fn(),
|
1089
|
+
once: jest.fn(),
|
1090
|
+
kill: jest.fn(),
|
1091
|
+
removeAllListeners: jest.fn()
|
1092
|
+
}
|
1093
|
+
|
1094
|
+
mockSpawn.mockReturnValue(mockChild)
|
1095
|
+
|
1096
|
+
const build = {
|
1097
|
+
name: "dev-hmr",
|
1098
|
+
bundler: "webpack",
|
1099
|
+
environment: {
|
1100
|
+
NODE_ENV: "development",
|
1101
|
+
WEBPACK_SERVE: "true"
|
1102
|
+
},
|
1103
|
+
configFile: configPath,
|
1104
|
+
outputs: ["client"]
|
1105
|
+
}
|
1106
|
+
|
1107
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
1108
|
+
|
1109
|
+
// Wait for spawn to be called
|
1110
|
+
await waitForAsync()
|
1111
|
+
|
1112
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
1113
|
+
([event]) => event === "data"
|
1114
|
+
)[1]
|
1115
|
+
// Simulate webpack 5.x.x compiled message
|
1116
|
+
stdoutHandler(Buffer.from("webpack 5.95.0 compiled successfully\n"))
|
1117
|
+
|
1118
|
+
await new Promise((r) => {
|
1119
|
+
setTimeout(r, 50)
|
1120
|
+
})
|
1121
|
+
|
1122
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
1123
|
+
([event]) => event === "exit"
|
1124
|
+
)[1]
|
1125
|
+
exitHandler(143)
|
1126
|
+
|
1127
|
+
const result = await validationPromise
|
1128
|
+
expect(result.success).toBe(true)
|
1129
|
+
})
|
1130
|
+
|
1131
|
+
it("should detect modern rspack 0.x compiled messages", async () => {
|
1132
|
+
const configPath = join(testDir, "rspack.config.js")
|
1133
|
+
writeFileSync(configPath, "module.exports = {}")
|
1134
|
+
|
1135
|
+
const mockChild = {
|
1136
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1137
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1138
|
+
on: jest.fn(),
|
1139
|
+
once: jest.fn(),
|
1140
|
+
kill: jest.fn(),
|
1141
|
+
removeAllListeners: jest.fn()
|
1142
|
+
}
|
1143
|
+
|
1144
|
+
mockSpawn.mockReturnValue(mockChild)
|
1145
|
+
|
1146
|
+
const build = {
|
1147
|
+
name: "dev-hmr",
|
1148
|
+
bundler: "rspack",
|
1149
|
+
environment: {
|
1150
|
+
NODE_ENV: "development",
|
1151
|
+
HMR: "true"
|
1152
|
+
},
|
1153
|
+
configFile: configPath,
|
1154
|
+
outputs: ["client"]
|
1155
|
+
}
|
1156
|
+
|
1157
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
1158
|
+
|
1159
|
+
// Wait for spawn to be called
|
1160
|
+
await waitForAsync()
|
1161
|
+
|
1162
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
1163
|
+
([event]) => event === "data"
|
1164
|
+
)[1]
|
1165
|
+
// Simulate rspack 0.x.x compiled message
|
1166
|
+
stdoutHandler(Buffer.from("rspack 0.7.5 compiled successfully\n"))
|
1167
|
+
|
1168
|
+
await new Promise((r) => {
|
1169
|
+
setTimeout(r, 50)
|
1170
|
+
})
|
1171
|
+
|
1172
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
1173
|
+
([event]) => event === "exit"
|
1174
|
+
)[1]
|
1175
|
+
exitHandler(143)
|
1176
|
+
|
1177
|
+
const result = await validationPromise
|
1178
|
+
expect(result.success).toBe(true)
|
1179
|
+
})
|
1180
|
+
|
1181
|
+
it("should not match incomplete version patterns", async () => {
|
1182
|
+
const configPath = join(testDir, "webpack.config.js")
|
1183
|
+
writeFileSync(configPath, "module.exports = {}")
|
1184
|
+
|
1185
|
+
const mockChild = {
|
1186
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1187
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1188
|
+
on: jest.fn(),
|
1189
|
+
once: jest.fn(),
|
1190
|
+
kill: jest.fn(),
|
1191
|
+
removeAllListeners: jest.fn()
|
1192
|
+
}
|
1193
|
+
|
1194
|
+
mockSpawn.mockReturnValue(mockChild)
|
1195
|
+
|
1196
|
+
const build = {
|
1197
|
+
name: "dev-hmr",
|
1198
|
+
bundler: "webpack",
|
1199
|
+
environment: {
|
1200
|
+
NODE_ENV: "development",
|
1201
|
+
WEBPACK_SERVE: "true"
|
1202
|
+
},
|
1203
|
+
configFile: configPath,
|
1204
|
+
outputs: ["client"]
|
1205
|
+
}
|
1206
|
+
|
1207
|
+
const shortTimeoutValidator = new BuildValidator({
|
1208
|
+
verbose: false,
|
1209
|
+
timeout: 100
|
1210
|
+
})
|
1211
|
+
|
1212
|
+
const validationPromise = shortTimeoutValidator.validateBuild(
|
1213
|
+
build,
|
1214
|
+
testDir
|
1215
|
+
)
|
1216
|
+
|
1217
|
+
// Wait for spawn to be called
|
1218
|
+
await waitForAsync()
|
1219
|
+
|
1220
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
1221
|
+
([event]) => event === "data"
|
1222
|
+
)[1]
|
1223
|
+
// These should NOT match the success patterns - they're warnings/errors about versions
|
1224
|
+
stdoutHandler(Buffer.from("WARNING: webpack 5.x may have issues\n"))
|
1225
|
+
stdoutHandler(Buffer.from("ERROR: rspack 0.x requires node 18+\n"))
|
1226
|
+
|
1227
|
+
// Wait for timeout (since no success pattern was matched)
|
1228
|
+
await new Promise((r) => {
|
1229
|
+
setTimeout(r, 150)
|
1230
|
+
})
|
1231
|
+
|
1232
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM")
|
1233
|
+
|
1234
|
+
const exitHandler = mockChild.on.mock.calls.find(
|
1235
|
+
([event]) => event === "exit"
|
1236
|
+
)[1]
|
1237
|
+
exitHandler(143) // SIGTERM exit
|
1238
|
+
|
1239
|
+
const result = await validationPromise
|
1240
|
+
|
1241
|
+
// Should have timed out, not succeeded
|
1242
|
+
expect(result.success).toBe(false)
|
1243
|
+
// Should have captured the warning and error
|
1244
|
+
expect(result.warnings.some((w) => w.includes("webpack 5.x"))).toBe(true)
|
1245
|
+
expect(result.errors.some((e) => e.includes("rspack 0.x"))).toBe(true)
|
1246
|
+
// Should have timeout error
|
1247
|
+
expect(result.errors.some((e) => e.includes("Timeout"))).toBe(true)
|
1248
|
+
})
|
1249
|
+
})
|
1250
|
+
|
1251
|
+
describe("error spawn handling", () => {
|
1252
|
+
it("should handle spawn error gracefully", async () => {
|
1253
|
+
const configPath = join(testDir, "webpack.config.js")
|
1254
|
+
writeFileSync(configPath, "module.exports = {}")
|
1255
|
+
|
1256
|
+
const mockChild = {
|
1257
|
+
stdout: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1258
|
+
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
|
1259
|
+
on: jest.fn(),
|
1260
|
+
once: jest.fn(),
|
1261
|
+
kill: jest.fn(),
|
1262
|
+
removeAllListeners: jest.fn()
|
1263
|
+
}
|
1264
|
+
|
1265
|
+
mockSpawn.mockReturnValue(mockChild)
|
1266
|
+
|
1267
|
+
const build = {
|
1268
|
+
name: "prod",
|
1269
|
+
bundler: "webpack",
|
1270
|
+
environment: { NODE_ENV: "production" },
|
1271
|
+
configFile: configPath,
|
1272
|
+
outputs: ["client"]
|
1273
|
+
}
|
1274
|
+
|
1275
|
+
// Start validation (don't await yet)
|
1276
|
+
const validationPromise = validator.validateBuild(build, testDir)
|
1277
|
+
|
1278
|
+
// Simulate spawn error
|
1279
|
+
const errorHandler = mockChild.on.mock.calls.find(
|
1280
|
+
([event]) => event === "error"
|
1281
|
+
)[1]
|
1282
|
+
errorHandler(new Error("ENOENT: command not found"))
|
1283
|
+
|
1284
|
+
// Now await the result
|
1285
|
+
const result = await validationPromise
|
1286
|
+
|
1287
|
+
expect(result.success).toBe(false)
|
1288
|
+
expect(result.errors.length).toBeGreaterThan(0)
|
1289
|
+
expect(result.errors[0]).toContain("Failed to start")
|
1290
|
+
})
|
1291
|
+
})
|
1292
|
+
})
|