diff --git a/.changeset/add-startup-scenario.md b/.changeset/add-startup-scenario.md new file mode 100644 index 000000000..d16816362 --- /dev/null +++ b/.changeset/add-startup-scenario.md @@ -0,0 +1,5 @@ +--- +"counterfact": minor +--- + +Add startup scenario: export a function named `startup` from `scenarios/index.ts` and it will run automatically when the server initializes, right before the REPL starts. Use it to seed dummy data so the server is immediately useful without any manual REPL commands. If `startup` is not exported, the server starts normally with no error. diff --git a/docs/features/repl.md b/docs/features/repl.md index 3499ff842..5c31b1271 100644 --- a/docs/features/repl.md +++ b/docs/features/repl.md @@ -100,9 +100,51 @@ After the command runs you can immediately use anything stored in `$.routes`: The `Scenario` type and `ApplyContext` interface are generated automatically into `types/scenario-context.ts` when you run Counterfact with type generation enabled. +## Startup scenario + +The `startup` export in `scenarios/index.ts` is special: it runs automatically when the server initializes, right before the REPL prompt appears. Use it to seed dummy data so the server is immediately useful without any manual REPL commands. + +```ts +// scenarios/index.ts +import type { Scenario } from "../types/scenario-context.js"; + +export const startup: Scenario = ($) => { + $.context.addPet({ name: "Fluffy", status: "available", photoUrls: [] }); + $.context.addPet({ name: "Rex", status: "sold", photoUrls: [] }); +}; +``` + +**Delegating to other scenario functions** keeps `startup` focused and readable. Pass `$` (and any extra arguments) to each helper: + +```ts +// scenarios/index.ts +import type { Scenario } from "../types/scenario-context.js"; +import { addPets } from "./pets.js"; +import { addOrders } from "./orders.js"; + +export const startup: Scenario = ($) => { + addPets($, 20, "dog"); + addOrders($, 5); +}; +``` + +```ts +// scenarios/pets.ts +import type { ApplyContext } from "../types/scenario-context.js"; + +export function addPets($: ApplyContext, count: number, species: string) { + for (let i = 0; i < count; i++) { + $.context.addPet({ name: `${species} ${i + 1}`, status: "available", photoUrls: [] }); + } +} +``` + +If `startup` is not exported from `scenarios/index.ts`, it is silently skipped — no error is thrown. + ## See also - [Route Builder](./route-builder.md) — fluent request builder with OpenAPI introspection - [State](./state.md) — the context objects you interact with from the REPL +- [Patterns: Scenario Scripts](../patterns/scenario-scripts.md) - [Patterns: Live Server Inspection with the REPL](../patterns/repl-inspection.md) - [Usage](../usage.md) diff --git a/docs/patterns/index.md b/docs/patterns/index.md index e6f51b31b..a7ba524d3 100644 --- a/docs/patterns/index.md +++ b/docs/patterns/index.md @@ -2,13 +2,14 @@ A pattern is a reusable solution to a recurring problem when building API simulations with Counterfact. Each pattern below describes a context, the problem it addresses, the solution, and its consequences. -Most projects start with [Explore a New API](./explore-new-api.md) or [Executable Spec](./executable-spec.md) to get a running server from an OpenAPI spec with no code. From there, [Mock APIs with Dummy Data](./mock-with-dummy-data.md) and [AI-Assisted Implementation](./ai-assisted-implementation.md) are the natural next steps for adding realistic responses — the former by hand, the latter with an AI agent doing the heavy lifting. As the mock grows, [Federated Context Files](./federated-context.md) and [Test the Context, Not the Handlers](./test-context-not-handlers.md) keep the stateful logic organized and reliable. Throughout all of this, [Live Server Inspection with the REPL](./repl-inspection.md) is Counterfact's most distinctive feature: it lets you seed data, send requests, and toggle behavior in real time without restarting. [Simulate Failures and Edge Cases](./simulate-failures.md) and [Simulate Realistic Latency](./simulate-latency.md) extend any mock to cover error paths and performance characteristics that real services exhibit. [Reference Implementation](./reference-implementation.md) and [Executable Spec](./executable-spec.md) make the mock a first-class artifact that teams can rely on as the API evolves. Finally, [Agentic Sandbox](./agentic-sandbox.md) and [Hybrid Proxy](./hybrid-proxy.md) address the two common integration strategies — isolating an AI agent from the real service, or blending mock and live traffic across endpoints. [Automated Integration Tests](./automated-integration-tests.md) shows how to embed the mock server in a test suite using the programmatic API, while [Custom Middleware](./custom-middleware.md) covers cross-cutting concerns like authentication and response headers without touching individual handlers. +Most projects start with [Explore a New API](./explore-new-api.md) or [Executable Spec](./executable-spec.md) to get a running server from an OpenAPI spec with no code. From there, [Mock APIs with Dummy Data](./mock-with-dummy-data.md) and [AI-Assisted Implementation](./ai-assisted-implementation.md) are the natural next steps for adding realistic responses — the former by hand, the latter with an AI agent doing the heavy lifting. [Scenario Scripts](./scenario-scripts.md) let you automate repetitive REPL interactions — seeding data, resetting state, building reusable request sequences — and the `startup` export runs one of those scripts automatically on every server start. As the mock grows, [Federated Context Files](./federated-context.md) and [Test the Context, Not the Handlers](./test-context-not-handlers.md) keep the stateful logic organized and reliable. Throughout all of this, [Live Server Inspection with the REPL](./repl-inspection.md) is Counterfact's most distinctive feature: it lets you seed data, send requests, and toggle behavior in real time without restarting. [Simulate Failures and Edge Cases](./simulate-failures.md) and [Simulate Realistic Latency](./simulate-latency.md) extend any mock to cover error paths and performance characteristics that real services exhibit. [Reference Implementation](./reference-implementation.md) and [Executable Spec](./executable-spec.md) make the mock a first-class artifact that teams can rely on as the API evolves. Finally, [Agentic Sandbox](./agentic-sandbox.md) and [Hybrid Proxy](./hybrid-proxy.md) address the two common integration strategies — isolating an AI agent from the real service, or blending mock and live traffic across endpoints. [Automated Integration Tests](./automated-integration-tests.md) shows how to embed the mock server in a test suite using the programmatic API, while [Custom Middleware](./custom-middleware.md) covers cross-cutting concerns like authentication and response headers without touching individual handlers. | Pattern | When to use it | |---|---| | [Explore a New API](./explore-new-api.md) | You have a spec but no running backend or production access | | [Executable Spec](./executable-spec.md) | You want immediate feedback on how spec changes affect the running server during API design | | [Mock APIs with Dummy Data](./mock-with-dummy-data.md) | You need realistic-looking responses to build a UI, run a demo, or write assertions | +| [Scenario Scripts](./scenario-scripts.md) | You want to automate REPL interactions, seed data on startup, or build reusable state configurations | | [AI-Assisted Implementation](./ai-assisted-implementation.md) | You want an AI agent to replace random responses with working handler logic | | [Federated Context Files](./federated-context.md) | You want each domain to own its state, with explicit cross-domain dependencies | | [Test the Context, Not the Handlers](./test-context-not-handlers.md) | You want to keep shared stateful logic reliable as the mock grows | diff --git a/docs/patterns/scenario-scripts.md b/docs/patterns/scenario-scripts.md new file mode 100644 index 000000000..92364d077 --- /dev/null +++ b/docs/patterns/scenario-scripts.md @@ -0,0 +1,133 @@ +# Scenario Scripts + +You want to automate repetitive REPL interactions — seeding data, configuring state, building reusable request sequences — so you don't have to type the same commands every session. + +## Problem + +The REPL is powerful for ad-hoc exploration, but many setup tasks are predictable: seed a set of pets before a demo, reset state to a known baseline for a test, or configure a reusable route builder for a common request. Retyping these commands every time the server restarts is tedious and error-prone. + +## Solution + +Write _scenario scripts_ — TypeScript files in the `scenarios/` directory that export named functions. Each function receives a single `$` argument that exposes the full live context and route builder. Run any export on demand from the REPL with `.scenario`, or have one run automatically at startup. + +### Writing a scenario + +Scenario functions are typed with the generated `Scenario` type: + +```ts +// scenarios/index.ts +import type { Scenario } from "../types/scenario-context.js"; + +export const soldPets: Scenario = ($) => { + // Mutate context directly — the same object route handlers see as $.context + $.context.petService.reset(); + $.context.petService.addPet({ id: 1, status: "sold" }); + $.context.petService.addPet({ id: 2, status: "available" }); + + // Store a pre-configured route builder in the REPL environment + $.routes.findSold = $ + .route("/pet/findByStatus") + .method("get") + .query({ status: "sold" }); +}; +``` + +### Running scenarios from the REPL + +Use the `.scenario` command. The argument is a slash-separated path where the last segment is the function name and everything before it is the file path relative to `scenarios/` (with `index.ts` as the default): + +``` +⬣> .scenario soldPets +Applied soldPets +``` + +| Command | File | Function | +|---|---|---| +| `.scenario soldPets` | `scenarios/index.ts` | `soldPets` | +| `.scenario pets/resetAll` | `scenarios/pets.ts` | `resetAll` | +| `.scenario pets/orders/pending` | `scenarios/pets/orders.ts` | `pending` | + +After running, anything stored in `$.routes` is immediately available in the REPL: + +```js +⬣> routes.findSold.send() +``` + +### Automatic startup + +Export a function named `startup` from `scenarios/index.ts` and Counterfact calls it automatically when the server initializes, right before the REPL prompt appears — no manual command required: + +```ts +// scenarios/index.ts +import type { Scenario } from "../types/scenario-context.js"; + +export const startup: Scenario = ($) => { + $.context.addPet({ name: "Fluffy", status: "available", photoUrls: [] }); + $.context.addPet({ name: "Rex", status: "sold", photoUrls: [] }); +}; +``` + +If `startup` is not exported, the server starts normally with no error. + +### Delegating to helpers with extra arguments + +Split large scenarios into focused helper functions in separate files and pass `$` (and any extra arguments) through. This keeps each file small and lets you call the same helpers from both `startup` and the REPL with different parameters: + +```ts +// scenarios/index.ts +import type { Scenario } from "../types/scenario-context.js"; +import { addPets } from "./pets.js"; +import { addOrders } from "./orders.js"; + +export const startup: Scenario = ($) => { + addPets($, 20, "dog"); // seed 20 dogs at startup + addOrders($, 5); +}; +``` + +```ts +// scenarios/pets.ts +import type { ApplyContext } from "../types/scenario-context.js"; + +export function addPets($: ApplyContext, count: number, species: string) { + for (let i = 0; i < count; i++) { + $.context.addPet({ + name: `${species} ${i + 1}`, + status: "available", + photoUrls: [], + }); + } +} +``` + +```ts +// scenarios/orders.ts +import type { ApplyContext } from "../types/scenario-context.js"; + +export function addOrders($: ApplyContext, count: number) { + for (let i = 0; i < count; i++) { + $.context.addOrder({ petId: i + 1, quantity: 1, status: "placed" }); + } +} +``` + +Helpers can also be called as standalone `.scenario` commands: + +``` +⬣> .scenario pets/addPets +``` + +## Consequences + +- Scenario functions are plain TypeScript — no special framework, fully type-checked, easy to test in isolation. +- `.scenario` provides on-demand state changes without editing handler files or restarting the server. +- `startup` gives the server a deterministic initial state on every restart, eliminating manual seeding steps. +- Helper functions accepting extra arguments let the same logic be used for both realistic production volumes (at startup) and minimal data (in the REPL during debugging). +- Scenarios live alongside your handler code, making them easy to discover and keep in sync with the API. + +## Related Patterns + +- [Mock APIs with Dummy Data](./mock-with-dummy-data.md) — the full range of approaches for populating server responses +- [Live Server Inspection with the REPL](./repl-inspection.md) — interactive exploration and state manipulation at runtime +- [Federated Context Files](./federated-context.md) — organize stateful logic that scenarios can seed +- [Simulate Failures and Edge Cases](./simulate-failures.md) — use scenarios to flip failure flags on demand diff --git a/src/app.ts b/src/app.ts index 034a0c9f5..f94e96778 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import nodePath from "node:path"; import { createHttpTerminator, type HttpTerminator } from "http-terminator"; import { startRepl as startReplServer } from "./repl/repl.js"; +import { createRouteFunction } from "./repl/route-builder.js"; import type { Config } from "./server/config.js"; import { ContextRegistry } from "./server/context-registry.js"; import { createKoaApp } from "./server/create-koa-app.js"; @@ -21,6 +22,38 @@ import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable export { loadOpenApiDocument } from "./server/load-openapi-document.js"; +type ApplyContext = { + context: Record; + loadContext: (path: string) => Record; + route: (path: string) => unknown; + routes: Record; +}; + +export async function runStartupScenario( + scenarioRegistry: ScenarioRegistry, + contextRegistry: ContextRegistry, + config: Config, + openApiDocument?: Parameters[2], +): Promise { + const indexModule = scenarioRegistry.getModule("index"); + + if (!indexModule || typeof indexModule["startup"] !== "function") { + return; + } + + const applyContext: ApplyContext = { + context: contextRegistry.find("/") as Record, + loadContext: (path: string) => + contextRegistry.find(path) as Record, + route: createRouteFunction(config.port, "localhost", openApiDocument), + routes: {}, + }; + + await (indexModule["startup"] as (ctx: ApplyContext) => Promise | void)( + applyContext, + ); +} + type MswHandlerMap = { [key: string]: (request: MockRequest) => Promise; }; @@ -182,6 +215,13 @@ export async function counterfact(config: Config) { await moduleLoader.load(); await moduleLoader.watch(); + await runStartupScenario( + scenarioRegistry, + contextRegistry, + config, + openApiDocument, + ); + const server = koaApp.listen({ port: config.port, }); diff --git a/src/typescript-generator/generate.ts b/src/typescript-generator/generate.ts index 522996239..834e6094f 100644 --- a/src/typescript-generator/generate.ts +++ b/src/typescript-generator/generate.ts @@ -293,6 +293,21 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari * $.routes.myRequest = $.route("/pets").method("get"); */ +/** + * startup() runs automatically when the server initializes, right before the + * REPL starts. Use it to seed dummy data so the server is ready to use + * immediately. It receives the same $ argument as all other scenario functions. + * + * Tip: delegate to other scenario functions and pass $ along so each function + * stays focused on a single concern. You can also pass additional arguments to + * configure them, e.g. addPets($, 20, "dog"). + * + * If you don't need a startup scenario, delete this function or leave it empty. + */ +export const startup: Scenario = ($) => { + void $; +}; + /** * An example scenario. To use it in the REPL, type: * .scenario help diff --git a/test/app.test.ts b/test/app.test.ts index 766aba43a..b12a9199b 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // import { describe, it, expect } from "@jest/globals"; import * as app from "../src/app"; +import { ContextRegistry } from "../src/server/context-registry"; import { Registry } from "../src/server/registry"; +import { ScenarioRegistry } from "../src/server/scenario-registry"; // Use the same HttpMethods type as in app.ts const httpMethod = "get"; @@ -130,3 +132,100 @@ describe("createMswHandlers", () => { expect(handlers[0]).toHaveProperty("path"); }); }); + +describe("runStartupScenario", () => { + it("calls startup from the index module if it exists", async () => { + const scenarioRegistry = new ScenarioRegistry(); + const contextRegistry = new ContextRegistry(); + let startupCalled = false; + + scenarioRegistry.add("index", { + startup: () => { + startupCalled = true; + }, + }); + + await (app as any).runStartupScenario( + scenarioRegistry, + contextRegistry, + mockConfig, + ); + + expect(startupCalled).toBe(true); + }); + + it("passes the applyContext ($) to startup", async () => { + const scenarioRegistry = new ScenarioRegistry(); + const contextRegistry = new ContextRegistry(); + let receivedContext: any; + + scenarioRegistry.add("index", { + startup: ($: any) => { + receivedContext = $; + }, + }); + + await (app as any).runStartupScenario( + scenarioRegistry, + contextRegistry, + mockConfig, + ); + + expect(receivedContext).toBeDefined(); + expect(typeof receivedContext.context).toBe("object"); + expect(typeof receivedContext.loadContext).toBe("function"); + expect(typeof receivedContext.route).toBe("function"); + expect(typeof receivedContext.routes).toBe("object"); + }); + + it("does nothing if there is no index module", async () => { + const scenarioRegistry = new ScenarioRegistry(); + const contextRegistry = new ContextRegistry(); + + await expect( + (app as any).runStartupScenario( + scenarioRegistry, + contextRegistry, + mockConfig, + ), + ).resolves.toBeUndefined(); + }); + + it("does nothing if startup is not a function in the index module", async () => { + const scenarioRegistry = new ScenarioRegistry(); + const contextRegistry = new ContextRegistry(); + + scenarioRegistry.add("index", { + startup: 42, + }); + + await expect( + (app as any).runStartupScenario( + scenarioRegistry, + contextRegistry, + mockConfig, + ), + ).resolves.toBeUndefined(); + }); + + it("awaits an async startup function", async () => { + const scenarioRegistry = new ScenarioRegistry(); + const contextRegistry = new ContextRegistry(); + let resolved = false; + + scenarioRegistry.add("index", { + startup: async () => { + await Promise.resolve(); + resolved = true; + }, + }); + + await (app as any).runStartupScenario( + scenarioRegistry, + contextRegistry, + mockConfig, + ); + + expect(resolved).toBe(true); + }); +});