Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/rename-apply-to-scenario.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"counterfact": patch
---

Rename the `.apply` REPL command to `.scenario`. Update all references in code, tests, and documentation.
10 changes: 5 additions & 5 deletions docs/adr/001-apply-command-with-function-injection.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ADR 001: .apply Command Design — Minimalist Function Injection
# ADR 001: .scenario Command Design — Minimalist Function Injection

## Status

Expand All @@ -8,7 +8,7 @@ Accepted

Counterfact's REPL lets developers interact with the running mock server from the terminal. A common need is to transition the server into a specific state (e.g. "all pets sold", "service unavailable") in a reproducible, shareable way. Today, operators must manually call REPL commands one by one; there is no mechanism to save and replay a named scenario.

The `.apply` command is proposed to address this: given a path argument, it loads and executes a user-authored script that mutates REPL context and routes, then reports what changed.
The `.scenario` command is proposed to address this: given a path argument, it loads and executes a user-authored script that mutates REPL context and routes, then reports what changed.

Three designs were proposed as working documents in `.github/issue-proposals/`:

Expand All @@ -27,7 +27,7 @@ Three designs were proposed as working documents in `.github/issue-proposals/`:

**Solution 1 (Minimalist Function Injection) is selected.**

An apply script is a TypeScript file with one or more named function exports. When `.apply <path>` is run, Counterfact splits the argument on `/`, uses the last segment as the function name and the rest as the file path (relative to `<basePath>/repl/`), dynamically imports the module, and calls the named function with a live `ApplyContext` (`$`) object:
An apply script is a TypeScript file with one or more named function exports. When `.scenario <path>` is run, Counterfact splits the argument on `/`, uses the last segment as the function name and the rest as the file path (relative to `<basePath>/repl/`), dynamically imports the module, and calls the named function with a live `ApplyContext` (`$`) object:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scenario script

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a80ee5d — "An apply script" is now "A scenario script".


```ts
// repl/sold-pets.ts
Expand Down Expand Up @@ -55,7 +55,7 @@ Scripts export named functions that receive `$: ApplyContext`. Counterfact resol

### Solution 2: Scenario Class with Lifecycle Hooks

Scripts export a named class that implements a `Scenario` interface with `setup()` and optional `teardown()` methods. Counterfact instantiates the class, calls `setup()`, and tracks applied instances in a map for later `.unapply`. A static `dependencies` array enables ordered composition.
Scripts export a named class that implements a `Scenario` interface with `setup()` and optional `teardown()` methods. Counterfact instantiates the class, calls `setup()`, and tracks applied instances in a map for later `.unscenario`. A static `dependencies` array enables ordered composition.

**Why not chosen:** Class syntax and lifecycle coupling add complexity that is not justified until the need for teardown and dependency ordering is proven in practice. These concerns can be layered on top of Solution 1 once the basic command exists.

Expand All @@ -81,7 +81,7 @@ Identical surface syntax to Solution 1, but Counterfact wraps `context` and `rou

### Risks and downsides

- Without lifecycle hooks, accumulated state across many `.apply` calls may be difficult to reason about.
- Without lifecycle hooks, accumulated state across many `.scenario` calls may be difficult to reason about.
- If teardown proves to be a common need, adding it later will require extending the API in a backward-compatible way.
- Proxy-based auto-diffing (Solution 3) remains attractive for DX; deferring it means script authors will need to be disciplined about documenting context changes in the short term.

Expand Down
14 changes: 7 additions & 7 deletions docs/features/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,21 @@ await req.send()

See the [Route Builder guide](./route-builder.md) for full documentation.

## Scenario scripts with `.apply`
## Scenario scripts with `.scenario`

For more complex setups you can automate REPL interactions by writing _scenario scripts_ — plain TypeScript files that export named functions. Run them with `.apply`:
For more complex setups you can automate REPL interactions by writing _scenario scripts_ — plain TypeScript files that export named functions. Run them with `.scenario`:

