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