From cacb03af6f8d311a0eefa56e92b53f367dc29c55 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 11:41:02 -0700 Subject: [PATCH 1/2] test(aws-ssm): stub the aws CLI in detect tests so CI never starts the real one The detect tests started the real aws CLI for the default region. On GitHub runners that can take more than 5 s, so the tests timed out on main. They now put a stub aws first on PATH, and a new test checks the region comes from the stub. detectAwsRegion passes env explicitly, because Bun only looks up the current PATH when env is given. --- src/deploy/adapters/awsSsm.ts | 3 +++ tests/deploy/awsSsm.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/deploy/adapters/awsSsm.ts b/src/deploy/adapters/awsSsm.ts index d0ce4e8e..88c30921 100644 --- a/src/deploy/adapters/awsSsm.ts +++ b/src/deploy/adapters/awsSsm.ts @@ -81,8 +81,11 @@ function which(bin: string): string | null { /** Region from the ambient aws CLI config, for picker defaults. */ export function detectAwsRegion(): string | null { + // env is passed on purpose: Bun only looks up the current PATH when env is + // given, and the tests put a stub aws first on PATH. Node behaves the same. const r = spawnSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8', + env: process.env, }); return r.status === 0 ? r.stdout.trim() || null : null; } diff --git a/tests/deploy/awsSsm.test.ts b/tests/deploy/awsSsm.test.ts index 13434116..66689b65 100644 --- a/tests/deploy/awsSsm.test.ts +++ b/tests/deploy/awsSsm.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -86,6 +86,31 @@ describe('awsSsm — name mapping', () => { }); describe('awsSsm — detect', () => { + // detect() asks the aws CLI for a default region. Starting the real aws CLI + // can take more than 5 s on CI runners, so these tests put a stub first on + // PATH that answers at once with a fixed region. + const STUB_BIN = join(tmpdir(), `capy-aws-stub-${process.pid}`); + const STUB_REGION = 'stub-region-1'; + const realPath = process.env.PATH; + beforeAll(() => { + mkdirSync(STUB_BIN, { recursive: true }); + writeFileSync(join(STUB_BIN, 'aws'), `#!/bin/sh\necho ${STUB_REGION}\n`, { mode: 0o755 }); + process.env.PATH = `${STUB_BIN}:${realPath}`; + }); + afterAll(() => { + process.env.PATH = realPath; + rmSync(STUB_BIN, { recursive: true, force: true }); + }); + + test('uses the region from the aws CLI (the stub, never the real aws)', async () => { + writeFileSync( + join(ROOT, 'main.tf'), + 'data "aws_ssm_parameter" "db" {\n name = "/capy/prod/database-url"\n}\n', + ); + const r = await awsSsmAdapter.detect(ROOT); + expect(r.options?.region).toBe(STUB_REGION); + }); + test('infers prefix + kebab naming from literal tf parameter names', async () => { mkdirSync(join(ROOT, 'infra'), { recursive: true }); writeFileSync( From 76e30e31e7d72b0a931e53d13ca28f46668467db Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 11:46:59 -0700 Subject: [PATCH 2/2] test(aws-ssm): stub aws for the whole test file; no test starts the real aws CLI The deploy test for a partially-missing var set went on to real aws calls and timed out on CI too. The stub now covers the whole file and fails every command except configure, and which() and spawnAsync pass env so Bun looks up the stub. The happy-path preflight test no longer needs the real aws on PATH, so it always runs. --- src/deploy/adapters/awsSsm.ts | 9 +++---- tests/deploy/awsSsm.test.ts | 45 +++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/deploy/adapters/awsSsm.ts b/src/deploy/adapters/awsSsm.ts index 88c30921..8e944616 100644 --- a/src/deploy/adapters/awsSsm.ts +++ b/src/deploy/adapters/awsSsm.ts @@ -75,14 +75,15 @@ export function parameterNameFor(varName: string, opts: AwsSsmOptions): string { // ── Helpers ──────────────────────────────────────────────────────────────── function which(bin: string): string | null { - const r = spawnSync('which', [bin], { encoding: 'utf-8' }); + const r = spawnSync('which', [bin], { encoding: 'utf-8', env: process.env }); return r.status === 0 ? r.stdout.trim() || null : null; } /** Region from the ambient aws CLI config, for picker defaults. */ export function detectAwsRegion(): string | null { - // env is passed on purpose: Bun only looks up the current PATH when env is - // given, and the tests put a stub aws first on PATH. Node behaves the same. + // env is passed on purpose (here, in which() and in spawnAsync): Bun only + // looks up the current PATH when env is given, and the tests put a stub aws + // first on PATH. Node behaves the same either way. const r = spawnSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8', env: process.env, @@ -96,7 +97,7 @@ function spawnAsync( stdin?: string, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); diff --git a/tests/deploy/awsSsm.test.ts b/tests/deploy/awsSsm.test.ts index 66689b65..15b5d2f5 100644 --- a/tests/deploy/awsSsm.test.ts +++ b/tests/deploy/awsSsm.test.ts @@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } fr import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { spawnSync } from 'child_process'; import { awsSsmAdapter, leafFor, @@ -13,13 +12,29 @@ import { } from '../../src/deploy/adapters/awsSsm'; import { TargetConfig } from '../../src/deploy/adapter'; -// "Happy path" preflight needs the aws CLI on PATH (the binary check is the -// last step before ok:true). CI doesn't ship it, so gate that test. -// Config-error tests short-circuit before the binary check. -const HAS_AWS = spawnSync('which', ['aws']).status === 0; - const ROOT = join(tmpdir(), `capy-aws-ssm-adapter-${process.pid}`); +// No test here may start the real aws CLI: on CI runners it can take more +// than 5 s to start, and a deploy test would reach out to AWS. A stub aws goes +// first on PATH for the whole file. It answers `configure get region` with a +// fixed region and fails every other command at once. +const STUB_BIN = join(tmpdir(), `capy-aws-stub-${process.pid}`); +const STUB_REGION = 'stub-region-1'; +const realPath = process.env.PATH; +beforeAll(() => { + mkdirSync(STUB_BIN, { recursive: true }); + writeFileSync( + join(STUB_BIN, 'aws'), + `#!/bin/sh\nif [ "$1" = configure ]; then echo ${STUB_REGION}; exit 0; fi\necho "stub aws: no AWS calls in tests" >&2\nexit 1\n`, + { mode: 0o755 }, + ); + process.env.PATH = `${STUB_BIN}:${realPath}`; +}); +afterAll(() => { + process.env.PATH = realPath; + rmSync(STUB_BIN, { recursive: true, force: true }); +}); + beforeEach(() => { if (existsSync(ROOT)) rmSync(ROOT, { recursive: true, force: true }); mkdirSync(ROOT, { recursive: true }); @@ -86,22 +101,6 @@ describe('awsSsm — name mapping', () => { }); describe('awsSsm — detect', () => { - // detect() asks the aws CLI for a default region. Starting the real aws CLI - // can take more than 5 s on CI runners, so these tests put a stub first on - // PATH that answers at once with a fixed region. - const STUB_BIN = join(tmpdir(), `capy-aws-stub-${process.pid}`); - const STUB_REGION = 'stub-region-1'; - const realPath = process.env.PATH; - beforeAll(() => { - mkdirSync(STUB_BIN, { recursive: true }); - writeFileSync(join(STUB_BIN, 'aws'), `#!/bin/sh\necho ${STUB_REGION}\n`, { mode: 0o755 }); - process.env.PATH = `${STUB_BIN}:${realPath}`; - }); - afterAll(() => { - process.env.PATH = realPath; - rmSync(STUB_BIN, { recursive: true, force: true }); - }); - test('uses the region from the aws CLI (the stub, never the real aws)', async () => { writeFileSync( join(ROOT, 'main.tf'), @@ -218,7 +217,7 @@ describe('awsSsm — preflight (config errors, no aws CLI needed)', () => { expect(r.reason).toContain('no vars'); }); - test.if(HAS_AWS)('valid config passes with aws on PATH', async () => { + test('valid config passes with aws on PATH', async () => { const r = await awsSsmAdapter.preflight(baseTarget(), { cwd: ROOT }); expect(r.ok).toBe(true); });