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
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { trackEvent, trackSuccess, trackFailure, detectCiProvider, resetTelemetryIdentityCache, migrateLegacyTelemetryId } from '../src/core/telemetry.js';
|
|
7
|
+
|
|
8
|
+
const SHA256_UNKNOWN_PREFIX = crypto.createHash('sha256').update('unknown').digest('hex').substring(0, 16);
|
|
9
|
+
|
|
10
|
+
describe('trackEvent capture', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
resetTelemetryIdentityCache();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
delete process.env.DO_NOT_TRACK;
|
|
18
|
+
delete process.env.GRADA_TELEMETRY_ID_PATH;
|
|
19
|
+
delete process.env.DEPLOY_STACK_TELEMETRY_ID_PATH;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function mockFetch() {
|
|
23
|
+
const fn = vi.fn().mockResolvedValue({});
|
|
24
|
+
vi.stubGlobal('fetch', fn);
|
|
25
|
+
return fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lastPayload(fetchMock) {
|
|
29
|
+
const [, { body }] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
|
|
30
|
+
return JSON.parse(body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it.each([
|
|
34
|
+
'exec_run',
|
|
35
|
+
'diagnose_run',
|
|
36
|
+
'status_run',
|
|
37
|
+
'gc_run',
|
|
38
|
+
'doctor_run',
|
|
39
|
+
'rollback_run',
|
|
40
|
+
'secrets_pushed',
|
|
41
|
+
'secrets_pull',
|
|
42
|
+
'secrets_audit',
|
|
43
|
+
'logs_streamed',
|
|
44
|
+
'sync_ai_executed',
|
|
45
|
+
'project_provisioned',
|
|
46
|
+
'project_ejected',
|
|
47
|
+
'infrastructure_applied',
|
|
48
|
+
'infrastructure_destroyed',
|
|
49
|
+
'recovery_prompted',
|
|
50
|
+
'recovery_failed',
|
|
51
|
+
'cli-error',
|
|
52
|
+
])('sends legitimate event %s', (eventName) => {
|
|
53
|
+
const fetchMock = mockFetch();
|
|
54
|
+
trackEvent(eventName, { projectName: 'test' });
|
|
55
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each(['message', 'data', 'true', true, 1])('captures unexpected event %s without dropping it', (eventName) => {
|
|
59
|
+
const fetchMock = mockFetch();
|
|
60
|
+
trackEvent(eventName, { projectName: 'test' });
|
|
61
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(lastPayload(fetchMock).event).toBe(String(eventName));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each([undefined, null, '', ' '])('drops missing or blank event %s', (eventName) => {
|
|
66
|
+
const fetchMock = mockFetch();
|
|
67
|
+
trackEvent(eventName, { projectName: 'test' });
|
|
68
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each([[{}], [['a', 'b']], [() => {}]])(
|
|
72
|
+
'drops object, array, or function eventName without sending',
|
|
73
|
+
(eventName) => {
|
|
74
|
+
const fetchMock = mockFetch();
|
|
75
|
+
trackEvent(eventName, { projectName: 'test' });
|
|
76
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// The array case exercises properties-wrapping, so its eventName is a
|
|
81
|
+
// plain string: array eventNames are dropped as non-serializable.
|
|
82
|
+
it.each([['test', 'test'], ['test', ['a', 'b']]])(
|
|
83
|
+
'wraps non-object properties without spreading indexed keys',
|
|
84
|
+
(eventName, properties) => {
|
|
85
|
+
const fetchMock = mockFetch();
|
|
86
|
+
trackEvent(eventName, properties);
|
|
87
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
88
|
+
const payload = lastPayload(fetchMock);
|
|
89
|
+
expect(payload.properties.raw_properties).toEqual(properties);
|
|
90
|
+
expect(payload.properties).not.toHaveProperty('0');
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it('hashes a non-string projectName into project_id without throwing', () => {
|
|
95
|
+
const fetchMock = mockFetch();
|
|
96
|
+
expect(() => trackEvent('test', { projectName: 42 })).not.toThrow();
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
98
|
+
const payload = lastPayload(fetchMock);
|
|
99
|
+
const expectedId = crypto.createHash('sha256').update('42').digest('hex').substring(0, 16);
|
|
100
|
+
expect(payload.properties.project_id).toBe(expectedId);
|
|
101
|
+
expect(payload.properties).not.toHaveProperty('projectName');
|
|
102
|
+
expect(payload.distinct_id).toMatch(/^[0-9a-f]{16}$/);
|
|
103
|
+
expect(payload.distinct_id).not.toBe(SHA256_UNKNOWN_PREFIX);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps one stable machine distinct_id with and without projectName', () => {
|
|
107
|
+
const fetchMock = mockFetch();
|
|
108
|
+
trackEvent('with_name', { projectName: 'myapp' });
|
|
109
|
+
trackEvent('without_name');
|
|
110
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
111
|
+
const [first, second] = fetchMock.mock.calls.map(([, { body }]) => JSON.parse(body));
|
|
112
|
+
expect(first.distinct_id).toMatch(/^[0-9a-f]{16}$/);
|
|
113
|
+
expect(second.distinct_id).toBe(first.distinct_id);
|
|
114
|
+
expect(first.distinct_id).not.toBe(SHA256_UNKNOWN_PREFIX);
|
|
115
|
+
expect(first.properties.project_id).toBe(
|
|
116
|
+
crypto.createHash('sha256').update('myapp').digest('hex').substring(0, 16)
|
|
117
|
+
);
|
|
118
|
+
expect(second.properties.project_id).toBe(
|
|
119
|
+
crypto.createHash('sha256').update(path.basename(process.cwd())).digest('hex').substring(0, 16)
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('persists file identity across cache resets, even when CI=true', () => {
|
|
124
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-id-'));
|
|
125
|
+
try {
|
|
126
|
+
const idPath = path.join(dir, 'nested', 'telemetry-id');
|
|
127
|
+
process.env.DEPLOY_STACK_TELEMETRY_ID_PATH = idPath;
|
|
128
|
+
const savedCI = process.env.CI;
|
|
129
|
+
process.env.CI = 'true';
|
|
130
|
+
try {
|
|
131
|
+
const fetchMock = mockFetch();
|
|
132
|
+
trackEvent('first_run', { projectName: 'test' });
|
|
133
|
+
expect(fs.existsSync(idPath)).toBe(true);
|
|
134
|
+
const firstId = lastPayload(fetchMock).distinct_id;
|
|
135
|
+
expect(firstId).toMatch(/^[0-9a-f]{16}$/);
|
|
136
|
+
resetTelemetryIdentityCache();
|
|
137
|
+
trackEvent('second_run', { projectName: 'test' });
|
|
138
|
+
expect(lastPayload(fetchMock).distinct_id).toBe(firstId);
|
|
139
|
+
} finally {
|
|
140
|
+
if (savedCI === undefined) delete process.env.CI;
|
|
141
|
+
else process.env.CI = savedCI;
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('tags automated test runs without blocking them', () => {
|
|
149
|
+
const fetchMock = mockFetch();
|
|
150
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
151
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
152
|
+
const payload = lastPayload(fetchMock);
|
|
153
|
+
// VITEST is set by the runner itself, so this must be true here.
|
|
154
|
+
expect(payload.properties.is_test_env).toBe(true);
|
|
155
|
+
expect(typeof payload.properties.is_tty).toBe('boolean');
|
|
156
|
+
expect(typeof payload.properties.is_cli_entry).toBe('boolean');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it.each([
|
|
160
|
+
['/repo/bin/cli.js', true],
|
|
161
|
+
['/opt/tools/deploy-stack', true],
|
|
162
|
+
['/opt/tools/grada', true],
|
|
163
|
+
['/opt/tools/grada-run', true],
|
|
164
|
+
['/repo/node_modules/vitest/vitest.mjs', false],
|
|
165
|
+
])('detects CLI entry from argv[1] %s as %s', (entry, expected) => {
|
|
166
|
+
const savedArgv = process.argv;
|
|
167
|
+
process.argv = ['node', entry];
|
|
168
|
+
try {
|
|
169
|
+
const fetchMock = mockFetch();
|
|
170
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
171
|
+
expect(lastPayload(fetchMock).properties.is_cli_entry).toBe(expected);
|
|
172
|
+
} finally {
|
|
173
|
+
process.argv = savedArgv;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('telemetry identity migration', () => {
|
|
178
|
+
it('copies a legacy identity to the new path exactly once', () => {
|
|
179
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-migrate-'));
|
|
180
|
+
try {
|
|
181
|
+
const legacyPath = path.join(dir, '.deploy-stack', 'telemetry-id');
|
|
182
|
+
const newPath = path.join(dir, '.grada', 'telemetry-id');
|
|
183
|
+
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
|
184
|
+
fs.writeFileSync(legacyPath, 'legacy-uuid\n');
|
|
185
|
+
expect(migrateLegacyTelemetryId(newPath, legacyPath)).toBe(true);
|
|
186
|
+
expect(fs.readFileSync(newPath, 'utf-8')).toBe('legacy-uuid\n');
|
|
187
|
+
expect(migrateLegacyTelemetryId(newPath, legacyPath)).toBe(false);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('does nothing when no legacy identity exists', () => {
|
|
194
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-migrate-'));
|
|
195
|
+
try {
|
|
196
|
+
const newPath = path.join(dir, '.grada', 'telemetry-id');
|
|
197
|
+
expect(migrateLegacyTelemetryId(newPath, path.join(dir, '.deploy-stack', 'telemetry-id'))).toBe(false);
|
|
198
|
+
expect(fs.existsSync(newPath)).toBe(false);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('prefers GRADA_TELEMETRY_ID_PATH over the legacy override', () => {
|
|
205
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-id-'));
|
|
206
|
+
try {
|
|
207
|
+
const gradaPath = path.join(dir, 'grada', 'telemetry-id');
|
|
208
|
+
const legacyPath = path.join(dir, 'legacy', 'telemetry-id');
|
|
209
|
+
process.env.GRADA_TELEMETRY_ID_PATH = gradaPath;
|
|
210
|
+
process.env.DEPLOY_STACK_TELEMETRY_ID_PATH = legacyPath;
|
|
211
|
+
const fetchMock = mockFetch();
|
|
212
|
+
trackEvent('override_run', { projectName: 'test' });
|
|
213
|
+
expect(fs.existsSync(gradaPath)).toBe(true);
|
|
214
|
+
expect(fs.existsSync(legacyPath)).toBe(false);
|
|
215
|
+
expect(lastPayload(fetchMock).distinct_id).toMatch(/^[0-9a-f]{16}$/);
|
|
216
|
+
} finally {
|
|
217
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('prefers GRADA_FRAMEWORK over the legacy framework var', () => {
|
|
222
|
+
process.env.GRADA_FRAMEWORK = 'grada-nextjs';
|
|
223
|
+
process.env.DEPLOY_STACK_FRAMEWORK = 'legacy-nextjs';
|
|
224
|
+
try {
|
|
225
|
+
const fetchMock = mockFetch();
|
|
226
|
+
trackEvent('framework_run', { projectName: 'test' });
|
|
227
|
+
expect(lastPayload(fetchMock).properties.framework).toBe('grada-nextjs');
|
|
228
|
+
} finally {
|
|
229
|
+
delete process.env.GRADA_FRAMEWORK;
|
|
230
|
+
delete process.env.DEPLOY_STACK_FRAMEWORK;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('still honors the legacy framework var alone', () => {
|
|
235
|
+
process.env.DEPLOY_STACK_FRAMEWORK = 'legacy-nextjs';
|
|
236
|
+
try {
|
|
237
|
+
const fetchMock = mockFetch();
|
|
238
|
+
trackEvent('framework_run', { projectName: 'test' });
|
|
239
|
+
expect(lastPayload(fetchMock).properties.framework).toBe('legacy-nextjs');
|
|
240
|
+
} finally {
|
|
241
|
+
delete process.env.DEPLOY_STACK_FRAMEWORK;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('detectCiProvider', () => {
|
|
247
|
+
const CI_KEYS = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'CI', 'CONTINUOUS_INTEGRATION'];
|
|
248
|
+
|
|
249
|
+
const withEnv = (vars, fn) => {
|
|
250
|
+
const saved = {};
|
|
251
|
+
for (const key of CI_KEYS) {
|
|
252
|
+
saved[key] = process.env[key];
|
|
253
|
+
delete process.env[key];
|
|
254
|
+
}
|
|
255
|
+
Object.assign(process.env, vars);
|
|
256
|
+
try {
|
|
257
|
+
fn();
|
|
258
|
+
} finally {
|
|
259
|
+
for (const key of CI_KEYS) {
|
|
260
|
+
if (saved[key] === undefined) delete process.env[key];
|
|
261
|
+
else process.env[key] = saved[key];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
it.each([
|
|
267
|
+
[{ GITHUB_ACTIONS: 'true', CI: 'true' }, 'github_actions'],
|
|
268
|
+
[{ GITLAB_CI: 'true' }, 'gitlab_ci'],
|
|
269
|
+
[{ CIRCLECI: 'true' }, 'circleci'],
|
|
270
|
+
[{ JENKINS_URL: 'http://jenkins:8080/' }, 'jenkins'],
|
|
271
|
+
[{ CI: 'true' }, 'generic_ci'],
|
|
272
|
+
[{ CONTINUOUS_INTEGRATION: 'true' }, 'generic_ci'],
|
|
273
|
+
[{}, 'none'],
|
|
274
|
+
])('maps %s to %s', (vars, expected) => {
|
|
275
|
+
withEnv(vars, () => {
|
|
276
|
+
expect(detectCiProvider()).toBe(expected);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('prefers specific providers over generic CI flags', () => {
|
|
281
|
+
withEnv({ CI: 'true', GITLAB_CI: 'true', GITHUB_ACTIONS: 'true' }, () => {
|
|
282
|
+
expect(detectCiProvider()).toBe('github_actions');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('includes ci_provider in the base payload', () => {
|
|
287
|
+
withEnv({ GITHUB_ACTIONS: 'true', CI: 'true' }, () => {
|
|
288
|
+
const fetchMock = mockFetch();
|
|
289
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
290
|
+
const payload = lastPayload(fetchMock);
|
|
291
|
+
expect(payload.properties.is_ci).toBe(true);
|
|
292
|
+
expect(payload.properties.ci_provider).toBe('github_actions');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('is_ci consistency', () => {
|
|
298
|
+
const CI_KEYS = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'CI', 'CONTINUOUS_INTEGRATION'];
|
|
299
|
+
|
|
300
|
+
const withEnv = (vars, fn) => {
|
|
301
|
+
const saved = {};
|
|
302
|
+
for (const key of CI_KEYS) {
|
|
303
|
+
saved[key] = process.env[key];
|
|
304
|
+
delete process.env[key];
|
|
305
|
+
}
|
|
306
|
+
Object.assign(process.env, vars);
|
|
307
|
+
try {
|
|
308
|
+
fn();
|
|
309
|
+
} finally {
|
|
310
|
+
for (const key of CI_KEYS) {
|
|
311
|
+
if (saved[key] === undefined) delete process.env[key];
|
|
312
|
+
else process.env[key] = saved[key];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
it('reports is_ci true when CI is unset but a provider is detected', () => {
|
|
318
|
+
withEnv({ GITHUB_ACTIONS: 'true' }, () => {
|
|
319
|
+
const fetchMock = mockFetch();
|
|
320
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
321
|
+
const payload = lastPayload(fetchMock);
|
|
322
|
+
expect(payload.properties.ci_provider).toBe('github_actions');
|
|
323
|
+
expect(payload.properties.is_ci).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("treats 'false' and '0' as inactive", () => {
|
|
328
|
+
withEnv({ CI: 'false', GITHUB_ACTIONS: 'false' }, () => {
|
|
329
|
+
const fetchMock = mockFetch();
|
|
330
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
331
|
+
const payload = lastPayload(fetchMock);
|
|
332
|
+
expect(payload.properties.ci_provider).toBe('none');
|
|
333
|
+
expect(payload.properties.is_ci).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
withEnv({ CI: '0' }, () => {
|
|
336
|
+
expect(detectCiProvider()).toBe('none');
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('cli_command gating', () => {
|
|
342
|
+
const savedArgv = process.argv;
|
|
343
|
+
const savedCliCommand = process.env.CLI_COMMAND;
|
|
344
|
+
|
|
345
|
+
afterEach(() => {
|
|
346
|
+
process.argv = savedArgv;
|
|
347
|
+
if (savedCliCommand === undefined) delete process.env.CLI_COMMAND;
|
|
348
|
+
else process.env.CLI_COMMAND = savedCliCommand;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("reports 'module_import' for programmatic imports regardless of argv[2]", () => {
|
|
352
|
+
process.argv = ['node', '/repo/scripts/runner.mjs', 'deploy-stack'];
|
|
353
|
+
delete process.env.CLI_COMMAND;
|
|
354
|
+
const fetchMock = mockFetch();
|
|
355
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
356
|
+
const payload = lastPayload(fetchMock);
|
|
357
|
+
expect(payload.properties.is_cli_entry).toBe(false);
|
|
358
|
+
expect(payload.properties.cli_command).toBe('module_import');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('preserves CLI_COMMAND and subcommand args on real CLI invocations', () => {
|
|
362
|
+
process.argv = ['node', '/repo/bin/cli.js', 'db', 'connect'];
|
|
363
|
+
process.env.CLI_COMMAND = 'db connect';
|
|
364
|
+
const fetchMock = mockFetch();
|
|
365
|
+
trackEvent('db_connect_run', { projectName: 'test' });
|
|
366
|
+
expect(lastPayload(fetchMock).properties.cli_command).toBe('db connect');
|
|
367
|
+
|
|
368
|
+
delete process.env.CLI_COMMAND;
|
|
369
|
+
process.argv = ['node', '/repo/bin/cli.js', 'status', '--json'];
|
|
370
|
+
trackEvent('status_run', { projectName: 'test' });
|
|
371
|
+
expect(lastPayload(fetchMock).properties.cli_command).toBe('status --json');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('marks interactive runs as non-test environments', () => {
|
|
376
|
+
const savedVitest = process.env.VITEST;
|
|
377
|
+
const savedNodeEnv = process.env.NODE_ENV;
|
|
378
|
+
delete process.env.VITEST;
|
|
379
|
+
process.env.NODE_ENV = 'production';
|
|
380
|
+
try {
|
|
381
|
+
const fetchMock = mockFetch();
|
|
382
|
+
trackEvent('exec_run', { projectName: 'test' });
|
|
383
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
384
|
+
expect(JSON.parse(body).properties.is_test_env).toBe(false);
|
|
385
|
+
} finally {
|
|
386
|
+
if (savedVitest === undefined) delete process.env.VITEST;
|
|
387
|
+
else process.env.VITEST = savedVitest;
|
|
388
|
+
if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
389
|
+
else process.env.NODE_ENV = savedNodeEnv;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('trackSuccess', () => {
|
|
395
|
+
beforeEach(() => {
|
|
396
|
+
resetTelemetryIdentityCache();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
afterEach(() => {
|
|
400
|
+
vi.unstubAllGlobals();
|
|
401
|
+
delete process.env.DO_NOT_TRACK;
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('stamps success:true and flushes before returning', async () => {
|
|
405
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
406
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
407
|
+
await trackSuccess('exec_run', { projectName: 'test' });
|
|
408
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
409
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
410
|
+
expect(JSON.parse(body).properties).toMatchObject({ success: true });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('forces success:true even when the payload says otherwise', async () => {
|
|
414
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
415
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
416
|
+
await trackSuccess('exec_run', { success: false });
|
|
417
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
418
|
+
expect(JSON.parse(body).properties.success).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it.each(['test', 42, true])(
|
|
422
|
+
'wraps primitive properties %s as raw_properties without spreading',
|
|
423
|
+
async (properties) => {
|
|
424
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
425
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
426
|
+
await trackSuccess('exec_run', properties);
|
|
427
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
428
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
429
|
+
const payload = JSON.parse(body).properties;
|
|
430
|
+
expect(payload).toMatchObject({ raw_properties: properties, success: true });
|
|
431
|
+
expect(payload).not.toHaveProperty('0');
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
it('wraps array properties as raw_properties without spreading indices', async () => {
|
|
436
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
437
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
438
|
+
await trackSuccess('exec_run', ['a', 'b']);
|
|
439
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
440
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
441
|
+
const payload = JSON.parse(body).properties;
|
|
442
|
+
expect(payload).toMatchObject({ raw_properties: ['a', 'b'], success: true });
|
|
443
|
+
expect(payload).not.toHaveProperty('0');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('sends only the success flag when properties are null', async () => {
|
|
447
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
448
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
449
|
+
await trackSuccess('exec_run', null);
|
|
450
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
451
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
452
|
+
const payload = JSON.parse(body).properties;
|
|
453
|
+
expect(payload.success).toBe(true);
|
|
454
|
+
expect(payload).not.toHaveProperty('raw_properties');
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe('trackFailure', () => {
|
|
459
|
+
beforeEach(() => {
|
|
460
|
+
resetTelemetryIdentityCache();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
afterEach(() => {
|
|
464
|
+
vi.unstubAllGlobals();
|
|
465
|
+
delete process.env.DO_NOT_TRACK;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('stamps success:false and flushes before returning', async () => {
|
|
469
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
470
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
471
|
+
await trackFailure('exec_run', { projectName: 'test', error_code: 'BOOM' });
|
|
472
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
473
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
474
|
+
expect(JSON.parse(body).properties).toMatchObject({ success: false, error_code: 'BOOM' });
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('forces success:false even when the payload says otherwise', async () => {
|
|
478
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
479
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
480
|
+
await trackFailure('exec_run', { success: true });
|
|
481
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
482
|
+
expect(JSON.parse(body).properties.success).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it.each(['test', 42, true])(
|
|
486
|
+
'wraps primitive properties %s as raw_properties without spreading',
|
|
487
|
+
async (properties) => {
|
|
488
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
489
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
490
|
+
await trackFailure('exec_run', properties);
|
|
491
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
492
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
493
|
+
const payload = JSON.parse(body).properties;
|
|
494
|
+
expect(payload).toMatchObject({ raw_properties: properties, success: false });
|
|
495
|
+
expect(payload).not.toHaveProperty('0');
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
it('wraps array properties as raw_properties without spreading indices', async () => {
|
|
500
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
501
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
502
|
+
await trackFailure('exec_run', ['a', 'b']);
|
|
503
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
504
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
505
|
+
const payload = JSON.parse(body).properties;
|
|
506
|
+
expect(payload).toMatchObject({ raw_properties: ['a', 'b'], success: false });
|
|
507
|
+
expect(payload).not.toHaveProperty('0');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('sends only the success flag when properties are null', async () => {
|
|
511
|
+
const fetchMock = vi.fn().mockResolvedValue({});
|
|
512
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
513
|
+
await trackFailure('exec_run', null);
|
|
514
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
515
|
+
const [, { body }] = fetchMock.mock.calls[0];
|
|
516
|
+
const payload = JSON.parse(body).properties;
|
|
517
|
+
expect(payload.success).toBe(false);
|
|
518
|
+
expect(payload).not.toHaveProperty('raw_properties');
|
|
519
|
+
});
|
|
520
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
|
|
4
|
+
const { mockSpawn } = vi.hoisted(() => ({
|
|
5
|
+
mockSpawn: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('child_process', () => ({
|
|
9
|
+
spawn: mockSpawn,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { runTerraformCommand, getTerraformOutputs } from '../src/utils/terraform.js';
|
|
13
|
+
|
|
14
|
+
function makeChild({ code = 0, stdout = '', stderr = '', error = null } = {}) {
|
|
15
|
+
const child = new EventEmitter();
|
|
16
|
+
child.stdout = new EventEmitter();
|
|
17
|
+
child.stderr = new EventEmitter();
|
|
18
|
+
process.nextTick(() => {
|
|
19
|
+
if (error) {
|
|
20
|
+
child.emit('error', error);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
|
24
|
+
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
|
25
|
+
child.emit('close', code);
|
|
26
|
+
});
|
|
27
|
+
return child;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockSpawn.mockReset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('runTerraformCommand', () => {
|
|
35
|
+
it('resolves on exit 0 and pipes the latest stdout line to the spinner', async () => {
|
|
36
|
+
mockSpawn.mockReturnValue(makeChild({ stdout: 'first\nsecond\n' }));
|
|
37
|
+
const spin = { message: vi.fn() };
|
|
38
|
+
|
|
39
|
+
await expect(runTerraformCommand(['apply'], '/tmp', spin, 'Applying')).resolves.toBeUndefined();
|
|
40
|
+
expect(mockSpawn).toHaveBeenCalledWith('terraform', ['apply'], { cwd: '/tmp' });
|
|
41
|
+
expect(spin.message).toHaveBeenCalledWith(expect.stringContaining('second'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects with the captured stderr on non-zero exit', async () => {
|
|
45
|
+
mockSpawn.mockReturnValue(makeChild({ code: 1, stderr: 'boom\n' }));
|
|
46
|
+
const spin = { message: vi.fn() };
|
|
47
|
+
|
|
48
|
+
await expect(runTerraformCommand(['apply'], '/tmp', spin, 'Applying')).rejects.toThrow('boom');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back to the exit code when stderr is empty', async () => {
|
|
52
|
+
mockSpawn.mockReturnValue(makeChild({ code: 3 }));
|
|
53
|
+
const spin = { message: vi.fn() };
|
|
54
|
+
|
|
55
|
+
await expect(runTerraformCommand(['apply'], '/tmp', spin, 'Applying')).rejects.toThrow('with code 3');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects on spawn errors', async () => {
|
|
59
|
+
mockSpawn.mockReturnValue(makeChild({ error: new Error('spawn terraform ENOENT') }));
|
|
60
|
+
const spin = { message: vi.fn() };
|
|
61
|
+
|
|
62
|
+
await expect(runTerraformCommand(['apply'], '/tmp', spin, 'Applying')).rejects.toThrow('ENOENT');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('getTerraformOutputs', () => {
|
|
67
|
+
it('parses JSON output', async () => {
|
|
68
|
+
mockSpawn.mockReturnValue(makeChild({ stdout: '{"url":"https://x"}' }));
|
|
69
|
+
|
|
70
|
+
await expect(getTerraformOutputs('/tmp')).resolves.toEqual({ url: 'https://x' });
|
|
71
|
+
expect(mockSpawn).toHaveBeenCalledWith('terraform', ['output', '-json'], { cwd: '/tmp' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves to {} on unparseable output, non-zero exit, and spawn errors', async () => {
|
|
75
|
+
mockSpawn.mockReturnValueOnce(makeChild({ stdout: 'not json' }));
|
|
76
|
+
await expect(getTerraformOutputs('/tmp')).resolves.toEqual({});
|
|
77
|
+
|
|
78
|
+
mockSpawn.mockReturnValueOnce(makeChild({ code: 1 }));
|
|
79
|
+
await expect(getTerraformOutputs('/tmp')).resolves.toEqual({});
|
|
80
|
+
|
|
81
|
+
mockSpawn.mockReturnValueOnce(makeChild({ error: new Error('ENOENT') }));
|
|
82
|
+
await expect(getTerraformOutputs('/tmp')).resolves.toEqual({});
|
|
83
|
+
});
|
|
84
|
+
});
|