@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.
- package/.github/workflows/e2e.yml +73 -0
- package/apps/docs/src/content/docs/cli/add.md +2 -1
- package/apps/docs/src/content/docs/cli/apply.md +5 -2
- package/apps/docs/src/content/docs/cli/destroy.md +7 -3
- package/apps/docs/src/content/docs/cli/eject.md +6 -2
- package/apps/docs/src/content/docs/guides/headless.md +12 -0
- package/apps/docs/src/content/docs/roadmap.md +5 -3
- package/apps/docs/src/content/docs/testing-strategy.md +6 -1
- package/bin/cli.js +7 -7
- package/package.json +3 -1
- package/specs/e2e-testing.md +50 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/apply.js +4 -4
- package/src/commands/destroy.js +3 -3
- package/src/commands/eject.js +5 -2
- package/src/core/parser.js +2 -0
- package/src/utils/command.js +13 -0
- package/tests/add.test.js +12 -31
- package/tests/ai.test.js +9 -12
- package/tests/apply.test.js +6 -48
- package/tests/capabilities.test.js +5 -8
- package/tests/command.test.js +9 -10
- package/tests/db.test.js +6 -37
- package/tests/destroy.test.js +6 -32
- package/tests/diagnose.test.js +7 -21
- package/tests/doctor.test.js +6 -13
- package/tests/domain.test.js +12 -29
- package/tests/drift.test.js +12 -27
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/exec.test.js +6 -20
- package/tests/gc.test.js +6 -36
- package/tests/headless.test.js +37 -108
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/logs.test.js +4 -15
- package/tests/resolvers.test.js +5 -8
- package/tests/rollback.test.js +6 -28
- package/tests/secrets.test.js +6 -29
- package/tests/sleep-wake.test.js +11 -29
- package/tests/status.test.js +6 -20
- package/tests/visualizer.test.js +12 -28
- package/vitest.config.js +2 -1
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- 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
|
+
});
|
package/tests/exec.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 } 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
|
-
|
|
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
|
|
package/tests/headless.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
169
|
-
const
|
|
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
|
-
|
|
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
|
|
199
|
-
const
|
|
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
|
-
|
|
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
|
|
245
|
-
const
|
|
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
|
-
|
|
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
|
|
274
|
-
const
|
|
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
|
-
|
|
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
|
|
316
|
-
const
|
|
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
|
-
|
|
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
|
|
349
|
-
const
|
|
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
|
-
|
|
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
|
|
380
|
-
const
|
|
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
|
-
|
|
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
|
|
415
|
-
const
|
|
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
|
-
|
|
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
|
|
452
|
-
const
|
|
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
|
-
|
|
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
|
|
497
|
-
const
|
|
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
|
-
|
|
509
|
-
logSpy.mockRestore();
|
|
510
|
-
errorSpy.mockRestore();
|
|
439
|
+
spies.restore();
|
|
511
440
|
}
|
|
512
441
|
}
|
|
513
442
|
|