@rashidee/co2 1.2.5
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/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- package/static/index.html +24 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
# Testing Patterns — Vitest for TypeScript CLI
|
|
2
|
+
|
|
3
|
+
This reference describes the complete testing strategy for CLI applications. Include
|
|
4
|
+
relevant content in Section 14 of the generated specification.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Testing Architecture
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
test/
|
|
12
|
+
├── unit/
|
|
13
|
+
│ └── services/
|
|
14
|
+
│ ├── init.service.test.ts ← Pure unit tests; no I/O, no process.exit
|
|
15
|
+
│ └── deploy.service.test.ts
|
|
16
|
+
├── integration/
|
|
17
|
+
│ └── commands/
|
|
18
|
+
│ ├── init.test.ts ← Invoke Commander with controlled argv
|
|
19
|
+
│ └── deploy.test.ts
|
|
20
|
+
└── helpers/
|
|
21
|
+
├── fixtures.ts ← Shared test data factories
|
|
22
|
+
├── tmp.ts ← Temp directory utilities
|
|
23
|
+
└── argv.ts ← argv builder helpers
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## vitest.config.ts
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { defineConfig } from 'vitest/config'
|
|
32
|
+
|
|
33
|
+
export default defineConfig({
|
|
34
|
+
test: {
|
|
35
|
+
globals: true,
|
|
36
|
+
environment: 'node',
|
|
37
|
+
include: ['test/**/*.test.ts'],
|
|
38
|
+
exclude: ['node_modules', 'dist'],
|
|
39
|
+
testTimeout: 10_000,
|
|
40
|
+
hookTimeout: 10_000,
|
|
41
|
+
coverage: {
|
|
42
|
+
provider: 'v8',
|
|
43
|
+
reporter: ['text', 'lcov', 'html'],
|
|
44
|
+
include: ['src/**/*.ts'],
|
|
45
|
+
exclude: [
|
|
46
|
+
'src/cli.ts', // entry point — tested via integration
|
|
47
|
+
'src/ui/**/*.ts', // UI utilities — covered via integration tests
|
|
48
|
+
'src/**/*.d.ts',
|
|
49
|
+
],
|
|
50
|
+
thresholds: {
|
|
51
|
+
lines: 80,
|
|
52
|
+
functions: 80,
|
|
53
|
+
branches: 70,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Service Unit Testing
|
|
63
|
+
|
|
64
|
+
Services have no terminal I/O, no `process.exit`, and no `console.log`. They are
|
|
65
|
+
pure TypeScript and testable in isolation.
|
|
66
|
+
|
|
67
|
+
### Constructor injection pattern (testable services)
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// src/services/deploy.service.ts
|
|
71
|
+
import type { Got } from 'got'
|
|
72
|
+
import { getHttpClient } from './http.client.js'
|
|
73
|
+
import { CliError } from '../errors.js'
|
|
74
|
+
|
|
75
|
+
export interface DeployOptions {
|
|
76
|
+
environment: string
|
|
77
|
+
dryRun: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface DeployResult {
|
|
81
|
+
deploymentId: string
|
|
82
|
+
environment: string
|
|
83
|
+
status: 'pending' | 'success' | 'failed'
|
|
84
|
+
url: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class DeployService {
|
|
88
|
+
constructor(
|
|
89
|
+
// Inject dependencies — default to real implementation; override in tests
|
|
90
|
+
private readonly http: Got = getHttpClient(),
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
async deploy(options: DeployOptions): Promise<DeployResult> {
|
|
94
|
+
if (options.dryRun) {
|
|
95
|
+
return {
|
|
96
|
+
deploymentId: 'dry-run',
|
|
97
|
+
environment: options.environment,
|
|
98
|
+
status: 'pending',
|
|
99
|
+
url: 'https://staging.example.com',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return this.http
|
|
103
|
+
.post('deployments', { json: { environment: options.environment } })
|
|
104
|
+
.json<DeployResult>()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Unit test
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// test/unit/services/deploy.service.test.ts
|
|
113
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
114
|
+
import type { Got } from 'got'
|
|
115
|
+
import { DeployService } from '../../../src/services/deploy.service.js'
|
|
116
|
+
|
|
117
|
+
// Minimal got mock — only mock what DeployService actually uses
|
|
118
|
+
function makeHttpMock(response: unknown): Got {
|
|
119
|
+
const postMock = vi.fn().mockReturnValue({
|
|
120
|
+
json: vi.fn().mockResolvedValue(response),
|
|
121
|
+
})
|
|
122
|
+
return { post: postMock } as unknown as Got
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('DeployService', () => {
|
|
126
|
+
describe('deploy', () => {
|
|
127
|
+
it('returns dry-run result without calling HTTP', async () => {
|
|
128
|
+
const http = makeHttpMock({})
|
|
129
|
+
const service = new DeployService(http)
|
|
130
|
+
|
|
131
|
+
const result = await service.deploy({ environment: 'staging', dryRun: true })
|
|
132
|
+
|
|
133
|
+
expect(result.deploymentId).toBe('dry-run')
|
|
134
|
+
expect(result.status).toBe('pending')
|
|
135
|
+
expect(http.post).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('calls the deployments endpoint with correct payload', async () => {
|
|
139
|
+
const mockResponse: import('../../../src/services/deploy.service.js').DeployResult = {
|
|
140
|
+
deploymentId: 'dep-123',
|
|
141
|
+
environment: 'production',
|
|
142
|
+
status: 'success',
|
|
143
|
+
url: 'https://app.example.com',
|
|
144
|
+
}
|
|
145
|
+
const http = makeHttpMock(mockResponse)
|
|
146
|
+
const service = new DeployService(http)
|
|
147
|
+
|
|
148
|
+
const result = await service.deploy({ environment: 'production', dryRun: false })
|
|
149
|
+
|
|
150
|
+
expect(http.post).toHaveBeenCalledWith('deployments', {
|
|
151
|
+
json: { environment: 'production' },
|
|
152
|
+
})
|
|
153
|
+
expect(result.deploymentId).toBe('dep-123')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('propagates CliError from HTTP client', async () => {
|
|
157
|
+
const { CliError } = await import('../../../src/errors.js')
|
|
158
|
+
const http = {
|
|
159
|
+
post: vi.fn().mockReturnValue({
|
|
160
|
+
json: vi.fn().mockRejectedValue(CliError.networkError('Connection refused')),
|
|
161
|
+
}),
|
|
162
|
+
} as unknown as Got
|
|
163
|
+
const service = new DeployService(http)
|
|
164
|
+
|
|
165
|
+
await expect(service.deploy({ environment: 'staging', dryRun: false }))
|
|
166
|
+
.rejects.toThrow('Connection refused')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Command Integration Testing
|
|
175
|
+
|
|
176
|
+
Test commands by invoking Commander's `parseAsync` with a controlled `argv` array.
|
|
177
|
+
Mock the service layer to avoid real side effects.
|
|
178
|
+
|
|
179
|
+
### argv builder helper
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// test/helpers/argv.ts
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a process.argv-compatible array for Commander.
|
|
186
|
+
* Prepend the required 'node' and 'script' args.
|
|
187
|
+
*/
|
|
188
|
+
export function buildArgv(...args: string[]): string[] {
|
|
189
|
+
return ['node', 'cli.js', ...args]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Capture output helper
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// test/helpers/capture.ts
|
|
197
|
+
import { vi } from 'vitest'
|
|
198
|
+
|
|
199
|
+
export interface CapturedOutput {
|
|
200
|
+
stdout: string[]
|
|
201
|
+
stderr: string[]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Spy on process.stdout.write and process.stderr.write.
|
|
206
|
+
* Call restore() in afterEach.
|
|
207
|
+
*/
|
|
208
|
+
export function captureOutput(): { captured: CapturedOutput; restore: () => void } {
|
|
209
|
+
const captured: CapturedOutput = { stdout: [], stderr: [] }
|
|
210
|
+
|
|
211
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((data) => {
|
|
212
|
+
captured.stdout.push(String(data))
|
|
213
|
+
return true
|
|
214
|
+
})
|
|
215
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((data) => {
|
|
216
|
+
captured.stderr.push(String(data))
|
|
217
|
+
return true
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
captured,
|
|
222
|
+
restore: () => {
|
|
223
|
+
stdoutSpy.mockRestore()
|
|
224
|
+
stderrSpy.mockRestore()
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Command integration test
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// test/integration/commands/deploy.test.ts
|
|
234
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
235
|
+
import { Command } from 'commander'
|
|
236
|
+
import { registerDeployCommand } from '../../../src/commands/deploy/index.js'
|
|
237
|
+
import { DeployService } from '../../../src/services/deploy.service.js'
|
|
238
|
+
import { buildArgv } from '../../helpers/argv.js'
|
|
239
|
+
import { captureOutput } from '../../helpers/capture.js'
|
|
240
|
+
|
|
241
|
+
// Mock the service module — isolate command from real HTTP
|
|
242
|
+
vi.mock('../../../src/services/deploy.service.js', () => ({
|
|
243
|
+
DeployService: vi.fn(),
|
|
244
|
+
}))
|
|
245
|
+
|
|
246
|
+
describe('deploy command', () => {
|
|
247
|
+
let program: Command
|
|
248
|
+
let capture: ReturnType<typeof captureOutput>
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
program = new Command()
|
|
252
|
+
// Add global options that commands expect
|
|
253
|
+
program.option('--json', '', false)
|
|
254
|
+
program.option('-v, --verbose', '', false)
|
|
255
|
+
registerDeployCommand(program)
|
|
256
|
+
capture = captureOutput()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
afterEach(() => {
|
|
260
|
+
capture.restore()
|
|
261
|
+
vi.clearAllMocks()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('outputs JSON result with --json flag', async () => {
|
|
265
|
+
const mockDeploy = vi.fn().mockResolvedValue({
|
|
266
|
+
deploymentId: 'dep-42',
|
|
267
|
+
environment: 'staging',
|
|
268
|
+
status: 'success',
|
|
269
|
+
url: 'https://staging.example.com',
|
|
270
|
+
})
|
|
271
|
+
vi.mocked(DeployService).mockImplementation(() => ({ deploy: mockDeploy }) as never)
|
|
272
|
+
|
|
273
|
+
await program.parseAsync(buildArgv('deploy', '--json', '-e', 'staging'))
|
|
274
|
+
|
|
275
|
+
const stdout = capture.captured.stdout.join('')
|
|
276
|
+
const parsed = JSON.parse(stdout) as { success: boolean; data: { deploymentId: string } }
|
|
277
|
+
expect(parsed.success).toBe(true)
|
|
278
|
+
expect(parsed.data.deploymentId).toBe('dep-42')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('exits with code 1 on network error', async () => {
|
|
282
|
+
const { CliError } = await import('../../../src/errors.js')
|
|
283
|
+
vi.mocked(DeployService).mockImplementation(() => ({
|
|
284
|
+
deploy: vi.fn().mockRejectedValue(CliError.networkError('Timeout')),
|
|
285
|
+
}) as never)
|
|
286
|
+
|
|
287
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
288
|
+
throw new Error('process.exit called')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await expect(
|
|
292
|
+
program.parseAsync(buildArgv('deploy', '-e', 'staging'))
|
|
293
|
+
).rejects.toThrow('process.exit called')
|
|
294
|
+
|
|
295
|
+
expect(exitSpy).toHaveBeenCalledWith(5) // ExitCodes.NETWORK_ERROR
|
|
296
|
+
exitSpy.mockRestore()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('uses dry-run flag and does not call HTTP', async () => {
|
|
300
|
+
const mockDeploy = vi.fn().mockResolvedValue({
|
|
301
|
+
deploymentId: 'dry-run', environment: 'staging', status: 'pending', url: '',
|
|
302
|
+
})
|
|
303
|
+
vi.mocked(DeployService).mockImplementation(() => ({ deploy: mockDeploy }) as never)
|
|
304
|
+
|
|
305
|
+
await program.parseAsync(buildArgv('deploy', '--dry-run', '-e', 'staging'))
|
|
306
|
+
|
|
307
|
+
expect(mockDeploy).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({ dryRun: true })
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Configuration Testing
|
|
317
|
+
|
|
318
|
+
Use temp directories to avoid polluting the real user config directory.
|
|
319
|
+
|
|
320
|
+
### Temp directory helper
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// test/helpers/tmp.ts
|
|
324
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
325
|
+
import { tmpdir } from 'node:os'
|
|
326
|
+
import { join } from 'node:path'
|
|
327
|
+
import { vi } from 'vitest'
|
|
328
|
+
|
|
329
|
+
export interface TmpDirContext {
|
|
330
|
+
dir: string
|
|
331
|
+
cleanup: () => Promise<void>
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function createTmpDir(prefix = 'cli-test-'): Promise<TmpDirContext> {
|
|
335
|
+
const dir = await mkdtemp(join(tmpdir(), prefix))
|
|
336
|
+
return {
|
|
337
|
+
dir,
|
|
338
|
+
cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### User config test
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
// test/unit/config/user.config.test.ts
|
|
347
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
348
|
+
import { createTmpDir, type TmpDirContext } from '../../helpers/tmp.js'
|
|
349
|
+
|
|
350
|
+
describe('getUserConfig', () => {
|
|
351
|
+
let tmp: TmpDirContext
|
|
352
|
+
let getUserConfig: () => ReturnType<typeof import('../../../src/config/user.config.js').getUserConfig>
|
|
353
|
+
|
|
354
|
+
beforeEach(async () => {
|
|
355
|
+
tmp = await createTmpDir()
|
|
356
|
+
// Override XDG_CONFIG_HOME so conf writes to our tmp dir
|
|
357
|
+
process.env['XDG_CONFIG_HOME'] = tmp.dir
|
|
358
|
+
// Re-import to get a fresh conf instance pointing to tmp dir
|
|
359
|
+
const mod = await import('../../../src/config/user.config.js?v=' + Date.now())
|
|
360
|
+
getUserConfig = mod.getUserConfig
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
afterEach(async () => {
|
|
364
|
+
delete process.env['XDG_CONFIG_HOME']
|
|
365
|
+
await tmp.cleanup()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('returns default value for unset key', () => {
|
|
369
|
+
const config = getUserConfig()
|
|
370
|
+
expect(config.get('defaultEnv')).toBe('staging')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('persists a set value', () => {
|
|
374
|
+
const config = getUserConfig()
|
|
375
|
+
config.set('defaultEnv', 'production')
|
|
376
|
+
expect(config.get('defaultEnv')).toBe('production')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('resets to defaults on clear', () => {
|
|
380
|
+
const config = getUserConfig()
|
|
381
|
+
config.set('defaultEnv', 'production')
|
|
382
|
+
config.clear()
|
|
383
|
+
expect(config.get('defaultEnv')).toBe('staging')
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Project config test
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
// test/unit/config/project.config.test.ts
|
|
392
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
393
|
+
import { writeFile } from 'node:fs/promises'
|
|
394
|
+
import { join } from 'node:path'
|
|
395
|
+
import { createTmpDir, type TmpDirContext } from '../../helpers/tmp.js'
|
|
396
|
+
|
|
397
|
+
describe('loadProjectConfig', () => {
|
|
398
|
+
let tmp: TmpDirContext
|
|
399
|
+
|
|
400
|
+
beforeEach(async () => {
|
|
401
|
+
tmp = await createTmpDir()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
afterEach(async () => {
|
|
405
|
+
await tmp.cleanup()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('returns defaults when no config file exists', async () => {
|
|
409
|
+
const { loadProjectConfig, resetProjectConfigCache } = await import('../../../src/config/project.config.js')
|
|
410
|
+
resetProjectConfigCache()
|
|
411
|
+
const config = await loadProjectConfig(tmp.dir)
|
|
412
|
+
expect(config.environment).toBe('staging')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('loads and validates a .mytoolrc.json file', async () => {
|
|
416
|
+
await writeFile(
|
|
417
|
+
join(tmp.dir, '.{{BINARY_NAME}}rc.json'),
|
|
418
|
+
JSON.stringify({ environment: 'production', region: 'eu-west-1' })
|
|
419
|
+
)
|
|
420
|
+
const { loadProjectConfig, resetProjectConfigCache } = await import('../../../src/config/project.config.js')
|
|
421
|
+
resetProjectConfigCache()
|
|
422
|
+
const config = await loadProjectConfig(tmp.dir)
|
|
423
|
+
expect(config.environment).toBe('production')
|
|
424
|
+
expect(config.region).toBe('eu-west-1')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('throws CliError for invalid config', async () => {
|
|
428
|
+
await writeFile(
|
|
429
|
+
join(tmp.dir, '.{{BINARY_NAME}}rc.json'),
|
|
430
|
+
JSON.stringify({ environment: 'invalid-env' })
|
|
431
|
+
)
|
|
432
|
+
const { loadProjectConfig, resetProjectConfigCache } = await import('../../../src/config/project.config.js')
|
|
433
|
+
const { CliError } = await import('../../../src/errors.js')
|
|
434
|
+
resetProjectConfigCache()
|
|
435
|
+
await expect(loadProjectConfig(tmp.dir)).rejects.toThrow(CliError)
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## Testing with File System
|
|
443
|
+
|
|
444
|
+
For commands that read or write files, use the temp directory helper and assert on
|
|
445
|
+
filesystem state:
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { readFile, access } from 'node:fs/promises'
|
|
449
|
+
import { constants as fsConst } from 'node:fs'
|
|
450
|
+
import { join } from 'node:path'
|
|
451
|
+
|
|
452
|
+
it('creates expected files in the target directory', async () => {
|
|
453
|
+
const tmp = await createTmpDir()
|
|
454
|
+
const service = new InitService()
|
|
455
|
+
|
|
456
|
+
await service.execute('my-project', {
|
|
457
|
+
template: 'default',
|
|
458
|
+
yes: true,
|
|
459
|
+
dryRun: false,
|
|
460
|
+
verbose: false,
|
|
461
|
+
json: false,
|
|
462
|
+
targetDir: tmp.dir, // use tmp instead of cwd
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// Assert files were created
|
|
466
|
+
await expect(access(join(tmp.dir, 'my-project', 'package.json'), fsConst.F_OK))
|
|
467
|
+
.resolves.toBeUndefined()
|
|
468
|
+
|
|
469
|
+
const pkgJson = JSON.parse(
|
|
470
|
+
await readFile(join(tmp.dir, 'my-project', 'package.json'), 'utf-8')
|
|
471
|
+
) as { name: string }
|
|
472
|
+
expect(pkgJson.name).toBe('my-project')
|
|
473
|
+
|
|
474
|
+
await tmp.cleanup()
|
|
475
|
+
})
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Shell Command Testing (when Shell Execution = yes)
|
|
481
|
+
|
|
482
|
+
Mock `execa` to test commands that spawn child processes:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
import { vi } from 'vitest'
|
|
486
|
+
|
|
487
|
+
vi.mock('execa', () => ({
|
|
488
|
+
execa: vi.fn().mockResolvedValue({ stdout: 'v22.0.0', stderr: '', exitCode: 0 }),
|
|
489
|
+
}))
|
|
490
|
+
|
|
491
|
+
it('calls git init in the new project directory', async () => {
|
|
492
|
+
const { execa } = await import('execa')
|
|
493
|
+
const service = new InitService()
|
|
494
|
+
await service.execute('my-project', { ...defaultOpts, initGit: true })
|
|
495
|
+
|
|
496
|
+
expect(vi.mocked(execa)).toHaveBeenCalledWith(
|
|
497
|
+
'git', ['init'], expect.objectContaining({ cwd: expect.stringContaining('my-project') })
|
|
498
|
+
)
|
|
499
|
+
})
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
## Test Coverage Strategy
|
|
505
|
+
|
|
506
|
+
| Layer | Test Type | Coverage Goal |
|
|
507
|
+
|------------------------|----------------------------|---------------|
|
|
508
|
+
| Services | Unit | ≥ 90% |
|
|
509
|
+
| Command handlers | Integration | ≥ 75% |
|
|
510
|
+
| Config modules | Unit | ≥ 85% |
|
|
511
|
+
| Repositories | Unit (in-memory DB) | ≥ 90% |
|
|
512
|
+
| `poll()` utility | Unit (fake timers) | ≥ 85% |
|
|
513
|
+
| `runBatch()` utility | Unit | ≥ 90% |
|
|
514
|
+
| `DaemonManager` | Unit (mocked process.kill) | ≥ 80% |
|
|
515
|
+
| `runDaemon()` loop | Skipped | N/A (event loop) |
|
|
516
|
+
| UI utilities | Skipped | N/A (no logic)|
|
|
517
|
+
| Entry point `cli.ts` | Skipped | N/A |
|
|
518
|
+
|
|
519
|
+
Services are the highest-value coverage target — all business logic lives there.
|
|
520
|
+
Command handlers are tested at integration level — mock services, assert on output.
|
|
521
|
+
Repositories use an in-memory SQLite database via `createDbClient(':memory:')`.
|
|
522
|
+
Async utilities (`poll`, `runBatch`) are pure logic and unit-testable without CLI overhead.
|
|
523
|
+
`runDaemon()` is an infinite loop by design — it is excluded from coverage and verified
|
|
524
|
+
manually or via integration test of `daemon start` + `daemon stop`.
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## Async Pattern Testing
|
|
529
|
+
|
|
530
|
+
### Testing `poll()` — fake timers
|
|
531
|
+
|
|
532
|
+
Avoid real `setTimeout` waits by using Vitest's fake timers. The key is advancing
|
|
533
|
+
time with `vi.advanceTimersByTimeAsync` inside the same tick as the `poll()` call.
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// test/unit/utils/poll.test.ts
|
|
537
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
538
|
+
import { poll } from '../../../src/utils/poll.js'
|
|
539
|
+
|
|
540
|
+
// Suppress ora output in tests
|
|
541
|
+
vi.mock('ora', () => ({
|
|
542
|
+
default: () => ({
|
|
543
|
+
start: () => ({ text: '', isSpinning: true }),
|
|
544
|
+
succeed: vi.fn(),
|
|
545
|
+
fail: vi.fn(),
|
|
546
|
+
stop: vi.fn(),
|
|
547
|
+
isSpinning: false,
|
|
548
|
+
}),
|
|
549
|
+
}))
|
|
550
|
+
|
|
551
|
+
describe('poll()', () => {
|
|
552
|
+
beforeEach(() => vi.useFakeTimers())
|
|
553
|
+
afterEach(() => vi.useRealTimers())
|
|
554
|
+
|
|
555
|
+
it('resolves when check() returns non-null', async () => {
|
|
556
|
+
let calls = 0
|
|
557
|
+
|
|
558
|
+
const pollPromise = poll({
|
|
559
|
+
label: 'Waiting for job',
|
|
560
|
+
intervalMs: 1_000,
|
|
561
|
+
check: async () => {
|
|
562
|
+
calls++
|
|
563
|
+
return calls >= 3 ? { status: 'success', url: 'https://example.com' } : null
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// Advance through 3 check intervals
|
|
568
|
+
await vi.advanceTimersByTimeAsync(3_100)
|
|
569
|
+
|
|
570
|
+
const result = await pollPromise
|
|
571
|
+
expect(result.value).toEqual({ status: 'success', url: 'https://example.com' })
|
|
572
|
+
expect(result.elapsedMs).toBeGreaterThanOrEqual(0)
|
|
573
|
+
expect(calls).toBe(3)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('throws CliError when timeoutMs is exceeded', async () => {
|
|
577
|
+
const pollPromise = poll({
|
|
578
|
+
label: 'Waiting',
|
|
579
|
+
timeoutMs: 5_000,
|
|
580
|
+
intervalMs: 1_000,
|
|
581
|
+
check: async () => null,
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
await vi.advanceTimersByTimeAsync(6_000)
|
|
585
|
+
|
|
586
|
+
await expect(pollPromise).rejects.toMatchObject({
|
|
587
|
+
code: 'POLL_TIMEOUT',
|
|
588
|
+
message: expect.stringContaining('Timed out'),
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('calls onTick with elapsed time on each interval', async () => {
|
|
593
|
+
const ticks: number[] = []
|
|
594
|
+
|
|
595
|
+
const pollPromise = poll({
|
|
596
|
+
label: 'Waiting',
|
|
597
|
+
intervalMs: 1_000,
|
|
598
|
+
timeoutMs: 10_000,
|
|
599
|
+
onTick: (_spinner, elapsed) => ticks.push(elapsed),
|
|
600
|
+
check: async () => {
|
|
601
|
+
if (ticks.length >= 2) return { done: true }
|
|
602
|
+
return null
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
await vi.advanceTimersByTimeAsync(3_000)
|
|
607
|
+
await pollPromise
|
|
608
|
+
|
|
609
|
+
expect(ticks.length).toBeGreaterThanOrEqual(2)
|
|
610
|
+
// Each tick elapsed should be increasing
|
|
611
|
+
expect(ticks[1]!).toBeGreaterThan(ticks[0]!)
|
|
612
|
+
})
|
|
613
|
+
})
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Testing `runBatch()` — concurrency and error handling
|
|
617
|
+
|
|
618
|
+
`runBatch` is synchronous-feeling from the test's perspective — no timers needed.
|
|
619
|
+
Focus on concurrency behaviour, error accumulation, and bail semantics.
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
// test/unit/utils/batch.test.ts
|
|
623
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
624
|
+
import { runBatch } from '../../../src/utils/batch.js'
|
|
625
|
+
|
|
626
|
+
describe('runBatch()', () => {
|
|
627
|
+
it('processes all items and returns results array', async () => {
|
|
628
|
+
const summary = await runBatch({
|
|
629
|
+
total: 5,
|
|
630
|
+
items: [1, 2, 3, 4, 5],
|
|
631
|
+
process: async (n) => n * 2,
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
expect(summary.succeeded).toBe(5)
|
|
635
|
+
expect(summary.failed).toBe(0)
|
|
636
|
+
expect(summary.results).toEqual([2, 4, 6, 8, 10])
|
|
637
|
+
expect(summary.durationMs).toBeGreaterThanOrEqual(0)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('collects errors and continues when bail = false (default)', async () => {
|
|
641
|
+
const summary = await runBatch({
|
|
642
|
+
total: 3,
|
|
643
|
+
items: [1, 2, 3],
|
|
644
|
+
process: async (n) => {
|
|
645
|
+
if (n === 2) throw new Error('item 2 failed')
|
|
646
|
+
return n * 10
|
|
647
|
+
},
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
expect(summary.succeeded).toBe(2)
|
|
651
|
+
expect(summary.failed).toBe(1)
|
|
652
|
+
expect(summary.results).toContain(10)
|
|
653
|
+
expect(summary.results).toContain(30)
|
|
654
|
+
expect(summary.errors[0]?.error.message).toBe('item 2 failed')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('halts after first error when bail = true', async () => {
|
|
658
|
+
const processed: number[] = []
|
|
659
|
+
|
|
660
|
+
await runBatch({
|
|
661
|
+
total: 10,
|
|
662
|
+
items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
|
663
|
+
bail: true,
|
|
664
|
+
process: async (n) => {
|
|
665
|
+
processed.push(n)
|
|
666
|
+
if (n === 2) throw new Error('bail trigger')
|
|
667
|
+
return n
|
|
668
|
+
},
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
// With concurrency=1 (default), processing stops at or after item 2
|
|
672
|
+
expect(processed.length).toBeLessThan(10)
|
|
673
|
+
expect(processed).toContain(2)
|
|
674
|
+
expect(processed).not.toContain(3)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('reports progress via onProgress callback after each item', async () => {
|
|
678
|
+
const snapshots: number[] = []
|
|
679
|
+
|
|
680
|
+
await runBatch({
|
|
681
|
+
total: 4,
|
|
682
|
+
items: [1, 2, 3, 4],
|
|
683
|
+
process: async (n) => n,
|
|
684
|
+
onProgress: (p) => snapshots.push(p.processed),
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
expect(snapshots).toEqual([1, 2, 3, 4])
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('runs items in parallel up to concurrency limit', async () => {
|
|
691
|
+
// Track the maximum number of concurrent executions
|
|
692
|
+
let concurrent = 0
|
|
693
|
+
let maxConcurrent = 0
|
|
694
|
+
|
|
695
|
+
await runBatch({
|
|
696
|
+
total: 6,
|
|
697
|
+
items: [1, 2, 3, 4, 5, 6],
|
|
698
|
+
concurrency: 3,
|
|
699
|
+
process: async (n) => {
|
|
700
|
+
concurrent++
|
|
701
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent)
|
|
702
|
+
await new Promise(r => setTimeout(r, 10)) // simulate async work
|
|
703
|
+
concurrent--
|
|
704
|
+
return n
|
|
705
|
+
},
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
expect(maxConcurrent).toBeLessThanOrEqual(3)
|
|
709
|
+
expect(maxConcurrent).toBeGreaterThan(1) // actually ran in parallel
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('processes async iterables (not just arrays)', async () => {
|
|
713
|
+
async function* generateItems() {
|
|
714
|
+
yield 'a'
|
|
715
|
+
yield 'b'
|
|
716
|
+
yield 'c'
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const summary = await runBatch({
|
|
720
|
+
total: 3,
|
|
721
|
+
items: generateItems(),
|
|
722
|
+
process: async (s) => s.toUpperCase(),
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
expect(summary.results).toEqual(['A', 'B', 'C'])
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Testing `DaemonManager` — mocking process signals
|
|
731
|
+
|
|
732
|
+
Test `isRunning()`, `start()`, and `stop()` without spawning real processes.
|
|
733
|
+
Mock `process.kill` for signal-0 checks and `child_process.spawn` for start.
|
|
734
|
+
|
|
735
|
+
```typescript
|
|
736
|
+
// test/unit/services/daemon.service.test.ts
|
|
737
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
738
|
+
import { DaemonManager } from '../../../src/services/daemon.service.js'
|
|
739
|
+
|
|
740
|
+
// ── conf mock — in-memory store ───────────────────────────────────────────────
|
|
741
|
+
const mockStore = new Map<string, unknown>()
|
|
742
|
+
|
|
743
|
+
vi.mock('../../../src/config/user.config.js', () => ({
|
|
744
|
+
getUserConfig: () => ({
|
|
745
|
+
get: (k: string) => mockStore.get(k),
|
|
746
|
+
set: (k: string, v: unknown) => { mockStore.set(k, v) },
|
|
747
|
+
delete: (k: string) => { mockStore.delete(k) },
|
|
748
|
+
}),
|
|
749
|
+
}))
|
|
750
|
+
|
|
751
|
+
describe('DaemonManager', () => {
|
|
752
|
+
beforeEach(() => mockStore.clear())
|
|
753
|
+
|
|
754
|
+
describe('isRunning()', () => {
|
|
755
|
+
it('returns false when no PID is stored', async () => {
|
|
756
|
+
const manager = new DaemonManager()
|
|
757
|
+
expect(await manager.isRunning()).toBe(false)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('returns true when the stored PID is alive (signal-0 succeeds)', async () => {
|
|
761
|
+
mockStore.set('daemonPid', 12345)
|
|
762
|
+
vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
763
|
+
|
|
764
|
+
const manager = new DaemonManager()
|
|
765
|
+
expect(await manager.isRunning()).toBe(true)
|
|
766
|
+
|
|
767
|
+
vi.restoreAllMocks()
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('returns false and clears PID when signal-0 throws ESRCH (process gone)', async () => {
|
|
771
|
+
mockStore.set('daemonPid', 99999)
|
|
772
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
773
|
+
const err = Object.assign(new Error('ESRCH'), { code: 'ESRCH' })
|
|
774
|
+
throw err
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
const manager = new DaemonManager()
|
|
778
|
+
expect(await manager.isRunning()).toBe(false)
|
|
779
|
+
expect(mockStore.has('daemonPid')).toBe(false) // PID was cleared
|
|
780
|
+
|
|
781
|
+
killSpy.mockRestore()
|
|
782
|
+
})
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
describe('stop()', () => {
|
|
786
|
+
it('sends SIGTERM to the stored PID', async () => {
|
|
787
|
+
mockStore.set('daemonPid', 12345)
|
|
788
|
+
mockStore.set('daemonStartedAt', new Date().toISOString())
|
|
789
|
+
|
|
790
|
+
const killSpy = vi.spyOn(process, 'kill')
|
|
791
|
+
.mockReturnValueOnce(true) // SIGTERM send
|
|
792
|
+
.mockImplementation(() => { // subsequent signal-0 checks → process gone
|
|
793
|
+
const err = Object.assign(new Error('ESRCH'), { code: 'ESRCH' })
|
|
794
|
+
throw err
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
const manager = new DaemonManager()
|
|
798
|
+
await manager.stop(1_000)
|
|
799
|
+
|
|
800
|
+
expect(killSpy).toHaveBeenCalledWith(12345, 'SIGTERM')
|
|
801
|
+
expect(mockStore.has('daemonPid')).toBe(false)
|
|
802
|
+
|
|
803
|
+
killSpy.mockRestore()
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
it('is a no-op when daemon is not running', async () => {
|
|
807
|
+
const killSpy = vi.spyOn(process, 'kill')
|
|
808
|
+
const manager = new DaemonManager()
|
|
809
|
+
await manager.stop()
|
|
810
|
+
|
|
811
|
+
expect(killSpy).not.toHaveBeenCalled()
|
|
812
|
+
killSpy.mockRestore()
|
|
813
|
+
})
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
describe('getStatus()', () => {
|
|
817
|
+
it('returns running=false when no PID stored', async () => {
|
|
818
|
+
vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
819
|
+
const manager = new DaemonManager()
|
|
820
|
+
const status = await manager.getStatus()
|
|
821
|
+
expect(status.running).toBe(false)
|
|
822
|
+
vi.restoreAllMocks()
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('returns uptime when daemon is running', async () => {
|
|
826
|
+
const startedAt = new Date(Date.now() - 60_000) // 1 minute ago
|
|
827
|
+
mockStore.set('daemonPid', 12345)
|
|
828
|
+
mockStore.set('daemonStartedAt', startedAt.toISOString())
|
|
829
|
+
vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
830
|
+
|
|
831
|
+
const manager = new DaemonManager()
|
|
832
|
+
const status = await manager.getStatus()
|
|
833
|
+
|
|
834
|
+
expect(status.running).toBe(true)
|
|
835
|
+
expect(status.pid).toBe(12345)
|
|
836
|
+
expect(status.uptimeMs).toBeGreaterThanOrEqual(60_000)
|
|
837
|
+
|
|
838
|
+
vi.restoreAllMocks()
|
|
839
|
+
})
|
|
840
|
+
})
|
|
841
|
+
})
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### Testing commands that use `poll()` or `runBatch()`
|
|
845
|
+
|
|
846
|
+
For command integration tests, mock the utility modules entirely. The utilities are
|
|
847
|
+
tested independently above — at the command level you only need to verify that the
|
|
848
|
+
correct utility is called with the right options.
|
|
849
|
+
|
|
850
|
+
```typescript
|
|
851
|
+
// test/integration/commands/deploy.test.ts (polling command)
|
|
852
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
853
|
+
import { Command } from 'commander'
|
|
854
|
+
import { registerDeployCommand } from '../../../src/commands/deploy/index.js'
|
|
855
|
+
import { buildArgv } from '../../helpers/argv.js'
|
|
856
|
+
import { captureOutput } from '../../helpers/capture.js'
|
|
857
|
+
|
|
858
|
+
// Mock poll utility — prevents real intervals in integration tests
|
|
859
|
+
vi.mock('../../../src/utils/poll.js', () => ({
|
|
860
|
+
poll: vi.fn().mockResolvedValue({
|
|
861
|
+
value: { status: 'success', url: 'https://staging.example.com' },
|
|
862
|
+
elapsedMs: 5_000,
|
|
863
|
+
}),
|
|
864
|
+
}))
|
|
865
|
+
|
|
866
|
+
vi.mock('../../../src/services/deploy.service.js', () => ({
|
|
867
|
+
DeployService: vi.fn().mockImplementation(() => ({
|
|
868
|
+
trigger: vi.fn().mockResolvedValue({ deploymentId: 'dep-abc' }),
|
|
869
|
+
getStatus: vi.fn().mockResolvedValue({ status: 'success', url: 'https://staging.example.com' }),
|
|
870
|
+
})),
|
|
871
|
+
}))
|
|
872
|
+
|
|
873
|
+
describe('deploy command — polling path', () => {
|
|
874
|
+
let program: Command
|
|
875
|
+
let capture: ReturnType<typeof captureOutput>
|
|
876
|
+
|
|
877
|
+
beforeEach(() => {
|
|
878
|
+
program = new Command()
|
|
879
|
+
program.option('--json', '', false).option('-v, --verbose', '', false)
|
|
880
|
+
registerDeployCommand(program)
|
|
881
|
+
capture = captureOutput()
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
afterEach(() => {
|
|
885
|
+
capture.restore()
|
|
886
|
+
vi.clearAllMocks()
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
it('exits 0 and prints deployment ID without --watch', async () => {
|
|
890
|
+
await program.parseAsync(buildArgv('deploy', 'staging'))
|
|
891
|
+
const { poll } = await import('../../../src/utils/poll.js')
|
|
892
|
+
expect(poll).not.toHaveBeenCalled()
|
|
893
|
+
expect(capture.captured.stdout.join('')).toContain('dep-abc')
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it('calls poll() when --watch flag is provided', async () => {
|
|
897
|
+
await program.parseAsync(buildArgv('deploy', 'staging', '--watch'))
|
|
898
|
+
const { poll } = await import('../../../src/utils/poll.js')
|
|
899
|
+
expect(poll).toHaveBeenCalledOnce()
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('outputs JSON with --json --watch', async () => {
|
|
903
|
+
await program.parseAsync(buildArgv('deploy', 'staging', '--watch', '--json'))
|
|
904
|
+
const raw = capture.captured.stdout.join('')
|
|
905
|
+
const parsed = JSON.parse(raw) as { success: boolean }
|
|
906
|
+
expect(parsed.success).toBe(true)
|
|
907
|
+
})
|
|
908
|
+
})
|
|
909
|
+
```
|