diff --git a/.changeset/rename-apply-to-scenario.md b/.changeset/rename-apply-to-scenario.md new file mode 100644 index 000000000..f2ffaf55c --- /dev/null +++ b/.changeset/rename-apply-to-scenario.md @@ -0,0 +1,5 @@ +--- +"counterfact": patch +--- + +Rename the `.apply` REPL command to `.scenario`. Update all references in code, tests, and documentation. diff --git a/docs/adr/001-apply-command-with-function-injection.md b/docs/adr/001-apply-command-with-function-injection.md index b8c8cb33e..4ca38123c 100644 --- a/docs/adr/001-apply-command-with-function-injection.md +++ b/docs/adr/001-apply-command-with-function-injection.md @@ -1,4 +1,4 @@ -# ADR 001: .apply Command Design — Minimalist Function Injection +# ADR 001: .scenario Command Design — Minimalist Function Injection ## Status @@ -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/`: @@ -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 ` is run, Counterfact splits the argument on `/`, uses the last segment as the function name and the rest as the file path (relative to `/repl/`), dynamically imports the module, and calls the named function with a live `ApplyContext` (`$`) object: +A scenario script is a TypeScript file with one or more named function exports. When `.scenario ` is run, Counterfact splits the argument on `/`, uses the last segment as the function name and the rest as the file path (relative to `/repl/`), dynamically imports the module, and calls the named function with a live `ApplyContext` (`$`) object: ```ts // repl/sold-pets.ts @@ -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. @@ -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. diff --git a/docs/features/repl.md b/docs/features/repl.md index df98c6db2..3499ff842 100644 --- a/docs/features/repl.md +++ b/docs/features/repl.md @@ -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 `/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 `/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 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 }`: diff --git a/src/repl/repl.ts b/src/repl/repl.ts index 784fa00c4..8051a4db4 100644 --- a/src/repl/repl.ts +++ b/src/repl/repl.ts @@ -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( @@ -44,8 +44,8 @@ export function createCompleter( scenarioRegistry?: ScenarioRegistry, ) { return (line: string, callback: CompleterCallback): void => { - // Check for .apply completion: .apply - const applyMatch = line.match(/^\.apply\s+(?\S*)$/u); + // Check for .scenario completion: .scenario + const applyMatch = line.match(/^\.scenario\s+(?\S*)$/u); if (applyMatch) { const partial = applyMatch.groups?.["partial"] ?? ""; @@ -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 "); + print("usage: .scenario "); this.clearBufferedCommand(); this.displayPrompt(); return; @@ -337,7 +337,7 @@ export function startRepl( this.displayPrompt(); }, - help: 'apply a scenario script (".apply " calls the named export from scenarios/)', + help: 'apply a scenario script (".scenario " calls the named export from scenarios/)', }); return replServer; diff --git a/src/typescript-generator/generate.ts b/src/typescript-generator/generate.ts index dbc3ba9dc..522996239 100644 --- a/src/typescript-generator/generate.ts +++ b/src/typescript-generator/generate.ts @@ -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 + * .scenario */ /** @@ -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 $; diff --git a/test/repl/repl.test.ts b/test/repl/repl.test.ts index 0d71d9fd7..bebc0beb9 100644 --- a/test/repl/repl.test.ts +++ b/test/repl/repl.test.ts @@ -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(); @@ -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" }); @@ -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" }); @@ -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" }); @@ -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( @@ -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); @@ -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, @@ -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 "); + expect(harness.output).toContain("usage: .scenario "); 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); @@ -578,7 +578,7 @@ describe("REPL", () => { }); }); - describe(".apply tab completion", () => { + describe(".scenario tab completion", () => { function callCompleter( completer: ReturnType, line: string, @@ -606,7 +606,7 @@ 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"); @@ -614,12 +614,12 @@ describe("REPL", () => { 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([]); }); @@ -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"); @@ -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"); @@ -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/"); @@ -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([]); });