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.
@@ -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
+ })