grada-run 0.0.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -6
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,779 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { runDiagnose, runLambdaDiagnose, pickMostRecentTask, extractTaskIdFromArn, formatAge, parseTaskDefinitionRef } from '../src/commands/diagnose.js';
7
+ import { trackEvent } from '../src/core/telemetry.js';
8
+ import { outro } from '@clack/prompts';
9
+
10
+ // Mock the AWS SDK clients (no real credentials needed)
11
+ const {
12
+ mockEcsSend,
13
+ mockLogsSend,
14
+ MockECSClient,
15
+ MockListTasksCommand,
16
+ MockDescribeTasksCommand,
17
+ MockLogsClient,
18
+ MockFilterLogEventsCommand
19
+ } = vi.hoisted(() => {
20
+ const ecsSend = vi.fn();
21
+ const logsSend = vi.fn();
22
+ return {
23
+ mockEcsSend: ecsSend,
24
+ mockLogsSend: logsSend,
25
+ MockECSClient: vi.fn(function () {
26
+ this.send = ecsSend;
27
+ }),
28
+ MockListTasksCommand: vi.fn(function (input) {
29
+ Object.assign(this, input);
30
+ }),
31
+ MockDescribeTasksCommand: vi.fn(function (input) {
32
+ Object.assign(this, input);
33
+ }),
34
+ MockLogsClient: vi.fn(function () {
35
+ this.send = logsSend;
36
+ }),
37
+ MockFilterLogEventsCommand: vi.fn(function (input) {
38
+ Object.assign(this, input);
39
+ })
40
+ };
41
+ });
42
+
43
+ vi.mock('@aws-sdk/client-ecs', () => {
44
+ return {
45
+ ECSClient: MockECSClient,
46
+ DescribeTasksCommand: MockDescribeTasksCommand,
47
+ ListTasksCommand: MockListTasksCommand
48
+ };
49
+ });
50
+
51
+ vi.mock('@aws-sdk/client-cloudwatch-logs', () => {
52
+ return {
53
+ CloudWatchLogsClient: MockLogsClient,
54
+ FilterLogEventsCommand: MockFilterLogEventsCommand
55
+ };
56
+ });
57
+
58
+ // Silence interactive prompts and telemetry during tests
59
+ vi.mock('@clack/prompts', () => ({
60
+ intro: vi.fn(),
61
+ outro: vi.fn(),
62
+ spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })
63
+ }));
64
+
65
+ vi.mock('../src/core/telemetry.js', () => {
66
+ const trackEvent = vi.fn();
67
+ const flushTelemetry = vi.fn().mockResolvedValue();
68
+ // Mirrors the real trackSuccess delegation so success-path assertions
69
+ // keep observing trackEvent (the real helper is unit-tested separately).
70
+ const trackSuccess = vi.fn(async (event, properties) => {
71
+ trackEvent(event, { ...properties, success: true });
72
+ await flushTelemetry();
73
+ });
74
+ const trackFailure = vi.fn(async (event, properties) => {
75
+ trackEvent(event, { ...properties, success: false });
76
+ await flushTelemetry();
77
+ });
78
+ return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
79
+ });
80
+
81
+ describe('Command: diagnose', () => {
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ mockEcsSend.mockReset();
85
+ mockLogsSend.mockReset();
86
+ // Default: no running tasks (keeps pre-existing tests on the unresolved path).
87
+ mockEcsSend.mockResolvedValue({ taskArns: [] });
88
+ });
89
+
90
+ it('should extract and format the stoppedReason from a failed ECS task', async () => {
91
+ const stoppedReason = 'OutOfMemoryError: Container killed due to memory usage';
92
+
93
+ // 1. ListTasks -> one stopped task; DescribeTasks -> failed task detail
94
+ mockEcsSend
95
+ .mockResolvedValueOnce({
96
+ taskArns: ['arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123']
97
+ })
98
+ .mockResolvedValueOnce({
99
+ tasks: [
100
+ {
101
+ taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123',
102
+ stoppedReason,
103
+ stoppedAt: new Date().toISOString(),
104
+ containers: [
105
+ { name: 'app', exitCode: 137, reason: 'Essential container in task exited' }
106
+ ]
107
+ }
108
+ ]
109
+ });
110
+
111
+ // 2. CloudWatch -> 55 events, only the last 50 should be used
112
+ const events = Array.from({ length: 55 }, (_, i) => ({
113
+ message: i === 54 ? `FATAL ${stoppedReason}` : `log line ${i + 1}`
114
+ }));
115
+ mockLogsSend.mockResolvedValueOnce({ events });
116
+
117
+ // 3. Capture formatted console output
118
+ const output = [];
119
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
120
+ output.push(args.join(' '));
121
+ });
122
+
123
+ try {
124
+ const result = await runDiagnose({
125
+ cluster: 'test-cluster',
126
+ region: 'us-east-2',
127
+ logGroup: '/ecs/test'
128
+ });
129
+
130
+ // Return value carries the extracted reason
131
+ expect(result.stoppedReason).toBe(stoppedReason);
132
+ // Last-50-lines limit honored
133
+ expect(result.logs).toHaveLength(50);
134
+ // Formatted output highlights the exact error
135
+ expect(output.join('\n')).toContain(stoppedReason);
136
+ // Log fetch asked CloudWatch for the last 50 lines
137
+ expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
138
+ expect.objectContaining({ limit: 50 })
139
+ );
140
+ } finally {
141
+ consoleSpy.mockRestore();
142
+ }
143
+ });
144
+
145
+ it('should report healthy when there are no stopped tasks', async () => {
146
+ mockEcsSend.mockResolvedValueOnce({ taskArns: [] });
147
+
148
+ const output = [];
149
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
150
+ output.push(args.join(' '));
151
+ });
152
+
153
+ try {
154
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2' });
155
+ expect(result.healthy).toBe(true);
156
+ expect(mockLogsSend).not.toHaveBeenCalled();
157
+ expect(output.join('\n')).toMatch(/healthy|No stopped tasks/i);
158
+ expect(trackEvent).toHaveBeenCalledWith(
159
+ 'diagnose_run',
160
+ expect.objectContaining({ success: true, healthy: true, log_source: 'none' })
161
+ );
162
+ } finally {
163
+ consoleSpy.mockRestore();
164
+ }
165
+ });
166
+
167
+ describe('pickMostRecentTask', () => {
168
+ const task = (overrides) => ({
169
+ taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/aaa',
170
+ containers: [{ name: 'app', exitCode: 1 }],
171
+ ...overrides,
172
+ });
173
+
174
+ it('selects the latest stoppedAt across unordered tasks', () => {
175
+ const oldest = task({ taskArn: 'arn/old', stoppedAt: '2026-09-26T12:00:00.000Z' });
176
+ const newest = task({ taskArn: 'arn/new', stoppedAt: '2026-09-26T12:19:47.000Z' });
177
+ const middle = task({ taskArn: 'arn/mid', stoppedAt: '2026-09-26T12:10:00.000Z' });
178
+ expect(pickMostRecentTask([oldest, newest, middle]).taskArn).toBe('arn/new');
179
+ });
180
+
181
+ it('falls back to startedAt then createdAt when stoppedAt is missing', () => {
182
+ const noStop = task({ taskArn: 'arn/started', startedAt: '2026-09-26T12:15:00.000Z' });
183
+ const createdOnly = task({ taskArn: 'arn/created', createdAt: '2026-09-26T12:18:00.000Z' });
184
+ const ancient = task({ taskArn: 'arn/old', stoppedAt: '2026-09-26T11:00:00.000Z' });
185
+ // createdAt 12:18 beats startedAt 12:15; both beat stoppedAt 11:00 here
186
+ // only to prove fallbacks participate — stoppedAt still wins when newest.
187
+ expect(pickMostRecentTask([ancient, noStop, createdOnly]).taskArn).toBe('arn/created');
188
+ const freshStop = task({ taskArn: 'arn/fresh', stoppedAt: '2026-09-26T12:20:00.000Z' });
189
+ expect(pickMostRecentTask([createdOnly, freshStop]).taskArn).toBe('arn/fresh');
190
+ });
191
+
192
+ it('returns null for empty input', () => {
193
+ expect(pickMostRecentTask([])).toBeNull();
194
+ expect(pickMostRecentTask(null)).toBeNull();
195
+ });
196
+
197
+ it('extractTaskIdFromArn returns the trailing task id', () => {
198
+ expect(extractTaskIdFromArn('arn:aws:ecs:us-east-2:123456789012:task/test-cluster/4ae4e44b8e484ac481d44fc408e3b9e7'))
199
+ .toBe('4ae4e44b8e484ac481d44fc408e3b9e7');
200
+ expect(extractTaskIdFromArn('no-slash')).toBe('');
201
+ expect(extractTaskIdFromArn(undefined)).toBe('');
202
+ });
203
+ });
204
+
205
+ it('selects the most recently stopped task even when listed out of order', async () => {
206
+ const arns = ['arn-old', 'arn-new', 'arn-mid'];
207
+ mockEcsSend
208
+ .mockResolvedValueOnce({ taskArns: arns })
209
+ .mockResolvedValueOnce({
210
+ tasks: [
211
+ { taskArn: 'arn-old', stoppedReason: 'old', stoppedAt: '2026-09-26T12:00:00.000Z', containers: [{ name: 'app', exitCode: 1 }] },
212
+ { taskArn: 'arn-new', stoppedReason: 'fresh crash', stoppedAt: '2026-09-26T12:19:47.000Z', containers: [{ name: 'app', exitCode: 1 }] },
213
+ { taskArn: 'arn-mid', stoppedReason: 'mid', stoppedAt: '2026-09-26T12:10:00.000Z', containers: [{ name: 'app', exitCode: 1 }] },
214
+ ]
215
+ });
216
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
217
+
218
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
219
+ expect(result.taskArn).toBe('arn-new');
220
+ expect(result.stoppedReason).toBe('fresh crash');
221
+ });
222
+
223
+ it('describes up to 100 ARNs in one batch', async () => {
224
+ const arns = Array.from({ length: 101 }, (_, i) => `arn-${i}`);
225
+ mockEcsSend
226
+ .mockResolvedValueOnce({ taskArns: arns })
227
+ .mockResolvedValueOnce({ tasks: [] });
228
+ mockLogsSend.mockResolvedValueOnce({ events: [] });
229
+
230
+ await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
231
+ expect(MockDescribeTasksCommand).toHaveBeenCalledWith(
232
+ expect.objectContaining({ tasks: expect.arrayContaining(arns.slice(0, 100)) })
233
+ );
234
+ const described = MockDescribeTasksCommand.mock.calls[0][0].tasks;
235
+ expect(described).toHaveLength(100);
236
+ expect(described).not.toContain('arn-100');
237
+ });
238
+
239
+ it('scopes the first log query to the crashed task stream', async () => {
240
+ mockEcsSend
241
+ .mockResolvedValueOnce({ taskArns: ['arn-task'] })
242
+ .mockResolvedValueOnce({
243
+ tasks: [{
244
+ taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/4ae4e44b8e484ac481d44fc408e3b9e7',
245
+ stoppedReason: 'crash',
246
+ stoppedAt: '2026-09-26T12:19:47.000Z',
247
+ containers: [{ name: 'myapp-container', exitCode: 1 }],
248
+ }]
249
+ });
250
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'FATAL: boom' }] });
251
+
252
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
253
+ expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
254
+ expect.objectContaining({
255
+ logGroupName: '/ecs/test',
256
+ logStreamNamePrefix: 'ecs/myapp-container/4ae4e44b8e484ac481d44fc408e3b9e7',
257
+ limit: 50,
258
+ })
259
+ );
260
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
261
+ expect(result.logs).toEqual(['FATAL: boom']);
262
+ expect(trackEvent).toHaveBeenCalledWith(
263
+ 'diagnose_run',
264
+ expect.objectContaining({ success: true, healthy: false, log_source: 'task-stream' })
265
+ );
266
+ });
267
+
268
+ it('falls back to a group-wide 1h query when the task stream is empty', async () => {
269
+ mockEcsSend
270
+ .mockResolvedValueOnce({ taskArns: ['arn-task'] })
271
+ .mockResolvedValueOnce({
272
+ tasks: [{
273
+ taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
274
+ stoppedReason: 'crash',
275
+ stoppedAt: '2026-09-26T12:19:47.000Z',
276
+ containers: [{ name: 'app', exitCode: 1 }],
277
+ }]
278
+ });
279
+ mockLogsSend
280
+ .mockResolvedValueOnce({ events: [] })
281
+ .mockResolvedValueOnce({ events: [{ message: 'group tail line' }] });
282
+
283
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
284
+ expect(mockLogsSend).toHaveBeenCalledTimes(2);
285
+ expect(MockFilterLogEventsCommand).toHaveBeenNthCalledWith(2,
286
+ expect.objectContaining({
287
+ logGroupName: '/ecs/test',
288
+ startTime: expect.any(Number),
289
+ limit: 50,
290
+ })
291
+ );
292
+ const secondInput = MockFilterLogEventsCommand.mock.calls[1][0];
293
+ expect(secondInput.logStreamNamePrefix).toBeUndefined();
294
+ expect(secondInput.startTime).toBeGreaterThan(Date.now() - 61 * 60 * 1000);
295
+ expect(result.logs).toEqual(['group tail line']);
296
+ expect(trackEvent).toHaveBeenCalledWith(
297
+ 'diagnose_run',
298
+ expect.objectContaining({ success: true, healthy: false, log_source: 'group-fallback' })
299
+ );
300
+ });
301
+
302
+ it("reports log_source 'none' when both queries return empty", async () => {
303
+ mockEcsSend
304
+ .mockResolvedValueOnce({ taskArns: ['arn-task'] })
305
+ .mockResolvedValueOnce({
306
+ tasks: [{
307
+ taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
308
+ stoppedReason: 'crash',
309
+ stoppedAt: '2026-09-26T12:19:47.000Z',
310
+ containers: [{ name: 'app', exitCode: 1 }],
311
+ }]
312
+ });
313
+ mockLogsSend
314
+ .mockResolvedValueOnce({ events: [] })
315
+ .mockResolvedValueOnce({ events: [] });
316
+
317
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
318
+ expect(result.logs).toEqual([]);
319
+ expect(trackEvent).toHaveBeenCalledWith(
320
+ 'diagnose_run',
321
+ expect.objectContaining({ success: true, healthy: false, log_source: 'none' })
322
+ );
323
+ });
324
+
325
+ describe('recovered service detection', () => {
326
+ const stoppedTask = {
327
+ taskArn: 'arn-stopped',
328
+ stoppedReason: 'old crash',
329
+ stoppedAt: '2026-09-26T12:10:00.000Z',
330
+ containers: [{ name: 'app', exitCode: 1 }],
331
+ };
332
+
333
+ const mockStoppedOnly = () => {
334
+ mockEcsSend
335
+ .mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
336
+ .mockResolvedValueOnce({ tasks: [stoppedTask] });
337
+ };
338
+
339
+ const runWithOutput = async () => {
340
+ const output = [];
341
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
342
+ output.push(args.join(' '));
343
+ });
344
+ try {
345
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
346
+ return { result, text: stripVTControlCharacters(output.join('\n')) };
347
+ } finally {
348
+ consoleSpy.mockRestore();
349
+ }
350
+ };
351
+
352
+ it('reports recovery without fetching logs when a running task is newer', async () => {
353
+ mockStoppedOnly();
354
+ mockEcsSend
355
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
356
+ .mockResolvedValueOnce({
357
+ tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:15:00.000Z', containers: [{ name: 'app' }] }],
358
+ });
359
+
360
+ const { result, text } = await runWithOutput();
361
+ expect(result).toMatchObject({
362
+ healthy: true,
363
+ recovered: true,
364
+ stoppedReason: 'old crash',
365
+ taskArn: 'arn-stopped',
366
+ containerName: 'app',
367
+ logs: [],
368
+ });
369
+ expect(mockLogsSend).not.toHaveBeenCalled();
370
+ expect(text).toContain('Service recovered');
371
+ expect(text).toContain('Previous crash');
372
+ expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('recovered after that crash'));
373
+ expect(vi.mocked(outro)).not.toHaveBeenCalledWith(expect.stringContaining('Fix the error above'));
374
+ expect(trackEvent).toHaveBeenCalledWith(
375
+ 'diagnose_run',
376
+ expect.objectContaining({ success: true, healthy: true, recovered: true, log_source: 'none' })
377
+ );
378
+ });
379
+
380
+ it('stays unresolved when the running task started at the same instant (tie)', async () => {
381
+ mockStoppedOnly();
382
+ mockEcsSend
383
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
384
+ .mockResolvedValueOnce({
385
+ tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:10:00.000Z', containers: [{ name: 'app' }] }],
386
+ });
387
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
388
+
389
+ const { result } = await runWithOutput();
390
+ expect(result.healthy).toBe(false);
391
+ expect(result.recovered).toBeUndefined();
392
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
393
+ });
394
+
395
+ it('stays unresolved when running tasks predate the stop (failed rollout)', async () => {
396
+ mockStoppedOnly();
397
+ mockEcsSend
398
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
399
+ .mockResolvedValueOnce({
400
+ tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:00:00.000Z', containers: [{ name: 'app' }] }],
401
+ });
402
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
403
+
404
+ const { result, text } = await runWithOutput();
405
+ expect(result.healthy).toBe(false);
406
+ expect(text).toContain('Stopped reason: old crash');
407
+ expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('Fix the error above'));
408
+ });
409
+
410
+ it('stays unresolved when running ARNs return no task details', async () => {
411
+ mockStoppedOnly();
412
+ mockEcsSend
413
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
414
+ .mockResolvedValueOnce({ tasks: [] });
415
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
416
+
417
+ const { result } = await runWithOutput();
418
+ expect(result.healthy).toBe(false);
419
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ it('diagnoses the crash when the desired-RUNNING task is still PENDING (crash loop)', async () => {
423
+ // Live case: ListTasks({ desiredStatus: 'RUNNING' }) returns a
424
+ // 2-second-old PENDING task (CannotPullContainerError loop) next
425
+ // to the STOPPED crash. Fresh createdAt must not read as recovery.
426
+ mockStoppedOnly();
427
+ mockEcsSend
428
+ .mockResolvedValueOnce({ taskArns: ['arn-pending'] })
429
+ .mockResolvedValueOnce({
430
+ tasks: [{
431
+ taskArn: 'arn-pending',
432
+ desiredStatus: 'RUNNING',
433
+ lastStatus: 'PENDING',
434
+ createdAt: '2026-09-26T12:15:00.000Z',
435
+ containers: [{ name: 'app' }],
436
+ }],
437
+ });
438
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
439
+
440
+ const { result, text } = await runWithOutput();
441
+ expect(result.healthy).toBe(false);
442
+ expect(result.recovered).toBeUndefined();
443
+ expect(text).toContain('Stopped reason: old crash');
444
+ expect(text).not.toContain('Service recovered');
445
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
446
+ });
447
+
448
+ it('diagnoses the crash when the only RUNNING task is UNHEALTHY', async () => {
449
+ mockStoppedOnly();
450
+ mockEcsSend
451
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
452
+ .mockResolvedValueOnce({
453
+ tasks: [{
454
+ taskArn: 'arn-running',
455
+ lastStatus: 'RUNNING',
456
+ healthStatus: 'UNHEALTHY',
457
+ startedAt: '2026-09-26T12:15:00.000Z',
458
+ containers: [{ name: 'app' }],
459
+ }],
460
+ });
461
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
462
+
463
+ const { result, text } = await runWithOutput();
464
+ expect(result.healthy).toBe(false);
465
+ expect(result.recovered).toBeUndefined();
466
+ expect(text).not.toContain('Service recovered');
467
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
468
+ });
469
+ });
470
+
471
+ describe('parseTaskDefinitionRef', () => {
472
+ it('parses family and revision from ECS task definition ARNs', () => {
473
+ expect(parseTaskDefinitionRef('arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:9'))
474
+ .toEqual({ family: 'myapp-task', revision: 9 });
475
+ });
476
+
477
+ it('returns null for missing or malformed ARNs', () => {
478
+ expect(parseTaskDefinitionRef(undefined)).toBeNull();
479
+ expect(parseTaskDefinitionRef('arn-stopped')).toBeNull();
480
+ expect(parseTaskDefinitionRef('arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task')).toBeNull();
481
+ });
482
+ });
483
+
484
+ describe('revision-based recovery', () => {
485
+ const taskDef = (rev) => `arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:${rev}`;
486
+
487
+ const mockStoppedAtRev = (rev, stoppedAt) => {
488
+ mockEcsSend
489
+ .mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
490
+ .mockResolvedValueOnce({
491
+ tasks: [{
492
+ taskArn: 'arn-stopped',
493
+ taskDefinitionArn: taskDef(rev),
494
+ stoppedReason: 'old crash',
495
+ stoppedAt,
496
+ containers: [{ name: 'app', exitCode: 1 }],
497
+ }],
498
+ });
499
+ };
500
+
501
+ const mockRunningAtRev = (rev, startedAt) => {
502
+ mockEcsSend
503
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
504
+ .mockResolvedValueOnce({
505
+ tasks: [{
506
+ taskArn: 'arn-running',
507
+ taskDefinitionArn: taskDef(rev),
508
+ lastStatus: 'RUNNING',
509
+ startedAt,
510
+ containers: [{ name: 'app' }],
511
+ }],
512
+ });
513
+ };
514
+
515
+ const runWithOutput = async () => {
516
+ const output = [];
517
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
518
+ output.push(args.join(' '));
519
+ });
520
+ try {
521
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
522
+ return { result, text: stripVTControlCharacters(output.join('\n')) };
523
+ } finally {
524
+ consoleSpy.mockRestore();
525
+ }
526
+ };
527
+
528
+ it('recovers when the stop is on a superseded revision even if it stopped later (drain order)', async () => {
529
+ // Live case: :9 started 14:44:19, :8 drained at 14:44:26.
530
+ mockStoppedAtRev(8, '2026-09-26T14:44:26.000Z');
531
+ mockRunningAtRev(9, '2026-09-26T14:44:19.000Z');
532
+
533
+ const { result, text } = await runWithOutput();
534
+ expect(result).toMatchObject({ healthy: true, recovered: true });
535
+ expect(mockLogsSend).not.toHaveBeenCalled();
536
+ expect(text).toContain('Revision 9 is now running');
537
+ });
538
+
539
+ it('recovers a rollback when the older revision restarted after the crash', async () => {
540
+ // :9 crashed, then `rollback 8` launched a fresh :8 afterwards.
541
+ mockStoppedAtRev(9, '2026-09-26T14:40:00.000Z');
542
+ mockRunningAtRev(8, '2026-09-26T14:45:00.000Z');
543
+
544
+ const { result } = await runWithOutput();
545
+ expect(result).toMatchObject({ healthy: true, recovered: true });
546
+ expect(mockLogsSend).not.toHaveBeenCalled();
547
+ });
548
+
549
+ it('stays unresolved when the crash is on a newer revision than the survivor', async () => {
550
+ // Failed rollout: :8 kept serving while :9 crashed.
551
+ mockStoppedAtRev(9, '2026-09-26T14:44:26.000Z');
552
+ mockRunningAtRev(8, '2026-09-26T14:40:00.000Z');
553
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
554
+
555
+ const { result, text } = await runWithOutput();
556
+ expect(result.healthy).toBe(false);
557
+ expect(result.recovered).toBeUndefined();
558
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
559
+ expect(text).toMatch(/\(stopped \S+ ago\)/);
560
+ });
561
+
562
+ it('falls back to timestamps when revisions match or are unparseable', async () => {
563
+ // Same revision: newer running task still recovers via timestamps.
564
+ mockStoppedAtRev(9, '2026-09-26T14:40:00.000Z');
565
+ mockRunningAtRev(9, '2026-09-26T14:45:00.000Z');
566
+
567
+ const first = await runWithOutput();
568
+ expect(first.result).toMatchObject({ healthy: true, recovered: true });
569
+
570
+ // Unparseable ARNs: timestamp rule decides (older running → unresolved).
571
+ vi.clearAllMocks();
572
+ mockEcsSend.mockResolvedValue({ taskArns: [] });
573
+ mockEcsSend
574
+ .mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
575
+ .mockResolvedValueOnce({
576
+ tasks: [{
577
+ taskArn: 'arn-stopped',
578
+ stoppedReason: 'old crash',
579
+ stoppedAt: '2026-09-26T14:44:00.000Z',
580
+ containers: [{ name: 'app', exitCode: 1 }],
581
+ }],
582
+ })
583
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
584
+ .mockResolvedValueOnce({
585
+ tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T14:40:00.000Z', containers: [{ name: 'app' }] }],
586
+ });
587
+ mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
588
+
589
+ const second = await runWithOutput();
590
+ expect(second.result.healthy).toBe(false);
591
+ });
592
+
593
+ it('prefers container exit time over task stop time for the comparison', async () => {
594
+ // Task fully stopped (ENI deprovisioned) after the running task
595
+ // started, but the container itself exited long before.
596
+ mockEcsSend
597
+ .mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
598
+ .mockResolvedValueOnce({
599
+ tasks: [{
600
+ taskArn: 'arn-stopped',
601
+ stoppedReason: 'old crash',
602
+ executionStoppedAt: '2026-09-26T14:30:00.000Z',
603
+ stoppedAt: '2026-09-26T14:50:00.000Z',
604
+ containers: [{ name: 'app', exitCode: 1 }],
605
+ }],
606
+ })
607
+ .mockResolvedValueOnce({ taskArns: ['arn-running'] })
608
+ .mockResolvedValueOnce({
609
+ tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T14:40:00.000Z', containers: [{ name: 'app' }] }],
610
+ });
611
+
612
+ const { result } = await runWithOutput();
613
+ expect(result).toMatchObject({ healthy: true, recovered: true });
614
+ });
615
+ });
616
+
617
+ describe('formatAge', () => {
618
+ it('formats seconds, minutes, hours, and days', () => {
619
+ expect(formatAge(0)).toBe('0s');
620
+ expect(formatAge(-5000)).toBe('0s');
621
+ expect(formatAge(45 * 1000)).toBe('45s');
622
+ expect(formatAge(5 * 60 * 1000)).toBe('5m');
623
+ expect(formatAge(2 * 60 * 60 * 1000)).toBe('2h');
624
+ expect(formatAge(3 * 24 * 60 * 60 * 1000)).toBe('3d');
625
+ });
626
+ });
627
+
628
+ it('reports a missing log group instead of an empty stream', async () => {
629
+ mockEcsSend
630
+ .mockResolvedValueOnce({ taskArns: ['arn-task'] })
631
+ .mockResolvedValueOnce({
632
+ tasks: [{
633
+ taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
634
+ stoppedReason: 'crash',
635
+ stoppedAt: '2026-09-26T12:19:47.000Z',
636
+ containers: [{ name: 'app', exitCode: 1 }],
637
+ }]
638
+ });
639
+ const notFound = new Error('The specified log group does not exist.');
640
+ notFound.name = 'ResourceNotFoundException';
641
+ mockLogsSend.mockRejectedValueOnce(notFound);
642
+
643
+ const output = [];
644
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
645
+ output.push(args.join(' '));
646
+ });
647
+ try {
648
+ const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/missing' });
649
+ expect(mockLogsSend).toHaveBeenCalledTimes(1);
650
+ expect(result.logs).toEqual([]);
651
+ expect(output.join('\n')).toContain('Log group not found');
652
+ expect(trackEvent).toHaveBeenCalledWith(
653
+ 'diagnose_run',
654
+ expect.objectContaining({ success: true, healthy: false, log_source: 'none' })
655
+ );
656
+ } finally {
657
+ consoleSpy.mockRestore();
658
+ }
659
+ });
660
+
661
+ it('should exit gracefully on UnrecognizedClientException instead of throwing', async () => {
662
+ const error = new Error('Invalid token');
663
+ error.name = 'UnrecognizedClientException';
664
+ mockEcsSend.mockRejectedValueOnce(error);
665
+
666
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
667
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
668
+
669
+ try {
670
+ await runDiagnose({ cluster: 'test-cluster', region: 'us-east-1', spawnSyncImpl: () => ({ status: 0 }) });
671
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('AWS Session Expired'));
672
+ expect(exitSpy).toHaveBeenCalledWith(1);
673
+ } finally {
674
+ exitSpy.mockRestore();
675
+ consoleSpy.mockRestore();
676
+ }
677
+ });
678
+
679
+ it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
680
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
681
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
682
+ const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
683
+ try {
684
+ const result = await runDiagnose(null);
685
+ expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
686
+ expect(exitSpy).toHaveBeenCalledWith(1);
687
+ expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({
688
+ success: false,
689
+ error_code: 'PROJECT_NOT_INITIALIZED',
690
+ }));
691
+ } finally {
692
+ cwdSpy.mockRestore();
693
+ exitSpy.mockRestore();
694
+ consoleSpy.mockRestore();
695
+ }
696
+ });
697
+ });
698
+
699
+ describe('Command: diagnose on --target lambda projects', () => {
700
+ let exitSpy;
701
+
702
+ beforeEach(() => {
703
+ vi.clearAllMocks();
704
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
705
+ });
706
+
707
+ afterEach(() => {
708
+ exitSpy.mockRestore();
709
+ });
710
+
711
+ const ACTIVE = JSON.stringify({ Configuration: { State: 'Active', LastUpdateStatus: 'Successful' } });
712
+
713
+ function baseInput(overrides = {}) {
714
+ return {
715
+ projectName: 'myapp',
716
+ region: 'us-east-2',
717
+ logGroup: '/aws/lambda/myapp-fn',
718
+ spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: ACTIVE, stderr: '' })),
719
+ logsClient: { send: vi.fn().mockResolvedValue({ events: [] }) },
720
+ ...overrides,
721
+ };
722
+ }
723
+
724
+ it('reports healthy when the function is active with no error logs', async () => {
725
+ const result = await runLambdaDiagnose(baseInput());
726
+ expect(result.healthy).toBe(true);
727
+ expect(result.functionName).toBe('myapp-fn');
728
+ expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({ healthy: true, success: true }));
729
+ });
730
+
731
+ it('surfaces failed updates and recent error logs', async () => {
732
+ const failed = JSON.stringify({ Configuration: { State: 'Active', LastUpdateStatus: 'Failed' } });
733
+ const logsClient = { send: vi.fn().mockResolvedValue({ events: [{ message: 'ERROR boom' }, { message: 'ok line' }] }) };
734
+ const result = await runLambdaDiagnose(baseInput({
735
+ spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: failed, stderr: '' })),
736
+ logsClient,
737
+ }));
738
+ expect(result.healthy).toBe(false);
739
+ expect(result.logs).toHaveLength(2);
740
+ expect(outro).toHaveBeenCalled();
741
+ });
742
+
743
+ it('flags error logs even when the configuration looks healthy', async () => {
744
+ const logsClient = { send: vi.fn().mockResolvedValue({ events: [{ message: 'FATAL out of memory' }] }) };
745
+ const result = await runLambdaDiagnose(baseInput({ logsClient }));
746
+ expect(result.healthy).toBe(false);
747
+ });
748
+
749
+ it('handles missing functions and missing CLIs without throwing', async () => {
750
+ const missing = await runLambdaDiagnose(baseInput({
751
+ spawnSyncImpl: vi.fn(() => ({ status: 254, stdout: '', stderr: 'ResourceNotFoundException' })),
752
+ }));
753
+ expect(missing).toMatchObject({ healthy: false, reason: 'not-deployed' });
754
+
755
+ await runLambdaDiagnose(baseInput({ spawnSyncImpl: vi.fn(() => ({ error: { code: 'ENOENT' } })) }));
756
+ expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({ error_code: 'AWS_CLI_MISSING' }));
757
+ });
758
+
759
+ it('dispatches lambda projects from runDiagnose', async () => {
760
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diagnose-lambda-test-'));
761
+ try {
762
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
763
+ fs.writeFileSync(
764
+ path.join(dir, 'terraform', 'main.tf'),
765
+ 'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
766
+ );
767
+ const result = await runDiagnose({
768
+ cwd: dir,
769
+ region: 'us-east-2',
770
+ spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: ACTIVE, stderr: '' })),
771
+ logsClient: { send: vi.fn().mockResolvedValue({ events: [] }) },
772
+ });
773
+ expect(result.healthy).toBe(true);
774
+ expect(result.functionName).toBe('myapp-fn');
775
+ } finally {
776
+ fs.rmSync(dir, { recursive: true, force: true });
777
+ }
778
+ });
779
+ });