```
⬣> .apply soldPets
⬣> .scenario soldPets
```

**Path resolution:** the argument to `.apply` is a slash-separated path. The last segment is the function name; everything before it is the file path, resolved relative to `<basePath>/scenarios/` (with `index.ts` as the default file).
**Path resolution:** the argument to `.scenario` is a slash-separated path. The last segment is the function name; everything before it is the file path, resolved relative to `<basePath>/scenarios/` (with `index.ts` as the default file).

| Command | File | Function |
|---|---|---|
| `.apply foo` | `scenarios/index.ts` | `foo` |
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |
| `.scenario foo` | `scenarios/index.ts` | `foo` |
| `.scenario foo/bar` | `scenarios/foo.ts` | `bar` |
| `.scenario foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change foo, bar, and baz to something more realistic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in a80ee5d with petstore-flavoured examples:

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


A scenario function receives a single argument with `{ context, loadContext, routes, route }`:

Expand Down
12 changes: 6 additions & 6 deletions src/repl/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const ROUTE_BUILDER_METHODS = [
*
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
* @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
* exported function names and file-key prefixes from the loaded scenario modules.
*/
export function createCompleter(
Expand All @@ -44,8 +44,8 @@ export function createCompleter(
scenarioRegistry?: ScenarioRegistry,
) {
return (line: string, callback: CompleterCallback): void => {
// Check for .apply completion: .apply <partial>
const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);
// Check for .scenario completion: .scenario <partial>
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);

if (applyMatch) {
const partial = applyMatch.groups?.["partial"] ?? "";
Expand Down Expand Up @@ -272,12 +272,12 @@ export function startRepl(

replServer.context.routes = {};

replServer.defineCommand("apply", {
replServer.defineCommand("scenario", {
async action(text: string) {
const parts = text.trim().split("/").filter(Boolean);

if (parts.length === 0) {
print("usage: .apply <path>");
print("usage: .scenario <path>");
this.clearBufferedCommand();
this.displayPrompt();
return;
Expand Down Expand Up @@ -337,7 +337,7 @@ export function startRepl(
this.displayPrompt();
},

help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
help: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
});

return replServer;
Expand Down
4 changes: 2 additions & 2 deletions src/typescript-generator/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
/**
* Scenario scripts are plain TypeScript functions that receive the live REPL
* environment and can read or mutate server state. Run them from the REPL with:
* .apply <functionName>
* .scenario <functionName>
*/

/**
Expand All @@ -295,7 +295,7 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari

/**
* An example scenario. To use it in the REPL, type:
* .apply help
* .scenario help
*/
export const help: Scenario = ($) => {
void $;
Expand Down
39 changes: 21 additions & 18 deletions test/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe("REPL", () => {
expect(harness.isReset()).toBe(true);
});

describe(".apply command", () => {
describe(".scenario command", () => {
it("calls the named export from scenarios/index for a single-segment path", async () => {
const scenarioRegistry = new ScenarioRegistry();

Expand All @@ -282,7 +282,7 @@ describe("REPL", () => {

const { harness, contextRegistry } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "foo");
await harness.callAsync("scenario", "foo");

expect(harness.output).toContain("Applied foo");
expect(contextRegistry.find("/")).toMatchObject({ applied: "foo" });
Expand All @@ -300,7 +300,7 @@ describe("REPL", () => {

const { harness, contextRegistry } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "myscript/bar");
await harness.callAsync("scenario", "myscript/bar");

expect(harness.output).toContain("Applied myscript/bar");
expect(contextRegistry.find("/")).toMatchObject({ applied: "bar" });
Expand All @@ -317,7 +317,7 @@ describe("REPL", () => {

const { harness, contextRegistry } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "foo/bar/baz");
await harness.callAsync("scenario", "foo/bar/baz");

expect(harness.output).toContain("Applied foo/bar/baz");
expect(contextRegistry.find("/")).toMatchObject({ applied: "baz" });
Expand All @@ -334,7 +334,7 @@ describe("REPL", () => {

const { harness } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "setup");
await harness.callAsync("scenario", "setup");

expect(harness.output).toContain("Applied setup");
expect(
Expand All @@ -348,7 +348,7 @@ describe("REPL", () => {
const scenarioRegistry = new ScenarioRegistry();
const { harness } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "nonexistent");
await harness.callAsync("scenario", "nonexistent");

expect(harness.output[0]).toMatch(/Error: Could not find/u);
expect(harness.isReset()).toBe(true);
Expand All @@ -363,7 +363,7 @@ describe("REPL", () => {

const { harness } = createHarness(scenarioRegistry);

await harness.callAsync("apply", "notAFunction");
await harness.callAsync("scenario", "notAFunction");

expect(harness.output[0]).toMatch(
/Error: "notAFunction" is not a function/u,
Expand All @@ -374,16 +374,16 @@ describe("REPL", () => {
it("shows a usage message when called with no argument", async () => {
const { harness } = createHarness();

await harness.callAsync("apply", "");
await harness.callAsync("scenario", "");

expect(harness.output).toContain("usage: .apply <path>");
expect(harness.output).toContain("usage: .scenario <path>");
expect(harness.isReset()).toBe(true);
});

it("rejects path traversal using '..' segments", async () => {
const { harness } = createHarness();

await harness.callAsync("apply", "../secret/foo");
await harness.callAsync("scenario", "../secret/foo");

expect(harness.output[0]).toMatch(/Error: Path must not contain/u);
expect(harness.isReset()).toBe(true);
Expand Down Expand Up @@ -578,7 +578,7 @@ describe("REPL", () => {
});
});

describe(".apply tab completion", () => {
describe(".scenario tab completion", () => {
function callCompleter(
completer: ReturnType<typeof createCompleter>,
line: string,
Expand Down Expand Up @@ -606,20 +606,20 @@ describe("REPL", () => {
// Partial "sold" — should match soldPets only, not the non-function export
const [completions, prefix] = await callCompleter(
completer,
".apply sold",
".scenario sold",
);

expect(prefix).toBe("sold");
expect(completions).toEqual(["soldPets"]);
expect(completions).not.toContain("resetAll");

// Partial "reset" — should match resetAll only
const [completions2] = await callCompleter(completer, ".apply reset");
const [completions2] = await callCompleter(completer, ".scenario reset");

expect(completions2).toEqual(["resetAll"]);

// Non-function exports should not be suggested
const [completions3] = await callCompleter(completer, ".apply not");
const [completions3] = await callCompleter(completer, ".scenario not");

expect(completions3).toEqual([]);
});
Expand All @@ -632,7 +632,10 @@ describe("REPL", () => {

const registry = new Registry();
const completer = createCompleter(registry, undefined, scenarioRegistry);
const [completions, prefix] = await callCompleter(completer, ".apply ");
const [completions, prefix] = await callCompleter(
completer,
".scenario ",
);

expect(prefix).toBe("");
expect(completions).toContain("foo");
Expand All @@ -649,7 +652,7 @@ describe("REPL", () => {
const completer = createCompleter(registry, undefined, scenarioRegistry);
const [completions, prefix] = await callCompleter(
completer,
".apply myscript/sol",
".scenario myscript/sol",
);

expect(prefix).toBe("myscript/sol");
Expand All @@ -665,7 +668,7 @@ describe("REPL", () => {
const completer = createCompleter(registry, undefined, scenarioRegistry);
const [completions, prefix] = await callCompleter(
completer,
".apply pets/",
".scenario pets/",
);

expect(prefix).toBe("pets/");
Expand All @@ -675,7 +678,7 @@ describe("REPL", () => {
it("returns empty completions when scenarioRegistry is not provided", async () => {
const registry = new Registry();
const completer = createCompleter(registry);
const [completions] = await callCompleter(completer, ".apply sold");
const [completions] = await callCompleter(completer, ".scenario sold");

expect(completions).toEqual([]);
});
Expand Down
Loading