@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.
- package/.agents/instructions/learned-knowledge.instructions.md +23 -0
- package/.github/workflows/npm-publish.yml +3 -0
- package/.github/workflows/pr-test.yml +23 -0
- package/.husky/post-merge +1 -0
- package/.husky/pre-commit +2 -0
- package/README.md +48 -1
- package/bin/setup.js +23 -0
- package/package.json +7 -2
- package/scripts/setup/constants.js +226 -0
- package/scripts/setup/discover.js +236 -0
- package/scripts/setup/discover.test.js +388 -0
- package/scripts/setup/hooks.js +261 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +102 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +639 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +172 -0
- package/scripts/setup/ui.js +503 -0
- package/scripts/setup/utils.js +371 -0
|
@@ -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
|
+
})
|