workflow-state-machines.snapshot.ts4.5 KBView on GitHub
#!/usr/bin/env tsx
/**
 * Layer 3 — CDK Snapshot Test (topology regression guard)
 *
 * Synthesizes the ConversationSync state machine and compares the step
 * definitions against a committed snapshot. Fails if any of the following
 * change without an explicit snapshot update:
 *   - Step names (which must match the dispatch table in step-registry.ts)
 *   - Timeouts
 *   - Concurrency values on Map states
 *   - Step chain order (a step is added, removed, or reordered)
 *
 * Run:  pnpm --ignore-workspace --dir aws exec tsx __tests__/workflow-state-machines.snapshot.ts
 * Update snapshot: add --update flag
 */

import * as cdk from 'aws-cdk-lib';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { CedarWorkflowStateMachines } from '../lib/stacks/workflow-state-machines.js';
import type { CedarAwsEnvironmentConfig } from '../lib/config.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SNAPSHOT_PATH = resolve(__dirname, '__snapshots__/conversation-sync-state-machine.json');
const UPDATE = process.argv.includes('--update');

// ─── Minimal config for synthesis ─────────────────────────────────────────────

const mockConfig = {
  name: 'staging',
  prefix: 'aws-staging',
  secretPrefix: '/cedar/aws-staging',
  customDomains: {},
  account: '123456789012',
  region: 'us-east-1',
} as unknown as CedarAwsEnvironmentConfig;

// ─── Synthesize ───────────────────────────────────────────────────────────────

const app = new cdk.App();
const stack = new cdk.Stack(app, 'TestStack', {
  env: { account: '123456789012', region: 'us-east-1' },
});

const queue = new sqs.Queue(stack, 'TestQueue');
// Construct is registered on the stack — the variable is intentionally unused
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _machines = new CedarWorkflowStateMachines(stack, 'WF', {
  config: mockConfig,
  stepQueue: queue,
});

// Extract the ConversationSync state machine definition
const synth = app.synth();
const template = synth.getStackByName('TestStack').template;

// Pull out just the state machine resources (not queues, roles, etc.)
const stateMachines = Object.entries(template.Resources as Record<string, { Type: string; Properties: Record<string, unknown> }>)
  .filter(([, r]) => r.Type === 'AWS::StepFunctions::StateMachine')
  .reduce((acc, [key, resource]) => {
    // Extract just the name + definition, not ARNs or role refs
    const props = resource.Properties;
    const name = typeof props.StateMachineName === 'string'
      ? props.StateMachineName
      : (props.StateMachineName as { 'Fn::Join': unknown[] })?.['Fn::Join']?.[1]?.join('') ?? key;

    acc[key] = {
      name,
      definition: props.DefinitionString ?? props.Definition,
      timeout: props.TimeoutSeconds,
    };
    return acc;
  }, {} as Record<string, unknown>);

// ─── Snapshot compare / update ────────────────────────────────────────────────

const actual = JSON.stringify(stateMachines, null, 2);

if (UPDATE || !existsSync(SNAPSHOT_PATH)) {
  const snapshotDir = resolve(__dirname, '__snapshots__');
  if (!existsSync(snapshotDir)) {
    const { mkdirSync } = await import('fs');
    mkdirSync(snapshotDir, { recursive: true });
  }
  writeFileSync(SNAPSHOT_PATH, actual, 'utf-8');
  console.log(`✓ Snapshot ${UPDATE ? 'updated' : 'created'}: ${SNAPSHOT_PATH}`);
  process.exit(0);
}

const expected = readFileSync(SNAPSHOT_PATH, 'utf-8');

if (actual === expected) {
  console.log('✓ CDK state machine snapshot matches.');
  process.exit(0);
} else {
  console.error('✗ CDK state machine snapshot MISMATCH.');
  console.error('');
  console.error('The Step Functions topology has changed. If this is intentional, run:');
  console.error('  pnpm --ignore-workspace --dir aws exec tsx __tests__/workflow-state-machines.snapshot.ts --update');
  console.error('');
  console.error('This test protects against:');
  console.error('  - Step name changes (must match dispatch table in step-registry.ts)');
  console.error('  - Timeout changes');
  console.error('  - Concurrency changes on Map states');
  console.error('  - Steps accidentally dropped from the chain');
  process.exit(1);
}