Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-startup-scenario.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions docs/features/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,47 @@ 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
Expand Down
3 changes: 2 additions & 1 deletion docs/patterns/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. To seed that data automatically on every restart, use the [Seed Data on Startup](./startup-scenario.md) pattern. 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 |
| [Seed Data on Startup](./startup-scenario.md) | You want the server to have dummy data ready the moment it starts, without manual REPL commands |
| [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 |
Expand Down
85 changes: 85 additions & 0 deletions docs/patterns/startup-scenario.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Seed Data on Startup

You want the mock server to have realistic dummy data from the moment it starts, without having to type commands into the REPL every time.

## Problem

A stateful mock server starts with an empty context. Every time you restart the server you have to manually re-seed data through the REPL before your client code has anything to work with. This is tedious during development and error-prone in demos.

## Solution

Export a function called `startup` from `scenarios/index.ts`. Counterfact calls it automatically when the server initializes, right before the REPL prompt appears. It receives the same `$` argument as any other scenario function, so it has full access to the context and route builder.

```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: [] });
$.context.addPet({ name: "Bella", status: "pending", photoUrls: [] });
};
```

If `startup` is absent from `scenarios/index.ts`, the server starts normally with no error.

## Keeping startup clean by delegating to helpers

When seeding many resources, delegate to focused helper functions in other scenario files and pass `$` along. You can also pass additional arguments to configure 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: [],
});
}
}
```

```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" });
}
}
```

Each helper can be re-used as a standalone `.scenario` command from the REPL:

```
⬣> .scenario pets/addPets
```

## Consequences

- The server is immediately useful from the first request, with no manual seeding step.
- Restarting the server always produces the same deterministic initial state.
- Each helper function stays small and focused; `startup` serves as an explicit, readable composition root.
- Because helpers accept extra arguments, the same function can be called from `startup` with production-like volumes and from the REPL with minimal data during debugging.

## 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) — use `.scenario` commands to adjust state after startup
- [Federated Context Files](./federated-context.md) — organize stateful logic across multiple context files
40 changes: 40 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +22,38 @@ import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable

export { loadOpenApiDocument } from "./server/load-openapi-document.js";

type ApplyContext = {
context: Record<string, unknown>;
loadContext: (path: string) => Record<string, unknown>;
route: (path: string) => unknown;
routes: Record<string, unknown>;
};

export async function runStartupScenario(
scenarioRegistry: ScenarioRegistry,
contextRegistry: ContextRegistry,
config: Config,
openApiDocument?: Parameters<typeof createRouteFunction>[2],
): Promise<void> {
const indexModule = scenarioRegistry.getModule("index");

if (!indexModule || typeof indexModule["startup"] !== "function") {
return;
}

const applyContext: ApplyContext = {
context: contextRegistry.find("/") as Record<string, unknown>,
loadContext: (path: string) =>
contextRegistry.find(path) as Record<string, unknown>,
route: createRouteFunction(config.port, "localhost", openApiDocument),
routes: {},
};

await (indexModule["startup"] as (ctx: ApplyContext) => Promise<void> | void)(
applyContext,
);
}

type MswHandlerMap = {
[key: string]: (request: MockRequest) => Promise<unknown>;
};
Expand Down Expand Up @@ -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,
});
Expand Down
15 changes: 15 additions & 0 deletions src/typescript-generator/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions test/app.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
Loading