@grada-run/grada 0.32.0 → 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 (49) hide show
  1. package/.github/workflows/e2e.yml +73 -0
  2. package/apps/docs/src/content/docs/cli/add.md +2 -1
  3. package/apps/docs/src/content/docs/cli/apply.md +5 -2
  4. package/apps/docs/src/content/docs/cli/destroy.md +7 -3
  5. package/apps/docs/src/content/docs/cli/eject.md +6 -2
  6. package/apps/docs/src/content/docs/guides/headless.md +12 -0
  7. package/apps/docs/src/content/docs/roadmap.md +5 -3
  8. package/apps/docs/src/content/docs/testing-strategy.md +6 -1
  9. package/bin/cli.js +7 -7
  10. package/package.json +3 -1
  11. package/specs/e2e-testing.md +50 -0
  12. package/specs/test-suite-deduplication.md +42 -0
  13. package/src/commands/apply.js +4 -4
  14. package/src/commands/destroy.js +3 -3
  15. package/src/commands/eject.js +5 -2
  16. package/src/core/parser.js +2 -0
  17. package/src/utils/command.js +13 -0
  18. package/tests/add.test.js +12 -31
  19. package/tests/ai.test.js +9 -12
  20. package/tests/apply.test.js +6 -48
  21. package/tests/capabilities.test.js +5 -8
  22. package/tests/command.test.js +9 -10
  23. package/tests/db.test.js +6 -37
  24. package/tests/destroy.test.js +6 -32
  25. package/tests/diagnose.test.js +7 -21
  26. package/tests/doctor.test.js +6 -13
  27. package/tests/domain.test.js +12 -29
  28. package/tests/drift.test.js +12 -27
  29. package/tests/e2e/helpers.js +122 -0
  30. package/tests/e2e/tier0.e2e.test.js +142 -0
  31. package/tests/e2e/tier1.live.e2e.test.js +118 -0
  32. package/tests/exec.test.js +6 -20
  33. package/tests/gc.test.js +6 -36
  34. package/tests/headless.test.js +37 -108
  35. package/tests/helpers/clack.js +103 -0
  36. package/tests/helpers/console.js +41 -0
  37. package/tests/helpers/telemetry.js +37 -0
  38. package/tests/helpers/tmpdir.js +36 -0
  39. package/tests/logs.test.js +4 -15
  40. package/tests/resolvers.test.js +5 -8
  41. package/tests/rollback.test.js +6 -28
  42. package/tests/secrets.test.js +6 -29
  43. package/tests/sleep-wake.test.js +11 -29
  44. package/tests/status.test.js +6 -20
  45. package/tests/visualizer.test.js +12 -28
  46. package/vitest.config.js +2 -1
  47. package/vitest.e2e.tier0.config.js +8 -0
  48. package/vitest.e2e.tier1.config.js +8 -0
  49. package/specs/deploy-stack-to-grada-run-rebrand.md +0 -45
