diff --git a/.changeset/bb-republish-patch.md b/.changeset/bb-republish-patch.md new file mode 100644 index 00000000..7960b016 --- /dev/null +++ b/.changeset/bb-republish-patch.md @@ -0,0 +1,12 @@ +--- +"@aws-blocks/bb-app-setting": patch +"@aws-blocks/bb-auth-cognito": patch +"@aws-blocks/bb-auth-oidc": patch +"@aws-blocks/bb-distributed-table": patch +"@aws-blocks/bb-email-client": patch +"@aws-blocks/bb-knowledge-base": patch +"@aws-blocks/bb-kv-store": patch +"@aws-blocks/bb-tracer": patch +--- + +Minor test improvements diff --git a/.changeset/windows-spawn-npx.md b/.changeset/windows-spawn-npx.md new file mode 100644 index 00000000..87202b6c --- /dev/null +++ b/.changeset/windows-spawn-npx.md @@ -0,0 +1,5 @@ +--- +"@aws-blocks/core": patch +--- + +Fix `deploy`, `sandbox`, and `destroy` failing on Windows: spawn `npm`/`npx`/`cdk` via `cross-spawn` (resolves the `.cmd` shims) and import the backend through a `file://` URL so absolute paths like `D:\...` work during CDK synth. diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml new file mode 100644 index 00000000..2f001cee --- /dev/null +++ b/.github/workflows/windows-e2e.yml @@ -0,0 +1,55 @@ +name: Windows E2E + +# Scaffolds the `backend` template from a local registry (packed off this repo) +# and runs the real `npm run dev`/`sandbox`/`deploy` on Windows, exercising the +# client tooling (spawning, CDK synth, paths). Deployed logic runs in Lambda and +# is OS-independent, so behavioral suites stay on Linux. Scheduled + manual only. + +on: + workflow_dispatch: + schedule: + - cron: '0 8 * * *' + - cron: '0 14 * * *' + +concurrency: + group: windows-e2e-${{ github.ref }} + cancel-in-progress: true + +permissions: + id-token: write + contents: read + +jobs: + windows-e2e: + name: Windows E2E (dev + sandbox + deploy) + runs-on: windows-latest + timeout-minutes: 120 + environment: publish + env: + # Unique per run so a cancelled run can't collide with the next. + BLOCKS_STACK_SUFFIX: w${{ github.run_number }} + E2E_FROM_EMAIL: ${{ secrets.E2E_FROM_EMAIL }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: npm + + - run: npm ci + - run: npm run build + + - name: Pack to local registry + run: npm run publish:dry-run + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Scaffold template and run user commands + run: node scripts/ci/windows-template-e2e.mjs diff --git a/package-lock.json b/package-lock.json index 1c79e0fa..ee56578a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52798,6 +52798,7 @@ "@aws-blocks/hosting": "*", "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-ssm": "^3.700.0", + "cross-spawn": "^7.0.6", "http-proxy": "^1.18.1" }, "bin": { @@ -52805,6 +52806,7 @@ "blocks-telemetry": "dist/scripts/telemetry-cli.js" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/http-proxy": "^1.17.16", "@types/node": "^20.0.0", "typescript": "^5.3.0", diff --git a/packages/core/package.json b/packages/core/package.json index 082a1936..fc1b8789 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,6 +55,7 @@ "blocks-telemetry": "./dist/scripts/telemetry-cli.js" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/http-proxy": "^1.17.16", "@types/node": "^20.0.0", "typescript": "^5.3.0", @@ -64,6 +65,7 @@ "@aws-blocks/hosting": "*", "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-ssm": "^3.700.0", + "cross-spawn": "^7.0.6", "http-proxy": "^1.18.1" }, "peerDependencies": { diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index bbc73444..7387d26f 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -6,6 +6,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups'; import { Construct } from 'constructs'; +import { pathToFileURL } from 'node:url'; import { DEFAULT_NODE_RUNTIME } from './node-version.js'; import { addBlocksStackMetadata } from './stack-metadata.js'; import { finalizeConfigRegistry, registerConfig } from './config-registry.js'; @@ -230,8 +231,11 @@ export class BlocksBackend extends Construct { static async create(scope: Construct, id: string, props: BlocksBackendProps) { assertCdkConditionActive(); const backend = new BlocksBackend(scope, id, props); - // ESM caches modules by URL — append a unique query string so each stage re-executes the module body - const mod = await import(`${props.backendCDKPath}?stack=${id}`); + // file:// URL (not a raw path) so the cache-busting query works on Windows, + // where an absolute path like `D:\...` is rejected as URL scheme `d:`. + const backendUrl = pathToFileURL(props.backendCDKPath); + backendUrl.searchParams.set('stack', id); + const mod = await import(backendUrl.href); if (typeof mod.default === 'function') { try { await mod.default(backend); diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index b34cd85a..cca1517f 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -3,6 +3,7 @@ import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; +import { pathToFileURL } from 'node:url'; import { type BlocksStackProps, type BlocksStack as BaseBlocksStack, @@ -51,8 +52,11 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { const actualScope = pipelineScope || scope; const stack = new BlocksStack(actualScope, id, props); - // ESM caches modules by URL — append a unique query string so each stage re-executes the module body - const mod = await import(`${props.backendCDKPath}?stack=${id}`); + // file:// URL (not a raw path) so the cache-busting query works on Windows, + // where an absolute path like `D:\...` is rejected as URL scheme `d:`. + const backendUrl = pathToFileURL(props.backendCDKPath); + backendUrl.searchParams.set('stack', id); + const mod = await import(backendUrl.href); if (typeof mod.default === 'function') { try { await mod.default(stack); diff --git a/packages/core/src/pipeline/pipeline-construct.ts b/packages/core/src/pipeline/pipeline-construct.ts index c093c1f0..e8480863 100644 --- a/packages/core/src/pipeline/pipeline-construct.ts +++ b/packages/core/src/pipeline/pipeline-construct.ts @@ -15,6 +15,7 @@ import { import { Construct } from 'constructs'; import * as fs from 'fs'; import * as path from 'path'; +import { pathToFileURL } from 'node:url'; import type { BranchConfig, PipelineProps, @@ -579,8 +580,10 @@ async function importAppFileForStage( const listenersBefore = process.listeners('beforeExit').slice(); try { - // ESM caches modules by URL — append a unique query string so each stage re-executes the module body - await import(`${appFile}?stage=${encodeURIComponent(stageConfig.name)}`); + // file:// URL (not a raw path) so the cache-busting query works on Windows. + const appUrl = pathToFileURL(appFile); + appUrl.searchParams.set('stage', stageConfig.name); + await import(appUrl.href); } finally { // Remove any beforeExit listeners added during import. // The imported file's cdk.App() registers a synth() handler that would diff --git a/packages/core/src/scripts/deploy.ts b/packages/core/src/scripts/deploy.ts index 87bbe6dd..3eabb7a2 100644 --- a/packages/core/src/scripts/deploy.ts +++ b/packages/core/src/scripts/deploy.ts @@ -9,6 +9,7 @@ import { ensureSecrets, loadProductionEnv } from './ensure-secrets.js'; import { applyExternalMigrations } from './external-migrations-step.js'; import { trackCommand } from '../telemetry/trackCommand.js'; import { getCdkTelemetryEnv } from './cdk-telemetry-env.js'; +import { runSync } from './run-command.js'; export interface DeployOptions { cdkAppPath: string; @@ -56,7 +57,7 @@ export async function deploy(options: DeployOptions) { console.log(' - Frontend hosting (S3 + CloudFront)'); try { - execFileSync( + runSync( "npx", [ "cdk", "deploy", diff --git a/packages/core/src/scripts/destroy.ts b/packages/core/src/scripts/destroy.ts index 470a086a..c3a490e4 100644 --- a/packages/core/src/scripts/destroy.ts +++ b/packages/core/src/scripts/destroy.ts @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from 'node:child_process'; import { trackCommand } from '../telemetry/trackCommand.js'; import { getCdkTelemetryEnv } from './cdk-telemetry-env.js'; +import { runSync } from './run-command.js'; export interface DestroyOptions { cdkAppPath: string; @@ -15,7 +15,7 @@ export async function destroy(options: DestroyOptions) { console.log('šŸ—‘ļø Destroying production stack...'); try { - execFileSync( + runSync( "npx", [ "cdk", "destroy", diff --git a/packages/core/src/scripts/external-migrations-step.ts b/packages/core/src/scripts/external-migrations-step.ts index 2a53a6cf..ecd4d746 100644 --- a/packages/core/src/scripts/external-migrations-step.ts +++ b/packages/core/src/scripts/external-migrations-step.ts @@ -24,10 +24,10 @@ * here. */ -import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { findConnectionString } from './ensure-secrets.js'; import { extractDbRef, dbConnectionParameterName } from '../db-naming.js'; +import { runSync } from './run-command.js'; const DEFAULT_MIGRATIONS_DIR = './migrations'; /** Default output dir for db-pull generated files (database.types.ts / database.meta.ts). */ @@ -59,7 +59,7 @@ function runMigrateSubprocess( migrationsDir: string, regenerateTypesDir?: string, ): void { - execFileSync('npx', buildMigrateArgs(stage, migrationsDir, regenerateTypesDir), { + runSync('npx', buildMigrateArgs(stage, migrationsDir, regenerateTypesDir), { stdio: 'inherit', env: { ...process.env, BLOCKS_MIGRATE_URL: connValue }, }); diff --git a/packages/core/src/scripts/run-command.ts b/packages/core/src/scripts/run-command.ts new file mode 100644 index 00000000..8a92ecaa --- /dev/null +++ b/packages/core/src/scripts/run-command.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChildProcess, + SpawnOptions, + SpawnSyncOptions, +} from 'node:child_process'; +import spawn from 'cross-spawn'; + +// `npm`/`npx`/`cdk`/`tsx` are `.cmd` shims on Windows, which Node's +// execFileSync/spawn can't resolve (spawnSync ENOENT) and won't run without a +// shell. cross-spawn resolves the shim and quotes args safely (array form, no +// shell injection), so these wrappers work on Windows too. + +/** + * Run a command to completion (stdio inherited) and throw on failure — a + * cross-platform drop-in for `execFileSync` where only success/failure matters. + */ +export function runSync( + command: string, + args: string[], + options: SpawnSyncOptions = {}, +): void { + const result = spawn.sync(command, args, { stdio: 'inherit', ...options }); + + if (result.error) { + throw result.error; + } + if (result.signal) { + throw new Error(`${command} was terminated by signal ${result.signal}`); + } + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with code ${result.status}`); + } +} + +/** Spawn a long-running command and return the `ChildProcess` (e.g. `cdk watch`). */ +export function spawnCommand( + command: string, + args: string[], + options: SpawnOptions, +): ChildProcess { + return spawn(command, args, options); +} diff --git a/packages/core/src/scripts/sandbox.ts b/packages/core/src/scripts/sandbox.ts index 27fa9118..cd8fceb0 100644 --- a/packages/core/src/scripts/sandbox.ts +++ b/packages/core/src/scripts/sandbox.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync, spawn } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { writeFileSync, mkdirSync, readFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -10,6 +10,7 @@ import { applyExternalMigrations } from './external-migrations-step.js'; import { trackCommand } from '../telemetry/trackCommand.js'; import { buildAndSendEvent } from '../telemetry/client.js'; import { getCdkTelemetryEnv } from './cdk-telemetry-env.js'; +import { runSync, spawnCommand } from './run-command.js'; /** * Import the backend definition to populate the Scope BB registry. @@ -69,7 +70,7 @@ export async function startSandbox(options: SandboxOptions) { console.log(" (This may take a few minutes on first deploy)"); try { - execFileSync( + runSync( "npm", [ "exec", "cdk", "--", "deploy", @@ -138,7 +139,7 @@ export async function startSandbox(options: SandboxOptions) { console.log("🌐 Starting local dev server (proxying to AWS)..."); console.log(`\n Open http://localhost:${clientPort}\n`); - const cdkWatch = spawn("npx", [ + const cdkWatch = spawnCommand("npx", [ "cdk", "watch", "--hotswap", `--outputs-file`, `${outDir}/outputs.json`, `--context`, `projectRoot=${process.cwd()}`, @@ -162,7 +163,7 @@ export async function startSandbox(options: SandboxOptions) { const devServerCmd = devCommand || `npx tsx watch aws-blocks/scripts/server.ts`; const [cmd, ...args] = devServerCmd.split(' '); - const devServer = spawn(cmd, args, { + const devServer = spawnCommand(cmd, args, { stdio: "inherit", shell: true, env: { @@ -212,7 +213,7 @@ export async function destroySandbox(backendPath: string) { for (let attempt = 0; ; attempt++) { try { - execFileSync("npm", cdkArgs, { stdio: "inherit", env: cdkEnv }); + runSync("npm", cdkArgs, { stdio: "inherit", env: cdkEnv }); console.log(attempt === 0 ? "\nāœ… Sandbox destroyed!" : "\nāœ… Sandbox destroyed on retry!"); return; } catch (error) { diff --git a/scripts/ci/windows-template-e2e.mjs b/scripts/ci/windows-template-e2e.mjs new file mode 100644 index 00000000..82f21dd5 --- /dev/null +++ b/scripts/ci/windows-template-e2e.mjs @@ -0,0 +1,153 @@ +// Windows E2E driver: scaffold the `backend` template from a local registry +// (packed off this repo) and run the real user commands — `npm run dev`, +// `npm run sandbox` (+destroy), `npm run deploy` (+destroy). Readiness is keyed +// off product artifacts (config/outputs files), not log strings. Run from the +// repo root after `npm ci`, `npm run build`, and `npm run publish:dry-run`, +// with AWS creds in the env. Fail-fast; always tears down what it deployed. + +import { spawn, spawnSync, execSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const isWin = process.platform === 'win32'; +const ROOT = process.cwd(); +const REGISTRY = 'http://localhost:4873/registry/'; + +if (!existsSync(join(ROOT, 'dist-registry'))) { + console.error('dist-registry not found — run `npm run publish:dry-run` first.'); + process.exit(1); +} + +const children = []; + +function killTree(pid) { + if (!pid) return; + try { + if (isWin) execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); + else process.kill(-pid, 'SIGKILL'); + } catch { /* already gone */ } +} + +function shutdown() { + for (const c of children) killTree(c.pid); +} + +/** Run a one-shot command; throw on non-zero exit. */ +function run(cmd, args, opts = {}) { + console.log(`\n$ ${cmd} ${args.join(' ')} (cwd: ${opts.cwd ?? ROOT})`); + const r = spawnSync(cmd, args, { stdio: 'inherit', shell: isWin, ...opts }); + if (r.error) throw r.error; + if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited with ${r.status}`); +} + +/** Run a one-shot command; never throw (best-effort cleanup). */ +function runBestEffort(cmd, args, opts = {}) { + console.log(`\n$ ${cmd} ${args.join(' ')} (best-effort, cwd: ${opts.cwd ?? ROOT})`); + try { + spawnSync(cmd, args, { stdio: 'inherit', shell: isWin, ...opts }); + } catch (e) { + console.warn(` (cleanup ignored: ${e?.message ?? e})`); + } +} + +async function httpUp(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + return res.status > 0; + } catch { return false; } +} + +/** + * Spawn a long-running command and poll `ready()` until it returns true. + * Readiness is based on product artifacts (config/outputs files, ports), NOT + * console log strings, so it doesn't break when log wording changes. Rejects if + * the child exits early or the timeout elapses. Returns the child to kill. + */ +async function startUntilReady(cmd, args, opts, ready, timeoutMs) { + console.log(`\n$ ${cmd} ${args.join(' ')}`); + const child = spawn(cmd, args, { shell: isWin, detached: !isWin, stdio: 'inherit', ...opts }); + children.push(child); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`${cmd} ${args.join(' ')} exited (${child.exitCode}) before becoming ready`); + if (await ready()) return child; + await sleep(2000); + } + throw new Error(`${cmd} ${args.join(' ')} did not become ready within ${timeoutMs / 1000}s`); +} + +/** Origin (scheme://host:port) the dev server bound, read from the config file + * it writes (`.blocks-sandbox/config.json` -> apiUrl). null until present. */ +function devOrigin(appDir) { + try { + const cfg = JSON.parse(readFileSync(join(appDir, '.blocks-sandbox', 'config.json'), 'utf-8')); + return cfg.apiUrl ? new URL(cfg.apiUrl).origin : null; + } catch { return null; } +} + +async function main() { + // ── Local registry (node.exe + tsx loader — no shim needed) ────────────── + const registry = spawn(process.execPath, ['--import', 'tsx', 'scripts/publish/serve-local-registry.ts'], { + cwd: ROOT, stdio: 'inherit', detached: !isWin, + }); + children.push(registry); + for (let i = 0; ; i++) { + if (await httpUp(`${REGISTRY}@aws-blocks/blocks`)) break; + if (i > 30) throw new Error('Local registry did not start on :4873'); + await sleep(1000); + } + console.log('Local registry is up.'); + + // Scaffold under RUNNER_TEMP (a long path) — os.tmpdir() on Windows runners + // is the 8.3 short path (C:\Users\RUNNER~1\...), which breaks some tooling. + const baseTmp = process.env.RUNNER_TEMP || tmpdir(); + const work = mkdtempSync(join(baseTmp, 'bb-win-e2e-')); + const userNpmrc = join(work, '.npmrc'); + writeFileSync(userNpmrc, `@aws-blocks:registry=${REGISTRY}\n`); + const env = { ...process.env, NPM_CONFIG_USERCONFIG: userNpmrc, npm_config_cache: join(work, '.npm-cache') }; + + run('npm', ['install', '@aws-blocks/create-blocks-app@latest'], { cwd: work, env }); + const createBin = join(work, 'node_modules', '.bin', isWin ? 'create-blocks-app.cmd' : 'create-blocks-app'); + const app = join(work, 'my-app'); + // `backend` template is backend-only (no DynamoDB/GSI/Aurora) → fast deploy. + run(createBin, [app, '--template', 'backend'], { cwd: work, env }); + + const inApp = { cwd: app, env }; + + // Phase 1: dev — ready when config.json's bound port answers HTTP. + { + const dev = await startUntilReady('npm', ['run', 'dev'], inApp, async () => { + const origin = devOrigin(app); + return origin ? await httpUp(origin) : false; + }, 3 * 60_000); + killTree(dev.pid); + console.log('OK: npm run dev booted and is reachable'); + } + + // Phase 2: sandbox — ready when CDK writes the outputs file (before watch). + const sandboxOutputs = join(app, '.blocks-sandbox', 'outputs.json'); + rmSync(sandboxOutputs, { force: true }); + try { + const sb = await startUntilReady('npm', ['run', 'sandbox'], inApp, () => existsSync(sandboxOutputs), 30 * 60_000); + killTree(sb.pid); + console.log('OK: npm run sandbox deployed'); + } finally { + runBestEffort('npm', ['run', 'sandbox:destroy'], inApp); + } + + // Phase 3: production deploy → destroy. + try { + run('npm', ['run', 'deploy'], inApp); + console.log('OK: npm run deploy succeeded'); + } finally { + runBestEffort('npm', ['run', 'destroy'], inApp); + } + + console.log(`\nOK: scaffolded backend template dev + sandbox + deploy all work on ${process.platform}.`); +} + +main() + .then(() => { shutdown(); process.exit(0); }) + .catch((err) => { console.error(`\nFAIL: ${err?.message ?? err}`); shutdown(); process.exit(1); });