grada-run 0.0.2 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +29 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +75 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +105 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +127 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +126 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +65 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
package/tests/ai.test.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { syncAi } from '../src/commands/sync-ai.js';
|
|
5
|
+
import { injectManagedBlock } from '../src/utils/ai-rules.js';
|
|
6
|
+
|
|
7
|
+
// 1. Mock the interactive prompts to simulate user input
|
|
8
|
+
vi.mock('@clack/prompts', () => ({
|
|
9
|
+
intro: vi.fn(),
|
|
10
|
+
outro: vi.fn(),
|
|
11
|
+
// Simulate the user selecting 'claude' from the list and hitting Enter
|
|
12
|
+
multiselect: vi.fn().mockResolvedValue(['claude']),
|
|
13
|
+
spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
|
|
14
|
+
log: { success: vi.fn(), warn: vi.fn(), error: vi.fn(), message: vi.fn() }
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// 2. Mock telemetry to prevent real network calls
|
|
18
|
+
vi.mock('../src/core/telemetry.js', () => ({
|
|
19
|
+
trackEvent: vi.fn(),
|
|
20
|
+
flushTelemetry: vi.fn().mockResolvedValue(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('AI Context Synchronization', () => {
|
|
24
|
+
const originalCwd = process.cwd();
|
|
25
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-ai-env');
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
// Create a fake project directory and step into it
|
|
29
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
30
|
+
process.chdir(testDir);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
// Step back out and clean up
|
|
35
|
+
process.chdir(originalCwd);
|
|
36
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('safely injects managed blocks without overwriting existing user instructions', async () => {
|
|
41
|
+
// Setup 1: Create a CLAUDE.md with pre-existing user instructions
|
|
42
|
+
const existingUserText = 'Always use async/await. Never use promises directly.\n';
|
|
43
|
+
await fs.writeFile('CLAUDE.md', existingUserText);
|
|
44
|
+
|
|
45
|
+
// Setup 2: Create a dummy terraform state so syncAi can extract the region/port
|
|
46
|
+
await fs.mkdir('terraform', { recursive: true });
|
|
47
|
+
await fs.writeFile(path.join('terraform', 'main.tf'), 'region = "us-east-2"\nport = "8000"');
|
|
48
|
+
|
|
49
|
+
// Execute the CLI command
|
|
50
|
+
await syncAi();
|
|
51
|
+
|
|
52
|
+
// Assert: Read the file back and verify both contents exist
|
|
53
|
+
const finalContent = await fs.readFile('CLAUDE.md', 'utf-8');
|
|
54
|
+
|
|
55
|
+
// 1. The user's original rules MUST remain intact
|
|
56
|
+
expect(finalContent).toContain(existingUserText);
|
|
57
|
+
|
|
58
|
+
// 2. The grada managed block MUST be injected
|
|
59
|
+
expect(finalContent).toContain('grada');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('replaces a legacy markdown block instead of duplicating it', async () => {
|
|
63
|
+
await fs.writeFile(
|
|
64
|
+
'CLAUDE.md',
|
|
65
|
+
'User notes.\n\n<!-- BEGIN DEPLOY-STACK CONTEXT -->\nold rules\n<!-- END DEPLOY-STACK CONTEXT -->\n'
|
|
66
|
+
);
|
|
67
|
+
injectManagedBlock('CLAUDE.md', 'new rules', true);
|
|
68
|
+
const finalContent = await fs.readFile('CLAUDE.md', 'utf-8');
|
|
69
|
+
expect(finalContent).toContain('User notes.');
|
|
70
|
+
expect(finalContent).toContain('<!-- BEGIN GRADA CONTEXT -->');
|
|
71
|
+
expect(finalContent).toContain('new rules');
|
|
72
|
+
expect(finalContent).not.toContain('DEPLOY-STACK CONTEXT');
|
|
73
|
+
expect(finalContent).not.toContain('old rules');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('replaces a legacy hash block instead of duplicating it', async () => {
|
|
77
|
+
await fs.writeFile(
|
|
78
|
+
'.windsurfrules',
|
|
79
|
+
'User notes.\n\n# BEGIN DEPLOY-STACK CONTEXT\nold rules\n# END DEPLOY-STACK CONTEXT\n'
|
|
80
|
+
);
|
|
81
|
+
injectManagedBlock('.windsurfrules', 'new rules', false);
|
|
82
|
+
const finalContent = await fs.readFile('.windsurfrules', 'utf-8');
|
|
83
|
+
expect(finalContent).toContain('# BEGIN GRADA CONTEXT');
|
|
84
|
+
expect(finalContent).not.toContain('DEPLOY-STACK CONTEXT');
|
|
85
|
+
expect(finalContent).not.toContain('old rules');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('writes GRADA markers for brand-new files', async () => {
|
|
89
|
+
injectManagedBlock('FRESH.md', 'fresh rules', true);
|
|
90
|
+
const finalContent = await fs.readFile('FRESH.md', 'utf-8');
|
|
91
|
+
expect(finalContent).toContain('<!-- BEGIN GRADA CONTEXT -->');
|
|
92
|
+
expect(finalContent).toContain('<!-- END GRADA CONTEXT -->');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
|
+
|
|
8
|
+
// --- Deep mock for child_process.spawn (no real terraform binary) ---
|
|
9
|
+
const { mockSpawn } = vi.hoisted(() => ({
|
|
10
|
+
mockSpawn: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('child_process', () => ({
|
|
14
|
+
spawn: mockSpawn,
|
|
15
|
+
execSync: vi.fn(),
|
|
16
|
+
spawnSync: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// --- Silence interactive UI ---
|
|
20
|
+
const clack = vi.hoisted(() => ({
|
|
21
|
+
mockIntro: vi.fn(),
|
|
22
|
+
mockOutro: vi.fn(),
|
|
23
|
+
mockConfirm: vi.fn(),
|
|
24
|
+
mockCancel: vi.fn(),
|
|
25
|
+
mockIsCancel: vi.fn(() => false),
|
|
26
|
+
mockSpinnerStart: vi.fn(),
|
|
27
|
+
mockSpinnerStop: vi.fn(),
|
|
28
|
+
mockSpinnerMessage: vi.fn(),
|
|
29
|
+
mockLogSuccess: vi.fn(),
|
|
30
|
+
mockLogWarn: vi.fn(),
|
|
31
|
+
mockLogError: vi.fn(),
|
|
32
|
+
mockLogMessage: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('@clack/prompts', () => ({
|
|
36
|
+
intro: clack.mockIntro,
|
|
37
|
+
outro: clack.mockOutro,
|
|
38
|
+
confirm: clack.mockConfirm,
|
|
39
|
+
cancel: clack.mockCancel,
|
|
40
|
+
isCancel: clack.mockIsCancel,
|
|
41
|
+
spinner: vi.fn(() => ({
|
|
42
|
+
start: clack.mockSpinnerStart,
|
|
43
|
+
stop: clack.mockSpinnerStop,
|
|
44
|
+
message: clack.mockSpinnerMessage,
|
|
45
|
+
})),
|
|
46
|
+
log: {
|
|
47
|
+
success: clack.mockLogSuccess,
|
|
48
|
+
warn: clack.mockLogWarn,
|
|
49
|
+
error: clack.mockLogError,
|
|
50
|
+
message: clack.mockLogMessage,
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// --- Never hit network / AWS ---
|
|
55
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
56
|
+
const trackEvent = vi.fn();
|
|
57
|
+
const flushTelemetry = vi.fn().mockResolvedValue();
|
|
58
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
59
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
60
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
61
|
+
trackEvent(event, { ...properties, success: true });
|
|
62
|
+
await flushTelemetry();
|
|
63
|
+
});
|
|
64
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
65
|
+
trackEvent(event, { ...properties, success: false });
|
|
66
|
+
await flushTelemetry();
|
|
67
|
+
});
|
|
68
|
+
return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock('../src/utils/aws.js', async (importOriginal) => {
|
|
72
|
+
const actual = await importOriginal();
|
|
73
|
+
return {
|
|
74
|
+
...actual,
|
|
75
|
+
provisionStateBucket: vi.fn().mockResolvedValue({
|
|
76
|
+
awsAccountId: '123456789012',
|
|
77
|
+
stateBucketName: 'mock-tf-state-bucket',
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Keep the real parseTerraformConfig (pure file reads) but stub the
|
|
83
|
+
// interactive preview so tests stay headless.
|
|
84
|
+
vi.mock('../src/utils/visualizer.js', async (importOriginal) => {
|
|
85
|
+
const actual = await importOriginal();
|
|
86
|
+
return {
|
|
87
|
+
...actual,
|
|
88
|
+
renderDryRunPreview: vi.fn().mockResolvedValue(true),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
import { applyStack } from '../src/commands/apply.js';
|
|
93
|
+
import { renderDryRunPreview } from '../src/utils/visualizer.js';
|
|
94
|
+
import { provisionStateBucket } from '../src/utils/aws.js';
|
|
95
|
+
import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
|
|
96
|
+
|
|
97
|
+
const COST_PROPS_SHAPE = {
|
|
98
|
+
projectName: expect.any(String),
|
|
99
|
+
estimated_monthly_usd: expect.any(Number),
|
|
100
|
+
cpu: expect.any(Number),
|
|
101
|
+
memory: expect.any(Number),
|
|
102
|
+
has_db: expect.any(Boolean),
|
|
103
|
+
has_worker: expect.any(Boolean),
|
|
104
|
+
addons: expect.any(Array),
|
|
105
|
+
addon_count: expect.any(Number),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Build a fake terraform child process: stdout/stderr EventEmitters
|
|
109
|
+
// plus close/error on the child itself, matching apply.js usage.
|
|
110
|
+
function makeChild({ code = 0, stdout = '', stderr = '', error = null } = {}) {
|
|
111
|
+
const child = new EventEmitter();
|
|
112
|
+
child.stdout = new EventEmitter();
|
|
113
|
+
child.stderr = new EventEmitter();
|
|
114
|
+
process.nextTick(() => {
|
|
115
|
+
if (error) {
|
|
116
|
+
child.emit('error', error);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
|
120
|
+
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
|
121
|
+
child.emit('close', code);
|
|
122
|
+
});
|
|
123
|
+
return child;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function exitError(code) {
|
|
127
|
+
return Object.assign(new Error(`process.exit:${code}`), { exitCode: code });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('Command: apply (mocked terraform spawn)', () => {
|
|
131
|
+
const originalCwd = process.cwd();
|
|
132
|
+
let tmpDir;
|
|
133
|
+
let exitSpy;
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
vi.clearAllMocks();
|
|
137
|
+
clack.mockConfirm.mockResolvedValue(true);
|
|
138
|
+
clack.mockIsCancel.mockReturnValue(false);
|
|
139
|
+
// process.exit throws so execution halts like the real runtime.
|
|
140
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
141
|
+
throw exitError(code);
|
|
142
|
+
});
|
|
143
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apply-test-'));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
process.chdir(originalCwd);
|
|
148
|
+
exitSpy.mockRestore();
|
|
149
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function writeProject(files = {}) {
|
|
153
|
+
fs.mkdirSync(path.join(tmpDir, 'terraform'), { recursive: true });
|
|
154
|
+
fs.writeFileSync(path.join(tmpDir, 'terraform', 'main.tf'), '# fake stack\n');
|
|
155
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
156
|
+
const full = path.join(tmpDir, rel);
|
|
157
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
158
|
+
fs.writeFileSync(full, content);
|
|
159
|
+
}
|
|
160
|
+
process.chdir(tmpDir);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('exits 1 when no terraform directory exists', async () => {
|
|
164
|
+
process.chdir(tmpDir); // empty tmpdir: no terraform/main.tf
|
|
165
|
+
await expect(applyStack({})).rejects.toMatchObject({ exitCode: 1 });
|
|
166
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
167
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('dry run renders preview and exits 0 without spawning terraform', async () => {
|
|
171
|
+
writeProject();
|
|
172
|
+
await expect(applyStack({ isDryRun: true })).rejects.toMatchObject({ exitCode: 0 });
|
|
173
|
+
expect(renderDryRunPreview).toHaveBeenCalledWith(expect.anything(), true);
|
|
174
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
175
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
176
|
+
'infrastructure_dry_run',
|
|
177
|
+
expect.objectContaining({ success: true, ...COST_PROPS_SHAPE })
|
|
178
|
+
);
|
|
179
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
180
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('provisions successfully: init + apply exit 0 and prints app/direct URL outputs', async () => {
|
|
184
|
+
writeProject();
|
|
185
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
186
|
+
expect(cmd).toBe('terraform');
|
|
187
|
+
if (args[0] === 'init') return makeChild({ code: 0, stdout: 'init ok' });
|
|
188
|
+
if (args[0] === 'apply') return makeChild({ code: 0, stdout: 'apply complete' });
|
|
189
|
+
if (args[0] === 'output')
|
|
190
|
+
return makeChild({
|
|
191
|
+
code: 0,
|
|
192
|
+
stdout: JSON.stringify({
|
|
193
|
+
cloudfront_url: { value: 'https://d123.cloudfront.net' },
|
|
194
|
+
alb_direct_url: { value: 'http://alb-123.us-east-2.elb.amazonaws.com' },
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
return makeChild({ code: 0 });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const outroSpy = clack.mockOutro;
|
|
201
|
+
// Success exit(0) lives INSIDE applyStack's try block, so a throwing
|
|
202
|
+
// process.exit mock would be swallowed as a failure. Use a no-op here.
|
|
203
|
+
exitSpy.mockImplementation(() => { });
|
|
204
|
+
await applyStack({});
|
|
205
|
+
|
|
206
|
+
expect(mockSpawn).toHaveBeenCalledTimes(3);
|
|
207
|
+
expect(mockSpawn.mock.calls[0][1]).toEqual(['init', '-upgrade']);
|
|
208
|
+
expect(mockSpawn.mock.calls[1][1]).toEqual(['apply', '-auto-approve']);
|
|
209
|
+
expect(mockSpawn.mock.calls[2][1]).toEqual(['output', '-json']);
|
|
210
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
211
|
+
expect(outroSpy).toHaveBeenCalled();
|
|
212
|
+
const outroText = stripVTControlCharacters(outroSpy.mock.calls.map((call) => call[0]).join('\n'));
|
|
213
|
+
expect(outroText).toContain('App URL: https://d123.cloudfront.net (global CDN — share this link)');
|
|
214
|
+
expect(outroText).toContain('Direct URL: http://alb-123.us-east-2.elb.amazonaws.com (bypasses the CDN — for debugging)');
|
|
215
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
216
|
+
'infrastructure_applied',
|
|
217
|
+
expect.objectContaining({ success: true, ...COST_PROPS_SHAPE })
|
|
218
|
+
);
|
|
219
|
+
// No dangling spinner: init stops the spinner before apply restarts it.
|
|
220
|
+
const firstStop = clack.mockSpinnerStop.mock.invocationCallOrder[0];
|
|
221
|
+
const secondStart = clack.mockSpinnerStart.mock.invocationCallOrder[1];
|
|
222
|
+
expect(firstStop).toBeLessThan(secondStart);
|
|
223
|
+
expect(clack.mockSpinnerStop.mock.calls[0][0]).toContain('Terraform initialized.');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('exits 1 and tracks failure when terraform apply exits non-zero', async () => {
|
|
227
|
+
writeProject();
|
|
228
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
229
|
+
if (args[0] === 'init') return makeChild({ code: 0, stdout: 'init ok' });
|
|
230
|
+
if (args[0] === 'apply')
|
|
231
|
+
return makeChild({ code: 1, stderr: 'Error: generic apply boom' });
|
|
232
|
+
return makeChild({ code: 0, stdout: '{}' });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await expect(applyStack({})).rejects.toMatchObject({ exitCode: 1 });
|
|
236
|
+
expect(clack.mockLogError).toHaveBeenCalled();
|
|
237
|
+
expect(provisionStateBucket).not.toHaveBeenCalled();
|
|
238
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
239
|
+
'infrastructure_applied',
|
|
240
|
+
expect.objectContaining({ success: false, ...COST_PROPS_SHAPE })
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('recovers from a missing state bucket: recreates bucket and resumes', async () => {
|
|
245
|
+
writeProject();
|
|
246
|
+
let applyCalls = 0;
|
|
247
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
248
|
+
if (args[0] === 'init') return makeChild({ code: 0, stdout: 'init ok' });
|
|
249
|
+
if (args[0] === 'apply') {
|
|
250
|
+
applyCalls += 1;
|
|
251
|
+
if (applyCalls === 1)
|
|
252
|
+
return makeChild({ code: 1, stderr: 'NoSuchBucket: state bucket does not exist' });
|
|
253
|
+
return makeChild({ code: 0, stdout: 'apply complete' });
|
|
254
|
+
}
|
|
255
|
+
if (args[0] === 'output')
|
|
256
|
+
return makeChild({ code: 0, stdout: '{}' });
|
|
257
|
+
return makeChild({ code: 0 });
|
|
258
|
+
});
|
|
259
|
+
clack.mockConfirm.mockResolvedValue(true); // accept recovery prompt
|
|
260
|
+
|
|
261
|
+
// Final success exit(0) is inside try: use no-op exit so it is not swallowed.
|
|
262
|
+
exitSpy.mockImplementation(() => { });
|
|
263
|
+
await applyStack({});
|
|
264
|
+
|
|
265
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
266
|
+
expect(provisionStateBucket).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(trackEvent).toHaveBeenCalledWith('recovery_accepted', expect.anything());
|
|
268
|
+
expect(trackEvent).toHaveBeenCalledWith('recovery_successful', expect.anything());
|
|
269
|
+
expect(applyCalls).toBe(2);
|
|
270
|
+
// Initial confirm-mode preview plus the print-only preview on the
|
|
271
|
+
// autoApprove recovery resume.
|
|
272
|
+
expect(renderDryRunPreview).toHaveBeenCalledTimes(2);
|
|
273
|
+
expect(renderDryRunPreview).toHaveBeenNthCalledWith(1, expect.anything(), false);
|
|
274
|
+
expect(renderDryRunPreview).toHaveBeenNthCalledWith(2, expect.anything(), true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('still prints the preview in print-only mode when autoApprove is true', async () => {
|
|
278
|
+
writeProject();
|
|
279
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
280
|
+
if (args[0] === 'output') return makeChild({ code: 0, stdout: '{}' });
|
|
281
|
+
return makeChild({ code: 0 });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Success exit(0) lives inside the try block: no-op exit like above.
|
|
285
|
+
exitSpy.mockImplementation(() => { });
|
|
286
|
+
await applyStack({ autoApprove: true });
|
|
287
|
+
|
|
288
|
+
expect(renderDryRunPreview).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(renderDryRunPreview).toHaveBeenCalledWith(expect.anything(), true);
|
|
290
|
+
expect(clack.mockConfirm).not.toHaveBeenCalled();
|
|
291
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('aborts when the user declines state-bucket recovery', async () => {
|
|
295
|
+
writeProject();
|
|
296
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
297
|
+
if (args[0] === 'init') return makeChild({ code: 0 });
|
|
298
|
+
return makeChild({ code: 1, stderr: 'NoSuchBucket: gone' });
|
|
299
|
+
});
|
|
300
|
+
clack.mockConfirm.mockResolvedValue(false); // decline recovery
|
|
301
|
+
|
|
302
|
+
await expect(applyStack({})).rejects.toMatchObject({ exitCode: 1 });
|
|
303
|
+
expect(provisionStateBucket).not.toHaveBeenCalled();
|
|
304
|
+
expect(trackEvent).toHaveBeenCalledWith('recovery_declined', expect.anything());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('prints OIDC guidance when the GitHub provider already exists', async () => {
|
|
308
|
+
writeProject();
|
|
309
|
+
mockSpawn.mockImplementation((cmd, args = []) => {
|
|
310
|
+
if (args[0] === 'init') return makeChild({ code: 0 });
|
|
311
|
+
return makeChild({
|
|
312
|
+
code: 1,
|
|
313
|
+
stderr: 'EntityAlreadyExists: token.actions.githubusercontent.com provider exists',
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await expect(applyStack({})).rejects.toMatchObject({ exitCode: 1 });
|
|
318
|
+
expect(clack.mockLogError).toHaveBeenCalledWith(
|
|
319
|
+
expect.stringContaining('OIDC')
|
|
320
|
+
);
|
|
321
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
322
|
+
'infrastructure_applied',
|
|
323
|
+
expect.objectContaining({ success: false })
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('treats spawn errors (missing binary) as apply failure without AWS calls', async () => {
|
|
328
|
+
writeProject();
|
|
329
|
+
mockSpawn.mockImplementation(() =>
|
|
330
|
+
makeChild({ error: new Error('spawn terraform ENOENT') })
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
await expect(applyStack({})).rejects.toMatchObject({ exitCode: 1 });
|
|
334
|
+
expect(provisionStateBucket).not.toHaveBeenCalled();
|
|
335
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
336
|
+
'infrastructure_applied',
|
|
337
|
+
expect.objectContaining({ success: false })
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('warns when the environment is asleep but still applies', async () => {
|
|
342
|
+
writeProject({
|
|
343
|
+
'.deploy-stack/sleep-state.json': JSON.stringify({ default: { env: 'default' } }),
|
|
344
|
+
});
|
|
345
|
+
await expect(applyStack({ isDryRun: true })).rejects.toMatchObject({ exitCode: 0 });
|
|
346
|
+
const warns = clack.mockLogWarn.mock.calls.map((call) => stripVTControlCharacters(String(call[0]))).join('\n');
|
|
347
|
+
expect(warns).toContain('is asleep');
|
|
348
|
+
expect(warns).toContain('npx grada-run wake');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('stays silent about sleep when the environment is awake', async () => {
|
|
352
|
+
writeProject();
|
|
353
|
+
await expect(applyStack({ isDryRun: true })).rejects.toMatchObject({ exitCode: 0 });
|
|
354
|
+
const warns = clack.mockLogWarn.mock.calls.map((call) => stripVTControlCharacters(String(call[0]))).join('\n');
|
|
355
|
+
expect(warns).not.toContain('is asleep');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('is wired into bin/cli.js', () => {
|
|
359
|
+
const cliPath = path.resolve(process.cwd(), 'bin/cli.js');
|
|
360
|
+
// tests run from repo root after chdir restore; resolve from original cwd
|
|
361
|
+
const resolved = path.isAbsolute(cliPath) ? cliPath : path.join(originalCwd, 'bin/cli.js');
|
|
362
|
+
const content = fs.readFileSync(path.join(originalCwd, 'bin/cli.js'), 'utf8');
|
|
363
|
+
expect(resolved).toContain('cli.js');
|
|
364
|
+
expect(content).toContain('applyStack');
|
|
365
|
+
expect(content).toContain("'apply'");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('apply: fuzzer hardening', () => {
|
|
370
|
+
it.each([null, 'string', 42, true])('applyStack(%s) behaves like {} instead of throwing', async (bad) => {
|
|
371
|
+
// The repo root has no terraform/ dir, so the no-terraform guard fires
|
|
372
|
+
// before any AWS call — deterministically, without network.
|
|
373
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
374
|
+
try {
|
|
375
|
+
const result = await applyStack(bad);
|
|
376
|
+
expect(result).toEqual({ ok: false });
|
|
377
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
378
|
+
} finally {
|
|
379
|
+
exitSpy.mockRestore();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('Command: apply on --target lambda projects', () => {
|
|
385
|
+
const originalCwd = process.cwd();
|
|
386
|
+
let tmpDir;
|
|
387
|
+
let exitSpy;
|
|
388
|
+
|
|
389
|
+
const LAMBDA_MAIN_TF = [
|
|
390
|
+
'provider "aws" {',
|
|
391
|
+
' region = "us-east-2"',
|
|
392
|
+
'}',
|
|
393
|
+
'locals {',
|
|
394
|
+
' app_name = "myapp${local.env_suffix}"',
|
|
395
|
+
'}',
|
|
396
|
+
'resource "aws_lambda_function" "app" {',
|
|
397
|
+
' function_name = "${local.app_name}-fn"',
|
|
398
|
+
'}',
|
|
399
|
+
'',
|
|
400
|
+
].join('\n');
|
|
401
|
+
|
|
402
|
+
beforeEach(() => {
|
|
403
|
+
vi.clearAllMocks();
|
|
404
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
405
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apply-lambda-test-'));
|
|
406
|
+
fs.mkdirSync(path.join(tmpDir, 'terraform'), { recursive: true });
|
|
407
|
+
fs.writeFileSync(path.join(tmpDir, 'terraform', 'main.tf'), LAMBDA_MAIN_TF);
|
|
408
|
+
process.chdir(tmpDir);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
afterEach(() => {
|
|
412
|
+
process.chdir(originalCwd);
|
|
413
|
+
exitSpy.mockRestore();
|
|
414
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
function mockOutputs(outputs) {
|
|
418
|
+
mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: JSON.stringify(outputs) }));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function mockAws({ imageExists }) {
|
|
422
|
+
return vi.fn((cmd, args) => {
|
|
423
|
+
if (args[1] === 'describe-images') {
|
|
424
|
+
return imageExists
|
|
425
|
+
? { status: 0, stdout: '{"imageDetails": [{"imageTags": ["latest"]}]}', stderr: '' }
|
|
426
|
+
: { status: 0, stdout: '{"imageDetails": []}', stderr: '' };
|
|
427
|
+
}
|
|
428
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "u"}', stderr: '' };
|
|
429
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
it('targets the ECR repo, seeds :latest when missing, and prints the API URL', async () => {
|
|
434
|
+
mockOutputs({
|
|
435
|
+
cloudfront_url: { value: 'https://d123.cloudfront.net' },
|
|
436
|
+
api_gateway_url: { value: 'https://abc.execute-api.us-east-2.amazonaws.com' },
|
|
437
|
+
});
|
|
438
|
+
const terraformCalls = [];
|
|
439
|
+
const runTerraformImpl = vi.fn(async (args) => { terraformCalls.push(args); });
|
|
440
|
+
const spawnSyncImpl = mockAws({ imageExists: false });
|
|
441
|
+
|
|
442
|
+
await applyStack({ autoApprove: true, runTerraformImpl, spawnSyncImpl });
|
|
443
|
+
|
|
444
|
+
expect(terraformCalls[0]).toEqual(['init', '-upgrade']);
|
|
445
|
+
expect(terraformCalls[1]).toEqual(['apply', '-target=aws_ecr_repository.app', '-input=false', '-auto-approve']);
|
|
446
|
+
expect(terraformCalls[2]).toEqual(['apply', '-auto-approve']);
|
|
447
|
+
const awsVerbs = spawnSyncImpl.mock.calls.map(([, args]) => args[1]);
|
|
448
|
+
expect(awsVerbs[0]).toBe('describe-images');
|
|
449
|
+
expect(awsVerbs).toContain('put-image');
|
|
450
|
+
|
|
451
|
+
const outroText = stripVTControlCharacters(clack.mockOutro.mock.calls.map((call) => call[0]).join('\n'));
|
|
452
|
+
expect(outroText).toContain('App URL: https://d123.cloudfront.net');
|
|
453
|
+
expect(outroText).toContain('API URL: https://abc.execute-api.us-east-2.amazonaws.com (bypasses the CDN — for debugging)');
|
|
454
|
+
expect(outroText).not.toContain('Direct URL:');
|
|
455
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('skips seeding when :latest already exists', async () => {
|
|
459
|
+
mockOutputs({ cloudfront_url: { value: 'https://d123.cloudfront.net' } });
|
|
460
|
+
const runTerraformImpl = vi.fn(async () => { });
|
|
461
|
+
const spawnSyncImpl = mockAws({ imageExists: true });
|
|
462
|
+
|
|
463
|
+
await applyStack({ autoApprove: true, runTerraformImpl, spawnSyncImpl });
|
|
464
|
+
|
|
465
|
+
expect(spawnSyncImpl).toHaveBeenCalledTimes(1);
|
|
466
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('fails fast with guidance when Day-0 seeding fails', async () => {
|
|
470
|
+
mockOutputs({});
|
|
471
|
+
const runTerraformImpl = vi.fn(async () => { });
|
|
472
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
473
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
474
|
+
exitSpy.mockImplementation((code) => { throw exitError(code); });
|
|
475
|
+
try {
|
|
476
|
+
const spawnSyncImpl = vi.fn(() => ({ status: 254, stdout: '', stderr: 'AccessDeniedException' }));
|
|
477
|
+
await expect(applyStack({ autoApprove: true, runTerraformImpl, spawnSyncImpl })).rejects.toMatchObject({ exitCode: 1 });
|
|
478
|
+
expect(runTerraformImpl).not.toHaveBeenCalledWith(['apply', '-auto-approve']);
|
|
479
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
480
|
+
'infrastructure_applied',
|
|
481
|
+
expect.objectContaining({ success: false })
|
|
482
|
+
);
|
|
483
|
+
} finally {
|
|
484
|
+
logSpy.mockRestore();
|
|
485
|
+
errorSpy.mockRestore();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../src/utils/args.js';
|
|
3
|
+
|
|
4
|
+
describe('parseFlags', () => {
|
|
5
|
+
it('parses string flags in space and = forms with last-wins', () => {
|
|
6
|
+
expect(parseFlags(['--region', 'a', '--region=b'], { string: ['region'] })).toEqual({
|
|
7
|
+
options: { region: 'b' },
|
|
8
|
+
rest: [],
|
|
9
|
+
});
|
|
10
|
+
expect(parseFlags(['--project-name=x'], { string: ['project-name'] }).options).toEqual({
|
|
11
|
+
projectName: 'x',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('supports explicit key overrides', () => {
|
|
16
|
+
expect(parseFlags(['--headless'], { boolean: [{ name: 'headless', key: 'isHeadless' }] }).options).toEqual({
|
|
17
|
+
isHeadless: true,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('drops trailing string flags without a value', () => {
|
|
22
|
+
expect(parseFlags(['--cluster'], { string: ['cluster'] })).toEqual({ options: {}, rest: [] });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('consumes the next arg as a value even when it looks like a flag', () => {
|
|
26
|
+
expect(parseFlags(['--cluster', '--service', 'x'], { string: ['cluster', 'service'] })).toEqual({
|
|
27
|
+
options: { cluster: '--service' },
|
|
28
|
+
rest: ['x'],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('coerces number flags in both forms', () => {
|
|
33
|
+
expect(parseFlags(['--tail', '50'], { number: ['tail'] }).options).toEqual({ tail: 50 });
|
|
34
|
+
expect(parseFlags(['--tail=25'], { number: ['tail'] }).options).toEqual({ tail: 25 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses booleans bare and =true/=false', () => {
|
|
38
|
+
expect(parseFlags(['--force'], { boolean: ['force'] }).options).toEqual({ force: true });
|
|
39
|
+
expect(parseFlags(['--force=false'], { boolean: ['force'] }).options).toEqual({ force: false });
|
|
40
|
+
expect(parseFlags(['--force=yes'], { boolean: ['force'] }).options).toEqual({ force: false });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('ignores =forms on bare-only booleans', () => {
|
|
44
|
+
expect(parseFlags(['--error'], { bareBoolean: ['error'] }).options).toEqual({ error: true });
|
|
45
|
+
expect(parseFlags(['--error=false'], { bareBoolean: ['error'] })).toEqual({ options: {}, rest: [] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('expands single-dash aliases to their long flags', () => {
|
|
49
|
+
expect(parseFlags(['-f'], { bareBoolean: ['follow'], alias: { f: 'follow' } }).options).toEqual({
|
|
50
|
+
follow: true,
|
|
51
|
+
});
|
|
52
|
+
expect(parseFlags(['-x'], { bareBoolean: ['follow'], alias: { f: 'follow' } }).rest).toEqual(['-x']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns positionals and unknown args in rest', () => {
|
|
56
|
+
expect(parseFlags(['a', '--unknown', 'b', '--unknown=x'], { string: ['region'] })).toEqual({
|
|
57
|
+
options: {},
|
|
58
|
+
rest: ['a', 'b'],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('normalizeOptions', () => {
|
|
64
|
+
it('passes objects through untouched', () => {
|
|
65
|
+
const options = { region: 'x' };
|
|
66
|
+
expect(normalizeOptions(options)).toBe(options);
|
|
67
|
+
expect(normalizeOptions({})).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each([null, undefined, 'string', 42, true])('coerces %s to {}', (value) => {
|
|
71
|
+
expect(normalizeOptions(value)).toEqual({});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('normalizeArgv', () => {
|
|
76
|
+
it('copies arrays', () => {
|
|
77
|
+
const argv = ['db', 'migrate'];
|
|
78
|
+
const copy = normalizeArgv(argv);
|
|
79
|
+
expect(copy).toEqual(argv);
|
|
80
|
+
expect(copy).not.toBe(argv);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([null, undefined, 'string', 42, true, { port: 'string' }])('coerces %s to []', (value) => {
|
|
84
|
+
expect(normalizeArgv(value)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|