@abarcenas/ai-workflow-template 1.29.0 → 1.32.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.
@@ -0,0 +1,639 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // index.test.js — Unit tests for the setup orchestrator (main function)
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Tests the arg parsing, phase ordering, flag-based skipping, exit code
6
+ // determination, error handling, and UI interactions.
7
+ //
8
+ // All external modules (discover, hooks, prepare, husky-init, sync-phase,
9
+ // utils, ui) are mocked so tests don't touch the real filesystem or spawn
10
+ // real child processes.
11
+ //
12
+ // Covered scenarios (≥10, per plan T17 + task description):
13
+ // 1. --help flag → prints help, exits 0
14
+ // 2. -h short flag → same as --help
15
+ // 3. --version flag → prints version, exits 0
16
+ // 4. -v short flag → same as --version
17
+ // 5. --dry-run flag → all phases run in dry-run mode
18
+ // 6. --skip-hooks flag → hooks + husky-init skipped
19
+ // 7. --skip-sync flag → sync phase skipped
20
+ // 8. --skip-prepare flag → prepare phase skipped
21
+ // 9. Unknown/unsupported flag → handled gracefully, pipeline runs
22
+ // 10. Full pipeline (default flags) → all 5 phases called, exit 0
23
+ // 11. Discovery failure → fatal, exit 2
24
+ // 12. Phase failure (hooks) → continues, exit 2
25
+ // 13. Warning aggregation (no git) → exit 1
26
+ // 14. CI mode detection → ciModeBanner shown
27
+ // 15. Quiet mode → no header/summary
28
+ // 16. Multiple skips combined → skip-hooks + skip-prepare + skip-sync
29
+ // ═════════════════════════════════════════════════════════════════════════════
30
+
31
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
32
+
33
+ // ═════════════════════════════════════════════════════════════════════════════
34
+ // Module mocks — all external dependencies are fully mocked so tests are
35
+ // isolated and fast. Each mock factory returns vi.fn() for every exported
36
+ // function that the orchestrator imports.
37
+ // ═════════════════════════════════════════════════════════════════════════════
38
+
39
+ vi.mock('./utils.js', () => ({
40
+ parseCliArgs: vi.fn(),
41
+ resolvePackageRoot: vi.fn(),
42
+ }))
43
+
44
+ vi.mock('./ui.js', () => ({
45
+ ciModeBanner: vi.fn(),
46
+ header: vi.fn(),
47
+ help: vi.fn(),
48
+ section: vi.fn(),
49
+ stepError: vi.fn(),
50
+ stepSuccess: vi.fn(),
51
+ stepWarn: vi.fn(),
52
+ summary: vi.fn(),
53
+ }))
54
+
55
+ vi.mock('./discover.js', () => ({
56
+ discover: vi.fn(),
57
+ }))
58
+
59
+ vi.mock('./hooks.js', () => ({
60
+ installHooks: vi.fn(),
61
+ }))
62
+
63
+ vi.mock('./prepare.js', () => ({
64
+ handlePrepare: vi.fn(),
65
+ }))
66
+
67
+ vi.mock('./husky-init.js', () => ({
68
+ initHusky: vi.fn(),
69
+ }))
70
+
71
+ vi.mock('./sync-phase.js', () => ({
72
+ runSyncPhase: vi.fn(),
73
+ }))
74
+
75
+ // ═════════════════════════════════════════════════════════════════════════════
76
+ // Imports — these resolve to the mocked modules above
77
+ // ═════════════════════════════════════════════════════════════════════════════
78
+
79
+ import { main } from './index.js'
80
+ import { parseCliArgs, resolvePackageRoot } from './utils.js'
81
+ import {
82
+ ciModeBanner,
83
+ header,
84
+ help,
85
+ section,
86
+ stepError,
87
+ stepSuccess,
88
+ stepWarn,
89
+ summary,
90
+ } from './ui.js'
91
+ import { discover } from './discover.js'
92
+ import { installHooks } from './hooks.js'
93
+ import { handlePrepare } from './prepare.js'
94
+ import { initHusky } from './husky-init.js'
95
+ import { runSyncPhase } from './sync-phase.js'
96
+ import { EXIT_CODES } from './constants.js'
97
+
98
+ // ═════════════════════════════════════════════════════════════════════════════
99
+ // Test fixtures
100
+ // ═════════════════════════════════════════════════════════════════════════════
101
+
102
+ /**
103
+ * Default flags object — every property is `false` so the full pipeline runs.
104
+ * Individual tests override the specific flag they're testing.
105
+ */
106
+ const defaultFlags = {
107
+ force: false,
108
+ dryRun: false,
109
+ yes: false,
110
+ skipHooks: false,
111
+ skipPrepare: false,
112
+ skipSync: false,
113
+ help: false,
114
+ version: false,
115
+ quiet: false,
116
+ verbose: false,
117
+ }
118
+
119
+ /**
120
+ * Default context object returned by the mocked `discover()`. Represents a
121
+ * standard git-enabled consumer project with no existing hooks.
122
+ */
123
+ const defaultContext = {
124
+ consumerRoot: '/tmp/test-consumer',
125
+ hasGit: true,
126
+ hasHuskyDir: false,
127
+ hasPackageJson: true,
128
+ consumerPackageJson: { name: 'test-consumer' },
129
+ existingHooks: {
130
+ 'pre-commit': { exists: false, content: null, isManaged: false },
131
+ 'post-merge': { exists: false, content: null, isManaged: false },
132
+ },
133
+ existingPrepare: null,
134
+ prepareIsSimple: false,
135
+ isCI: false,
136
+ nodeVersion: 20,
137
+ dryRun: false,
138
+ }
139
+
140
+ /**
141
+ * Default successful results from each phase module.
142
+ */
143
+ const defaultHookResults = [
144
+ {
145
+ hook: 'pre-commit',
146
+ action: 'created',
147
+ message: 'pre-commit hook created',
148
+ },
149
+ {
150
+ hook: 'post-merge',
151
+ action: 'created',
152
+ message: 'post-merge hook created',
153
+ },
154
+ ]
155
+
156
+ const defaultPrepareResult = {
157
+ action: 'added',
158
+ message: 'Added "prepare": "husky" to package.json',
159
+ }
160
+
161
+ const defaultHuskyResult = {
162
+ action: 'initialized',
163
+ message: 'Husky initialized successfully',
164
+ }
165
+
166
+ const defaultSyncResult = {
167
+ action: 'completed',
168
+ message: 'Sync phase completed',
169
+ results: [
170
+ { script: 'sync.js', success: true, exitCode: 0, output: '' },
171
+ { script: 'normalize-memory.js', success: true, exitCode: 0, output: '' },
172
+ ],
173
+ }
174
+
175
+ // ═════════════════════════════════════════════════════════════════════════════
176
+ // Tests
177
+ // ═════════════════════════════════════════════════════════════════════════════
178
+
179
+ describe('main() — setup orchestrator', () => {
180
+ let logSpy
181
+
182
+ beforeEach(() => {
183
+ vi.clearAllMocks()
184
+
185
+ // Default mock implementations
186
+ parseCliArgs.mockReturnValue({ ...defaultFlags })
187
+ discover.mockResolvedValue({ ...defaultContext })
188
+ installHooks.mockResolvedValue(defaultHookResults)
189
+ handlePrepare.mockResolvedValue(defaultPrepareResult)
190
+ initHusky.mockResolvedValue(defaultHuskyResult)
191
+ runSyncPhase.mockResolvedValue(defaultSyncResult)
192
+
193
+ // Spy on console.log for --version tests
194
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
195
+ })
196
+
197
+ afterEach(() => {
198
+ logSpy?.mockRestore()
199
+ })
200
+
201
+ // ── 1. --help flag ───────────────────────────────────────────────────────
202
+
203
+ it('--help: prints help text and exits with code 0', async () => {
204
+ parseCliArgs.mockReturnValue({ ...defaultFlags, help: true })
205
+
206
+ const code = await main(['--help'])
207
+
208
+ expect(code).toBe(EXIT_CODES.SUCCESS)
209
+ expect(help).toHaveBeenCalledTimes(1)
210
+ // Early exit means no pipeline phases run
211
+ expect(discover).not.toHaveBeenCalled()
212
+ expect(installHooks).not.toHaveBeenCalled()
213
+ expect(handlePrepare).not.toHaveBeenCalled()
214
+ expect(initHusky).not.toHaveBeenCalled()
215
+ expect(runSyncPhase).not.toHaveBeenCalled()
216
+ // No header or summary for early-exit flags
217
+ expect(header).not.toHaveBeenCalled()
218
+ expect(summary).not.toHaveBeenCalled()
219
+ })
220
+
221
+ // ── 2. -h short flag ─────────────────────────────────────────────────────
222
+
223
+ it('-h: short help flag prints help text and exits with code 0', async () => {
224
+ parseCliArgs.mockReturnValue({ ...defaultFlags, help: true })
225
+
226
+ const code = await main(['-h'])
227
+
228
+ expect(code).toBe(EXIT_CODES.SUCCESS)
229
+ expect(help).toHaveBeenCalledTimes(1)
230
+ expect(discover).not.toHaveBeenCalled()
231
+ expect(installHooks).not.toHaveBeenCalled()
232
+ expect(header).not.toHaveBeenCalled()
233
+ })
234
+
235
+ // ── 3. --version flag ────────────────────────────────────────────────────
236
+
237
+ it('--version: prints version string and exits with code 0', async () => {
238
+ parseCliArgs.mockReturnValue({ ...defaultFlags, version: true })
239
+
240
+ const code = await main(['--version'])
241
+
242
+ expect(code).toBe(EXIT_CODES.SUCCESS)
243
+ expect(logSpy).toHaveBeenCalled()
244
+ expect(logSpy.mock.calls[0][0]).toEqual(expect.any(String))
245
+ // Early exit means no pipeline phases run
246
+ expect(discover).not.toHaveBeenCalled()
247
+ expect(help).not.toHaveBeenCalled()
248
+ })
249
+
250
+ // ── 4. -v short flag ─────────────────────────────────────────────────────
251
+
252
+ it('-v: short version flag prints version string and exits with code 0', async () => {
253
+ parseCliArgs.mockReturnValue({ ...defaultFlags, version: true })
254
+
255
+ const code = await main(['-v'])
256
+
257
+ expect(code).toBe(EXIT_CODES.SUCCESS)
258
+ expect(logSpy).toHaveBeenCalled()
259
+ expect(logSpy.mock.calls[0][0]).toEqual(expect.any(String))
260
+ expect(discover).not.toHaveBeenCalled()
261
+ })
262
+
263
+ // ── 5. --dry-run flag ────────────────────────────────────────────────────
264
+
265
+ it('--dry-run: all phases run in dry-run mode, exits 0', async () => {
266
+ parseCliArgs.mockReturnValue({ ...defaultFlags, dryRun: true })
267
+ const ctx = { ...defaultContext, dryRun: true }
268
+ discover.mockResolvedValue(ctx)
269
+ // Phase mocks return dry-run results
270
+ installHooks.mockResolvedValue([
271
+ { hook: 'pre-commit', action: 'dry-run', message: 'Would create pre-commit' },
272
+ { hook: 'post-merge', action: 'dry-run', message: 'Would create post-merge' },
273
+ ])
274
+ handlePrepare.mockResolvedValue({ action: 'dry-run', message: 'Would add husky' })
275
+ initHusky.mockResolvedValue({ action: 'dry-run', message: 'Would initialize husky' })
276
+ runSyncPhase.mockResolvedValue({ action: 'dry-run', message: 'Would run sync scripts' })
277
+
278
+ const code = await main(['--dry-run'])
279
+
280
+ expect(code).toBe(EXIT_CODES.SUCCESS)
281
+ // Verify parseCliArgs received the actual argv
282
+ expect(parseCliArgs).toHaveBeenCalledWith(['--dry-run'])
283
+ // Verify all phases were called
284
+ expect(discover).toHaveBeenCalledTimes(1)
285
+ expect(installHooks).toHaveBeenCalledTimes(1)
286
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
287
+ expect(initHusky).toHaveBeenCalledTimes(1)
288
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
289
+ // Header and summary shown (not quiet)
290
+ expect(header).toHaveBeenCalledTimes(1)
291
+ expect(summary).toHaveBeenCalledTimes(1)
292
+ })
293
+
294
+ // ── 6. --skip-hooks flag ─────────────────────────────────────────────────
295
+
296
+ it('--skip-hooks: hooks and husky-init phases are skipped', async () => {
297
+ parseCliArgs.mockReturnValue({ ...defaultFlags, skipHooks: true })
298
+
299
+ const code = await main(['--skip-hooks'])
300
+
301
+ expect(code).toBe(EXIT_CODES.SUCCESS)
302
+ // Hooks and husky-init should NOT be called
303
+ expect(installHooks).not.toHaveBeenCalled()
304
+ expect(initHusky).not.toHaveBeenCalled()
305
+ // But other phases still run
306
+ expect(discover).toHaveBeenCalledTimes(1)
307
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
308
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
309
+ })
310
+
311
+ // ── 7. --skip-sync flag ──────────────────────────────────────────────────
312
+
313
+ it('--skip-sync: sync phase is skipped', async () => {
314
+ parseCliArgs.mockReturnValue({ ...defaultFlags, skipSync: true })
315
+
316
+ const code = await main(['--skip-sync'])
317
+
318
+ expect(code).toBe(EXIT_CODES.SUCCESS)
319
+ expect(runSyncPhase).not.toHaveBeenCalled()
320
+ // All other phases still run
321
+ expect(discover).toHaveBeenCalledTimes(1)
322
+ expect(installHooks).toHaveBeenCalledTimes(1)
323
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
324
+ expect(initHusky).toHaveBeenCalledTimes(1)
325
+ })
326
+
327
+ // ── 8. --skip-prepare flag ───────────────────────────────────────────────
328
+
329
+ it('--skip-prepare: prepare phase is skipped', async () => {
330
+ parseCliArgs.mockReturnValue({ ...defaultFlags, skipPrepare: true })
331
+
332
+ const code = await main(['--skip-prepare'])
333
+
334
+ expect(code).toBe(EXIT_CODES.SUCCESS)
335
+ expect(handlePrepare).not.toHaveBeenCalled()
336
+ // All other phases still run
337
+ expect(discover).toHaveBeenCalledTimes(1)
338
+ expect(installHooks).toHaveBeenCalledTimes(1)
339
+ expect(initHusky).toHaveBeenCalledTimes(1)
340
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
341
+ })
342
+
343
+ // ── 9. Unknown flag ──────────────────────────────────────────────────────
344
+
345
+ it('unknown flag: handled gracefully, full pipeline runs with defaults', async () => {
346
+ // parseCliArgs silently ignores unknown flags and returns all-defaults
347
+ parseCliArgs.mockReturnValue({ ...defaultFlags })
348
+
349
+ const code = await main(['--bogus-flag'])
350
+
351
+ expect(code).toBe(EXIT_CODES.SUCCESS)
352
+ // Full pipeline runs as if no flags were passed
353
+ expect(discover).toHaveBeenCalledTimes(1)
354
+ expect(installHooks).toHaveBeenCalledTimes(1)
355
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
356
+ expect(initHusky).toHaveBeenCalledTimes(1)
357
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
358
+ expect(header).toHaveBeenCalledTimes(1)
359
+ expect(summary).toHaveBeenCalledTimes(1)
360
+ })
361
+
362
+ // ── 10. Full pipeline (default flags) ────────────────────────────────────
363
+
364
+ it('full pipeline: all 5 phases called in order, exits 0', async () => {
365
+ const code = await main([])
366
+
367
+ expect(code).toBe(EXIT_CODES.SUCCESS)
368
+ // All phases called exactly once
369
+ expect(discover).toHaveBeenCalledTimes(1)
370
+ expect(installHooks).toHaveBeenCalledTimes(1)
371
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
372
+ expect(initHusky).toHaveBeenCalledTimes(1)
373
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
374
+ // Header displayed
375
+ expect(header).toHaveBeenCalledTimes(1)
376
+ // Summary displayed
377
+ expect(summary).toHaveBeenCalledTimes(1)
378
+ // Section headers called for each phase
379
+ expect(section).toHaveBeenCalled()
380
+ // stepSuccess called for discovery (hasGit = true)
381
+ expect(stepSuccess).toHaveBeenCalled()
382
+ })
383
+
384
+ // ── 11. Discovery failure ────────────────────────────────────────────────
385
+
386
+ it('discovery failure: fatal error, exits with FATAL (2)', async () => {
387
+ discover.mockRejectedValue(new Error('Git not found'))
388
+
389
+ const code = await main([])
390
+
391
+ expect(code).toBe(EXIT_CODES.FATAL)
392
+ expect(stepError).toHaveBeenCalledWith(
393
+ expect.stringContaining('Git not found'),
394
+ )
395
+ // No subsequent phases should run
396
+ expect(installHooks).not.toHaveBeenCalled()
397
+ expect(handlePrepare).not.toHaveBeenCalled()
398
+ expect(initHusky).not.toHaveBeenCalled()
399
+ expect(runSyncPhase).not.toHaveBeenCalled()
400
+ // Summary is shown even on fatal (not quiet mode)
401
+ expect(summary).toHaveBeenCalledTimes(1)
402
+ })
403
+
404
+ // ── 12. Phase failure (hooks) → continues, but exits 2 ──────────────────
405
+
406
+ it('phase failure: hooks phase fails, other phases continue, exits FATAL (2)', async () => {
407
+ installHooks.mockRejectedValue(new Error('Permission denied'))
408
+
409
+ const code = await main([])
410
+
411
+ expect(code).toBe(EXIT_CODES.FATAL)
412
+ // Error logged for hooks
413
+ expect(stepError).toHaveBeenCalledWith(
414
+ expect.stringContaining('Permission denied'),
415
+ )
416
+ // Other phases still run
417
+ expect(discover).toHaveBeenCalledTimes(1)
418
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
419
+ expect(initHusky).toHaveBeenCalledTimes(1)
420
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
421
+ // Summary is shown
422
+ expect(summary).toHaveBeenCalledTimes(1)
423
+ })
424
+
425
+ // ── 13. Non-git repo → hooks skipped, exit 1 ────────────────────────────
426
+
427
+ it('no git repository: hooks and husky-init skipped with warnings, exits WARNINGS (1)', async () => {
428
+ discover.mockResolvedValue({
429
+ ...defaultContext,
430
+ hasGit: false,
431
+ hasHuskyDir: false,
432
+ consumerRoot: '/tmp/non-git-project',
433
+ })
434
+
435
+ const code = await main([])
436
+
437
+ expect(code).toBe(EXIT_CODES.WARNINGS)
438
+ // Hooks and husky-init were NOT called
439
+ expect(installHooks).not.toHaveBeenCalled()
440
+ expect(initHusky).not.toHaveBeenCalled()
441
+ // stepWarn was called for the git-related skip messages
442
+ expect(stepWarn).toHaveBeenCalled()
443
+ // Other phases still run
444
+ expect(discover).toHaveBeenCalledTimes(1)
445
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
446
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
447
+ // Summary shown
448
+ expect(summary).toHaveBeenCalledTimes(1)
449
+ })
450
+
451
+ // ── 14. CI mode detection ────────────────────────────────────────────────
452
+
453
+ it('CI mode: ciModeBanner shown when context.isCI is true', async () => {
454
+ discover.mockResolvedValue({
455
+ ...defaultContext,
456
+ isCI: true,
457
+ })
458
+
459
+ const code = await main([])
460
+
461
+ expect(code).toBe(EXIT_CODES.SUCCESS)
462
+ expect(ciModeBanner).toHaveBeenCalledTimes(1)
463
+ // CI mode doesn't skip phases (that's done by --skip-hooks)
464
+ expect(discover).toHaveBeenCalledTimes(1)
465
+ })
466
+
467
+ it('CI mode: ciModeBanner NOT shown in quiet mode even when isCI is true', async () => {
468
+ parseCliArgs.mockReturnValue({ ...defaultFlags, quiet: true })
469
+ discover.mockResolvedValue({
470
+ ...defaultContext,
471
+ isCI: true,
472
+ })
473
+
474
+ const code = await main(['--quiet'])
475
+
476
+ expect(code).toBe(EXIT_CODES.SUCCESS)
477
+ expect(ciModeBanner).not.toHaveBeenCalled()
478
+ expect(header).not.toHaveBeenCalled()
479
+ expect(summary).not.toHaveBeenCalled()
480
+ })
481
+
482
+ // ── 15. Quiet mode ──────────────────────────────────────────────────────
483
+
484
+ it('--quiet: header and summary are suppressed, phases still run', async () => {
485
+ parseCliArgs.mockReturnValue({ ...defaultFlags, quiet: true })
486
+
487
+ const code = await main(['--quiet'])
488
+
489
+ expect(code).toBe(EXIT_CODES.SUCCESS)
490
+ expect(header).not.toHaveBeenCalled()
491
+ expect(summary).not.toHaveBeenCalled()
492
+ // All phases still execute
493
+ expect(discover).toHaveBeenCalledTimes(1)
494
+ expect(installHooks).toHaveBeenCalledTimes(1)
495
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
496
+ expect(initHusky).toHaveBeenCalledTimes(1)
497
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
498
+ })
499
+
500
+ // ── 16. Multiple skips combined ─────────────────────────────────────────
501
+
502
+ it('--skip-hooks --skip-prepare --skip-sync: only discovery runs', async () => {
503
+ parseCliArgs.mockReturnValue({
504
+ ...defaultFlags,
505
+ skipHooks: true,
506
+ skipPrepare: true,
507
+ skipSync: true,
508
+ })
509
+
510
+ const code = await main([
511
+ '--skip-hooks',
512
+ '--skip-prepare',
513
+ '--skip-sync',
514
+ ])
515
+
516
+ expect(code).toBe(EXIT_CODES.SUCCESS)
517
+ expect(discover).toHaveBeenCalledTimes(1)
518
+ expect(installHooks).not.toHaveBeenCalled()
519
+ expect(handlePrepare).not.toHaveBeenCalled()
520
+ expect(initHusky).not.toHaveBeenCalled()
521
+ expect(runSyncPhase).not.toHaveBeenCalled()
522
+ })
523
+
524
+ // ── 17. Verify parseCliArgs receives the argv ───────────────────────────
525
+
526
+ it('passes argv array to parseCliArgs unchanged', async () => {
527
+ const argv = ['--force', '--dry-run', '--verbose']
528
+ parseCliArgs.mockReturnValue({
529
+ ...defaultFlags,
530
+ force: true,
531
+ dryRun: true,
532
+ verbose: true,
533
+ })
534
+
535
+ await main(argv)
536
+
537
+ expect(parseCliArgs).toHaveBeenCalledWith(argv)
538
+ })
539
+
540
+ // ── 18. Discovery context is passed to each phase ───────────────────────
541
+
542
+ it('passes the discovery context object to each phase function', async () => {
543
+ const ctx = {
544
+ ...defaultContext,
545
+ consumerRoot: '/tmp/custom-path',
546
+ hasGit: true,
547
+ dryRun: false,
548
+ }
549
+ discover.mockResolvedValue(ctx)
550
+
551
+ await main([])
552
+
553
+ // Each phase receives the context (or extended version)
554
+ expect(installHooks).toHaveBeenCalledWith(ctx)
555
+ expect(handlePrepare).toHaveBeenCalledWith(ctx)
556
+ expect(initHusky).toHaveBeenCalledWith(ctx)
557
+ expect(runSyncPhase).toHaveBeenCalledWith(ctx)
558
+ })
559
+
560
+ // ── 19. Flags object passed to discover ─────────────────────────────────
561
+
562
+ it('passes the flags object to discover()', async () => {
563
+ const flags = { ...defaultFlags, dryRun: true }
564
+ parseCliArgs.mockReturnValue(flags)
565
+
566
+ await main(['--dry-run'])
567
+
568
+ expect(discover).toHaveBeenCalledWith(flags)
569
+ })
570
+
571
+ // ── 20. Verify --help takes priority over --version ─────────────────────
572
+
573
+ it('--help takes priority over --version when both are set', async () => {
574
+ parseCliArgs.mockReturnValue({
575
+ ...defaultFlags,
576
+ help: true,
577
+ version: true,
578
+ })
579
+
580
+ const code = await main(['--help', '--version'])
581
+
582
+ expect(code).toBe(EXIT_CODES.SUCCESS)
583
+ expect(help).toHaveBeenCalledTimes(1)
584
+ expect(logSpy).not.toHaveBeenCalled() // console.log NOT called for version
585
+ expect(discover).not.toHaveBeenCalled()
586
+ })
587
+
588
+ // ── 21. Error in prepare phase → continues ──────────────────────────────
589
+
590
+ it('prepare phase failure: error logged, other phases continue, exits FATAL (2)', async () => {
591
+ handlePrepare.mockRejectedValue(new Error('Write failed'))
592
+
593
+ const code = await main([])
594
+
595
+ expect(code).toBe(EXIT_CODES.FATAL)
596
+ expect(stepError).toHaveBeenCalledWith(
597
+ expect.stringContaining('Write failed'),
598
+ )
599
+ // Other phases still ran
600
+ expect(discover).toHaveBeenCalledTimes(1)
601
+ expect(installHooks).toHaveBeenCalledTimes(1)
602
+ expect(initHusky).toHaveBeenCalledTimes(1)
603
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
604
+ })
605
+
606
+ // ── 22. Error in husky-init → continues ────────────────────────────────
607
+
608
+ it('husky-init phase failure: error logged, other phases continue, exits FATAL (2)', async () => {
609
+ initHusky.mockRejectedValue(new Error('Husky v10 not supported'))
610
+
611
+ const code = await main([])
612
+
613
+ expect(code).toBe(EXIT_CODES.FATAL)
614
+ expect(stepError).toHaveBeenCalledWith(
615
+ expect.stringContaining('Husky v10'),
616
+ )
617
+ // Other phases still ran
618
+ expect(discover).toHaveBeenCalledTimes(1)
619
+ expect(installHooks).toHaveBeenCalledTimes(1)
620
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
621
+ expect(runSyncPhase).toHaveBeenCalledTimes(1)
622
+ })
623
+
624
+ // ── 23. Error in sync phase → continues ────────────────────────────────
625
+
626
+ it('sync phase failure: error logged, other phases continue, exits FATAL (2)', async () => {
627
+ runSyncPhase.mockRejectedValue(new Error('Script crashed'))
628
+
629
+ const code = await main([])
630
+
631
+ expect(code).toBe(EXIT_CODES.FATAL)
632
+ expect(stepError).toHaveBeenCalledWith(expect.stringContaining('Script crashed'))
633
+ // Other phases still ran
634
+ expect(discover).toHaveBeenCalledTimes(1)
635
+ expect(installHooks).toHaveBeenCalledTimes(1)
636
+ expect(handlePrepare).toHaveBeenCalledTimes(1)
637
+ expect(initHusky).toHaveBeenCalledTimes(1)
638
+ })
639
+ })