Writing scenarios: every scenario feature: structure, defaults, request and pattern matching, sequences, stateful and state-aware mocks # Writing Scenarios Overview > Learn how to write Scenarist scenarios with all available features Scenarist scenarios are **declarative TypeScript objects** that describe what mock responses to return. This guide helps you navigate the scenario features and choose the right ones for your tests. ## What Scenarios Can Do | Feature | What It Enables | When to Use | | ------------------------------------------------------ | ---------------------------------------------------------------- | --------------------------------------------------------------- | | [Basic Structure](/scenarios/basic-structure/) | Define mock responses for HTTP requests | Every scenario needs this | | [Request Matching](/scenarios/request-matching/) | Return different responses for same URL based on request content | Different responses for different users, tiers, or request data | | [Pattern Matching](/scenarios/pattern-matching/) | Match using regex, contains, startsWith, endsWith | Campaign codes, user agents, email domains, dynamic values | | [Response Sequences](/scenarios/response-sequences/) | Return different responses on successive calls | Polling, async job status, multi-step workflows | | [Stateful Mocks](/scenarios/stateful-mocks/) | Capture data from requests, inject into later responses | Shopping carts, user profiles, accumulated data | | [Default Scenarios](/scenarios/default-scenarios/) | Define baseline mocks, override only what changes | DRY scenarios, avoid duplication | | [Combining Features](/scenarios/combining-features/) | Use all features together | Complex realistic workflows | | [TypeScript Patterns](/scenarios/typescript-patterns/) | Type-safe scenario definitions | Autocomplete, compile-time errors | ## Which Feature Do I Need? **Start here:** Every scenario needs the [Basic Structure](/scenarios/basic-structure/) - this defines your mocks with method, URL, and response. Then ask yourself: ### “I need different responses for the same URL” **Based on request content?** Use [Request Matching](/scenarios/request-matching/) * Different pricing for premium vs standard users * Different responses based on API version header * Different data based on query parameters **Based on how many times it’s called?** Use [Response Sequences](/scenarios/response-sequences/) * Job status: pending → processing → complete * Polling patterns * Retry scenarios ### “I need to match dynamic values” Use [Pattern Matching](/scenarios/pattern-matching/) for: * Campaign codes: `x-campaign: summer-premium-2024` * User agents: `Mobile`, `Chrome`, `Safari` * Email domains: `@company.com` * File extensions: `.pdf`, `.jpg` ### “I need responses to reflect earlier requests” Use [Stateful Mocks](/scenarios/stateful-mocks/) for: * Shopping cart that accumulates items * User profile that reflects submitted data * Session data that persists across requests ### “I’m duplicating mocks across scenarios” Use [Default Scenarios](/scenarios/default-scenarios/) to: * Define happy path once in ‘default’ * Override only what changes in specialized scenarios * Keep scenarios DRY ### “I need all of the above” See [Combining Features](/scenarios/combining-features/) for examples of using multiple features together. ## Quick Example A minimal scenario with all required fields: ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const myScenario: ScenaristScenario = { id: 'my-scenario', // Unique identifier name: 'My Scenario', // Human-readable name description: 'What this scenario tests', mocks: [ { method: 'GET', url: 'https://api.example.com/user', response: { status: 200, body: { id: 1, name: 'Test User' }, }, }, ], }; ``` ## Why Declarative? Scenarist enforces **declarative patterns** - scenarios describe WHAT to return, not HOW to decide. No imperative functions with hidden if/else logic. This leads to: 1. **Visible intent** - Match criteria show what matters 2. **Composable features** - Specificity-based selection, automatic fallback 3. **Testable scenarios** - Can validate statically with TypeScript [Learn more about the philosophy →](/concepts/philosophy/#3-declarative-beats-imperative-for-test-setup) ## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Start with the fundamentals * [Request Matching →](/scenarios/request-matching/) - Different responses for same URL * [Response Sequences →](/scenarios/response-sequences/) - Polling and async workflows # Basic Structure > Foundational scenario definition with mocks, methods, URLs, and responses ## What This Enables Define HTTP mock responses that intercept requests to external APIs during tests. Every scenario needs this foundational structure. **Use cases:** * Mock any external API (Stripe, GitHub, Auth0, etc.) * Return controlled responses during tests * Define once, use across all tests ## Scenario Structure Every scenario requires these fields: ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const myScenario: ScenaristScenario = { id: 'my-scenario', // Unique identifier (required) name: 'My Scenario', // Human-readable name (required) description: 'What this scenario represents', // Documentation (required) mocks: [ // Array of mock definitions (required) { method: 'GET', url: 'https://api.example.com/user', response: { status: 200, body: { id: 1, name: 'Test User' }, }, }, ], }; ``` ### Required Fields | Field | Type | Description | | ------------- | -------- | -------------------------------------------------------- | | `id` | `string` | Unique identifier used when switching scenarios in tests | | `name` | `string` | Human-readable name for documentation and tooling | | `description` | `string` | Explains when and why to use this scenario | | `mocks` | `array` | Array of mock definitions | ## Mock Definition Each mock requires a `method`, `url`, and **one of** `response`, `sequence`, or `stateResponse`. ### Simple Response Mock ```typescript { method: 'GET', // HTTP method (required) url: 'https://api.example.com/user', // URL pattern (required) response: { // Single static response status: 200, body: { id: 1, name: 'Test User' }, headers: { 'x-custom': 'value' }, // Optional delay: 1000, // Optional delay in ms }, } ``` ### Sequence Mock For multiple responses (polling, async operations), use `sequence` instead of `response`: ```typescript { method: 'GET', url: 'https://api.example.com/job/:id', sequence: { // Response sequence responses: [ // Array of responses { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'complete' } }, ], repeat: 'last', // 'last' | 'cycle' | 'none' }, } ``` See [Response Sequences →](/scenarios/response-sequences/) for details. ## HTTP Methods Supported methods: * `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, `HEAD` ```typescript { method: 'GET', url: '...', response: {...} } { method: 'POST', url: '...', response: {...} } { method: 'PUT', url: '...', response: {...} } { method: 'DELETE', url: '...', response: {...} } { method: 'PATCH', url: '...', response: {...} } ``` ## URL Patterns URLs support four matching styles: ### Exact Match ```typescript url: 'https://api.example.com/users' // Matches: https://api.example.com/users // Doesn't match: https://api.example.com/users/123 ``` ### Path Parameters ```typescript url: 'https://api.example.com/users/:id' // Matches: https://api.example.com/users/123 // Matches: https://api.example.com/users/abc ``` ### Multi-Segment Parameters ```typescript url: 'https://api.example.com/users/:path+' // Matches: https://api.example.com/users/123 // Matches: https://api.example.com/users/123/profile ``` Glob wildcards such as `/users/*` are not supported in mock URLs; use a repeating parameter (`:path+`) or a RegExp instead. ### Regular Expressions Use native JavaScript RegExp for complex URL matching: ```typescript // Match any API version url: /https:\/\/api\.example\.com\/v\d+\/users/ // Matches: https://api.example.com/v1/users // Matches: https://api.example.com/v2/users // Matches: https://api.example.com/v99/users // Match numeric IDs only url: /\/users\/\d+$/ // Matches: /users/123 // Matches: /users/456789 // Doesn't match: /users/abc // Origin-agnostic matching (matches any host) url: /\/api\/products$/ // Matches: http://localhost:3000/api/products // Matches: https://api.example.com/api/products ``` RegExp uses **weak comparison** (partial matching), making it ideal for origin-agnostic patterns. See [Pattern Matching →](/scenarios/pattern-matching/) for advanced regex patterns. ## Response Structure Every response (single or in sequence) contains: ```typescript response: { status: 200, // HTTP status code (100-599, required) body: { // Response body (any value, optional) id: 1, data: 'example', }, headers: { // Response headers (string key-value pairs, optional) 'x-custom': 'value', 'x-request-id': 'abc123', }, delay: 1000, // Delay in milliseconds (optional) } ``` ### Status Codes Any valid HTTP status code (100-599): ```typescript // Success { status: 200, body: { success: true } } { status: 201, body: { id: 'created-123' } } { status: 204 } // No content // Client errors { status: 400, body: { error: 'Bad Request' } } { status: 401, body: { error: 'Unauthorized' } } { status: 404, body: { error: 'Not Found' } } // Server errors { status: 500, body: { error: 'Internal Server Error' } } { status: 503, body: { error: 'Service Unavailable' } } ``` ### Response Body The `body` field accepts any JSON-serializable value: ```typescript // Object body: { id: 1, name: 'User', roles: ['admin', 'user'] } // Array body: [{ id: 1 }, { id: 2 }, { id: 3 }] // Primitive body: 'Success' body: 42 body: true // Null body: null ``` ### Response Headers Custom headers as string key-value pairs: ```typescript headers: { 'content-type': 'application/json', 'x-request-id': 'req-123', 'x-ratelimit-remaining': '99', } ``` ### Response Delay Simulate network latency or slow responses: ```typescript // Simulate 2-second API response { method: 'GET', url: 'https://api.slow.com/data', response: { status: 200, body: { data: 'result' }, delay: 2000, // 2 seconds }, } ``` ## Complete Example ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const defaultScenario: ScenaristScenario = { id: 'default', name: 'Happy Path', description: 'All external APIs succeed with valid responses', mocks: [ // GitHub API - successful user lookup { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 200, body: { login: 'octocat', name: 'The Octocat', public_repos: 8, }, }, }, // Stripe API - successful payment { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123', status: 'succeeded', amount: 5000, }, }, }, // SendGrid API - email sent { method: 'POST', url: 'https://api.sendgrid.com/v3/mail/send', response: { status: 202, body: { message_id: 'msg_123' }, }, }, ], }; ``` ## Next Steps * [Request Matching →](/scenarios/request-matching/) - Different responses for same URL * [Response Sequences →](/scenarios/response-sequences/) - Polling and async workflows * [Default Scenarios →](/scenarios/default-scenarios/) - DRY scenario patterns # Default Scenarios > Fallback behavior, override patterns, and DRY scenario definitions ## What This Enables Define baseline mocks once in a ‘default’ scenario, then create specialized scenarios that override only what changes. Automatic fallback eliminates duplication. **Use cases:** * **DRY scenarios:** Define common mocks once, reuse everywhere * **Partial overrides:** Only define what’s different in each scenario * **Error scenarios:** Override one API to fail, others fall back to success * **Clean test setup:** No duplicating happy-path mocks in every scenario ## When to Use Always use a default scenario to: * Define your happy path (all APIs succeed) * Provide baseline responses for all tests * Enable specialized scenarios to focus on what’s different ## The ‘default’ Scenario Requirement Every scenarios object **must have a ‘default’ key** (enforced via schema validation): ```typescript import type { ScenaristScenarios } from '@scenarist/express-adapter'; export const scenarios = { default: defaultScenario, // ✅ Required success: successScenario, error: errorScenario, } as const satisfies ScenaristScenarios; // ❌ WRONG - Missing 'default' key export const scenarios = { success: successScenario, error: errorScenario, } as const satisfies ScenaristScenarios; // Error: Scenarios object must have a 'default' key ``` **Why ‘default’ is required:** 1. **Fallback behavior:** When no scenario is set, default is used 2. **Baseline mocks:** Provides common responses across all tests 3. **Clarity:** Makes baseline behavior obvious 4. **Safety:** Tests without explicit scenarios still work ## How Default Fallback Works When you switch to a specialized scenario, Scenarist collects the active scenario’s mocks for the request’s URL and method. If none of them is a fallback mock (a mock without `match` criteria), it also collects the default scenario’s mocks, then uses specificity-based selection. ```typescript // Default scenario: All APIs succeed export const defaultScenario: ScenaristScenario = { id: 'default', name: 'Happy Path', description: 'All external APIs succeed', mocks: [ { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 200, body: { login: 'octocat' } } }, { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { status: 'succeeded' } } }, { method: 'GET', url: 'https://api.weather.com/v1/:city', response: { status: 200, body: { temp: 18 } } }, ], }; // Error scenario: Override only GitHub export const githubErrorScenario: ScenaristScenario = { id: 'github-error', name: 'GitHub Error', description: 'GitHub returns 404, everything else succeeds', mocks: [ { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 404, body: { message: 'Not Found' } } }, // Stripe and Weather NOT defined → fall back to default ], }; ``` **When you switch to `github-error`:** * GitHub API → 404 (overridden by active scenario) * Stripe API → 200 (falls back to default) * Weather API → 200 (falls back to default) ## Partial Override (Not Full Replacement) Specialized scenarios only define **mocks they override**. Everything else falls back: ```typescript // ❌ WITHOUT DEFAULT FALLBACK - Duplication hell export const githubErrorScenario: ScenaristScenario = { mocks: [ // Override GitHub { method: 'GET', url: 'https://api.github.com/...', response: { status: 500 } }, // Must duplicate Stripe (unchanged) { method: 'POST', url: 'https://api.stripe.com/...', response: { status: 200, body: {...} } }, // Must duplicate Weather (unchanged) { method: 'GET', url: 'https://api.weather.com/...', response: { status: 200, body: {...} } }, // ... 50 more unchanged APIs duplicated ... ], }; // ✅ WITH DEFAULT FALLBACK - Only define what changes export const githubErrorScenario: ScenaristScenario = { mocks: [ // Only override what changes { method: 'GET', url: 'https://api.github.com/...', response: { status: 500 } }, // Everything else: default scenario automatically ], }; ``` ## URL + Method Matching Overrides work at the URL + method level: ```typescript // Default has both GET and POST for same base URL export const defaultScenario: ScenaristScenario = { mocks: [ { method: 'GET', url: '/api/data', response: { status: 200, body: { data: 'default' } } }, { method: 'POST', url: '/api/data', response: { status: 201, body: { created: true } } }, ], }; // Override only GET export const customScenario: ScenaristScenario = { mocks: [ { method: 'GET', url: '/api/data', response: { status: 200, body: { data: 'custom' } } }, // POST not defined → falls back to default ], }; // Result: // GET /api/data → custom response (override) // POST /api/data → default response (fallback) ``` ## Specificity-Based Selection When both default and active scenarios have mocks for the same URL, specificity determines the winner: * Mocks with `match` criteria are more specific * More criteria = higher specificity * Most specific wins ```typescript // Default: Simple fallback mocks: [ { method: 'POST', url: '/api/checkout', response: { status: 200, body: { price: 100 } } } // Specificity: 0 ] // Active: Match premium users mocks: [ { method: 'POST', url: '/api/checkout', match: { body: { tier: 'premium' } }, // Specificity: 1 response: { status: 200, body: { price: 80 } } } ] // Request with tier='premium' → Active scenario (specificity 1 > 0) // Request without tier → Default scenario (fallback) ``` ## Active Fallbacks Replace Default Mocks When the active scenario has a fallback mock (no match criteria) for a URL and method, the default scenario’s mocks for that URL and method are not considered at all: ```typescript // Default scenario { method: 'GET', url: '/api/data', response: { status: 200, body: { source: 'default' } } } // Active scenario ← Wins (default's mock is not a candidate) { method: 'GET', url: '/api/data', response: { status: 200, body: { source: 'active' } } } ``` This allows active scenarios to override default fallbacks without needing match criteria. ## Complete Example ```typescript import type { ScenaristScenarios } from '@scenarist/express-adapter'; export const scenarios = { // Default: All APIs work (happy path) default: { id: 'default', name: 'Happy Path', description: 'All external APIs succeed', mocks: [ { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 200, body: { login: 'octocat' } } }, { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { status: 'succeeded' } } }, { method: 'GET', url: 'https://api.weather.com/:city', response: { status: 200, body: { temp: 18 } } }, ], }, // GitHub error - others fall back githubError: { id: 'github-error', name: 'GitHub Not Found', description: 'GitHub 404, Stripe and Weather work', mocks: [ { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 404 } }, ], }, // Stripe error - others fall back stripeError: { id: 'stripe-error', name: 'Payment Failed', description: 'Stripe declines, GitHub and Weather work', mocks: [ { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 402, body: { error: 'Card declined' } } }, ], }, // Slow network - override all with delays slowNetwork: { id: 'slow-network', name: 'Slow Network', description: 'All APIs slow', mocks: [ { method: 'GET', url: 'https://api.github.com/users/:username', response: { status: 200, delay: 2000, body: { login: 'octocat' } } }, { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, delay: 1500, body: { status: 'succeeded' } } }, { method: 'GET', url: 'https://api.weather.com/:city', response: { status: 200, delay: 1000, body: { temp: 18 } } }, ], }, } as const satisfies ScenaristScenarios; ``` **Usage:** * No scenario switch → All APIs work (default) * `switchScenario('github-error')` → GitHub 404, Stripe/Weather work * `switchScenario('stripe-error')` → Stripe fails, GitHub/Weather work * `switchScenario('slow-network')` → All APIs slow ## When Default Is Used * Test doesn’t call `switchScenario()` * Test ID header is missing (manual testing) * Between test runs (before first scenario switch) ## Benefits Summary 1. **No Duplication:** Define common mocks once 2. **Clear Intent:** Specialized scenarios show exactly what changes 3. **Maintainability:** Update defaults, all scenarios benefit 4. **Safety:** Tests always have fallback behavior 5. **Flexibility:** Override as little or as much as needed ## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Scenario fundamentals * [Request Matching →](/scenarios/request-matching/) - Match within scenarios * [TypeScript Patterns →](/scenarios/typescript-patterns/) - Type-safe scenario definitions # Combining Features > Use request matching, sequences, stateful mocks, and state-aware mocking together ## What This Enables Combine all scenario features to create powerful, realistic test scenarios. Features work independently while maintaining their guarantees. **Use cases:** * Premium onboarding with progress tracking * User-specific workflows with state capture * Conditional sequences based on request content * Complex multi-step business processes * State machine workflows with automatic transitions ## Feature Combinations | Combination | What It Does | | --------------------------- | ----------------------------------------------- | | Matching + Sequences | Only matching requests advance the sequence | | Matching + State Capture | Capture different data based on request content | | Sequences + State Capture | Capture data as sequence progresses | | State-Aware + Matching | Mock selection based on accumulated state | | State-Aware + State Capture | Capture data that drives conditional responses | | All Features | Full workflow simulation with state machines | ## Matching + Sequences Only requests that match the criteria advance through the sequence: ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; const scenario: ScenaristScenario = { id: 'premium-onboarding', name: 'Premium Onboarding', description: 'Premium users get onboarding sequence, others see upgrade message', mocks: [ // Premium users advance through onboarding { method: 'GET', url: '/api/onboarding/step', match: { headers: { 'x-tier': 'premium' } }, sequence: { responses: [ { status: 200, body: { step: 1, message: 'Welcome!' } }, { status: 200, body: { step: 2, message: 'Configure...' } }, { status: 200, body: { step: 3, message: 'Complete!' } }, ], repeat: 'last', }, }, // Standard users see upgrade message (no sequence) { method: 'GET', url: '/api/onboarding/step', response: { status: 200, body: { message: 'Upgrade to premium for onboarding' }, }, }, ], }; ``` **Key insight:** Non-matching requests (standard users) don’t advance the premium sequence. The sequence position is preserved for the next matching request. ```plaintext Premium request 1 → Step 1 Standard request → "Upgrade" message (sequence unchanged) Premium request 2 → Step 2 Premium request 3 → Step 3 ``` ## Matching + State Capture different data based on request content: ```typescript const scenario: ScenaristScenario = { id: 'tiered-cart', name: 'Tiered Shopping Cart', description: 'Separate cart tracking for premium and standard items', mocks: [ // Capture premium items { method: 'POST', url: '/api/cart/add', match: { body: { tier: 'premium' } }, captureState: { 'premiumItems[]': 'body.productId', }, response: { status: 200, body: { added: true, tier: 'premium' } }, }, // Capture standard items { method: 'POST', url: '/api/cart/add', match: { body: { tier: 'standard' } }, captureState: { 'standardItems[]': 'body.productId', }, response: { status: 200, body: { added: true, tier: 'standard' } }, }, // Cart shows both { method: 'GET', url: '/api/cart', response: { status: 200, body: { premium: '{{state.premiumItems}}', standard: '{{state.standardItems}}', }, }, }, ], }; ``` ## Sequences + State Capture data as the sequence progresses: ```typescript const scenario: ScenaristScenario = { id: 'job-tracking', name: 'Job Progress Tracking', description: 'Capture progress through job sequence', mocks: [ // Job status with progress capture { method: 'GET', url: '/api/job/:id/status', sequence: { responses: [ { status: 200, body: { status: 'queued', progress: 0 } }, { status: 200, body: { status: 'running', progress: 50 } }, { status: 200, body: { status: 'complete', progress: 100 } }, ], repeat: 'last', }, captureState: { lastStatus: 'body.status', lastProgress: 'body.progress', }, }, // Dashboard shows captured progress { method: 'GET', url: '/api/dashboard', response: { status: 200, body: { jobStatus: '{{state.lastStatus}}', jobProgress: '{{state.lastProgress}}', }, }, }, ], }; ``` **Note:** `captureState` captures from the **request**, not the response. To track sequence progress in state, include progress info in the request or use a separate tracking mechanism. ## All Three Together Complete example combining matching, sequences, and state: ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const premiumOnboardingScenario: ScenaristScenario = { id: 'premium-onboarding-full', name: 'Premium User Onboarding', description: 'Multi-step onboarding with state and sequences for premium users', mocks: [ // Premium users: Onboarding sequence with profile capture { method: 'POST', url: '/api/onboarding', match: { headers: { 'x-tier': 'premium' } }, sequence: { responses: [ { status: 200, body: { step: 1, message: 'Welcome premium user!' } }, { status: 200, body: { step: 2, message: 'Set up your profile' } }, { status: 200, body: { step: 3, message: 'You are all set!' } }, ], repeat: 'last', }, captureState: { 'profileData.name': 'body.name', 'profileData.preferences[]': 'body.preference', 'completedSteps[]': 'body.stepNumber', }, }, // Standard users: Simple upgrade prompt { method: 'POST', url: '/api/onboarding', response: { status: 200, body: { message: 'Upgrade to premium for full onboarding' }, }, }, // Dashboard: Shows captured profile and progress { method: 'GET', url: '/api/dashboard', response: { status: 200, body: { profile: { name: '{{state.profileData.name}}', preferences: '{{state.profileData.preferences}}', }, onboarding: { completedSteps: '{{state.completedSteps}}', stepCount: '{{state.completedSteps.length}}', }, }, }, }, ], }; ``` **This enables:** 1. Premium header triggers premium onboarding sequence 2. Each step captures profile data from request 3. Standard users get upgrade message (don’t advance sequence) 4. Dashboard shows accumulated profile and progress 5. All isolated per test ID for parallel execution ## Real-World Workflow Example E-commerce checkout with tier-based pricing and order tracking: ```typescript export const checkoutWorkflowScenario: ScenaristScenario = { id: 'checkout-workflow', name: 'Checkout Workflow', description: 'Complete checkout with pricing tiers and order tracking', mocks: [ // Add to cart - track items by tier { method: 'POST', url: '/api/cart/add', match: { body: { itemType: 'premium' } }, captureState: { 'cart.premiumItems[]': 'body.productId', }, response: { status: 200, body: { added: true } }, }, { method: 'POST', url: '/api/cart/add', captureState: { 'cart.standardItems[]': 'body.productId', }, response: { status: 200, body: { added: true } }, }, // Checkout - premium users get discount { method: 'POST', url: '/api/checkout', match: { headers: { 'x-tier': 'premium' } }, captureState: { orderId: 'body.orderId', }, response: { status: 200, body: { discount: 20, orderId: '{{state.orderId}}' }, }, }, { method: 'POST', url: '/api/checkout', captureState: { orderId: 'body.orderId', }, response: { status: 200, body: { discount: 0, orderId: '{{state.orderId}}' }, }, }, // Order status - sequence through fulfillment { method: 'GET', url: '/api/order/:id/status', sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'processing' } }, { status: 200, body: { status: 'shipped' } }, { status: 200, body: { status: 'delivered' } }, ], repeat: 'last', }, }, // Order summary - shows cart contents and order { method: 'GET', url: '/api/order/summary', response: { status: 200, body: { orderId: '{{state.orderId}}', premiumItems: '{{state.cart.premiumItems}}', standardItems: '{{state.cart.standardItems}}', }, }, }, ], }; ``` ## State-Aware + Request Matching Use `match.state` with other match criteria for powerful state machines: ```typescript const scenario: ScenaristScenario = { id: 'approval-workflow', name: 'Approval Workflow', description: 'State-driven approval with role-based decisions', mocks: [ // Approve from pending_review state (admin only) { method: 'POST', url: '/api/application/decision', match: { state: { step: 'pending_review' }, body: { decision: 'approve' }, headers: { 'x-role': 'admin' } }, response: { status: 200, body: { status: 'approved' } }, afterResponse: { setState: { step: 'approved' } } }, // Reject from pending_review state (any reviewer) { method: 'POST', url: '/api/application/decision', match: { state: { step: 'pending_review' }, body: { decision: 'reject' } }, response: { status: 200, body: { status: 'rejected' } }, afterResponse: { setState: { step: 'rejected' } } }, // Status endpoint with stateResponse { method: 'GET', url: '/api/application/status', stateResponse: { default: { status: 200, body: { status: 'pending' } }, conditions: [ { when: { step: 'pending_review' }, then: { status: 200, body: { status: 'in_review' } } }, { when: { step: 'approved' }, then: { status: 200, body: { status: 'approved' } } }, { when: { step: 'rejected' }, then: { status: 200, body: { status: 'rejected' } } } ] } } ] }; ``` **This enables:** 1. `match.state` determines which mock handles the decision 2. Additional match criteria (body, headers) add role-based logic 3. `afterResponse.setState` advances the workflow 4. `stateResponse` provides status based on accumulated state ## Best Practices 1. **Keep it focused:** Each scenario should test a specific workflow, not everything 2. **Use default scenario:** Define happy path in default, override only differences 3. **Document intent:** Use clear `name` and `description` fields 4. **Consider test isolation:** State is per-test-ID, so parallel tests are safe 5. **Choose the right tool:** * Use `captureState` + templates for data flow * Use `stateResponse` for conditional responses * Use `match.state` for state-driven mock routing * Use `sequence` when call counts are predictable ## Next Steps * [State-Aware Mocking →](/scenarios/state-aware-mocking/) - State-driven behavior * [Request Matching →](/scenarios/request-matching/) - Matching criteria details * [Response Sequences →](/scenarios/response-sequences/) - Sequence behavior * [Stateful Mocks →](/scenarios/stateful-mocks/) - State capture and injection * [Default Scenarios →](/scenarios/default-scenarios/) - DRY patterns # Pattern Matching > Flexible matching with regex, contains, startsWith, endsWith strategies ## What This Enables Match URLs and request values using flexible patterns instead of exact strings. Useful for dynamic URLs, campaign codes, user agents, email domains, and file extensions. **Use cases:** * Version-agnostic API matching: `/api/v1/...`, `/api/v2/...` * Origin-agnostic URL patterns: Match any host * Marketing campaigns: `x-campaign: summer-premium-2024` * User agent detection: Mobile vs Desktop * Email domain filtering: `@company.com` * File type validation: `.pdf`, `.jpg` ## When to Use Use pattern matching when: * URL contains variable parts (API versions, numeric IDs) * You need origin-agnostic URL matching (any host) * Values contain variable parts (IDs, timestamps, campaigns) * You need substring matching (contains, prefix, suffix) * Multiple values should match the same mock (OR logic) * Exact string matching is too rigid ## URL Pattern Matching The mock’s `url` field accepts **native JavaScript RegExp** for flexible URL matching: ### Native RegExp (Recommended) ```typescript // Match any API version { method: 'GET', url: /https:\/\/api\.example\.com\/v\d+\/users/, response: { status: 200, body: { users: [] } } } // Matches: https://api.example.com/v1/users ✓ // Matches: https://api.example.com/v2/users ✓ // Matches: https://api.example.com/v99/users ✓ ``` ### Origin-Agnostic Patterns RegExp uses **weak comparison** (partial matching), making it perfect for matching URLs regardless of host: ```typescript // Match any origin { method: 'GET', url: /\/api\/products$/, response: { status: 200, body: { products: [] } } } // Matches: http://localhost:3000/api/products ✓ // Matches: https://api.example.com/api/products ✓ // Matches: https://staging.myapp.io/api/products ✓ ``` ### Common URL Patterns ```typescript // Numeric IDs only url: /\/users\/\d+$/ // Matches: /users/123, /users/456789 // Doesn't match: /users/abc, /users/ // Any path segment url: /\/api\/[^/]+\/items/ // Matches: /api/v1/items, /api/beta/items // Multiple path params url: /\/users\/\d+\/posts\/\d+/ // Matches: /users/1/posts/42 ``` ### Case-Insensitive URL Matching ```typescript url: /\/api\/users/i // Note the 'i' flag // Matches: /api/users, /API/USERS, /Api/Users ``` ## Match Criteria URL Patterns In addition to the mock’s `url` field, you can use pattern matching in `match.url` for more refined control: ```typescript { method: 'GET', url: 'https://api.github.com/users/:username', // Base pattern match: { url: /\/users\/\d+$/ // Only match numeric usernames }, response: { status: 200, body: { type: 'numeric-user' } } } ``` This is useful when you want path parameters for some cases but regex matching for others. ## Value Matching Strategies Scenarist provides **7 matching strategies** that work in URL, body, headers, and query: | Strategy | Syntax | Behavior | | ---------------- | ---------------------------------------------- | ----------------------------------- | | Plain String | `'value'` | Exact match (default) | | Native RegExp | `/pattern/flags` | Pattern match (recommended for URL) | | Equals | `{ equals: 'value' }` | Explicit exact match | | Contains | `{ contains: 'substring' }` | Value contains substring | | Starts With | `{ startsWith: 'prefix' }` | Value starts with prefix | | Ends With | `{ endsWith: 'suffix' }` | Value ends with suffix | | Serialized Regex | `{ regex: { source: 'pattern', flags: 'i' } }` | JSON-safe regex pattern | ## Strategy Examples ### Native RegExp Use native JavaScript RegExp for pattern matching (works in `url` and `match.url`): ```typescript // In mock url field url: /\/api\/v\d+\/users/ // In match.url field match: { url: /\/users\/\d+$/ } ``` ### Contains Match values containing a substring: ```typescript match: { headers: { 'user-agent': { contains: 'Mobile' } } } // Matches: 'Mozilla/5.0 (iPhone; Mobile)' ✓ // Matches: 'Mobile Safari' ✓ // Doesn't match: 'Chrome Desktop' ✗ ``` ### Starts With Match values with a prefix: ```typescript match: { body: { apiKey: { startsWith: 'sk_' } } } // Matches: 'sk_live_abc123' ✓ // Matches: 'sk_test_xyz789' ✓ // Doesn't match: 'pk_live_abc123' ✗ ``` ### Ends With Match values with a suffix: ```typescript match: { body: { filename: { endsWith: '.pdf' } } } // Matches: 'report.pdf' ✓ // Matches: 'invoice_2024.pdf' ✓ // Doesn't match: 'document.docx' ✗ ``` ### Serialized Regex For JSON-safe scenarios (stored in files or databases), use serialized regex: ```typescript match: { headers: { 'x-campaign': { regex: { source: 'premium|vip|exclusive', flags: 'i' } } } } // Matches: 'summer-premium-sale' ✓ // Matches: 'early-VIP-access' ✓ (case-insensitive) // Matches: 'exclusive-members-2024' ✓ // Doesn't match: 'standard-sale' ✗ ``` ## Where Strategies Apply All strategies work in: * ✅ **Mock URL** (`url` field) - Native RegExp only * ✅ **Match URL** (`match.url`) - All strategies * ✅ **Request Body** (`match.body`) - All strategies * ✅ **Request Headers** (`match.headers`) - All strategies * ✅ **Query Parameters** (`match.query`) - All strategies ```typescript { method: 'POST', url: /\/api\/v\d+\/products/, // Native RegExp in url match: { url: { contains: '/featured' }, // Strategy in match.url body: { email: { contains: '@company.com' }, apiKey: { startsWith: 'sk_' }, }, headers: { 'user-agent': { contains: 'Mobile' }, 'referer': { endsWith: '/checkout' }, }, query: { category: { regex: { source: '^(tech|science)$', flags: 'i' } }, } }, response: { status: 200, body: { ... } } } ``` ## Regex Reference ### Native RegExp Syntax ```typescript // In url field or match.url url: /pattern/flags // Examples url: /\/api\/users\/\d+/ // No flags url: /\/api\/users/i // Case-insensitive ``` ### Serialized Regex Syntax ```typescript // In match.body, match.headers, match.query, or match.url { regex: { source: 'pattern', // Regex pattern (without delimiters) flags: 'i' // Optional flags } } ``` ### Supported Flags | Flag | Name | Description | | ---- | ---------------- | ---------------------------------------- | | `i` | Case-insensitive | Most common - matches regardless of case | | `m` | Multiline | `^` and `$` match line boundaries | | `s` | Dotall | `.` matches newlines | | `u` | Unicode | Enables Unicode features | | `v` | Unicode sets | Enhanced Unicode support | ```typescript // Case-insensitive (most common) { regex: { source: 'premium|vip', flags: 'i' } } // Multiple flags { regex: { source: '/api/v\\d+/', flags: 'im' } } ``` ### Common Patterns **Alternatives (OR logic):** ```typescript { regex: { source: 'premium|vip|enterprise', flags: 'i' } } ``` **Exact match from options:** ```typescript { regex: { source: '^(tech|science|health)$', flags: 'i' } } ``` **Numeric patterns:** ```typescript // Version numbers (v1, v2, v3) { regex: { source: 'v\\d+', flags: '' } } // Semver (1.2.3) { regex: { source: '^\\d+\\.\\d+\\.\\d+$', flags: '' } } ``` **Email domains:** ```typescript { regex: { source: '@(gmail|yahoo|outlook)\\.com$', flags: 'i' } } ``` **File extensions:** ```typescript { regex: { source: '\\.(jpg|png|gif|webp)$', flags: 'i' } } ``` ## Real-World Examples ### Marketing Campaigns ```typescript import type { ScenaristMock } from '@scenarist/express-adapter'; const campaignMock: ScenaristMock = { method: 'GET', url: '/api/products', match: { headers: { 'x-campaign': { regex: { source: 'premium|vip|exclusive', flags: 'i' } } } }, response: { status: 200, body: { pricing: 'premium', discount: 25 } } }; ``` ### Mobile Detection ```typescript const mobileMock: ScenaristMock = { method: 'GET', url: '/api/config', match: { headers: { 'user-agent': { regex: { source: '(iPhone|iPad|Android)', flags: 'i' } } } }, response: { status: 200, body: { layout: 'mobile', features: ['touch', 'swipe'] } } }; ``` ### Referer Patterns ```typescript const checkoutMock: ScenaristMock = { method: 'POST', url: '/api/checkout', match: { headers: { 'referer': { regex: { source: '/checkout/(confirm|review)', flags: '' } } } }, response: { status: 200, body: { allowCheckout: true } } }; ``` ### Email Domain Filtering ```typescript const emailMock: ScenaristMock = { method: 'GET', url: '/api/search', match: { query: { email: { regex: { source: '@(gmail|yahoo|outlook)\\.com$', flags: 'i' } } } }, response: { status: 200, body: { provider: 'common-email' } } }; ``` ## Security: ReDoS Protection Scenarist validates all serialized regex patterns for **ReDoS (Regular Expression Denial of Service)** vulnerabilities: ```typescript // ✅ SAFE - Simple alternation { regex: { source: 'premium|vip', flags: 'i' } } // ✅ SAFE - Character classes { regex: { source: '[A-Z]{3}-\\d{4}', flags: '' } } // ❌ REJECTED - Catastrophic backtracking risk { regex: { source: '(a+)+b', flags: '' } } // Error: Regex pattern is unsafe (ReDoS vulnerability detected) ``` **Protection mechanisms:** * Pattern validation using `redos-detector` before scenario registration * Unsafe patterns rejected immediately with clear error messages * No runtime regex compilation for invalid patterns ## Combining with Other Matching Pattern matching combines with other match criteria (AND logic): ```typescript match: { body: { itemType: { contains: 'premium' }, // Pattern matching category: 'electronics', // Exact matching }, headers: { 'x-campaign': { regex: { source: 'summer|winter', flags: 'i' } }, 'x-region': 'eu', // Exact matching } } // ALL criteria must match ``` ## Next Steps * [Request Matching →](/scenarios/request-matching/) - Basic matching concepts * [Response Sequences →](/scenarios/response-sequences/) - Combine patterns with sequences * [Combining Features →](/scenarios/combining-features/) - Use all features together # Request Matching > Return different responses based on request body, headers, and query parameters ## What This Enables Return different responses for the same URL based on request content. Multiple mocks can exist for the same URL, and Scenarist selects the most specific match. **Use cases:** * Different pricing for premium vs standard users * Different API responses based on version header * Different data based on query parameters * Tiered functionality based on request content ## When to Use Use request matching when: * Same endpoint returns different responses based on who’s calling * You need to test different request payloads * API behavior varies by header values (API version, user tier, locale) * Query parameters change the response ## Match Criteria Match on URL, request body, headers, or query parameters using the `match` field: ```typescript import type { ScenaristMock } from '@scenarist/express-adapter'; const mock: ScenaristMock = { method: 'POST', url: '/api/checkout', match: { url: /\/checkout$/, // URL pattern (native RegExp) body: { tier: 'premium' }, // Partial body match headers: { 'x-api-version': 'v2' }, // Exact header match query: { detailed: 'true' }, // Exact query param match }, response: { status: 200, body: { discount: 20 } } }; ``` ### URL Matching Match URLs using strings, RegExp, or pattern strategies: ```typescript // Native RegExp (recommended) match: { url: /\/users\/\d+$/ // Match numeric user IDs only } // String strategies match: { url: { contains: '/api/v2/' } // URL contains substring url: { startsWith: 'https://' } // URL starts with prefix url: { endsWith: '/checkout' } // URL ends with suffix } ``` This is useful when your mock’s `url` field uses path parameters but you need finer control: ```typescript { method: 'GET', url: 'https://api.github.com/users/:username', // Accepts any username match: { url: /\/users\/\d+$/ // But only match numeric usernames }, response: { status: 200, body: { type: 'numeric-user' } } } ``` ### Body Matching (Partial) Body matching is **partial** - only specified fields must match. The request can have additional fields: ```typescript match: { body: { itemType: 'premium' } // Only checks itemType field } // Matches these requests: // { itemType: 'premium', quantity: 5, color: 'red' } ✓ // { itemType: 'premium' } ✓ // { itemType: 'standard' } ✗ ``` ### Header Matching (Exact) Header matching is **exact** for specified keys: ```typescript match: { headers: { 'x-user-tier': 'premium', 'x-region': 'eu', } } // Request must have these headers with exact values ``` ### Query Parameter Matching (Exact) Query parameter matching is **exact** for specified keys: ```typescript match: { query: { detailed: 'true', units: 'metric', } } // Request must have these query params with exact values ``` ### Combined Matching All criteria must match (AND logic): ```typescript match: { body: { itemType: 'premium' }, headers: { 'x-user-tier': 'gold' }, query: { region: 'us' }, } // All three must match for this mock to be selected ``` ## Specificity-Based Selection When multiple mocks match the same URL, Scenarist uses **specificity scoring** to choose the best match: * URL match = +1 point * Each body field = +1 point * Each header = +1 point * Each query param = +1 point * Each state key = +1 point * No match criteria = 0 points (fallback) **Most specific mock wins**, regardless of order. ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; const scenario: ScenaristScenario = { id: 'tiered-pricing', name: 'Tiered Pricing', description: 'Different pricing based on specificity', mocks: [ // Specificity: 2 (body.tier + body.category) { method: 'POST', url: '/api/products', match: { body: { tier: 'premium', category: 'electronics' } }, response: { status: 200, body: { discount: 30 } } }, // Specificity: 1 (body.tier only) { method: 'POST', url: '/api/products', match: { body: { tier: 'premium' } }, response: { status: 200, body: { discount: 20 } } }, // Specificity: 0 (no match criteria, fallback) { method: 'POST', url: '/api/products', response: { status: 200, body: { discount: 10 } } } ] }; // Request with tier='premium' and category='electronics' // → Returns 30% discount (specificity 2 wins) // Request with tier='premium' only // → Returns 20% discount (specificity 1 wins) // Request with neither // → Returns 10% discount (fallback) ``` ## OR Logic Since match criteria use AND logic, implement OR logic with separate mocks: ```typescript mocks: [ // Mock 1: Premium users { method: 'GET', url: '/api/products', match: { headers: { 'x-tier': 'premium' } }, response: { status: 200, body: { pricing: 'discounted' } } }, // Mock 2: VIP users (OR - separate mock) { method: 'GET', url: '/api/products', match: { headers: { 'x-tier': 'vip' } }, response: { status: 200, body: { pricing: 'discounted' } } }, // Fallback: Standard users { method: 'GET', url: '/api/products', response: { status: 200, body: { pricing: 'standard' } } } ] ``` For OR logic within a single field, use regex in [Pattern Matching →](/scenarios/pattern-matching/): ```typescript match: { headers: { 'x-tier': { regex: { source: '^(premium|vip|enterprise)$', flags: '' } } } } // Matches x-tier='premium' OR 'vip' OR 'enterprise' in one mock ``` ## Tiebreaker Rules When multiple mocks have **equal specificity**: **Mocks with match criteria (specificity > 0):** First match wins ```typescript mocks: [ // Both have specificity: 1 { match: { body: { type: 'premium' } }, response: { body: { discount: 20 } }, // ← Wins (first) }, { match: { body: { type: 'premium' } }, response: { body: { discount: 15 } }, }, ] ``` **Fallback mocks (specificity = 0):** Last match wins ```typescript // Two fallback mocks for the same endpoint in one scenario mocks: [ { response: { body: { tier: 'standard' } } }, { response: { body: { tier: 'premium' } } }, // ← Wins (last) ] ``` Active scenarios do not rely on this to override the default: an active scenario’s fallback mock excludes the default scenario’s mocks for that endpoint. ## Mock Type Priority When comparing fallback mocks (no match criteria), **dynamic response types have higher priority** than simple responses: | Mock Type | Fallback Priority | | --------------- | ----------------- | | `sequence` | 1 (higher) | | `stateResponse` | 1 (higher) | | `response` | 0 (lower) | This priority applies when fallback mocks compete within the same set of candidates. It does **not** stop an active scenario overriding the default: when the active scenario has a fallback mock for an endpoint, the default scenario’s mocks for that endpoint are not considered at all. ### Example: Overriding a Default Sequence ```typescript // Default scenario has a sequence const defaultScenario = { mocks: [ { method: 'GET', url: '/api/job/status', sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'complete' } } ], repeat: 'last' } } ] }; // Active scenario overrides with simple response const activeScenario = { mocks: [ { method: 'GET', url: '/api/job/status', response: { status: 200, body: { status: 'error' } } // ✅ Overrides the default sequence } ] }; ``` **Result:** Active’s `response` wins. Because the active scenario has a fallback mock for this endpoint, the default scenario’s `sequence` is not a candidate. ## Real-World Example ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const tieredPricingScenario: ScenaristScenario = { id: 'tiered-pricing', name: 'Tiered Pricing', description: 'Different pricing based on user tier and item type', mocks: [ // Premium users buying premium items - best discount { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { itemType: 'premium' }, headers: { 'x-user-tier': 'gold' }, }, response: { status: 200, body: { amount: 7000, discount: 'gold_premium_30' }, }, }, // Premium items (any user) { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { itemType: 'premium' } }, response: { status: 200, body: { amount: 8000, discount: 'premium_20' }, }, }, // Standard items { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { itemType: 'standard' } }, response: { status: 200, body: { amount: 10000 }, }, }, // Fallback for other item types { method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { amount: 5000 }, }, }, ], }; ``` ## Next Steps * [Pattern Matching →](/scenarios/pattern-matching/) - Regex and string patterns for flexible matching * [Response Sequences →](/scenarios/response-sequences/) - Combine matching with sequences * [Combining Features →](/scenarios/combining-features/) - Use matching with other features # Response Sequences > Multi-step responses for polling, async workflows, and state progression ## What This Enables Return different responses on successive calls to the same endpoint. Each request advances through a sequence of predefined responses. **Use cases:** * **Polling patterns:** Job status: pending → processing → complete * **Async workflows:** Payment: initiated → authorized → captured * **Rate limiting:** Allow N requests, then return 429 * **Retry scenarios:** Fail twice, succeed on third attempt ## When to Use Use response sequences when: * Behavior changes based on **number of calls** (not request content) * Testing polling or async job status * Simulating progressive workflows * Testing retry logic or rate limits **Not for request content differences** - use [Request Matching](/scenarios/request-matching/) instead. ## Basic Sequence Replace `response` with `sequence` containing an array of responses: ```typescript import type { ScenaristMock } from '@scenarist/express-adapter'; const mock: ScenaristMock = { method: 'GET', url: '/api/job/status', sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'processing' } }, { status: 200, body: { status: 'complete' } } ], repeat: 'last' // Options: 'last' | 'cycle' | 'none' } }; ``` **Behavior:** 1. First request → `{ status: 'pending' }` 2. Second request → `{ status: 'processing' }` 3. Third request → `{ status: 'complete' }` 4. Fourth+ requests → `{ status: 'complete' }` (repeats last) ## Repeat Modes ### `repeat: 'last'` (Default) Repeat the final response indefinitely after sequence exhausts: ```typescript sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'complete' } } ], repeat: 'last' } ``` ```plaintext Call 1 → pending Call 2 → complete Call 3 → complete (repeats) Call 4 → complete (repeats) ``` **Use for:** Most polling scenarios where final state persists. ### `repeat: 'cycle'` Loop back to the first response after sequence exhausts: ```typescript sequence: { responses: [ { status: 200, body: { weather: 'sunny' } }, { status: 200, body: { weather: 'cloudy' } }, { status: 200, body: { weather: 'rainy' } } ], repeat: 'cycle' } ``` ```plaintext Call 1 → sunny Call 2 → cloudy Call 3 → rainy Call 4 → sunny (cycles back) Call 5 → cloudy ``` **Use for:** Rotating data, round-robin behavior. ### `repeat: 'none'` Sequence exhausts completely, allowing fallback to next mock: ```typescript sequence: { responses: [ { status: 200, body: { attempt: 1 } }, { status: 200, body: { attempt: 2 } }, { status: 200, body: { attempt: 3 } } ], repeat: 'none' } ``` ```plaintext Call 1 → attempt 1 Call 2 → attempt 2 Call 3 → attempt 3 Call 4 → [Exhausted - falls through to next mock] ``` **Use for:** Rate limiting, limited-use tokens, finite sequences. ## Sequence with Fallback Combine `repeat: 'none'` with a fallback mock for rate limiting: ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; const scenario: ScenaristScenario = { id: 'rate-limited', name: 'Rate Limited API', description: 'Allow 3 requests, then rate limit', mocks: [ // First 3 requests succeed { method: 'POST', url: '/api/payment', sequence: { responses: [ { status: 200, body: { id: 'pay_1', status: 'pending' } }, { status: 200, body: { id: 'pay_2', status: 'pending' } }, { status: 200, body: { id: 'pay_3', status: 'succeeded' } }, ], repeat: 'none', // Exhausts after 3 calls }, }, // Request 4+ hits this fallback { method: 'POST', url: '/api/payment', response: { status: 429, body: { error: 'Rate limit exceeded' }, }, }, ], }; ``` ## GitHub Job Polling Example ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const githubPollingScenario: ScenaristScenario = { id: 'github-polling', name: 'GitHub Job Polling', description: 'Simulates async job progression', mocks: [ { method: 'GET', url: 'https://api.github.com/repos/:owner/:repo/actions/runs/:id', sequence: { responses: [ { status: 200, body: { status: 'queued', progress: 0 } }, { status: 200, body: { status: 'in_progress', progress: 50 } }, { status: 200, body: { status: 'completed', progress: 100 } }, ], repeat: 'last', }, }, ], }; ``` ## Combining Sequences with Matching Sequences can be combined with [Request Matching](/scenarios/request-matching/): ```typescript { method: 'GET', url: '/api/onboarding/step', match: { headers: { 'x-tier': 'premium' } }, sequence: { responses: [ { status: 200, body: { step: 1, message: 'Welcome!' } }, { status: 200, body: { step: 2, message: 'Configure...' } }, { status: 200, body: { step: 3, message: 'Complete!' } } ], repeat: 'last' } } ``` **Important:** Only **matching requests** advance the sequence. Non-matching requests don’t affect sequence position. ```plaintext Request with x-tier: premium → Step 1 Request without x-tier header → [Doesn't match, doesn't advance] Request with x-tier: premium → Step 2 Request with x-tier: premium → Step 3 ``` ## Retry Simulation Test retry logic by failing then succeeding: ```typescript { method: 'POST', url: '/api/external-service', sequence: { responses: [ { status: 503, body: { error: 'Service unavailable' } }, { status: 503, body: { error: 'Service unavailable' } }, { status: 200, body: { success: true } }, ], repeat: 'last' } } // Call 1 → 503 (retry) // Call 2 → 503 (retry) // Call 3 → 200 (success) // Call 4+ → 200 (stable) ``` ## Sequence Reset Sequences reset when: * Test switches to a different scenario * New test starts (different test ID) Each test has isolated sequence state - parallel tests don’t affect each other’s sequence positions. ## Next Steps * [Request Matching →](/scenarios/request-matching/) - Combine sequences with matching * [Stateful Mocks →](/scenarios/stateful-mocks/) - Capture state as sequence progresses * [Combining Features →](/scenarios/combining-features/) - Use all features together # State-Aware Mocking > Conditional responses and state transitions for workflow testing ## What This Enables Build state machines where mock responses depend on accumulated state from previous requests. Perfect for testing workflows where the same endpoint returns different data based on what happened earlier. **Use cases:** * **Loan applications:** Status changes from “pending” → “reviewing” → “approved” based on form submissions * **Multi-step workflows:** Same GET returns different data after POSTs modify state * **Feature flags:** Toggle behavior via API, subsequent requests reflect the change * **Authentication flows:** Login sets state, protected endpoints check it ## The Problem It Solves [Response Sequences](/scenarios/response-sequences/) work when you can predict the exact number of calls: ```typescript // This works IF you know there will be exactly 3 calls before the POST sequence: { responses: [ { body: { status: 'pending' } }, { body: { status: 'pending' } }, { body: { status: 'pending' } }, { body: { status: 'approved' } }, ] } ``` But with modern frontends (React re-renders, middleware, async timing), call counts are unpredictable. You might need 11 “pending” responses in one test and 15 in another. **State-aware mocking solves this:** Response changes based on **state**, not **call count**. ## Three Capabilities | Capability | Purpose | Category | | -------------------------------------------------------------------- | -------------------------------------------------- | ---------------------- | | [`stateResponse`](#state-driven-responses-stateresponse) | Return different responses based on current state | State-Driven Responses | | [`afterResponse.setState`](#state-transitions-afterresponsesetstate) | Mutate state after returning a response | State Transitions | | [`match.state`](#state-driven-matching-matchstate) | Select which mock handles a request based on state | State-Driven Matching | ## State-Driven Responses: `stateResponse` Return different responses from a single mock based on current test state. Use when one endpoint needs multiple possible responses depending on accumulated workflow state. ```typescript import type { ScenaristMock } from '@scenarist/express-adapter'; const mock: ScenaristMock = { method: 'GET', url: '/api/application/status', stateResponse: { default: { status: 200, body: { status: 'pending', message: 'Application not yet submitted' } }, conditions: [ { when: { step: 'submitted' }, then: { status: 200, body: { status: 'reviewing', message: 'Under review' } } }, { when: { step: 'reviewed' }, then: { status: 200, body: { status: 'approved', message: 'Application approved' } } } ] } }; ``` **Behavior:** * If state is empty or has no matching condition → returns `default` response * If `state.step === 'submitted'` → returns “reviewing” response * If `state.step === 'reviewed'` → returns “approved” response ### Specificity-Based Selection When multiple conditions match, the most specific one wins (more keys = more specific): ```typescript conditions: [ // Specificity: 1 (one key) { when: { step: 'reviewed' }, then: { body: { tier: 'basic' } } }, // Specificity: 2 (two keys) - wins when both match { when: { step: 'reviewed', urgent: true }, then: { body: { tier: 'priority' } } } ] // State: { step: 'reviewed', urgent: true } // → Returns 'priority' (2 keys beats 1 key) ``` ## State Transitions: `afterResponse.setState` Mutate test state **after** returning a response. Use to advance workflow state when a request completes. ```typescript { method: 'POST', url: '/api/application/submit', response: { status: 200, body: { success: true, message: 'Submitted' } }, afterResponse: { setState: { step: 'submitted' } } } ``` **Behavior:** 1. Mock returns the response (`{ success: true }`) 2. **After** response is sent, state is updated (`step: 'submitted'`) 3. Subsequent requests see the new state ### Conditional afterResponse When using `stateResponse`, you can define condition-specific `afterResponse` to run different state mutations based on which condition matched: ```typescript { method: 'GET', url: '/api/loan/status', stateResponse: { default: { status: 200, body: { status: 'pending' } }, conditions: [ { when: { submitted: true }, then: { status: 200, body: { status: 'reviewing' } }, afterResponse: { setState: { phase: 'review' } } // Condition-specific }, { when: { approved: true }, then: { status: 200, body: { status: 'complete' } }, afterResponse: null // Explicitly no mutation } ] }, afterResponse: { setState: { phase: 'initial' } } // Fallback for default } ``` **Resolution logic:** 1. If condition matched AND has `afterResponse` key → use condition’s (including `null`) 2. If condition matched AND has no `afterResponse` key → use mock-level afterResponse 3. If default matched → use mock-level afterResponse **Key insight:** `afterResponse: null` means “explicitly no state mutation” - different from omitting it (which inherits from mock-level). ### Works with Any Response Type `afterResponse.setState` combines with `response`, `sequence`, or `stateResponse`: ```typescript // With sequence { method: 'POST', url: '/api/verify', sequence: { responses: [ { status: 200, body: { verified: false } }, { status: 200, body: { verified: true } } ], repeat: 'last' }, afterResponse: { setState: { verificationAttempted: true } } } // With stateResponse { method: 'POST', url: '/api/process', stateResponse: { default: { status: 200, body: { processed: false } }, conditions: [ { when: { ready: true }, then: { status: 200, body: { processed: true } } } ] }, afterResponse: { setState: { processAttempted: true } } } ``` ## State-Driven Matching: `match.state` Select which mock handles a request based on current state. Different from `stateResponse` (one mock, many responses) - this selects **which mock**. ```typescript // Same endpoint, different mocks based on state const mocks = [ // When step is 'initial' → transition to 'reviewed' { method: 'POST', url: '/api/review', match: { state: { step: 'initial' } }, response: { status: 200, body: { newStatus: 'pending_approval' } }, afterResponse: { setState: { step: 'reviewed' } } }, // When step is 'reviewed' → transition to 'approved' { method: 'POST', url: '/api/review', match: { state: { step: 'reviewed' } }, response: { status: 200, body: { newStatus: 'approved' } }, afterResponse: { setState: { step: 'approved' } } }, // Fallback (no state match) → transition to 'reviewed' { method: 'POST', url: '/api/review', response: { status: 200, body: { newStatus: 'pending_approval' } }, afterResponse: { setState: { step: 'reviewed' } } } ]; ``` **Use case:** Same endpoint needs completely different behavior (not just different response data) based on workflow state. ### Combined with Other Match Criteria `match.state` works with existing match criteria (AND logic): ```typescript { method: 'POST', url: '/api/review', match: { state: { step: 'pending_review' }, body: { decision: 'approve' } }, response: { status: 200, body: { status: 'approved' } }, afterResponse: { setState: { step: 'approved' } } }, { method: 'POST', url: '/api/review', match: { state: { step: 'pending_review' }, body: { decision: 'reject' } }, response: { status: 200, body: { status: 'rejected' } }, afterResponse: { setState: { step: 'rejected' } } } ``` ## Complete Example: Loan Application ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const loanApplicationScenario: ScenaristScenario = { id: 'loan-application', name: 'Loan Application Workflow', description: 'State-aware loan workflow with automatic state transitions', mocks: [ // GET status - responds based on workflow state { method: 'GET', url: 'https://api.loans.com/application/status', stateResponse: { default: { status: 200, body: { status: 'pending', message: 'Not yet submitted' } }, conditions: [ { when: { step: 'submitted' }, then: { status: 200, body: { status: 'reviewing', message: 'Under review' } } }, { when: { step: 'reviewed' }, then: { status: 200, body: { status: 'approved', message: 'Approved!' } } } ] } }, // POST submit - advances state to 'submitted' { method: 'POST', url: 'https://api.loans.com/application/submit', response: { status: 200, body: { success: true, message: 'Application submitted' } }, afterResponse: { setState: { step: 'submitted' } } }, // POST review - advances state to 'reviewed' { method: 'POST', url: 'https://api.loans.com/application/review', response: { status: 200, body: { success: true, message: 'Review completed' } }, afterResponse: { setState: { step: 'reviewed' } } } ] }; ``` **Test workflow:** ```typescript test('loan application workflow', async ({ page, switchScenario }) => { await switchScenario(page, 'loan-application'); // Initial state - pending await page.goto('/application'); await expect(page.getByText('Not yet submitted')).toBeVisible(); // Submit form - advances state await page.click('[data-action="submit"]'); // Now shows reviewing await page.goto('/application'); await expect(page.getByText('Under review')).toBeVisible(); // Complete review - advances state again await page.click('[data-action="review"]'); // Now shows approved await page.goto('/application'); await expect(page.getByText('Approved!')).toBeVisible(); }); ``` ## Complete Example: Feature Flags ```typescript export const featureFlagsScenario: ScenaristScenario = { id: 'feature-flags', name: 'Feature Flags', description: 'Toggle features via API, subsequent requests reflect changes', mocks: [ // Toggle feature flag - captures state { method: 'POST', url: 'https://api.features.com/flags', captureState: { premiumEnabled: 'body.enabled' }, response: { status: 200, body: { success: true, message: 'Flag updated' } } }, // GET pricing - premium when flag enabled (match.state) { method: 'GET', url: 'https://api.pricing.com/pricing', match: { state: { premiumEnabled: true } }, response: { status: 200, body: { tier: 'premium', price: 50, discount: '50% off' } } }, // GET pricing - standard (fallback) { method: 'GET', url: 'https://api.pricing.com/pricing', response: { status: 200, body: { tier: 'standard', price: 100 } } } ] }; ``` ## When to Use What | Feature | Use When | | ------------------------ | --------------------------------------------------------------------- | | `stateResponse` | One endpoint, multiple possible responses based on accumulated state | | `afterResponse.setState` | Need to advance workflow state after a request | | `match.state` | Same endpoint needs completely different mock behavior based on state | | `captureState` | Need to capture request data for injection into responses | | `sequence` | Behavior depends on call count (predictable number of calls) | ## State vs Sequences | Aspect | State-Aware Mocking | Sequences | | ----------------- | ------------------------- | --------------------------- | | Changes based on | Accumulated state | Call count | | Predictable calls | Not required | Required | | Use case | Workflows, state machines | Polling with known count | | Resilience | Resilient to re-renders | Fragile with variable calls | **Rule of thumb:** If you find yourself padding sequences with extra responses “just in case,” switch to state-aware mocking. ## State Isolation State is isolated per test ID - parallel tests don’t interfere: ```typescript // Test 1 (test-id: abc-123) POST /submit → state.step = 'submitted' GET /status → 'reviewing' // Test 2 (test-id: xyz-789) - simultaneous GET /status → 'pending' (independent state) ``` ## State Reset State resets when: * Scenario switches (clean slate for new scenario) * A new test starts with a different test ID This ensures idempotent tests. ## Combining with Other Features State-aware mocking combines with all existing features: ```typescript { method: 'POST', url: '/api/checkout', match: { state: { cartReady: true }, // State matching headers: { 'x-tier': 'premium' } // Request matching }, stateResponse: { default: { status: 200, body: { discount: 10 } }, conditions: [ { when: { loyaltyTier: 'gold' }, then: { status: 200, body: { discount: 20 } } } ] }, afterResponse: { setState: { checkoutComplete: true } } } ``` ## State Model State is Shared Across ALL Endpoints State is stored in a **single flat object per test ID**, not namespaced by endpoint. This is intentional - it enables coordination between endpoints. ```plaintext ┌──────────────────────────────────────────────────┐ │ Test ID: test-checkout-123 │ │ ┌─────────────────────────────────────────────┐ │ │ │ Shared State │ │ │ │ { 'cart.items': 3, 'user.tier': 'premium' }│ │ │ └─────────────────────────────────────────────┘ │ │ ↑ write ↑ write ↓ read │ │ POST /cart/add POST /login GET /pricing │ └──────────────────────────────────────────────────┘ ``` ### Namespace Your Keys Since state is shared, use namespaced keys to avoid collisions: ```typescript // ✅ DO: Namespace your keys by domain afterResponse: { setState: { 'cart.itemCount': 3 } } afterResponse: { setState: { 'user.authenticated': true } } when: { 'cart.itemCount': 3, 'user.authenticated': true } // ❌ DON'T: Use generic keys that could collide afterResponse: { setState: { count: 3 } } // What count? afterResponse: { setState: { status: 'active' } } // Which status? ``` ### Why Not Per-Endpoint? The primary use case is **cross-endpoint coordination**: ```typescript // POST /api/loan/submit sets state { method: 'POST', url: '/api/loan/submit', response: { status: 200 }, afterResponse: { setState: { 'loan.submitted': true } }, } // GET /api/loan/status reads that state { method: 'GET', url: '/api/loan/status', stateResponse: { default: { status: 200, body: { step: 'pending' } }, conditions: [ { when: { 'loan.submitted': true }, then: { status: 200, body: { step: 'reviewing' } } }, ], }, } ``` If state were per-endpoint, this coordination pattern wouldn’t work. ## Debugging State When tests fail, you often need to inspect the current state to understand what went wrong. Scenarist provides debug tools for this. ### Debug Endpoint The Express adapter exposes a debug endpoint at `GET /__scenarist__/state`. In Next.js, create the route yourself with `createStateEndpoint()` (for example at `/api/__scenarist__/state`): ```bash curl -H "x-scenarist-test-id: test-123" http://localhost:3000/__scenarist__/state ``` **Response:** ```json { "testId": "test-123", "state": { "cart.items": 3, "user.tier": "premium", "checkout.started": true } } ``` ### Playwright Debug Fixtures For Playwright tests, use the `debugState` and `waitForDebugState` fixtures: ```typescript import { test, expect } from './fixtures'; test('checkout flow', async ({ page, switchScenario, debugState, waitForDebugState }) => { await switchScenario(page, 'checkout'); await page.goto('/cart'); // Add item to cart await page.click('#add-item'); // Debug: Check what state was set const state = await debugState(page); console.log('After add item:', state); // → { 'cart.items': 1, 'cart.total': 29.99 } // Wait for async state to stabilize await page.click('#checkout'); const finalState = await waitForDebugState( page, (s) => s['checkout.status'] === 'complete', { timeout: 10000 } ); expect(finalState['checkout.status']).toBe('complete'); }); ``` **Available fixtures:** * `debugState(page)` - Fetch current state (no testId needed - fixture manages it) * `waitForDebugState(page, condition, options)` - Poll until condition is met **Configure the endpoint in playwright.config.ts:** ```typescript export default defineConfig({ use: { baseURL: 'http://localhost:3000', scenaristStateEndpoint: '/__scenarist__/state', // Default value }, }); ``` ## Next Steps * [Stateful Mocks →](/scenarios/stateful-mocks/) - Capture and inject request data * [Response Sequences →](/scenarios/response-sequences/) - Call-count based responses * [Request Matching →](/scenarios/request-matching/) - Match on request content * [Combining Features →](/scenarios/combining-features/) - Use all features together # Stateful Mocks > Capture state from requests and inject it into responses ## What This Enables Capture data from requests and inject it into subsequent responses. Build state over multiple requests that affects later responses. **Use cases:** * **Shopping cart:** Add items, cart endpoint shows accumulated items * **User profiles:** Submit data, profile endpoint reflects it * **Session data:** Capture authentication, use in later requests * **Form workflows:** Capture step data, show in confirmation ## When to Use Use stateful mocks when: * Later responses should reflect earlier request data * Testing multi-step workflows where data accumulates * Need to verify data flows through your application correctly **Not for call-count behavior** - use [Response Sequences](/scenarios/response-sequences/) instead. ## Basic State Capture and Injection ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; const scenario: ScenaristScenario = { id: 'user-profile', name: 'User Profile State', description: 'Captures profile updates and injects into responses', mocks: [ // Capture state from POST request { method: 'POST', url: '/api/profile/update', captureState: { userName: 'body.name', // Capture from body.name userEmail: 'body.email', // Capture from body.email }, response: { status: 200, body: { success: true } } }, // Inject state into GET response { method: 'GET', url: '/api/profile', response: { status: 200, body: { name: '{{state.userName}}', // Inject captured value email: '{{state.userEmail}}', // Inject captured value } } } ] }; ``` **Workflow:** 1. POST to `/api/profile/update` with `{ name: 'Alice', email: 'alice@example.com' }` 2. Scenarist captures `userName='Alice'`, `userEmail='alice@example.com'` 3. GET to `/api/profile` returns `{ name: 'Alice', email: 'alice@example.com' }` ## State Capture Syntax ### Path Expressions Extract values from request body, headers, or query: ```typescript captureState: { userId: 'body.user.id', // Nested body field email: 'headers.x-user-email', // Header value region: 'query.region', // Query parameter } ``` **Request example:** ```json // Body: { "user": { "id": "usr_123", "name": "Alice" } } // Headers: { "x-user-email": "alice@example.com" } // Query: ?region=eu // Captures: // userId = 'usr_123' // email = 'alice@example.com' // region = 'eu' ``` ### Array Append Syntax Build arrays over multiple requests using `[]` suffix: ```typescript captureState: { 'cartItems[]': 'body.productId', // Appends to array 'tags[]': 'body.tag', // Another array } ``` **Behavior across requests:** ```typescript // Request 1: { productId: 'prod-1' } // State: { cartItems: ['prod-1'] } // Request 2: { productId: 'prod-2' } // State: { cartItems: ['prod-1', 'prod-2'] } // Request 3: { productId: 'prod-3' } // State: { cartItems: ['prod-1', 'prod-2', 'prod-3'] } ``` ### Nested Path Support Capture deeply nested values: ```typescript captureState: { userName: 'body.user.profile.displayName', billingCountry: 'body.payment.address.country', } ``` ## State Injection Syntax ### Template Syntax Inject captured state into responses using `{{state.key}}`: ```typescript response: { status: 200, body: { user: '{{state.userId}}', // Scalar value items: '{{state.cartItems}}', // Array count: '{{state.cartItems.length}}', // Array property profile: { name: '{{state.userName}}', // Nested injection }, }, } ``` ### Missing State When a state key doesn’t exist, a value that is only a template becomes `null`. A template embedded in a longer string remains as-is: ```typescript // If state.userName is not captured: body: { name: '{{state.userName}}' } // Returns: { name: null } // If state.cartItems is not captured: body: { summary: 'Items: {{state.cartItems}}' } // Returns: { summary: 'Items: {{state.cartItems}}' } ``` ## Shopping Cart Example ```typescript import type { ScenaristScenario } from '@scenarist/express-adapter'; export const shoppingCartScenario: ScenaristScenario = { id: 'shopping-cart', name: 'Shopping Cart', description: 'Cart with state persistence across requests', mocks: [ // Add item - captures product ID { method: 'POST', url: 'https://api.store.com/cart/add', captureState: { 'cartItems[]': 'body.productId', // Append to array }, response: { status: 200, body: { success: true }, }, }, // Get cart - injects captured items { method: 'GET', url: 'https://api.store.com/cart', response: { status: 200, body: { items: '{{state.cartItems}}', count: '{{state.cartItems.length}}', }, }, }, // Clear cart - resets by setting to empty array { method: 'DELETE', url: 'https://api.store.com/cart', captureState: { cartItems: 'body.resetTo', // Overwrite (not append) }, response: { status: 200, body: { cleared: true }, }, }, ], }; ``` ## State Isolation Per Test ID **State is isolated per test ID.** Each parallel test maintains independent state: ```typescript // Test 1 (test-id: abc-123) POST /api/cart/add { productId: 'prod-1' } GET /api/cart // Returns { items: ['prod-1'] } // Test 2 (test-id: xyz-789) - runs simultaneously POST /api/cart/add { productId: 'prod-999' } GET /api/cart // Returns { items: ['prod-999'] } // No interference - each test has isolated state ``` This isolation is automatic via the test ID header. ## State Lifecycle 1. **Capture:** Extract values from request when mock is matched 2. **Store:** Save values keyed by test ID (isolated per test) 3. **Inject:** Replace templates in responses with stored values 4. **Reset:** Clear state when scenario switches (clean slate) ### State Reset State resets when: * Test switches to a different scenario via `switchScenario()` * New test starts with a different test ID ```typescript test('cart workflow', async ({ page, switchScenario }) => { await switchScenario(page, 'shopping-cart'); // Add items... await fetch('/api/cart/add', { body: { productId: 'prod-1' } }); // State: { cartItems: ['prod-1'] } // Switch to different scenario await switchScenario(page, 'different-scenario'); // State is cleared await switchScenario(page, 'shopping-cart'); // State is empty - cart is now empty }); ``` ## Combining with Other Features State capture works with [Request Matching](/scenarios/request-matching/): ```typescript { method: 'POST', url: '/api/cart/add', match: { body: { itemType: 'premium' } // Only capture premium items }, captureState: { 'premiumItems[]': 'body.productId', }, response: { status: 200, body: { success: true } } } ``` State capture also works with [Response Sequences](/scenarios/response-sequences/): ```typescript { method: 'POST', url: '/api/onboarding', sequence: { responses: [ { status: 200, body: { step: 1 } }, { status: 200, body: { step: 2 } }, { status: 200, body: { step: 3 } }, ], repeat: 'last' }, captureState: { 'completedSteps[]': 'body.stepNumber', } } ``` ## Next Steps * [State-Aware Mocking →](/scenarios/state-aware-mocking/) - Conditional responses based on state * [Request Matching →](/scenarios/request-matching/) - Combine state with matching * [Response Sequences →](/scenarios/response-sequences/) - Capture state through sequences * [Combining Features →](/scenarios/combining-features/) - Use all features together # TypeScript Patterns > Type-safe scenario definitions with autocomplete and compile-time validation ## What This Enables Type-safe scenario definitions that provide autocomplete in tests and catch errors at compile time. **Use cases:** * **Autocomplete:** `switchScenario(page, '...')` suggests valid scenario IDs * **Compile-time errors:** Typos in scenario names caught immediately * **Structure validation:** Missing required fields caught at compile time * **Refactoring safety:** Rename scenarios confidently ## The Scenarios Object Pattern Organize scenarios in a typed object: ```typescript import type { ScenaristScenarios } from '@scenarist/express-adapter'; export const scenarios = { default: defaultScenario, // Required: 'default' key success: successScenario, error: errorScenario, premiumUser: premiumUserScenario, } as const satisfies ScenaristScenarios; ``` ## Why `as const satisfies` The `as const satisfies ScenaristScenarios` pattern is essential for type safety. Each part serves a distinct purpose: ### `as const` - Preserves Literal Types ```typescript // WITHOUT as const - scenario IDs become generic 'string' const scenarios = { default: defaultScenario, premiumUser: premiumUserScenario, }; type ScenarioId = keyof typeof scenarios; // Result: string (not useful for autocomplete) // WITH as const - scenario IDs are preserved as literal types const scenarios = { default: defaultScenario, premiumUser: premiumUserScenario, } as const; type ScenarioId = keyof typeof scenarios; // Result: 'default' | 'premiumUser' (enables autocomplete!) ``` ### `satisfies ScenaristScenarios` - Validates Structure ```typescript // Using : type annotation WIDENS the type (loses literals) const scenarios: ScenaristScenarios = { default: defaultScenario, premiumUser: premiumUserScenario, }; type ScenarioId = keyof typeof scenarios; // Result: string (annotation widened the type) // Using satisfies VALIDATES without widening const scenarios = { default: defaultScenario, premiumUser: premiumUserScenario, } as const satisfies ScenaristScenarios; type ScenarioId = keyof typeof scenarios; // Result: 'default' | 'premiumUser' (validated AND preserved!) ``` ### Together They Enable 1. **Autocomplete in tests:** `switchScenario(page, '...')` suggests valid scenario IDs 2. **Compile-time errors:** `switchScenario(page, 'typo')` fails immediately 3. **Structure validation:** Missing fields caught at compile time ```typescript // In your Playwright tests: await switchScenario(page, 'premiumUser'); // ✅ Autocomplete works await switchScenario(page, 'typo'); // ❌ TypeScript error ``` ### Comparison | Pattern | Autocomplete | Validation | | --------------------------------------- | ------------ | ---------- | | `as const satisfies ScenaristScenarios` | ✅ | ✅ | | `as const` only | ✅ | ❌ | | `satisfies` only | ❌ | ✅ | | `: ScenaristScenarios` annotation | ❌ | ✅ | ## Extracting ScenarioId Type Create a type for valid scenario IDs: ```typescript export const scenarios = { default: defaultScenario, success: successScenario, error: errorScenario, } as const satisfies ScenaristScenarios; // Extract scenario ID type export type ScenarioId = keyof typeof scenarios; // 'default' | 'success' | 'error' // Use in helper functions export function getScenario(id: ScenarioId) { return scenarios[id]; } ``` ## Import Patterns Types are re-exported from all adapter packages: ```typescript // Express import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/express-adapter'; // Next.js App Router import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app'; // Next.js Pages Router import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/pages'; ``` ## Complete Example lib/scenarios.ts ```typescript import type { ScenaristScenario, ScenaristScenarios, } from '@scenarist/express-adapter'; // Define individual scenarios const defaultScenario: ScenaristScenario = { id: 'default', name: 'Happy Path', description: 'All external APIs succeed', mocks: [ { method: 'GET', url: 'https://api.example.com/user', response: { status: 200, body: { name: 'Test User' } }, }, ], }; const errorScenario: ScenaristScenario = { id: 'error', name: 'API Error', description: 'External API returns error', mocks: [ { method: 'GET', url: 'https://api.example.com/user', response: { status: 500, body: { error: 'Server Error' } }, }, ], }; const premiumUserScenario: ScenaristScenario = { id: 'premium-user', name: 'Premium User', description: 'User has premium tier', mocks: [ { method: 'GET', url: 'https://api.example.com/user', response: { status: 200, body: { name: 'Premium User', tier: 'premium' } }, }, ], }; // Export typed scenarios object export const scenarios = { default: defaultScenario, error: errorScenario, premiumUser: premiumUserScenario, } as const satisfies ScenaristScenarios; // Export scenario ID type for use in tests export type ScenarioId = keyof typeof scenarios; ``` ## Using in Tests tests/example.spec.ts ```typescript import { test as base, expect } from '@playwright/test'; import type { ScenarioId } from '../lib/scenarios'; // Type-safe fixture const test = base.extend<{ switchScenario: (id: ScenarioId) => Promise }>({ switchScenario: async ({ page }, use) => { await use(async (id: ScenarioId) => { await page.request.post('/__scenario__', { data: { scenario: id }, }); }); }, }); test('handles premium user', async ({ page, switchScenario }) => { await switchScenario('premiumUser'); // ✅ Autocomplete await switchScenario('typo'); // ❌ TypeScript error }); ``` ## Organizing Large Scenario Sets For many scenarios, organize by feature: scenarios/auth.ts ```typescript export const authScenarios = { loggedIn: loggedInScenario, loggedOut: loggedOutScenario, sessionExpired: sessionExpiredScenario, }; // scenarios/payment.ts export const paymentScenarios = { paymentSuccess: paymentSuccessScenario, paymentDeclined: paymentDeclinedScenario, paymentPending: paymentPendingScenario, }; // scenarios/index.ts import { authScenarios } from './auth'; import { paymentScenarios } from './payment'; export const scenarios = { default: defaultScenario, ...authScenarios, ...paymentScenarios, } as const satisfies ScenaristScenarios; export type ScenarioId = keyof typeof scenarios; ``` ## Type Errors You’ll See ### Missing ‘default’ Key ```typescript const scenarios = { success: successScenario, // ❌ Not a compile-time error: createScenarist() throws at runtime // "Scenarios object must have a 'default' key" } as const satisfies ScenaristScenarios; ``` ### Invalid Scenario Structure ```typescript const badScenario: ScenaristScenario = { id: 'bad', // ❌ Error: Property 'name' is missing // ❌ Error: Property 'description' is missing mocks: [], }; ``` ### Invalid Mock Structure ```typescript const scenario: ScenaristScenario = { id: 'test', name: 'Test', description: 'Test scenario', mocks: [ { method: 'GET', url: '/api/test', // ❌ Not a compile-time error: a request that selects this mock // fails at runtime because it has no 'response', 'sequence', or 'stateResponse' }, ], }; ``` ## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Scenario definition fundamentals * [Default Scenarios →](/scenarios/default-scenarios/) - The required ‘default’ key * [Overview →](/scenarios/overview/) - Feature decision guide