Skip to content

Commit c9aab1e

Browse files
authored
Merge branch 'main' into copilot/move-openapi-example-to-fixtures
2 parents 447b2d9 + b6917f6 commit c9aab1e

File tree

6 files changed

+46
-38
lines changed

6 files changed

+46
-38
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"counterfact": patch
3+
---
4+
5+
Rename the `.apply` REPL command to `.scenario`. Update all references in code, tests, and documentation.

docs/adr/001-apply-command-with-function-injection.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# ADR 001: .apply Command Design — Minimalist Function Injection
1+
# ADR 001: .scenario Command Design — Minimalist Function Injection
22

33
## Status
44

@@ -8,7 +8,7 @@ Accepted
88

99
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.
1010

11-
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.
11+
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.
1212

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

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

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

30-
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:
30+
A scenario 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:
3131

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

5656
### Solution 2: Scenario Class with Lifecycle Hooks
5757

58-
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.
58+
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.
5959

6060
**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.
6161

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

8282
### Risks and downsides
8383

84-
- Without lifecycle hooks, accumulated state across many `.apply` calls may be difficult to reason about.
84+
- Without lifecycle hooks, accumulated state across many `.scenario` calls may be difficult to reason about.
8585
- If teardown proves to be a common need, adding it later will require extending the API in a backward-compatible way.
8686
- 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.
8787

docs/features/repl.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,21 @@ await req.send()
5656

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

59-
## Scenario scripts with `.apply`
59+
## Scenario scripts with `.scenario`
6060

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

6363
```
64-
⬣> .apply soldPets
64+
⬣> .scenario soldPets
6565
```
6666

67-
**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).
67+
**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).
6868

6969
| Command | File | Function |
7070
|---|---|---|
71-
| `.apply foo` | `scenarios/index.ts` | `foo` |
72-
| `.apply foo/bar` | `scenarios/foo.ts` | `bar` |
73-
| `.apply foo/bar/baz` | `scenarios/foo/bar.ts` | `baz` |
71+
| `.scenario soldPets` | `scenarios/index.ts` | `soldPets` |
72+
| `.scenario pets/resetAll` | `scenarios/pets.ts` | `resetAll` |
73+
| `.scenario pets/orders/pending` | `scenarios/pets/orders.ts` | `pending` |
7474

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

src/repl/repl.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const ROUTE_BUILDER_METHODS = [
3535
*
3636
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
3737
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
38-
* @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
38+
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
3939
* exported function names and file-key prefixes from the loaded scenario modules.
4040
*/
4141
export function createCompleter(
@@ -44,8 +44,8 @@ export function createCompleter(
4444
scenarioRegistry?: ScenarioRegistry,
4545
) {
4646
return (line: string, callback: CompleterCallback): void => {
47-
// Check for .apply completion: .apply <partial>
48-
const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);
47+
// Check for .scenario completion: .scenario <partial>
48+
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
4949

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

273273
replServer.context.routes = {};
274274

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

279279
if (parts.length === 0) {
280-
print("usage: .apply <path>");
280+
print("usage: .scenario <path>");
281281
this.clearBufferedCommand();
282282
this.displayPrompt();
283283
return;
@@ -337,7 +337,7 @@ export function startRepl(
337337
this.displayPrompt();
338338
},
339339

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

343343
return replServer;

src/typescript-generator/generate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
279279
/**
280280
* Scenario scripts are plain TypeScript functions that receive the live REPL
281281
* environment and can read or mutate server state. Run them from the REPL with:
282-
* .apply <functionName>
282+
* .scenario <functionName>
283283
*/
284284
285285
/**
@@ -295,7 +295,7 @@ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/scenari
295295
296296
/**
297297
* An example scenario. To use it in the REPL, type:
298-
* .apply help
298+
* .scenario help
299299
*/
300300
export const help: Scenario = ($) => {
301301
void $;

test/repl/repl.test.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ describe("REPL", () => {
270270
expect(harness.isReset()).toBe(true);
271271
});
272272

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

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

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

285-
await harness.callAsync("apply", "foo");
285+
await harness.callAsync("scenario", "foo");
286286

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

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

303-
await harness.callAsync("apply", "myscript/bar");
303+
await harness.callAsync("scenario", "myscript/bar");
304304

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

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

320-
await harness.callAsync("apply", "foo/bar/baz");
320+
await harness.callAsync("scenario", "foo/bar/baz");
321321

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

335335
const { harness } = createHarness(scenarioRegistry);
336336

337-
await harness.callAsync("apply", "setup");
337+
await harness.callAsync("scenario", "setup");
338338

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

351-
await harness.callAsync("apply", "nonexistent");
351+
await harness.callAsync("scenario", "nonexistent");
352352

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

364364
const { harness } = createHarness(scenarioRegistry);
365365

366-
await harness.callAsync("apply", "notAFunction");
366+
await harness.callAsync("scenario", "notAFunction");
367367

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

377-
await harness.callAsync("apply", "");
377+
await harness.callAsync("scenario", "");
378378

379-
expect(harness.output).toContain("usage: .apply <path>");
379+
expect(harness.output).toContain("usage: .scenario <path>");
380380
expect(harness.isReset()).toBe(true);
381381
});
382382

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

386-
await harness.callAsync("apply", "../secret/foo");
386+
await harness.callAsync("scenario", "../secret/foo");
387387

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

581-
describe(".apply tab completion", () => {
581+
describe(".scenario tab completion", () => {
582582
function callCompleter(
583583
completer: ReturnType<typeof createCompleter>,
584584
line: string,
@@ -606,20 +606,20 @@ describe("REPL", () => {
606606
// Partial "sold" — should match soldPets only, not the non-function export
607607
const [completions, prefix] = await callCompleter(
608608
completer,
609-
".apply sold",
609+
".scenario sold",
610610
);
611611

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

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

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

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

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

633633
const registry = new Registry();
634634
const completer = createCompleter(registry, undefined, scenarioRegistry);
635-
const [completions, prefix] = await callCompleter(completer, ".apply ");
635+
const [completions, prefix] = await callCompleter(
636+
completer,
637+
".scenario ",
638+
);
636639

637640
expect(prefix).toBe("");
638641
expect(completions).toContain("foo");
@@ -649,7 +652,7 @@ describe("REPL", () => {
649652
const completer = createCompleter(registry, undefined, scenarioRegistry);
650653
const [completions, prefix] = await callCompleter(
651654
completer,
652-
".apply myscript/sol",
655+
".scenario myscript/sol",
653656
);
654657

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

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

680683
expect(completions).toEqual([]);
681684
});

0 commit comments

Comments
 (0)