@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.
Files changed (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. 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
+ ```