@abarcenas/ai-workflow-template 1.45.0 → 1.46.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/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +110 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/AGENTS.md +42 -3
- package/README.md +5 -2
- package/package.json +1 -1
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -16,7 +16,7 @@ vi.mock('./utils.js', async (importOriginal) => {
|
|
|
16
16
|
const actual = await importOriginal()
|
|
17
17
|
return {
|
|
18
18
|
...actual,
|
|
19
|
-
getConsumerRoot: () => mockConsumerRoot,
|
|
19
|
+
getConsumerRoot: vi.fn(() => mockConsumerRoot),
|
|
20
20
|
getNodeVersion: vi.fn(() => 20),
|
|
21
21
|
isCI: vi.fn(() => false),
|
|
22
22
|
}
|
|
@@ -24,7 +24,7 @@ vi.mock('./utils.js', async (importOriginal) => {
|
|
|
24
24
|
|
|
25
25
|
import { discover } from './discover.js'
|
|
26
26
|
import { HOOK_MARKER, TEMPLATE_HOOKS } from './constants.js'
|
|
27
|
-
import { getNodeVersion, isCI } from './utils.js'
|
|
27
|
+
import { getConsumerRoot, getNodeVersion, isCI } from './utils.js'
|
|
28
28
|
|
|
29
29
|
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
30
30
|
|
|
@@ -406,3 +406,125 @@ describe('discover()', () => {
|
|
|
406
406
|
expect(ctx.verbose).toBe(false)
|
|
407
407
|
})
|
|
408
408
|
})
|
|
409
|
+
|
|
410
|
+
// ── Defensive / early-return paths ─────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe('discover() — defensive paths', () => {
|
|
413
|
+
beforeEach(() => {
|
|
414
|
+
mockConsumerRoot = ''
|
|
415
|
+
vi.mocked(getConsumerRoot).mockImplementation(() => mockConsumerRoot)
|
|
416
|
+
vi.mocked(getNodeVersion).mockReturnValue(20)
|
|
417
|
+
vi.mocked(isCI).mockReturnValue(false)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
afterEach(() => {
|
|
421
|
+
vi.mocked(getConsumerRoot).mockImplementation(() => mockConsumerRoot)
|
|
422
|
+
cleanupTempDirs()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('returns early with just node/CI info when the consumer root is empty', async () => {
|
|
426
|
+
mockConsumerRoot = ''
|
|
427
|
+
|
|
428
|
+
const ctx = await discover()
|
|
429
|
+
|
|
430
|
+
expect(ctx.consumerRoot).toBe('')
|
|
431
|
+
expect(ctx.hasGit).toBe(false)
|
|
432
|
+
expect(ctx.hasHuskyDir).toBe(false)
|
|
433
|
+
expect(ctx.hasPackageJson).toBe(false)
|
|
434
|
+
expect(ctx.consumerPackageJson).toBeNull()
|
|
435
|
+
expect(ctx.nodeVersion).toBe(20)
|
|
436
|
+
expect(ctx.isCI).toBe(false)
|
|
437
|
+
// Every hook keeps its safe default descriptor
|
|
438
|
+
for (const name of Object.keys(TEMPLATE_HOOKS)) {
|
|
439
|
+
expect(ctx.existingHooks[name]).toEqual({
|
|
440
|
+
exists: false,
|
|
441
|
+
content: null,
|
|
442
|
+
isManaged: false,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('returns early when resolving the consumer root throws', async () => {
|
|
448
|
+
vi.mocked(getConsumerRoot).mockImplementation(() => {
|
|
449
|
+
throw new Error('INIT_CWD exploded')
|
|
450
|
+
})
|
|
451
|
+
vi.mocked(isCI).mockReturnValue(true)
|
|
452
|
+
|
|
453
|
+
const ctx = await discover()
|
|
454
|
+
|
|
455
|
+
expect(ctx.consumerRoot).toBe('')
|
|
456
|
+
expect(ctx.isCI).toBe(true)
|
|
457
|
+
expect(ctx.nodeVersion).toBe(20)
|
|
458
|
+
expect(ctx.hasGit).toBe(false)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('falls back to isCI=false when CI detection throws', async () => {
|
|
462
|
+
const dir = createProject()
|
|
463
|
+
mockConsumerRoot = dir
|
|
464
|
+
vi.mocked(isCI).mockImplementation(() => {
|
|
465
|
+
throw new Error('env exploded')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const ctx = await discover()
|
|
469
|
+
|
|
470
|
+
expect(ctx.isCI).toBe(false)
|
|
471
|
+
expect(ctx.hasGit).toBe(true)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('falls back to nodeVersion=0 when version detection throws', async () => {
|
|
475
|
+
const dir = createProject()
|
|
476
|
+
mockConsumerRoot = dir
|
|
477
|
+
vi.mocked(getNodeVersion).mockImplementation(() => {
|
|
478
|
+
throw new Error('version exploded')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
const ctx = await discover()
|
|
482
|
+
|
|
483
|
+
expect(ctx.nodeVersion).toBe(0)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('treats an unreadable hook path as default when readFile fails', async () => {
|
|
487
|
+
const dir = createProject({ huskyDir: true })
|
|
488
|
+
// A directory named like a hook makes safeReadFile throw EISDIR
|
|
489
|
+
mkdirSync(join(dir, '.husky', 'pre-commit'), { recursive: true })
|
|
490
|
+
mockConsumerRoot = dir
|
|
491
|
+
|
|
492
|
+
const ctx = await discover()
|
|
493
|
+
|
|
494
|
+
expect(ctx.existingHooks['pre-commit']).toEqual({
|
|
495
|
+
exists: false,
|
|
496
|
+
content: null,
|
|
497
|
+
isManaged: false,
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('classifies prepare as complex when the classifier itself throws', async () => {
|
|
502
|
+
const dir = createProject({
|
|
503
|
+
packageJson: { name: 'trim-test', scripts: { prepare: 'npm run build' } },
|
|
504
|
+
})
|
|
505
|
+
mockConsumerRoot = dir
|
|
506
|
+
|
|
507
|
+
const originalTrim = String.prototype.trim
|
|
508
|
+
let calls = 0
|
|
509
|
+
// discover() trims the prepare script twice before handing it to
|
|
510
|
+
// isPrepareSimple(); making the third call throw exercises the catch.
|
|
511
|
+
const spy = vi
|
|
512
|
+
.spyOn(String.prototype, 'trim')
|
|
513
|
+
.mockImplementation(function mockedTrim() {
|
|
514
|
+
calls += 1
|
|
515
|
+
if (calls === 3) throw new Error('trim exploded')
|
|
516
|
+
return originalTrim.call(this)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
let ctx
|
|
520
|
+
try {
|
|
521
|
+
ctx = await discover()
|
|
522
|
+
} finally {
|
|
523
|
+
spy.mockRestore()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
expect(calls).toBeGreaterThanOrEqual(3)
|
|
527
|
+
expect(ctx.existingPrepare).toBe('npm run build')
|
|
528
|
+
expect(ctx.prepareIsSimple).toBe(false)
|
|
529
|
+
})
|
|
530
|
+
})
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 13. Result structure validation → all fields present
|
|
22
22
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
23
23
|
|
|
24
|
-
import { describe, it, expect, afterEach } from 'vitest'
|
|
24
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
25
25
|
import {
|
|
26
26
|
mkdtempSync,
|
|
27
27
|
mkdirSync,
|
|
@@ -143,6 +143,7 @@ describe('installHooks', () => {
|
|
|
143
143
|
existingHooks: {
|
|
144
144
|
'pre-commit': { exists: false, isManaged: false },
|
|
145
145
|
'post-merge': { exists: false, isManaged: false },
|
|
146
|
+
'post-commit': { exists: false, isManaged: false },
|
|
146
147
|
},
|
|
147
148
|
})
|
|
148
149
|
mkdirSync(join(ctx.consumerRoot, '.husky'))
|
|
@@ -301,6 +302,7 @@ describe('installHooks', () => {
|
|
|
301
302
|
existingHooks: {
|
|
302
303
|
'pre-commit': { exists: true, isManaged: true },
|
|
303
304
|
'post-merge': { exists: true, isManaged: true },
|
|
305
|
+
'post-commit': { exists: true, isManaged: true },
|
|
304
306
|
},
|
|
305
307
|
})
|
|
306
308
|
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
@@ -375,6 +377,7 @@ describe('installHooks', () => {
|
|
|
375
377
|
existingHooks: {
|
|
376
378
|
'pre-commit': { exists: false, isManaged: false },
|
|
377
379
|
'post-merge': { exists: false, isManaged: false },
|
|
380
|
+
'post-commit': { exists: false, isManaged: false },
|
|
378
381
|
},
|
|
379
382
|
})
|
|
380
383
|
mkdirSync(join(ctx.consumerRoot, '.husky'))
|
|
@@ -480,6 +483,7 @@ describe('installHooks', () => {
|
|
|
480
483
|
existingHooks: {
|
|
481
484
|
'pre-commit': { exists: true, isManaged: false },
|
|
482
485
|
'post-merge': { exists: true, isManaged: false },
|
|
486
|
+
'post-commit': { exists: true, isManaged: false },
|
|
483
487
|
},
|
|
484
488
|
})
|
|
485
489
|
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
@@ -509,6 +513,7 @@ describe('installHooks', () => {
|
|
|
509
513
|
existingHooks: {
|
|
510
514
|
'pre-commit': { exists: true, isManaged: true },
|
|
511
515
|
'post-merge': { exists: true, isManaged: true },
|
|
516
|
+
'post-commit': { exists: true, isManaged: true },
|
|
512
517
|
},
|
|
513
518
|
})
|
|
514
519
|
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
@@ -532,4 +537,186 @@ describe('installHooks', () => {
|
|
|
532
537
|
expect(r.message).toContain(r.hook)
|
|
533
538
|
}
|
|
534
539
|
})
|
|
540
|
+
it('force-overwrites a hook that the context reports but disk lost', async () => {
|
|
541
|
+
// Defensive path: context says the hook exists, but safeReadFile returns
|
|
542
|
+
// null, so the backup is written with empty content.
|
|
543
|
+
const ctx = makeContext({
|
|
544
|
+
hasHuskyDir: true,
|
|
545
|
+
force: true,
|
|
546
|
+
existingHooks: {
|
|
547
|
+
'pre-commit': { exists: true, isManaged: false },
|
|
548
|
+
'post-merge': { exists: true, isManaged: false },
|
|
549
|
+
'post-commit': { exists: true, isManaged: false },
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
mkdirSync(join(ctx.consumerRoot, '.husky'))
|
|
553
|
+
|
|
554
|
+
const results = await installHooks(ctx)
|
|
555
|
+
|
|
556
|
+
for (const r of results) {
|
|
557
|
+
expect(r.action).toBe('overwritten')
|
|
558
|
+
}
|
|
559
|
+
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
560
|
+
expect(readFileSync(join(huskyDir, 'pre-commit.bak'), 'utf-8')).toBe('')
|
|
561
|
+
expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
|
|
562
|
+
expectedContent('pre-commit'),
|
|
563
|
+
)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('merges against empty content when the reported hook is gone from disk', async () => {
|
|
567
|
+
const ctx = makeContext({
|
|
568
|
+
hasHuskyDir: true,
|
|
569
|
+
existingHooks: {
|
|
570
|
+
'pre-commit': { exists: true, isManaged: false },
|
|
571
|
+
'post-merge': { exists: true, isManaged: false },
|
|
572
|
+
'post-commit': { exists: true, isManaged: false },
|
|
573
|
+
},
|
|
574
|
+
})
|
|
575
|
+
mkdirSync(join(ctx.consumerRoot, '.husky'))
|
|
576
|
+
|
|
577
|
+
const results = await installHooks(ctx)
|
|
578
|
+
|
|
579
|
+
for (const r of results) {
|
|
580
|
+
expect(r.action).toBe('merged')
|
|
581
|
+
}
|
|
582
|
+
const merged = readFileSync(
|
|
583
|
+
join(ctx.consumerRoot, '.husky', 'pre-commit'),
|
|
584
|
+
'utf-8',
|
|
585
|
+
)
|
|
586
|
+
expect(merged.startsWith(`\n${HOOK_MERGE_SEPARATOR}\n`)).toBe(true)
|
|
587
|
+
expect(merged).toContain(expectedContent('pre-commit'))
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
// ── 17. Dry-run action classification (all four message branches) ──────────
|
|
592
|
+
|
|
593
|
+
describe('installHooks — dry-run classification', () => {
|
|
594
|
+
let logSpy
|
|
595
|
+
|
|
596
|
+
beforeEach(() => {
|
|
597
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
afterEach(() => {
|
|
601
|
+
logSpy.mockRestore()
|
|
602
|
+
cleanupTempDirs()
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('dry-run describes creating .husky/ when the directory is missing', async () => {
|
|
606
|
+
const ctx = makeContext({ hasHuskyDir: false, dryRun: true })
|
|
607
|
+
|
|
608
|
+
const results = await installHooks(ctx)
|
|
609
|
+
|
|
610
|
+
expect(results).toHaveLength(Object.keys(TEMPLATE_HOOKS).length)
|
|
611
|
+
for (const r of results) {
|
|
612
|
+
expect(r.action).toBe('dry-run')
|
|
613
|
+
expect(r.message).toContain('Would create .husky/')
|
|
614
|
+
expect(r.message).toContain(r.hook)
|
|
615
|
+
expect(r.message).toContain(
|
|
616
|
+
TEMPLATE_HOOKS[r.hook].description,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
expect(existsSync(join(ctx.consumerRoot, '.husky'))).toBe(false)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('dry-run describes writing when .husky/ exists without the hook', async () => {
|
|
623
|
+
const ctx = makeContext({
|
|
624
|
+
hasHuskyDir: true,
|
|
625
|
+
dryRun: true,
|
|
626
|
+
existingHooks: {
|
|
627
|
+
'pre-commit': { exists: false, isManaged: false },
|
|
628
|
+
'post-merge': { exists: false, isManaged: false },
|
|
629
|
+
'post-commit': { exists: false, isManaged: false },
|
|
630
|
+
},
|
|
631
|
+
})
|
|
632
|
+
mkdirSync(join(ctx.consumerRoot, '.husky'))
|
|
633
|
+
|
|
634
|
+
const results = await installHooks(ctx)
|
|
635
|
+
|
|
636
|
+
for (const r of results) {
|
|
637
|
+
expect(r.message).toContain('Would write')
|
|
638
|
+
expect(r.message).not.toContain('Would append')
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('dry-run describes an idempotent overwrite for managed hooks', async () => {
|
|
643
|
+
const ctx = makeContext({
|
|
644
|
+
hasHuskyDir: true,
|
|
645
|
+
dryRun: true,
|
|
646
|
+
existingHooks: {
|
|
647
|
+
'pre-commit': { exists: true, isManaged: true },
|
|
648
|
+
'post-merge': { exists: true, isManaged: true },
|
|
649
|
+
'post-commit': { exists: true, isManaged: true },
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
653
|
+
mkdirSync(huskyDir)
|
|
654
|
+
writeFileSync(join(huskyDir, 'pre-commit'), `${HOOK_MARKER}\nold\n`)
|
|
655
|
+
writeFileSync(join(huskyDir, 'post-merge'), `${HOOK_MARKER}\nold\n`)
|
|
656
|
+
const before = readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')
|
|
657
|
+
|
|
658
|
+
const results = await installHooks(ctx)
|
|
659
|
+
|
|
660
|
+
for (const r of results) {
|
|
661
|
+
expect(r.action).toBe('dry-run')
|
|
662
|
+
expect(r.message).toContain('idempotent update')
|
|
663
|
+
expect(r.message).toContain(r.hook)
|
|
664
|
+
}
|
|
665
|
+
expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(before)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('dry-run describes appending for unmanaged hooks without force', async () => {
|
|
669
|
+
const ctx = makeContext({
|
|
670
|
+
hasHuskyDir: true,
|
|
671
|
+
dryRun: true,
|
|
672
|
+
force: false,
|
|
673
|
+
flags: {},
|
|
674
|
+
existingHooks: {
|
|
675
|
+
'pre-commit': { exists: true, isManaged: false },
|
|
676
|
+
'post-merge': { exists: true, isManaged: false },
|
|
677
|
+
'post-commit': { exists: true, isManaged: false },
|
|
678
|
+
},
|
|
679
|
+
})
|
|
680
|
+
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
681
|
+
mkdirSync(huskyDir)
|
|
682
|
+
writeFileSync(join(huskyDir, 'pre-commit'), '#!/bin/sh\ncustom\n')
|
|
683
|
+
writeFileSync(join(huskyDir, 'post-merge'), '#!/bin/sh\ncustom\n')
|
|
684
|
+
|
|
685
|
+
const results = await installHooks(ctx)
|
|
686
|
+
|
|
687
|
+
for (const r of results) {
|
|
688
|
+
expect(r.action).toBe('dry-run')
|
|
689
|
+
expect(r.message).toContain('Would append to existing')
|
|
690
|
+
expect(r.message).not.toContain('force')
|
|
691
|
+
}
|
|
692
|
+
expect(readFileSync(join(huskyDir, 'pre-commit'), 'utf-8')).toBe(
|
|
693
|
+
'#!/bin/sh\ncustom\n',
|
|
694
|
+
)
|
|
695
|
+
expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('dry-run backs up + overwrites when force comes from flags', async () => {
|
|
699
|
+
const ctx = makeContext({
|
|
700
|
+
hasHuskyDir: true,
|
|
701
|
+
dryRun: true,
|
|
702
|
+
force: false,
|
|
703
|
+
flags: { force: true },
|
|
704
|
+
existingHooks: {
|
|
705
|
+
'pre-commit': { exists: true, isManaged: false },
|
|
706
|
+
'post-merge': { exists: true, isManaged: false },
|
|
707
|
+
'post-commit': { exists: true, isManaged: false },
|
|
708
|
+
},
|
|
709
|
+
})
|
|
710
|
+
const huskyDir = join(ctx.consumerRoot, '.husky')
|
|
711
|
+
mkdirSync(huskyDir)
|
|
712
|
+
|
|
713
|
+
const results = await installHooks(ctx)
|
|
714
|
+
|
|
715
|
+
for (const r of results) {
|
|
716
|
+
expect(r.message).toContain('overwrite with force')
|
|
717
|
+
}
|
|
718
|
+
// Nothing is actually written or backed up
|
|
719
|
+
expect(existsSync(join(huskyDir, 'pre-commit'))).toBe(false)
|
|
720
|
+
expect(existsSync(join(huskyDir, 'pre-commit.bak'))).toBe(false)
|
|
721
|
+
})
|
|
535
722
|
})
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// husky-init.test.js — Unit tests for the husky initialization phase
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The module resolves husky from the CONSUMER's node_modules and calls its
|
|
6
|
+
// programmatic API. Tests build throwaway consumer projects in temp dirs with
|
|
7
|
+
// a fake `node_modules/husky` fixture, so the real repository is never touched.
|
|
8
|
+
// `child_process.execSync` and `./ui.js` are mocked; `fs` is real.
|
|
9
|
+
//
|
|
10
|
+
// Covered scenarios:
|
|
11
|
+
// 1. dryRun wins over every other flag → action=dry-run, nothing runs
|
|
12
|
+
// 2. CI environment → action=skipped + warning
|
|
13
|
+
// 3. No .git directory → action=skipped + console.warn
|
|
14
|
+
// 4. husky not installed (resolve fails) → action=skipped + install hint
|
|
15
|
+
// 5. husky module throws on import → action=skipped (same catch)
|
|
16
|
+
// 6. core.hooksPath unset succeeds → execSync called, action=initialized
|
|
17
|
+
// 7. core.hooksPath unset throws → swallowed, action=initialized
|
|
18
|
+
// 8. husky() throws → action=failed with message
|
|
19
|
+
// 9. .husky/_/h missing post-condition → action=failed
|
|
20
|
+
// 10. idempotent second run → action=initialized both times
|
|
21
|
+
// 11. verbose flag forwards progress messages
|
|
22
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import {
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
mkdtempSync,
|
|
29
|
+
realpathSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from 'fs'
|
|
33
|
+
import { join } from 'path'
|
|
34
|
+
import { tmpdir } from 'os'
|
|
35
|
+
|
|
36
|
+
vi.mock('child_process', () => ({
|
|
37
|
+
execSync: vi.fn(),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
vi.mock('./ui.js', () => ({
|
|
41
|
+
info: vi.fn(),
|
|
42
|
+
warn: vi.fn(),
|
|
43
|
+
verbose: vi.fn(),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
import { execSync } from 'child_process'
|
|
47
|
+
import { info, warn, verbose } from './ui.js'
|
|
48
|
+
import { initHusky } from './husky-init.js'
|
|
49
|
+
|
|
50
|
+
// ── Fake husky fixtures ───────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const HUSKY_IMPLEMENTATIONS = {
|
|
53
|
+
success: `
|
|
54
|
+
const fs = require('fs')
|
|
55
|
+
const path = require('path')
|
|
56
|
+
module.exports = function husky(root) {
|
|
57
|
+
const dir = path.join(root, '.husky', '_')
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
59
|
+
fs.writeFileSync(path.join(dir, 'h'), '#!/bin/sh\\n')
|
|
60
|
+
}
|
|
61
|
+
`,
|
|
62
|
+
throws: `
|
|
63
|
+
module.exports = function husky() {
|
|
64
|
+
throw new Error('husky boom')
|
|
65
|
+
}
|
|
66
|
+
`,
|
|
67
|
+
noop: `
|
|
68
|
+
module.exports = function husky() {}
|
|
69
|
+
`,
|
|
70
|
+
importThrows: `
|
|
71
|
+
throw new Error('corrupt husky module')
|
|
72
|
+
`,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tempDirs = new Set()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a temporary consumer project.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} [options]
|
|
81
|
+
* @param {string} [options.husky='success'] - Fake husky behaviour key.
|
|
82
|
+
* @param {boolean} [options.withPackageJson=true]
|
|
83
|
+
* @returns {string} Absolute consumer root.
|
|
84
|
+
*/
|
|
85
|
+
function createConsumer({ husky = 'success', withPackageJson = true } = {}) {
|
|
86
|
+
const dir = mkdtempSync(join(realpathSync(tmpdir()), 'husky-init-test-'))
|
|
87
|
+
tempDirs.add(dir)
|
|
88
|
+
|
|
89
|
+
if (withPackageJson) {
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(dir, 'package.json'),
|
|
92
|
+
JSON.stringify({ name: 'fixture', version: '1.0.0' }, null, 2) + '\n',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (husky) {
|
|
97
|
+
const huskyDir = join(dir, 'node_modules', 'husky')
|
|
98
|
+
mkdirSync(huskyDir, { recursive: true })
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(huskyDir, 'package.json'),
|
|
101
|
+
JSON.stringify({ name: 'husky', version: '9.0.0', main: 'index.js' }),
|
|
102
|
+
)
|
|
103
|
+
writeFileSync(join(huskyDir, 'index.js'), HUSKY_IMPLEMENTATIONS[husky])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return dir
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Builds the context object accepted by initHusky().
|
|
111
|
+
*
|
|
112
|
+
* @param {string} consumerRoot
|
|
113
|
+
* @param {object} [overrides]
|
|
114
|
+
* @returns {object}
|
|
115
|
+
*/
|
|
116
|
+
function makeContext(consumerRoot, overrides = {}) {
|
|
117
|
+
return {
|
|
118
|
+
consumerRoot,
|
|
119
|
+
hasGit: true,
|
|
120
|
+
dryRun: false,
|
|
121
|
+
isCI: false,
|
|
122
|
+
verbose: false,
|
|
123
|
+
...overrides,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('initHusky', () => {
|
|
128
|
+
let consoleWarnSpy
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
vi.clearAllMocks()
|
|
132
|
+
execSync.mockReturnValue('')
|
|
133
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
consoleWarnSpy.mockRestore()
|
|
138
|
+
for (const dir of tempDirs) {
|
|
139
|
+
try {
|
|
140
|
+
rmSync(dir, { recursive: true, force: true })
|
|
141
|
+
} catch {
|
|
142
|
+
/* best-effort cleanup */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
tempDirs.clear()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ── 1. dry-run ───────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
it('dryRun: reports the planned action and touches nothing (wins over other flags)', async () => {
|
|
151
|
+
const dir = createConsumer()
|
|
152
|
+
|
|
153
|
+
const result = await initHusky(
|
|
154
|
+
makeContext(dir, { dryRun: true, isCI: true, hasGit: false }),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({
|
|
158
|
+
action: 'dry-run',
|
|
159
|
+
message: 'Would initialize husky',
|
|
160
|
+
})
|
|
161
|
+
expect(info).toHaveBeenCalledWith(
|
|
162
|
+
'[setup] Would initialize husky (generate .husky/_/ shims, set core.hooksPath)',
|
|
163
|
+
)
|
|
164
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
165
|
+
expect(existsSync(join(dir, '.husky'))).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── 2. CI ────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
it('CI environment: skips with a warning', async () => {
|
|
171
|
+
const dir = createConsumer()
|
|
172
|
+
|
|
173
|
+
const result = await initHusky(makeContext(dir, { isCI: true }))
|
|
174
|
+
|
|
175
|
+
expect(result).toEqual({
|
|
176
|
+
action: 'skipped',
|
|
177
|
+
message: 'Skipping husky init in CI environment',
|
|
178
|
+
})
|
|
179
|
+
expect(warn).toHaveBeenCalledWith(
|
|
180
|
+
'[setup] CI environment detected — skipping husky init',
|
|
181
|
+
)
|
|
182
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// ── 3. No git repository ────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
it('no .git directory: skips and warns on the console', async () => {
|
|
188
|
+
const dir = createConsumer()
|
|
189
|
+
|
|
190
|
+
const result = await initHusky(makeContext(dir, { hasGit: false }))
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
action: 'skipped',
|
|
194
|
+
message: 'No .git directory — not a git repository',
|
|
195
|
+
})
|
|
196
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
197
|
+
'[setup] No .git directory — not a git repository',
|
|
198
|
+
)
|
|
199
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ── 4. husky not installed ──────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
it('husky not installed: skips with an install hint', async () => {
|
|
205
|
+
const dir = createConsumer({ husky: null })
|
|
206
|
+
|
|
207
|
+
const result = await initHusky(makeContext(dir))
|
|
208
|
+
|
|
209
|
+
expect(result.action).toBe('skipped')
|
|
210
|
+
expect(result.message).toContain('npm install husky --save-dev')
|
|
211
|
+
expect(verbose).toHaveBeenCalledWith(
|
|
212
|
+
false,
|
|
213
|
+
'husky not found — skipping init',
|
|
214
|
+
)
|
|
215
|
+
expect(execSync).not.toHaveBeenCalled()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ── 5. husky import failure ─────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
it('corrupt husky module: import throws, treated as not installed', async () => {
|
|
221
|
+
const dir = createConsumer({ husky: 'importThrows' })
|
|
222
|
+
|
|
223
|
+
const result = await initHusky(makeContext(dir))
|
|
224
|
+
|
|
225
|
+
expect(result.action).toBe('skipped')
|
|
226
|
+
expect(result.message).toContain('husky is not installed')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ── 6. Success ───────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
it('success: clears core.hooksPath and verifies .husky/_/h', async () => {
|
|
232
|
+
const dir = createConsumer()
|
|
233
|
+
|
|
234
|
+
const result = await initHusky(makeContext(dir))
|
|
235
|
+
|
|
236
|
+
expect(result).toEqual({
|
|
237
|
+
action: 'initialized',
|
|
238
|
+
message: 'Husky initialized successfully',
|
|
239
|
+
})
|
|
240
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
241
|
+
'git config --local --unset core.hooksPath',
|
|
242
|
+
{ cwd: dir, stdio: 'pipe' },
|
|
243
|
+
)
|
|
244
|
+
expect(existsSync(join(dir, '.husky', '_', 'h'))).toBe(true)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// ── 7. Unset failure is swallowed ───────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
it('unset failure: continues and still initializes', async () => {
|
|
250
|
+
const dir = createConsumer()
|
|
251
|
+
execSync.mockImplementation(() => {
|
|
252
|
+
throw new Error('key does not exist')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const result = await initHusky(makeContext(dir))
|
|
256
|
+
|
|
257
|
+
expect(result.action).toBe('initialized')
|
|
258
|
+
expect(existsSync(join(dir, '.husky', '_', 'h'))).toBe(true)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ── 8. husky() throws ───────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
it('husky() throwing: returns action=failed with the error message', async () => {
|
|
264
|
+
const dir = createConsumer({ husky: 'throws' })
|
|
265
|
+
|
|
266
|
+
const result = await initHusky(makeContext(dir))
|
|
267
|
+
|
|
268
|
+
expect(result).toEqual({ action: 'failed', message: 'husky boom' })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ── 9. Post-condition failure ───────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
it('post-condition: missing .husky/_/h returns action=failed', async () => {
|
|
274
|
+
const dir = createConsumer({ husky: 'noop' })
|
|
275
|
+
|
|
276
|
+
const result = await initHusky(makeContext(dir))
|
|
277
|
+
|
|
278
|
+
expect(result.action).toBe('failed')
|
|
279
|
+
expect(result.message).toContain('.husky/_/h not found after init')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ── 10. Idempotency ─────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
it('is idempotent: a second run still reports initialized', async () => {
|
|
285
|
+
const dir = createConsumer()
|
|
286
|
+
|
|
287
|
+
const first = await initHusky(makeContext(dir))
|
|
288
|
+
const second = await initHusky(makeContext(dir))
|
|
289
|
+
|
|
290
|
+
expect(first.action).toBe('initialized')
|
|
291
|
+
expect(second.action).toBe('initialized')
|
|
292
|
+
expect(existsSync(join(dir, '.husky', '_', 'h'))).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ── 11. Verbose progress ────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
it('verbose: emits progress messages for each step', async () => {
|
|
298
|
+
const dir = createConsumer()
|
|
299
|
+
|
|
300
|
+
await initHusky(makeContext(dir, { verbose: true }))
|
|
301
|
+
|
|
302
|
+
const messages = verbose.mock.calls.map(([, message]) => message)
|
|
303
|
+
expect(verbose).toHaveBeenCalledWith(
|
|
304
|
+
true,
|
|
305
|
+
'Resolving husky from consumer node_modules…',
|
|
306
|
+
)
|
|
307
|
+
expect(messages).toContain('Clearing stale core.hooksPath…')
|
|
308
|
+
expect(messages).toContain('Calling husky()…')
|
|
309
|
+
expect(messages).toContain('Verifying .husky/_/h exists…')
|
|
310
|
+
})
|
|
311
|
+
})
|