diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ee7690 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +# Mirrors the root package.json scripts step for step: keep the two in +# correspondence when adding a check to either side. +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run typecheck + + - run: npm test + + - run: npm run build + + - name: Version lockstep + run: npm run check-version diff --git a/README.md b/README.md index d563660..66851a0 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,31 @@ npm test npm run build ``` -Releases bump every package to one version, then publish them all: +Releases bump every package to one version, then publish them all. `npm version` +fans the bump out through `scripts/set-version.mjs` (workspace manifests, internal +ranges, embedded `VERSION` constants) and refreshes the lockfile. It also creates +a release commit and a `v` tag, and refuses to run on a dirty tree, so +commit or stash first. Pass `-m` to keep the `chore(release): vX.Y.Z` commit +convention: ```sh -npm run set-version -- 0.2.0 +npm version 0.2.0 -m 'chore(release): v%s' npm run build npm publish --workspaces ``` +Add `--no-git-tag-version` to skip the commit and tag; the bump is then left +staged for you to commit yourself. + +To bump without `npm version`, run `npm run set-version -- 0.2.0` followed by +`npm install --package-lock-only`; `check-version` does not inspect the lockfile, +so a bare `set-version` leaves `package-lock.json` behind unnoticed. + +`npm run check-version` verifies the lockstep without changing anything: +every manifest on the root version, every internal dependency range at +`^`, every embedded `VERSION` constant matching. CI runs it alongside +typecheck, test, and build on every pull request and push to `main`. + ## Getting started (operators) ```sh diff --git a/okf-bundle/decisions/cdk-tests-share-one-outdir.md b/okf-bundle/decisions/cdk-tests-share-one-outdir.md new file mode 100644 index 0000000..6b60af9 --- /dev/null +++ b/okf-bundle/decisions/cdk-tests-share-one-outdir.md @@ -0,0 +1,51 @@ +--- +type: decision +title: CDK construct tests share one cloud-assembly outdir +description: Why construct tests build their App with test/support/test-app.ts + instead of new App(), and why vitest's timeout is 30 s. +tags: + - millwright + - testing + - cdk +timestamp: 2026-09-08T03:23:12.874Z +--- + +Every `Template.fromStack(...)` over the `Millwright` construct synthesizes a full stack, and that +stack bundles **ten `NodejsFunction` Lambdas with esbuild** (poller, launcher, sweep, three synth-job +functions, two run-executor functions, reporter, step-events writer) plus the synth tooling bundle +from `millwright-cli`. One synth costs roughly 1.5 s on a fast workstation and 5–11 s on a loaded +GitHub Actions runner — past vitest's 5 s default per-test budget. That is what made the first CI +workflow run (PR #36) fail with timeouts in `data-stores`, `millwright` and `run-executor-construct`. + +## What was decided + +- Construct tests build their App with `testApp()` from `packages/millwright-cdk/test/support/test-app.ts`, + never a bare `new App()`. The helper pins one cloud-assembly outdir per worker process. +- The root `vitest.config.ts` sets `testTimeout: 30_000`. + +## Why the shared outdir works + +`aws-cdk-lib`'s `AssetStaging` keeps a **process-wide cache** keyed on (outdir, source path, bundling +options). A bare `new App()` picks a fresh temp outdir every time, so the cache never hits and every +test in a file re-runs all the esbuild bundles. With one outdir per process the first synth in a file +bundles and every later synth reuses it: `data-stores.test.ts` dropped from ~21 s to ~4.5 s locally. + +The outdir must be **per process**, not shared across vitest workers: each App writes +`manifest.json` and `Test.template.json` into it, and concurrent workers would race on those files. + +## Why not `CDK_OUTDIR` + +Setting the env var would reach every `new App()` without touching tests, but `App` treats a set +`CDK_OUTDIR` as a request for `autoSynth`, registering a `beforeExit` listener per App. Hundreds of +Apps per run means listener-leak warnings and exit-time synths of half-built trees from the +"throws at construct time" tests. + +## Why not disable bundling + +The `aws:cdk:bundling-stacks` context can skip bundling entirely, but the tests would then stop +exercising the real bundling configuration (workspace aliases, entry points, formats). + +## Citations + +[1] [test-app.ts](../../packages/millwright-cdk/test/support/test-app.ts) +[2] [vitest.config.ts](../../vitest.config.ts) diff --git a/okf-bundle/decisions/index.md b/okf-bundle/decisions/index.md index 3e63f63..e9becbe 100644 --- a/okf-bundle/decisions/index.md +++ b/okf-bundle/decisions/index.md @@ -3,6 +3,7 @@ # Concepts * [BatchGetBuilds is authoritative for terminal job state](batchgetbuilds-authoritative.md) +* [CDK construct tests share one cloud-assembly outdir](cdk-tests-share-one-outdir.md) - Why construct tests build their App with test/support/test-app.ts instead of new App(), and why vitest's timeout is 30 s. * [Caught-timeout wake instead of task heartbeats](no-heartbeat-wake.md) * [Why polling instead of webhooks](no-webhooks.md) * [The poller is non-VPC](non-vpc-poller.md) diff --git a/okf-bundle/interfaces/packages.md b/okf-bundle/interfaces/packages.md index 9a47e89..3cf6f1f 100644 --- a/okf-bundle/interfaces/packages.md +++ b/okf-bundle/interfaces/packages.md @@ -30,11 +30,19 @@ between "millwright's own deployment is CDK" and "your workflows are not CDK". ```sh npm install && npm run typecheck && npm test && npm run build -npm run set-version -- 0.2.0 # bump every package to one version +npm version 0.2.0 -m 'chore(release): v%s' # bump every package, commit, tag v0.2.0 npm run build npm publish --workspaces ``` +`npm run check-version` (`scripts/set-version.mjs --check`) asserts the lockstep without writing: +every manifest on the root version, every `@copperbox/millwright-*` range at `^`, every +`src/version.ts` `VERSION` matching. `.github/workflows/ci.yml` runs typecheck, test, build, and +this check on pull requests and pushes to `main`. The root `version` lifecycle script fans an +`npm version` bump through `set-version.mjs` and refreshes the lockfile. `npm version` then +commits and tags `v`, and it refuses to run on a dirty tree; `--no-git-tag-version` +leaves the bump staged without a commit or tag. Publishing stays manual. + ## Related - [Deployment construct](deployment.md) · [Run model](../schemas/run-model.md) for the diff --git a/okf-bundle/log.md b/okf-bundle/log.md index c062eaf..b2089a0 100644 --- a/okf-bundle/log.md +++ b/okf-bundle/log.md @@ -1,5 +1,8 @@ # Update Log +## 2026-09-08 +* Record why CDK construct tests share one outdir and why vitest's timeout is 30 s + ## 2026-08-13 * **Update**: Updated [Deferred and out of scope for v1](/deferred-and-out-of-scope.md). diff --git a/package-lock.json b/package-lock.json index 2511a11..8496681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "license": "MIT", "workspaces": [ "packages/*" @@ -2727,12 +2727,12 @@ }, "packages/millwright-cdk": { "name": "@copperbox/millwright-cdk", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "dependencies": { - "@copperbox/millwright-cli": "^0.6.2", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-cli": "^0.7.0", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, @@ -2760,7 +2760,7 @@ }, "packages/millwright-cli": { "name": "@copperbox/millwright-cli", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1108.0", @@ -2773,8 +2773,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" @@ -2792,7 +2792,7 @@ }, "packages/millwright-state": { "name": "@copperbox/millwright-state", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" @@ -2800,7 +2800,7 @@ }, "packages/millwright-workflows": { "name": "@copperbox/millwright-workflows", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index ad0b294..e47b8da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millwright", - "version": "0.6.3", + "version": "0.7.0", "private": true, "description": "Millwright monorepo — polling-driven CI/CD in your own AWS account", "license": "MIT", @@ -11,7 +11,9 @@ "build": "npm run build --workspace @copperbox/millwright-state --workspace @copperbox/millwright-workflows --workspace @copperbox/millwright-cdk --workspace @copperbox/millwright-cli", "typecheck": "npm run typecheck --workspaces", "test": "vitest run", - "set-version": "node scripts/set-version.mjs" + "set-version": "node scripts/set-version.mjs", + "check-version": "node scripts/set-version.mjs --check", + "version": "node scripts/set-version.mjs && npm install --package-lock-only && git add -u package.json packages package-lock.json" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/millwright-cdk/package.json b/packages/millwright-cdk/package.json index 9204181..ff3a169 100644 --- a/packages/millwright-cdk/package.json +++ b/packages/millwright-cdk/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cdk", - "version": "0.6.2", + "version": "0.7.0", "description": "The Millwright CDK construct — deploys the millwright control plane into your AWS account", "license": "MIT", "repository": { @@ -23,9 +23,9 @@ "test": "vitest run" }, "dependencies": { - "@copperbox/millwright-cli": "^0.6.2", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-cli": "^0.7.0", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, diff --git a/packages/millwright-cdk/src/version.ts b/packages/millwright-cdk/src/version.ts index d811ac7..a3545c8 100644 --- a/packages/millwright-cdk/src/version.ts +++ b/packages/millwright-cdk/src/version.ts @@ -1,5 +1,5 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.2'; +export const VERSION = '0.7.0'; /** * Highest run-model schemaVersion this control plane accepts. Synth fails diff --git a/packages/millwright-cdk/test/build-project.test.ts b/packages/millwright-cdk/test/build-project.test.ts index 1a5ff25..c092da8 100644 --- a/packages/millwright-cdk/test/build-project.test.ts +++ b/packages/millwright-cdk/test/build-project.test.ts @@ -1,12 +1,13 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { BuildProject } from '../src'; +import { testApp } from './support/test-app'; function synth(): { buildProject: BuildProject; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const buildProject = new BuildProject(stack, 'BuildProject', { deploymentName: 'ci', artifactBucket: new s3.Bucket(stack, 'Artifacts'), diff --git a/packages/millwright-cdk/test/data-stores.test.ts b/packages/millwright-cdk/test/data-stores.test.ts index 8bbbbbf..1af8f70 100644 --- a/packages/millwright-cdk/test/data-stores.test.ts +++ b/packages/millwright-cdk/test/data-stores.test.ts @@ -1,7 +1,8 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import { describe, expect, it } from 'vitest'; import { Millwright, MillwrightProps } from '../src'; +import { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/team-boundary'; @@ -10,7 +11,7 @@ function templateFor(props: Partial = {}): { millwright: Millwright; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const millwright = new Millwright(stack, 'Millwright', { permissionsBoundary: BOUNDARY_ARN, ...props, @@ -169,7 +170,7 @@ describe('build log group (C17)', () => { }); it('rejects retention day counts CloudWatch does not support', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { diff --git a/packages/millwright-cdk/test/event-bus.test.ts b/packages/millwright-cdk/test/event-bus.test.ts index 7ddee0e..144b1c6 100644 --- a/packages/millwright-cdk/test/event-bus.test.ts +++ b/packages/millwright-cdk/test/event-bus.test.ts @@ -1,13 +1,14 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus } from '../src'; +import { testApp } from './support/test-app'; function synth(deploymentName = 'millwright'): { bus: MillwrightEventBus; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName }); return { bus, template: Template.fromStack(stack) }; } diff --git a/packages/millwright-cdk/test/launcher-construct.test.ts b/packages/millwright-cdk/test/launcher-construct.test.ts index 792d124..9fdcc34 100644 --- a/packages/millwright-cdk/test/launcher-construct.test.ts +++ b/packages/millwright-cdk/test/launcher-construct.test.ts @@ -1,12 +1,13 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { Launcher, MillwrightEventBus } from '../src'; +import { testApp } from './support/test-app'; function synth(): { launcher: Launcher; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName: 'ci' }); const launcher = new Launcher(stack, 'Launcher', { deploymentName: 'ci', diff --git a/packages/millwright-cdk/test/millwright.test.ts b/packages/millwright-cdk/test/millwright.test.ts index 02d94ea..3a54258 100644 --- a/packages/millwright-cdk/test/millwright.test.ts +++ b/packages/millwright-cdk/test/millwright.test.ts @@ -1,32 +1,27 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as iam from 'aws-cdk-lib/aws-iam'; import { describe, expect, it } from 'vitest'; import { Boundary, Millwright, MillwrightProps, SUPPORTED_SCHEMA_VERSION, VERSION } from '../src'; -import cdkPkg from '../package.json'; -import rootPkg from '../../../package.json'; -import cliPkg from '../../millwright-cli/package.json'; -import statePkg from '../../millwright-state/package.json'; -import workflowsPkg from '../../millwright-workflows/package.json'; -import { VERSION as CLI_VERSION } from '../../millwright-cli/src/version'; +import { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/team-boundary'; function stackWith(props: MillwrightProps): { stack: Stack; millwright: Millwright } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); return { stack, millwright: new Millwright(stack, 'Millwright', props) }; } describe('permissionsBoundary', () => { it('throws at construct time when absent', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect(() => new Millwright(stack, 'Millwright', {} as MillwrightProps)).toThrow( /permissions boundary/i, ); }); it('throws on a value that is neither an ARN nor Boundary.NONE', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { permissionsBoundary: 'team-boundary' }), ).toThrow(/managed policy ARN or Boundary\.NONE/); @@ -113,7 +108,7 @@ describe('manifest parameter', () => { it('rejects deployment names that cannot namespace SSM paths', () => { for (const bad of ['Millwright', 'has space', '-leading', 'a/b', '']) { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); expect( () => new Millwright(stack, 'Millwright', { @@ -166,19 +161,3 @@ describe('run executor wiring', () => { expect(millwright.buildProject.projectName).toBe(millwright.runExecutor.buildProjectName); }); }); - -describe('lockstep version', () => { - it('keeps the embedded VERSION constants in sync with their manifests', () => { - expect(VERSION).toBe(cdkPkg.version); - expect(CLI_VERSION).toBe(cliPkg.version); - }); - - it('keeps every workspace manifest on the root version', () => { - // A hand-edited bump that skips `npm run set-version` moves some subset - // of the five manifests; pinning all four workspaces to the root catches - // any divergence, not just the cdk one. - for (const pkg of [cdkPkg, cliPkg, statePkg, workflowsPkg]) { - expect(pkg.version, pkg.name).toBe(rootPkg.version); - } - }); -}); diff --git a/packages/millwright-cdk/test/poller-construct.test.ts b/packages/millwright-cdk/test/poller-construct.test.ts index ee4036c..a5a319a 100644 --- a/packages/millwright-cdk/test/poller-construct.test.ts +++ b/packages/millwright-cdk/test/poller-construct.test.ts @@ -1,16 +1,17 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as kms from 'aws-cdk-lib/aws-kms'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus, Poller } from '../src'; +import { testApp } from './support/test-app'; function synth(pollCadence = Duration.minutes(1)): { poller: Poller; stack: Stack; template: Template; } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName: 'ci' }); const poller = new Poller(stack, 'Poller', { deploymentName: 'ci', diff --git a/packages/millwright-cdk/test/reporter-construct.test.ts b/packages/millwright-cdk/test/reporter-construct.test.ts index 0687d64..1e2287b 100644 --- a/packages/millwright-cdk/test/reporter-construct.test.ts +++ b/packages/millwright-cdk/test/reporter-construct.test.ts @@ -1,4 +1,4 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as kms from 'aws-cdk-lib/aws-kms'; @@ -6,9 +6,10 @@ import { describe, expect, it } from 'vitest'; import { Reporter } from '../src'; import { checkStateKey } from '@copperbox/millwright-state'; import { coordinatesFromStreamRecords } from '../src/runtime/reporter/handler'; +import { testApp } from './support/test-app'; function synth(): { reporter: Reporter; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const reporter = new Reporter(stack, 'Reporter', { deploymentName: 'ci', stateTable: new dynamodb.Table(stack, 'StateTable', { diff --git a/packages/millwright-cdk/test/run-executor-construct.test.ts b/packages/millwright-cdk/test/run-executor-construct.test.ts index 5354dc3..9b50415 100644 --- a/packages/millwright-cdk/test/run-executor-construct.test.ts +++ b/packages/millwright-cdk/test/run-executor-construct.test.ts @@ -1,12 +1,13 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { describe, expect, it } from 'vitest'; import { RunExecutor } from '../src'; +import { testApp } from './support/test-app'; function synth(): { executor: RunExecutor; template: Template } { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const executor = new RunExecutor(stack, 'RunExecutor', { deploymentName: 'ci', stateTable: new dynamodb.Table(stack, 'StateTable', { diff --git a/packages/millwright-cdk/test/shim-assets.test.ts b/packages/millwright-cdk/test/shim-assets.test.ts index 1a50ac4..f60ad50 100644 --- a/packages/millwright-cdk/test/shim-assets.test.ts +++ b/packages/millwright-cdk/test/shim-assets.test.ts @@ -4,12 +4,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import cdkPkg from '../package.json'; import { ShimAssets, stageShimDelivery } from '../src'; +import { testApp } from './support/test-app'; const sh = promisify(execFile); @@ -104,7 +105,7 @@ describe('release build', () => { describe('shim assets construct (C13)', () => { it('deploys the staged delivery to the artifact bucket under control/shim/', () => { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); new ShimAssets(stack, 'ShimAssets', { deploymentName: 'ci', artifactBucket: new s3.Bucket(stack, 'Artifacts'), diff --git a/packages/millwright-cdk/test/step-events-writer-construct.test.ts b/packages/millwright-cdk/test/step-events-writer-construct.test.ts index 86875d1..6d7e193 100644 --- a/packages/millwright-cdk/test/step-events-writer-construct.test.ts +++ b/packages/millwright-cdk/test/step-events-writer-construct.test.ts @@ -1,11 +1,12 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { describe, expect, it } from 'vitest'; import { MillwrightEventBus, StepEventsWriter } from '../src'; +import { testApp } from './support/test-app'; function synth(deploymentName = 'ci'): Template { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const bus = new MillwrightEventBus(stack, 'EventBus', { deploymentName }); const table = new dynamodb.Table(stack, 'StateTable', { partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, diff --git a/packages/millwright-cdk/test/support/test-app.ts b/packages/millwright-cdk/test/support/test-app.ts new file mode 100644 index 0000000..0ec1ac4 --- /dev/null +++ b/packages/millwright-cdk/test/support/test-app.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { App, AppProps } from 'aws-cdk-lib'; + +/** + * A CDK App for construct tests, with one cloud-assembly outdir shared by every + * App in this worker process. + * + * Synthesizing the Millwright construct bundles ten Lambdas with esbuild (plus + * the synth tooling bundle), which costs seconds per synth and blew vitest's + * 5 s per-test budget on the CI runner. aws-cdk-lib's AssetStaging keeps a + * process-wide cache keyed on (outdir, source, bundling options), but a bare + * `new App()` picks a fresh temp outdir every time, so nothing ever hits it. + * Pinning the outdir per process means the first synth in a test file does the + * bundling and every later synth reuses it. + * + * The outdir is not removed afterwards: a bare `new App()` also leaves its temp + * dir behind, once per App, so this strictly reduces the litter. + */ +let outdir: string | undefined; + +export function testApp(props: AppProps = {}): App { + outdir ??= mkdtempSync(join(realpathSync(tmpdir()), 'millwright-cdk-test-')); + return new App({ outdir, ...props }); +} diff --git a/packages/millwright-cdk/test/sweep-construct.test.ts b/packages/millwright-cdk/test/sweep-construct.test.ts index 1e00fe7..6b1bd66 100644 --- a/packages/millwright-cdk/test/sweep-construct.test.ts +++ b/packages/millwright-cdk/test/sweep-construct.test.ts @@ -1,13 +1,14 @@ -import { App, Duration, Stack } from 'aws-cdk-lib'; +import { Duration, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { describe, expect, it } from 'vitest'; import { Sweep } from '../src'; +import { testApp } from './support/test-app'; const RUN_EXECUTOR_ARN = 'arn:aws:states:eu-west-1:123456789012:stateMachine:ci-run-executor'; function synth(deploymentName = 'ci'): Template { - const stack = new Stack(new App(), 'Test'); + const stack = new Stack(testApp(), 'Test'); const table = new dynamodb.Table(stack, 'StateTable', { partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING }, diff --git a/packages/millwright-cdk/test/synth-job-construct.test.ts b/packages/millwright-cdk/test/synth-job-construct.test.ts index e276052..5cf9957 100644 --- a/packages/millwright-cdk/test/synth-job-construct.test.ts +++ b/packages/millwright-cdk/test/synth-job-construct.test.ts @@ -1,8 +1,9 @@ -import { App, Stack } from 'aws-cdk-lib'; +import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { beforeAll, describe, expect, it } from 'vitest'; import { Millwright } from '../src/millwright'; import { SYNTH_IMAGE } from '../src/synth-image'; +import { testApp } from './support/test-app'; const BOUNDARY_ARN = 'arn:aws:iam::123456789012:policy/boundary'; @@ -10,7 +11,7 @@ let millwright: Millwright; let template: Template; beforeAll(() => { - const app = new App(); + const app = testApp(); const stack = new Stack(app, 'Test'); millwright = new Millwright(stack, 'Millwright', { permissionsBoundary: BOUNDARY_ARN }); template = Template.fromStack(stack); diff --git a/packages/millwright-cli/package.json b/packages/millwright-cli/package.json index 7bd81c8..9aae3a6 100644 --- a/packages/millwright-cli/package.json +++ b/packages/millwright-cli/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cli", - "version": "0.6.2", + "version": "0.7.0", "description": "millwright CLI — operate a millwright deployment from operator and developer machines", "license": "MIT", "repository": { @@ -33,8 +33,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.6.2", - "@copperbox/millwright-workflows": "^0.6.2", + "@copperbox/millwright-state": "^0.7.0", + "@copperbox/millwright-workflows": "^0.7.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" diff --git a/packages/millwright-cli/src/version.ts b/packages/millwright-cli/src/version.ts index cf52011..88a9716 100644 --- a/packages/millwright-cli/src/version.ts +++ b/packages/millwright-cli/src/version.ts @@ -1,2 +1,2 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.6.2'; +export const VERSION = '0.7.0'; diff --git a/packages/millwright-cli/test/local-run.test.ts b/packages/millwright-cli/test/local-run.test.ts index 5cdcce2..8f21504 100644 --- a/packages/millwright-cli/test/local-run.test.ts +++ b/packages/millwright-cli/test/local-run.test.ts @@ -73,11 +73,17 @@ interface JobPlan { */ class FakeExecutor implements Executor { readonly started: LocalJobSpec[] = []; + private startWaiters: ((spec: LocalJobSpec) => void)[] = []; constructor(private readonly plans: Readonly> = {}) {} async preflight(): Promise {} + /** Resolves when the next job container is started. */ + nextStart(): Promise { + return new Promise((resolve) => this.startWaiters.push(resolve)); + } + private emit(spec: LocalJobSpec, status: string, extra: Record = {}): void { fs.mkdirSync(path.dirname(spec.eventsFile), { recursive: true }); const detail = { @@ -96,6 +102,9 @@ class FakeExecutor implements Executor { start(spec: LocalJobSpec): LocalExecution { this.started.push(spec); + const waiters = this.startWaiters; + this.startWaiters = []; + for (const waiter of waiters) waiter(spec); const attempt = this.started.filter((s) => s.job === spec.job).length; const plan = this.plans[spec.job] ?? {}; let stopRequested = false; @@ -262,9 +271,13 @@ describe('millwright run — the local host', () => { const executor = new FakeExecutor({ build: { block: true } }); const h = harness(root, executor); + const started = executor.nextStart(); const run = localRun(h.deps, { workflow: 'ci' }); - // Let the build container start, then interrupt. - await new Promise((resolve) => setTimeout(resolve, 40)); + // Interrupt once the build container has started. A fixed sleep raced the + // run's setup (several real git subprocesses precede cancel registration): + // under load the cancel arrived before the handler existed, was dropped, + // and the blocked build never ended. + await started; h.cancel(); const result = await run; diff --git a/packages/millwright-state/package.json b/packages/millwright-state/package.json index 1039b19..813e381 100644 --- a/packages/millwright-state/package.json +++ b/packages/millwright-state/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-state", - "version": "0.6.2", + "version": "0.7.0", "description": "Millwright's shared data-plane helpers — state/polling table item accessors, SSM config-plane paths, S3 layout", "license": "MIT", "repository": { diff --git a/packages/millwright-workflows/package.json b/packages/millwright-workflows/package.json index 870a7dc..f52ef08 100644 --- a/packages/millwright-workflows/package.json +++ b/packages/millwright-workflows/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-workflows", - "version": "0.6.2", + "version": "0.7.0", "description": "Millwright workflow definition library — the only install in watched repos", "license": "MIT", "repository": { diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index 448e58a..ceb06e1 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -1,22 +1,23 @@ #!/usr/bin/env node -// Lockstep version bump: all three packages (and the root) always share one -// version, and the embedded src/version.ts constants track package.json. +// Lockstep version bump: all four packages (and the root) always share one +// version, every internal @copperbox/millwright-* dependency range is +// ^, and the embedded src/version.ts constants track package.json. // -// npm run set-version -- 0.2.0 +// npm version 0.2.0 # bump everything to 0.2.0 (via the +// # root `version` lifecycle script) +// npm run set-version -- 0.2.0 # the same bump without npm version +// npm run set-version -- --check # verify the tree is in lockstep (CI) +// +// Under `npm version` the script takes no argument: npm exports the new +// version as npm_package_version for every lifecycle script on every +// platform, whereas `$npm_package_version` in package.json only expands +// under a POSIX shell (cmd.exe hands it over as a literal). import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const version = process.argv[2]; +import { fileURLToPath, pathToFileURL } from 'node:url'; -if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version)) { - console.error('Usage: npm run set-version -- '); - process.exit(1); -} - -const packageDirs = [ +export const packageDirs = [ '.', 'packages/millwright-state', 'packages/millwright-workflows', @@ -24,24 +25,122 @@ const packageDirs = [ 'packages/millwright-cli', ]; -for (const dir of packageDirs) { - const manifestPath = join(root, dir, 'package.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - manifest.version = version; - for (const block of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - for (const dep of Object.keys(manifest[block] ?? {})) { - if (dep.startsWith('@copperbox/millwright-')) manifest[block][dep] = `^${version}`; +const dependencyBlocks = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; +const internalScope = '@copperbox/millwright-'; +const versionPattern = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/; +const versionConstant = /export const VERSION = '([^']*)';/; + +function readManifest(root, dir) { + return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')); +} + +function versionTsPath(root, dir) { + const path = join(root, dir, 'src', 'version.ts'); + return existsSync(path) ? path : undefined; +} + +/** + * Rewrites every manifest and embedded VERSION constant under `root` to + * `version`. Returns one log line per manifest touched. + */ +export function applyVersion(root, version) { + const log = []; + for (const dir of packageDirs) { + const manifest = readManifest(root, dir); + manifest.version = version; + for (const block of dependencyBlocks) { + for (const dep of Object.keys(manifest[block] ?? {})) { + if (dep.startsWith(internalScope)) manifest[block][dep] = `^${version}`; + } + } + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + log.push(`${manifest.name} -> ${version}`); + + const versionTs = versionTsPath(root, dir); + if (versionTs) { + const source = readFileSync(versionTs, 'utf8').replace( + versionConstant, + `export const VERSION = '${version}';`, + ); + writeFileSync(versionTs, source); } } - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`${manifest.name} -> ${version}`); - - const versionTs = join(root, dir, 'src', 'version.ts'); - if (existsSync(versionTs)) { - const source = readFileSync(versionTs, 'utf8').replace( - /export const VERSION = '[^']*';/, - `export const VERSION = '${version}';`, - ); - writeFileSync(versionTs, source); + return log; +} + +/** + * Compares every manifest and embedded VERSION constant under `root` against + * the root manifest's version. Returns the root version and one line per + * mismatch; an empty list means the tree is in lockstep. + */ +export function checkLockstep(root) { + const { version } = readManifest(root, '.'); + const mismatches = []; + for (const dir of packageDirs) { + const manifestPath = join(dir, 'package.json'); + const manifest = readManifest(root, dir); + if (manifest.version !== version) { + mismatches.push(`${manifestPath}: version is ${manifest.version}, expected ${version}`); + } + for (const block of dependencyBlocks) { + for (const [dep, range] of Object.entries(manifest[block] ?? {})) { + if (dep.startsWith(internalScope) && range !== `^${version}`) { + mismatches.push(`${manifestPath}: ${block}.${dep} is ${range}, expected ^${version}`); + } + } + } + + const versionTs = versionTsPath(root, dir); + if (versionTs) { + const found = readFileSync(versionTs, 'utf8').match(versionConstant)?.[1]; + if (found !== version) { + mismatches.push(`${join(dir, 'src', 'version.ts')}: VERSION is ${found ?? 'missing'}, expected ${version}`); + } + } + } + return { version, mismatches }; +} + +function usage() { + console.error('Usage: npm run set-version -- '); + console.error(' npm run set-version -- --check'); + console.error(' npm version (reads npm_package_version)'); + process.exit(1); +} + +/** + * The version to apply: the positional argument, or, when running as npm's + * `version` lifecycle script, the freshly bumped npm_package_version. + */ +export function resolveVersion(positional, env) { + if (positional.length > 0) return positional[0]; + if (env.npm_lifecycle_event === 'version') return env.npm_package_version; + return undefined; +} + +function main(args) { + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + const check = args.includes('--check'); + const positional = args.filter((arg) => arg !== '--check'); + if (positional.length > 1 || (check && positional.length > 0)) usage(); + + if (check) { + const { version, mismatches } = checkLockstep(root); + if (mismatches.length > 0) { + console.error(`Version lockstep broken (root is ${version}):`); + for (const line of mismatches) console.error(` ${line}`); + console.error('Run `npm run set-version -- ` to realign every package.'); + process.exit(1); + } + console.log(`All packages in lockstep at ${version}.`); + return; } + + const version = resolveVersion(positional, process.env); + if (!version || !versionPattern.test(version)) usage(); + for (const line of applyVersion(root, version)) console.log(line); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); } diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs new file mode 100644 index 0000000..25ce109 --- /dev/null +++ b/scripts/set-version.test.mjs @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { applyVersion, checkLockstep, packageDirs, resolveVersion } from './set-version.mjs'; + +const script = join(import.meta.dirname, 'set-version.mjs'); + +const names = { + '.': 'millwright', + 'packages/millwright-state': '@copperbox/millwright-state', + 'packages/millwright-workflows': '@copperbox/millwright-workflows', + 'packages/millwright-cdk': '@copperbox/millwright-cdk', + 'packages/millwright-cli': '@copperbox/millwright-cli', +}; + +const internalDeps = { + 'packages/millwright-cdk': { + dependencies: ['@copperbox/millwright-state', '@copperbox/millwright-workflows'], + devDependencies: ['@copperbox/millwright-cli'], + }, + 'packages/millwright-cli': { + dependencies: ['@copperbox/millwright-state', '@copperbox/millwright-workflows'], + }, +}; + +const withVersionTs = new Set(['packages/millwright-cdk', 'packages/millwright-cli']); + +const tmpdirs = []; + +afterEach(() => { + for (const dir of tmpdirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Builds a minimal lockstep tree at `version` and returns its root. */ +function fixture(version) { + const root = mkdtempSync(join(tmpdir(), 'set-version-')); + tmpdirs.push(root); + for (const dir of packageDirs) { + mkdirSync(join(root, dir), { recursive: true }); + const manifest = { name: names[dir], version }; + for (const [block, deps] of Object.entries(internalDeps[dir] ?? {})) { + manifest[block] = Object.fromEntries(deps.map((dep) => [dep, `^${version}`])); + } + manifest.devDependencies = { ...(manifest.devDependencies ?? {}), typescript: '^5.7.0' }; + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + if (withVersionTs.has(dir)) { + mkdirSync(join(root, dir, 'src'), { recursive: true }); + writeFileSync( + join(root, dir, 'src', 'version.ts'), + `// Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand.\nexport const VERSION = '${version}';\n`, + ); + } + } + return root; +} + +function readManifest(root, dir) { + return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')); +} + +function writeManifest(root, dir, manifest) { + writeFileSync(join(root, dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} + +describe('checkLockstep', () => { + it('reports no mismatches for a tree in lockstep', () => { + const root = fixture('0.6.3'); + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); + }); + + it('names a package whose version drifted from the root', () => { + const root = fixture('0.6.3'); + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.6.2'; + writeManifest(root, 'packages/millwright-cli', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cli/package.json'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); + }); + + it('names an internal dependency range that is not ^', () => { + const root = fixture('0.6.3'); + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies['@copperbox/millwright-cli'] = '^0.6.2'; + writeManifest(root, 'packages/millwright-cdk', manifest); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/package.json'); + expect(mismatches[0]).toContain('devDependencies'); + expect(mismatches[0]).toContain('@copperbox/millwright-cli'); + expect(mismatches[0]).toContain('^0.6.2'); + expect(mismatches[0]).toContain('^0.6.3'); + }); + + it('ignores external dependency ranges', () => { + const root = fixture('0.6.3'); + const manifest = readManifest(root, 'packages/millwright-cdk'); + manifest.devDependencies.typescript = '^0.0.1'; + writeManifest(root, 'packages/millwright-cdk', manifest); + expect(checkLockstep(root).mismatches).toEqual([]); + }); + + it('names an embedded VERSION constant that drifted', () => { + const root = fixture('0.6.3'); + writeFileSync( + join(root, 'packages/millwright-cdk/src/version.ts'), + "export const VERSION = '0.6.2';\n", + ); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(1); + expect(mismatches[0]).toContain('packages/millwright-cdk/src/version.ts'); + expect(mismatches[0]).toContain('0.6.2'); + expect(mismatches[0]).toContain('0.6.3'); + }); + + it('lists every mismatch rather than stopping at the first', () => { + const root = fixture('0.6.3'); + for (const dir of ['packages/millwright-state', 'packages/millwright-workflows']) { + const manifest = readManifest(root, dir); + manifest.version = '0.6.2'; + writeManifest(root, dir, manifest); + } + writeFileSync(join(root, 'packages/millwright-cli/src/version.ts'), "export const VERSION = '0.5.0';\n"); + + const { mismatches } = checkLockstep(root); + expect(mismatches).toHaveLength(3); + }); +}); + +describe('applyVersion', () => { + it('leaves a drifted tree in lockstep at the new version', () => { + const root = fixture('0.6.2'); + const manifest = readManifest(root, 'packages/millwright-cli'); + manifest.version = '0.5.0'; + writeManifest(root, 'packages/millwright-cli', manifest); + + applyVersion(root, '0.6.3'); + + expect(checkLockstep(root)).toEqual({ version: '0.6.3', mismatches: [] }); + expect(readManifest(root, 'packages/millwright-cdk').dependencies['@copperbox/millwright-state']).toBe('^0.6.3'); + expect(readFileSync(join(root, 'packages/millwright-cli/src/version.ts'), 'utf8')).toContain( + "export const VERSION = '0.6.3';", + ); + }); +}); + +describe('resolveVersion', () => { + it('reads npm_package_version only under the version lifecycle', () => { + const env = { npm_lifecycle_event: 'version', npm_package_version: '1.2.3' }; + expect(resolveVersion([], env)).toBe('1.2.3'); + expect(resolveVersion([], { ...env, npm_lifecycle_event: 'set-version' })).toBeUndefined(); + expect(resolveVersion([], { npm_package_version: '1.2.3' })).toBeUndefined(); + expect(resolveVersion(['0.9.0'], env)).toBe('0.9.0'); + }); +}); + +describe('command line', () => { + it('rejects a missing or malformed version', () => { + for (const args of [[], ['1.2']]) { + const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + expect(result.status, args.join(' ')).toBe(1); + expect(result.stderr).toContain('Usage:'); + } + }); + + it('ignores npm_package_version outside the version lifecycle', () => { + const env = { ...process.env, npm_package_version: '1.2.3' }; + delete env.npm_lifecycle_event; + const result = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + it('validates npm_package_version under the version lifecycle', () => { + const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: 'not-a-version' }; + const result = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + it('applies npm_package_version under the version lifecycle', () => { + // The script resolves its tree from its own location, so a copy inside a + // fixture rewrites the fixture rather than this checkout. + const root = fixture('0.6.3'); + mkdirSync(join(root, 'scripts')); + copyFileSync(script, join(root, 'scripts', 'set-version.mjs')); + const env = { ...process.env, npm_lifecycle_event: 'version', npm_package_version: '0.7.0' }; + const result = spawnSync(process.execPath, [join(root, 'scripts', 'set-version.mjs')], { encoding: 'utf8', env }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(checkLockstep(root)).toEqual({ version: '0.7.0', mismatches: [] }); + }); + + it('rejects --check combined with a version', () => { + const result = spawnSync(process.execPath, [script, '--check', '1.2.3'], { encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Usage:'); + }); + + it('passes --check against this repository', () => { + // The real tree is the one CI guards; this test is the only place the + // invariant is asserted, so a drift anywhere in the tree fails here. + const result = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/lockstep at \d+\.\d+\.\d+/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 430d7c1..9f3cc45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,13 @@ import { defineConfig } from 'vitest/config'; // Resolve workspace-internal imports to sources so tests run on a clean // checkout, without a prior `npm run build` producing each package's dist. export default defineConfig({ + // The millwright-cdk construct tests synthesize real stacks, and each first + // synth in a file bundles ten Lambdas with esbuild (see + // packages/millwright-cdk/test/support/test-app.ts). That is several + // seconds on a loaded CI runner — well past vitest's 5 s default. + test: { + testTimeout: 30_000, + }, resolve: { alias: { '@copperbox/millwright-state': path.resolve(