@@ -0,0 +1,142 @@
1
+ // Tier 0 E2E: fast, PR-friendly, no AWS credentials. Mock-AWS scaffold,
2
+ // local checks, and failure-path contract pinning against the real CLI.
3
+ import { describe, it, expect, beforeAll } from 'vitest';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ e2eEnv,
8
+ assertPrerequisites,
9
+ runCli,
10
+ runTerraform,
11
+ withTmpDir,
12
+ } from './helpers.js';
13
+
14
+ const env = e2eEnv({ mockAws: true });
15
+
16
+ beforeAll(() => {
17
+ assertPrerequisites();
18
+ });
19
+
20
+ describe('Tier 0: scaffold (ECS)', () => {
21
+ it('inits, adds an addon, and generates valid Terraform', async () => {
22
+ await withTmpDir('tier0-ecs', async (dir) => {
23
+ const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
24
+ expect(init.status).toBe(0);
25
+
26
+ const add = runCli(['add', 'queue:sqs', '--headless'], { cwd: dir, env });
27
+ expect(add.status).toBe(0);
28
+
29
+ expect(fs.existsSync(path.join(dir, 'README.md'))).toBe(true);
30
+ expect(fs.existsSync(path.join(dir, 'terraform', 'main.tf'))).toBe(true);
31
+ expect(fs.existsSync(path.join(dir, 'terraform', 'sqs.tf'))).toBe(true);
32
+ const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
33
+ expect(mainTf).toContain('SQS_QUEUE_URL');
34
+
35
+ runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
36
+ runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
37
+ });
38
+ });
39
+ });
40
+
41
+ describe('Tier 0: scaffold (Lambda)', () => {
42
+ it('inits a Lambda project with valid Terraform', async () => {
43
+ await withTmpDir('tier0-lambda', async (dir) => {
44
+ const init = runCli(['init', '--target', 'lambda', '--headless'], { cwd: dir, env });
45
+ expect(init.status).toBe(0);
46
+
47
+ const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
48
+ expect(mainTf).toContain('resource "aws_lambda_function" "app"');
49
+
50
+ runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
51
+ runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
52
+ });
53
+ });
54
+ });
55
+
56
+ describe('Tier 0: addon matrix', () => {
57
+ // queue:sqs is covered by the ECS scaffold test above (including the
58
+ // SQS_QUEUE_URL env-injection assertion); the rest get one
59
+ // scaffold-and-validate pass each.
60
+ const ADDON_MATRIX = [
61
+ { capability: 'storage:s3', file: 's3.tf', extraArgs: [] },
62
+ { capability: 'db:dynamodb', file: 'dynamodb.tf', extraArgs: [] },
63
+ { capability: 'db:redis', file: 'redis.tf', extraArgs: [] },
64
+ { capability: 'ai:bedrock', file: 'bedrock.tf', extraArgs: [] },
65
+ { capability: 'email:ses', file: 'ses.tf', extraArgs: ['--domain', 'example.com'] },
66
+ { capability: 'cron', file: 'cron.tf', extraArgs: [] },
67
+ ];
68
+
69
+ for (const { capability, file, extraArgs } of ADDON_MATRIX) {
70
+ it(`scaffolds valid Terraform for ${capability}`, async () => {
71
+ await withTmpDir(`tier0-add-${capability.replace(':', '-')}`, async (dir) => {
72
+ const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
73
+ expect(init.status).toBe(0);
74
+
75
+ const add = runCli(['add', capability, ...extraArgs, '--headless'], { cwd: dir, env });
76
+ expect(add.status).toBe(0);
77
+ expect(fs.existsSync(path.join(dir, 'terraform', file))).toBe(true);
78
+
79
+ runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
80
+ runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
81
+ });
82
+ });
83
+ }
84
+
85
+ it('composes multiple addons via init --with', async () => {
86
+ await withTmpDir('tier0-add-combo', async (dir) => {
87
+ const init = runCli(['init', '--target', 'ecs', '--with', 'queue:sqs,db:redis', '--headless'], { cwd: dir, env });
88
+ expect(init.status).toBe(0);
89
+ expect(fs.existsSync(path.join(dir, 'terraform', 'sqs.tf'))).toBe(true);
90
+ expect(fs.existsSync(path.join(dir, 'terraform', 'redis.tf'))).toBe(true);
91
+
92
+ runTerraform(['init', '-backend=false'], { cwd: path.join(dir, 'terraform'), env });
93
+ runTerraform(['validate'], { cwd: path.join(dir, 'terraform'), env });
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('Tier 0: local checks', () => {
99
+ it('ejects metadata from a scaffolded project', async () => {
100
+ await withTmpDir('tier0-eject', async (dir) => {
101
+ const init = runCli(['init', '--headless'], { cwd: dir, env });
102
+ expect(init.status).toBe(0);
103
+
104
+ const mainTfPath = path.join(dir, 'terraform', 'main.tf');
105
+ expect(fs.readFileSync(mainTfPath, 'utf-8')).toContain('ManagedBy');
106
+
107
+ const eject = runCli(['eject', '--yes'], { cwd: dir, env });
108
+ expect(eject.status).toBe(0);
109
+ expect(fs.readFileSync(mainTfPath, 'utf-8')).not.toContain('ManagedBy');
110
+ });
111
+ });
112
+
113
+ it('doctor exits 0', async () => {
114
+ await withTmpDir('tier0-doctor', async (dir) => {
115
+ const doctor = runCli(['doctor'], { cwd: dir, env });
116
+ expect(doctor.status).toBe(0);
117
+ });
118
+ });
119
+ });
120
+
121
+ describe('Tier 0: failure paths', () => {
122
+ it('apply outside a project exits 1 cleanly', async () => {
123
+ await withTmpDir('tier0-fail-apply', async (dir) => {
124
+ const result = runCli(['apply', '--headless'], { cwd: dir, env, capture: true });
125
+ expect(result.status).toBe(1);
126
+ expect(result.output).toContain('No terraform directory');
127
+ expect(result.output).not.toMatch(/^\s+at\s/m);
128
+ });
129
+ });
130
+
131
+ it('init with an invalid target exits 1 cleanly', async () => {
132
+ await withTmpDir('tier0-fail-target', async (dir) => {
133
+ // NOTE: --headless is required to reach flag validation: without
134
+ // it, init prompts for the target directory first and exits 0 on
135
+ // EOF (the spec shows this command without --headless).
136
+ const result = runCli(['init', '--target', 'fake-target', '--headless'], { cwd: dir, env, capture: true });
137
+ expect(result.status).toBe(1);
138
+ expect(result.output).toContain('Invalid compute target');
139
+ expect(result.output).not.toMatch(/^\s+at\s/m);
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,118 @@
1
+ // Tier 1 E2E: full live lifecycle against real AWS (nightly/manual only).
2
+ // Skips gracefully without credentials. Validated in CI, not locally.
3
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4
+ import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ e2eEnv,
9
+ assertPrerequisites,
10
+ makeTmpDir,
11
+ removeDir,
12
+ runCli,
13
+ sleep,
14
+ readBackendBucket,
15
+ readBackendRegion,
16
+ } from './helpers.js';
17
+
18
+ const env = e2eEnv({ mockAws: false });
19
+ const STATUS_POLL_INTERVAL_MS = 15000;
20
+ const STATUS_POLL_BUDGET_MS = 6 * 60 * 1000;
21
+ const BUCKET_POLL_INTERVAL_MS = 10000;
22
+ const BUCKET_POLL_BUDGET_MS = 3 * 60 * 1000;
23
+
24
+ // Serial execution is mandatory (ordered lifecycle); the whole suite is
25
+ // skipped without credentials. (Vitest 5 cannot chain .serial.skipIf.)
26
+ const describeLive = process.env.AWS_ACCESS_KEY_ID ? describe.serial : describe.skip;
27
+
28
+ describeLive('Tier 1: live lifecycle', () => {
29
+ let dir;
30
+ let suiteFailed = false;
31
+
32
+ beforeAll(() => {
33
+ assertPrerequisites();
34
+ dir = makeTmpDir('tier1-live');
35
+ });
36
+
37
+ afterAll(() => {
38
+ if (suiteFailed) {
39
+ console.log(`Tier 1 workspace preserved for debugging: ${dir}`);
40
+ return;
41
+ }
42
+ removeDir(dir);
43
+ });
44
+
45
+ async function step(fn) {
46
+ try {
47
+ await fn();
48
+ } catch (error) {
49
+ suiteFailed = true;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ it('init scaffolds the project', async () => {
55
+ await step(async () => {
56
+ const init = runCli(['init', '--target', 'ecs', '--headless'], { cwd: dir, env });
57
+ expect(init.status).toBe(0);
58
+ expect(fs.existsSync(path.join(dir, 'terraform', 'main.tf'))).toBe(true);
59
+ });
60
+ });
61
+
62
+ it('apply provisions infrastructure', async () => {
63
+ await step(async () => {
64
+ const apply = runCli(['apply', '--auto-approve'], { cwd: dir, env });
65
+ expect(apply.status).toBe(0);
66
+ });
67
+ });
68
+
69
+ it('status becomes healthy', async () => {
70
+ await step(async () => {
71
+ const deadline = Date.now() + STATUS_POLL_BUDGET_MS;
72
+ let healthy = false;
73
+ let lastOutput = '';
74
+ while (Date.now() < deadline) {
75
+ // --json exits 0 even when degraded, so the payload (not the
76
+ // exit code) drives the loop; parse failures just mean "wait".
77
+ const result = runCli(['status', '--json'], { cwd: dir, env, capture: true });
78
+ lastOutput = result.output;
79
+ try {
80
+ healthy = JSON.parse(result.stdout).healthy === true;
81
+ } catch {
82
+ healthy = false;
83
+ }
84
+ if (healthy) break;
85
+ await sleep(STATUS_POLL_INTERVAL_MS);
86
+ }
87
+ expect([healthy, lastOutput]).toEqual([true, expect.any(String)]);
88
+
89
+ const final = runCli(['status'], { cwd: dir, env });
90
+ expect(final.status).toBe(0);
91
+ });
92
+ });
93
+
94
+ it('destroy tears everything down', async () => {
95
+ await step(async () => {
96
+ const bucket = readBackendBucket(dir);
97
+ const region = readBackendRegion(dir);
98
+
99
+ const destroy = runCli(['destroy', '--yes'], { cwd: dir, env });
100
+ expect(destroy.status).toBe(0);
101
+
102
+ const s3 = new S3Client({ region });
103
+ const deadline = Date.now() + BUCKET_POLL_BUDGET_MS;
104
+ for (;;) {
105
+ try {
106
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
107
+ } catch (error) {
108
+ if (error?.name === 'NotFound' || error?.$metadata?.httpStatusCode === 404) return;
109
+ throw error;
110
+ }
111
+ if (Date.now() >= deadline) {
112
+ throw new Error(`state bucket ${bucket} still exists after destroy`);
113
+ }
114
+ await sleep(BUCKET_POLL_INTERVAL_MS);
115
+ }
116
+ });
117
+ });
118
+ });
@@ -1,4 +1,8 @@
1
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';
2
6
  import fs from 'fs';
3
7
  import os from 'os';
4
8
  import path from 'path';
@@ -19,27 +23,9 @@ import {
19
23
  import { hasAwsCli } from '../src/utils/aws.js';
20
24
  import { trackEvent } from '../src/core/telemetry.js';
21
25
 
22
- vi.mock('@clack/prompts', () => ({
23
- intro: vi.fn(),
24
- outro: vi.fn(),
25
- spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
26
- }));
26
+ vi.mock('@clack/prompts', () => clackPromptsMockFactory());
27
27
 
28
- vi.mock('../src/core/telemetry.js', () => {
29
- const trackEvent = vi.fn();
30
- const flushTelemetry = vi.fn(() => Promise.resolve());
31
- // Mirrors the real trackSuccess delegation so success-path assertions
32
- // keep observing trackEvent (the real helper is unit-tested separately).
33
- const trackSuccess = vi.fn(async (event, properties) => {
34
- trackEvent(event, { ...properties, success: true });
35
- await flushTelemetry();
36
- });
37
- const trackFailure = vi.fn(async (event, properties) => {
38
- trackEvent(event, { ...properties, success: false });
39
- await flushTelemetry();
40
- });
41
- return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
42
- });
28
+ vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
43
29
 
44
30
  const {
45
31
  MockListTasksCommand,
package/tests/gc.test.js CHANGED
@@ -1,4 +1,8 @@
1
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, clackMocks as clack } from './helpers/clack.js';
5
+ import { telemetryMockFactory } from './helpers/telemetry.js';
2
6
  import {
3
7
  runGc,
4
8
  parseGcArgs,
@@ -87,43 +91,9 @@ vi.mock('@aws-sdk/client-ec2', () => ({
87
91
  }));
88
92
 
89
93
  // --- Mock interactive prompts (controllable confirm) ---
90
- const clack = vi.hoisted(() => ({
91
- mockIntro: vi.fn(),
92
- mockOutro: vi.fn(),
93
- mockConfirm: vi.fn(),
94
- mockCancel: vi.fn(),
95
- mockSpinnerStart: vi.fn(),
96
- mockSpinnerStop: vi.fn(),
97
- mockSpinnerMessage: vi.fn(),
98
- }));
99
-
100
- vi.mock('@clack/prompts', () => ({
101
- intro: clack.mockIntro,
102
- outro: clack.mockOutro,
103
- confirm: clack.mockConfirm,
104
- cancel: clack.mockCancel,
105
- spinner: vi.fn(() => ({
106
- start: clack.mockSpinnerStart,
107
- stop: clack.mockSpinnerStop,
108
- message: clack.mockSpinnerMessage,
109
- })),
110
- }));
94
+ vi.mock('@clack/prompts', () => clackPromptsMockFactory());
111
95
 
112
- vi.mock('../src/core/telemetry.js', () => {
113
- const trackEvent = vi.fn();
114
- const flushTelemetry = vi.fn(() => Promise.resolve());
115
- // Mirrors the real trackSuccess delegation so success-path assertions
116
- // keep observing trackEvent (the real helper is unit-tested separately).
117
- const trackSuccess = vi.fn(async (event, properties) => {
118
- trackEvent(event, { ...properties, success: true });
119
- await flushTelemetry();
120
- });
121
- const trackFailure = vi.fn(async (event, properties) => {
122
- trackEvent(event, { ...properties, success: false });
123
- await flushTelemetry();
124
- });
125
- return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
126
- });
96
+ vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
127
97
 
128
98
  const PROJECT = 'myapp';
129
99
 
@@ -1,4 +1,9 @@
1
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, clackMocks as clack } from './helpers/clack.js';
5
+ import { telemetryMockFactory } from './helpers/telemetry.js';
6
+ import { mockConsoleTrio } from './helpers/console.js';
2
7
  import os from 'os';
3
8
  import fs from 'fs/promises';
4
9
  import path from 'path';
@@ -13,50 +18,7 @@ import { mainStack } from '../src/commands/init.js';
13
18
  // `commander` prompt calls exist under src/ or bin/), so mocking every
14
19
  // `@clack/prompts` export is the correct automation guard. `inquirer` is
15
20
  // not a dependency, so there is nothing to mock there.
16
- const clack = vi.hoisted(() => ({
17
- mockText: vi.fn(),
18
- mockSelect: vi.fn(),
19
- mockMultiselect: vi.fn(),
20
- mockConfirm: vi.fn(),
21
- mockGroup: vi.fn(),
22
- mockCancel: vi.fn(),
23
- mockIsCancel: vi.fn(() => false),
24
- mockIntro: vi.fn(),
25
- mockOutro: vi.fn(),
26
- mockNote: vi.fn(),
27
- mockLogSuccess: vi.fn(),
28
- mockLogWarn: vi.fn(),
29
- mockLogError: vi.fn(),
30
- mockLogInfo: vi.fn(),
31
- mockSpinnerStart: vi.fn(),
32
- mockSpinnerStop: vi.fn(),
33
- mockSpinnerMessage: vi.fn(),
34
- }));
35
-
36
- vi.mock('@clack/prompts', () => ({
37
- text: clack.mockText,
38
- select: clack.mockSelect,
39
- multiselect: clack.mockMultiselect,
40
- confirm: clack.mockConfirm,
41
- group: clack.mockGroup,
42
- cancel: clack.mockCancel,
43
- isCancel: clack.mockIsCancel,
44
- intro: clack.mockIntro,
45
- outro: clack.mockOutro,
46
- note: clack.mockNote,
47
- log: {
48
- success: clack.mockLogSuccess,
49
- warn: clack.mockLogWarn,
50
- error: clack.mockLogError,
51
- info: clack.mockLogInfo,
52
- message: clack.mockLogInfo,
53
- },
54
- spinner: vi.fn(() => ({
55
- start: clack.mockSpinnerStart,
56
- stop: clack.mockSpinnerStop,
57
- message: clack.mockSpinnerMessage,
58
- })),
59
- }));
21
+ vi.mock('@clack/prompts', () => clackPromptsMockFactory());
60
22
 
61
23
  // Never touch the real environment: fake terraform presence and AWS.
62
24
  vi.mock('../src/utils/system.js', () => ({
@@ -77,10 +39,7 @@ vi.mock('../src/utils/aws.js', () => ({
77
39
  }));
78
40
 
79
41
  // Silence telemetry so tests never hit the network.
80
- vi.mock('../src/core/telemetry.js', () => ({
81
- trackEvent: vi.fn(),
82
- flushTelemetry: vi.fn(async () => {}),
83
- }));
42
+ vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
84
43
 
85
44
  function expectNoInteractivePrompts() {
86
45
  expect(clack.mockText).not.toHaveBeenCalled();
@@ -165,9 +124,8 @@ describe('Headless contract (automation-safe)', () => {
165
124
  const parsed = parseCliArgs(process.argv.slice(2));
166
125
  expect(parsed.isHeadless).toBe(true);
167
126
 
168
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
169
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
170
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
127
+ const spies = mockConsoleTrio();
128
+ const { exitSpy, logSpy, errorSpy } = spies;
171
129
 
172
130
  try {
173
131
  await mainStack({
@@ -187,17 +145,14 @@ describe('Headless contract (automation-safe)', () => {
187
145
  const dockerfile = await fs.readFile(path.join(tmpDir, 'Dockerfile'), 'utf-8');
188
146
  expect(dockerfile).toContain('EXPOSE 3000');
189
147
  } finally {
190
- exitSpy.mockRestore();
191
- logSpy.mockRestore();
192
- errorSpy.mockRestore();
148
+ spies.restore();
193
149
  }
194
150
  });
195
151
 
196
152
  it('tolerates null headlessOptions in headless mode', async () => {
197
153
  process.chdir(tmpDir);
198
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
199
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
200
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
154
+ const spies = mockConsoleTrio();
155
+ const { exitSpy, logSpy, errorSpy } = spies;
201
156
 
202
157
  try {
203
158
  await mainStack({ isHeadless: true, headlessOptions: null });
@@ -207,9 +162,7 @@ describe('Headless contract (automation-safe)', () => {
207
162
  const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
208
163
  expect(mainTf).toContain('region = "us-east-2"');
209
164
  } finally {
210
- exitSpy.mockRestore();
211
- logSpy.mockRestore();
212
- errorSpy.mockRestore();
165
+ spies.restore();
213
166
  }
214
167
  });
215
168
 
@@ -241,9 +194,8 @@ describe('Headless contract (automation-safe)', () => {
241
194
  path.join(tmpDir, 'package.json'),
242
195
  JSON.stringify({ dependencies: { ioredis: '^5.0.0', pg: '^8.0.0' } })
243
196
  );
244
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
245
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
246
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
197
+ const spies = mockConsoleTrio();
198
+ const { exitSpy, logSpy, errorSpy } = spies;
247
199
 
248
200
  try {
249
201
  await mainStack({ isHeadless: true, headlessOptions: { dir: '.', framework: 'node' } });
@@ -258,9 +210,7 @@ describe('Headless contract (automation-safe)', () => {
258
210
  expect.objectContaining({ selected_addons: [], detected_addons: ['db:redis'] })
259
211
  );
260
212
  } finally {
261
- exitSpy.mockRestore();
262
- logSpy.mockRestore();
263
- errorSpy.mockRestore();
213
+ spies.restore();
264
214
  }
265
215
  });
266
216
 
@@ -270,9 +220,8 @@ describe('Headless contract (automation-safe)', () => {
270
220
  const parsed = parseCliArgs([
271
221
  '--headless', '--framework=node', '--with=db:redis,queue:sqs,ai:bedrock',
272
222
  ]);
273
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
274
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
275
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
223
+ const spies = mockConsoleTrio();
224
+ const { exitSpy, logSpy, errorSpy } = spies;
276
225
 
277
226
  try {
278
227
  await mainStack({
@@ -296,9 +245,7 @@ describe('Headless contract (automation-safe)', () => {
296
245
  expect.objectContaining({ selected_addons: ['db:redis', 'queue:sqs', 'ai:bedrock'] })
297
246
  );
298
247
  } finally {
299
- exitSpy.mockRestore();
300
- logSpy.mockRestore();
301
- errorSpy.mockRestore();
248
+ spies.restore();
302
249
  }
303
250
  });
304
251
 
@@ -312,9 +259,8 @@ describe('Headless contract (automation-safe)', () => {
312
259
  '--headless', '--framework=node', '--needsDatabase',
313
260
  '--with=email:ses', '--domain=example.com', '--setup-ci-migrate',
314
261
  ]);
315
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
316
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
317
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
262
+ const spies = mockConsoleTrio();
263
+ const { exitSpy, logSpy, errorSpy } = spies;
318
264
 
319
265
  try {
320
266
  await mainStack({
@@ -336,18 +282,15 @@ describe('Headless contract (automation-safe)', () => {
336
282
  expect.objectContaining({ selected_addons: ['email:ses'], migration_gate_enabled: true })
337
283
  );
338
284
  } finally {
339
- exitSpy.mockRestore();
340
- logSpy.mockRestore();
341
- errorSpy.mockRestore();
285
+ spies.restore();
342
286
  }
343
287
  });
344
288
 
345
289
  it('scaffolds drift.yml headless with --setup-ci-drift and skips it by default', async () => {
346
290
  process.chdir(tmpDir);
347
291
  const parsed = parseCliArgs(['--headless', '--framework=node', '--setup-ci-drift']);
348
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
349
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
350
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
292
+ const spies = mockConsoleTrio();
293
+ const { exitSpy, logSpy, errorSpy } = spies;
351
294
 
352
295
  try {
353
296
  await mainStack({
@@ -367,18 +310,15 @@ describe('Headless contract (automation-safe)', () => {
367
310
  expect.objectContaining({ drift_detection_enabled: true })
368
311
  );
369
312
  } finally {
370
- exitSpy.mockRestore();
371
- logSpy.mockRestore();
372
- errorSpy.mockRestore();
313
+ spies.restore();
373
314
  }
374
315
  });
375
316
 
376
317
  it('does not scaffold drift.yml without the flag', async () => {
377
318
  process.chdir(tmpDir);
378
319
  const parsed = parseCliArgs(['--headless', '--framework=node']);
379
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
380
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
381
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
320
+ const spies = mockConsoleTrio();
321
+ const { exitSpy, logSpy, errorSpy } = spies;
382
322
 
383
323
  try {
384
324
  await mainStack({
@@ -394,9 +334,7 @@ describe('Headless contract (automation-safe)', () => {
394
334
  expect.objectContaining({ drift_detection_enabled: false })
395
335
  );
396
336
  } finally {
397
- exitSpy.mockRestore();
398
- logSpy.mockRestore();
399
- errorSpy.mockRestore();
337
+ spies.restore();
400
338
  }
401
339
  });
402
340
 
@@ -411,9 +349,8 @@ describe('Headless contract (automation-safe)', () => {
411
349
  await fs.mkdir(path.join(tmpDir, 'terraform'), { recursive: true });
412
350
  await fs.writeFile(path.join(tmpDir, 'terraform', 'keep.tf'), '# user file');
413
351
  const parsed = parseCliArgs(argv);
414
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
415
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
416
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
352
+ const spies = mockConsoleTrio();
353
+ const { exitSpy, logSpy, errorSpy } = spies;
417
354
 
418
355
  try {
419
356
  const result = await mainStack({
@@ -434,9 +371,7 @@ describe('Headless contract (automation-safe)', () => {
434
371
  expect(await fs.readdir(tmpDir)).toEqual(['terraform']);
435
372
  expect(await fs.readFile(path.join(tmpDir, 'terraform', 'keep.tf'), 'utf-8')).toBe('# user file');
436
373
  } finally {
437
- exitSpy.mockRestore();
438
- logSpy.mockRestore();
439
- errorSpy.mockRestore();
374
+ spies.restore();
440
375
  }
441
376
  });
442
377
 
@@ -448,9 +383,8 @@ describe('Headless contract (automation-safe)', () => {
448
383
  const parsed = parseCliArgs([
449
384
  '--headless', '--framework=node', '--needsDatabase', `--db-engine=${engine}`,
450
385
  ]);
451
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
452
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
453
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
386
+ const spies = mockConsoleTrio();
387
+ const { exitSpy, logSpy, errorSpy } = spies;
454
388
 
455
389
  try {
456
390
  await mainStack({
@@ -471,9 +405,7 @@ describe('Headless contract (automation-safe)', () => {
471
405
  expect.objectContaining({ has_database: true, db_engine: engine })
472
406
  );
473
407
  } finally {
474
- exitSpy.mockRestore();
475
- logSpy.mockRestore();
476
- errorSpy.mockRestore();
408
+ spies.restore();
477
409
  }
478
410
  });
479
411
  });
@@ -493,9 +425,8 @@ describe('Headless: --target lambda', () => {
493
425
 
494
426
  async function runHeadlessLambda(argv) {
495
427
  const parsed = parseCliArgs(argv);
496
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
497
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
498
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
428
+ const spies = mockConsoleTrio();
429
+ const { exitSpy, logSpy, errorSpy } = spies;
499
430
  try {
500
431
  await mainStack({
501
432
  isHeadless: parsed.isHeadless,
@@ -505,9 +436,7 @@ describe('Headless: --target lambda', () => {
505
436
  });
506
437
  return { exitSpy };
507
438
  } finally {
508
- exitSpy.mockRestore();
509
- logSpy.mockRestore();
510
- errorSpy.mockRestore();
439
+ spies.restore();
511
440
  }
512
441
  }
513
442