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

Support `.scenario <group> <path>` in multi-API REPL sessions while preserving single-runner syntax.
6 changes: 6 additions & 0 deletions docs/features/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ For more complex setups you can automate REPL interactions by writing _scenario
⬣> .scenario soldPets
```

When running multiple APIs in one process, qualify the command with the API group:

```bash
⬣> .scenario billing soldPets
```

**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 |
Expand Down
3 changes: 0 additions & 3 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
const optionSource = program.getOptionValueSource(key);

if (optionSource !== "cli") {
(options as Record<string, unknown>)[key] = value;

Check warning on line 126 in src/cli/run.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand All @@ -142,8 +142,6 @@
// objects ({source, prefix, group}), it describes multiple API specs and
// is passed directly to counterfact() as the `specs` argument.

console.log("options", options);

const specs = normalizeSpecOption(options.spec);

if (specs === undefined && typeof options.spec === "string") {
Expand All @@ -165,7 +163,7 @@
)
) {
for (const action of actions) {
(options as Record<string, unknown>)[action] = true;

Check warning on line 166 in src/cli/run.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -256,7 +254,6 @@

const { start, startRepl } = await (async () => {
try {
console.log("specs = ", specs);
return await counterfact(config, specs);
} catch (error) {
process.stderr.write(
Expand Down
87 changes: 79 additions & 8 deletions src/repl/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,10 +423,57 @@

replServer.defineCommand("scenario", {
async action(text: string) {
const parts = text.trim().split("/").filter(Boolean);
const trimmedText = text.trim();
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
const usage = isMultiApi
? "usage: .scenario <group> <path>"
: "usage: .scenario <path>";
const { selectedBinding, scenarioPath } = (() => {
if (!isMultiApi) {
if (trimmedText === "") {
return { scenarioPath: undefined, selectedBinding: undefined };
}

return { scenarioPath: trimmedText, selectedBinding: rootBinding };
}

if (parsedArgs.length !== 2) {
return { scenarioPath: undefined, selectedBinding: undefined };
}

return {
scenarioPath: parsedArgs[1],
selectedBinding: groupedBindings.find(
(binding) => binding.key === parsedArgs[0],
),
};
})();

if (selectedBinding === undefined || scenarioPath === undefined) {
if (
isMultiApi &&
scenarioPath !== undefined &&
selectedBinding === undefined
) {
const groupName = parsedArgs[0] ?? "";
const availableGroups = groupedBindings.map((binding) => binding.key);

print(
`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`,
);
} else {
print(usage);
}

this.clearBufferedCommand();
this.displayPrompt();
return;
}

const parts = scenarioPath.split("/").filter(Boolean);

if (parts.length === 0) {
print("usage: .scenario <path>");
print(usage);
this.clearBufferedCommand();
this.displayPrompt();
return;
Expand All @@ -443,7 +490,7 @@
const fileKey =
parts.length === 1 ? "index" : parts.slice(0, -1).join("/");

const module = rootBinding.scenarioRegistry?.getModule(fileKey);
const module = selectedBinding.scenarioRegistry?.getModule(fileKey);

if (module === undefined) {
print(`Error: Could not find scenario file "${fileKey}"`);
Expand All @@ -452,7 +499,7 @@
return;
}

const fn = module[functionName];

Check warning on line 502 in src/repl/repl.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Variable Assigned to Object Injection Sink

if (typeof fn !== "function") {
print(
Expand All @@ -464,13 +511,35 @@
}

try {
const selectedRoutes = isMultiApi
? (
replServer.context["routes"] as Record<
string,
Record<string, unknown>
>
)[selectedBinding.key]
: (replServer.context["routes"] as Record<string, unknown>);

if (isMultiApi && selectedRoutes === undefined) {
print(
`Error: Could not resolve routes for API group "${selectedBinding.key}"`,
);
this.clearBufferedCommand();
this.displayPrompt();
return;
}

const applyContext = {
context: replServer.context["context"] as Record<string, unknown>,
loadContext: replServer.context["loadContext"] as (
context: selectedBinding.contextRegistry.find("/") as Record<
string,
unknown
>,
loadContext: ((path: string) =>
selectedBinding.contextRegistry.find(path)) as (
path: string,
) => Record<string, unknown>,
route: replServer.context["route"] as (path: string) => unknown,
routes: replServer.context["routes"] as Record<string, unknown>,
route: groupedRoute[selectedBinding.key] as (path: string) => unknown,
routes: selectedRoutes,
};

await (fn as (ctx: typeof applyContext) => Promise<void> | void)(
Expand All @@ -486,7 +555,9 @@
this.displayPrompt();
},

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

return replServer;
Expand Down
182 changes: 182 additions & 0 deletions test/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,136 @@ describe("REPL", () => {
).toMatchObject({ path: "/pets" });
});

it("supports `.scenario <group> <path>` in multi-API mode and binds that group's context", async () => {
const billingRegistry = new ScenarioRegistry();
const inventoryRegistry = new ScenarioRegistry();
const billingContextRegistry = new ContextRegistry();
const inventoryContextRegistry = new ContextRegistry();

billingRegistry.add("index", {
setup(ctx: {
context: Record<string, unknown>;
loadContext: (path: string) => Record<string, unknown>;
routes: Record<string, unknown>;
}) {
ctx.context["applied"] = "billing";
ctx.context["loaded"] = ctx.loadContext("/")["from"];
ctx.routes["routeFromScenario"] = "billing";
},
});
inventoryRegistry.add("index", {
setup(ctx: { context: Record<string, unknown> }) {
ctx.context["applied"] = "inventory";
},
});
billingContextRegistry.add("/", { from: "billing-root" });
inventoryContextRegistry.add("/", { from: "inventory-root" });

const { harness } = createHarness(undefined, [
{
contextRegistry: billingContextRegistry,
group: "billing",
registry: new Registry(),
scenarioRegistry: billingRegistry,
},
{
contextRegistry: inventoryContextRegistry,
group: "inventory",
registry: new Registry(),
scenarioRegistry: inventoryRegistry,
},
]);

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

expect(harness.output).toContain("Applied billing setup");
expect(billingContextRegistry.find("/")).toMatchObject({
applied: "billing",
from: "billing-root",
loaded: "billing-root",
});
expect(inventoryContextRegistry.find("/")).toMatchObject({
from: "inventory-root",
});
expect(
(
harness.server.context["routes"] as Record<
string,
Record<string, unknown>
>
)["billing"],
).toMatchObject({ routeFromScenario: "billing" });
expect(
(
harness.server.context["routes"] as Record<
string,
Record<string, unknown>
>
)["inventory"],
).toEqual({});
});

it("keeps group contexts isolated when applying a scenario to another group", async () => {
const billingRegistry = new ScenarioRegistry();
const inventoryRegistry = new ScenarioRegistry();
const billingContextRegistry = new ContextRegistry();
const inventoryContextRegistry = new ContextRegistry();

billingRegistry.add("index", {
setup(ctx: { context: Record<string, unknown> }) {
ctx.context["applied"] = "billing";
},
});
inventoryRegistry.add("index", {
setup(ctx: {
context: Record<string, unknown>;
routes: Record<string, unknown>;
}) {
ctx.context["applied"] = "inventory";
ctx.routes["inventoryRoute"] = true;
},
});

const { harness } = createHarness(undefined, [
{
contextRegistry: billingContextRegistry,
group: "billing",
registry: new Registry(),
scenarioRegistry: billingRegistry,
},
{
contextRegistry: inventoryContextRegistry,
group: "inventory",
registry: new Registry(),
scenarioRegistry: inventoryRegistry,
},
]);

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

expect(harness.output).toContain("Applied inventory setup");
expect(billingContextRegistry.find("/")).toEqual({});
expect(inventoryContextRegistry.find("/")).toMatchObject({
applied: "inventory",
});
expect(
(
harness.server.context["routes"] as Record<
string,
Record<string, unknown>
>
)["billing"],
).toEqual({});
expect(
(
harness.server.context["routes"] as Record<
string,
Record<string, unknown>
>
)["inventory"],
).toMatchObject({ inventoryRoute: true });
});

it("shows an error when the scenario file is not in the registry", async () => {
const scenarioRegistry = new ScenarioRegistry();
const { harness } = createHarness(scenarioRegistry);
Expand Down Expand Up @@ -491,6 +621,58 @@ describe("REPL", () => {
expect(harness.isReset()).toBe(true);
});

it("shows an error with available groups for unknown group names in multi-api mode", async () => {
const { harness } = createHarness(undefined, [
{
contextRegistry: new ContextRegistry(),
group: "billing",
registry: new Registry(),
scenarioRegistry: new ScenarioRegistry(),
},
{
contextRegistry: new ContextRegistry(),
group: "inventory",
registry: new Registry(),
scenarioRegistry: new ScenarioRegistry(),
},
]);

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

expect(harness.output[0]).toBe(
'Error: Unknown API group "payments". Available groups: billing, inventory',
);
expect(harness.isReset()).toBe(true);
});

it("shows multi-runner usage for missing or invalid arguments", async () => {
const { harness } = createHarness(undefined, [
{
contextRegistry: new ContextRegistry(),
group: "billing",
registry: new Registry(),
scenarioRegistry: new ScenarioRegistry(),
},
{
contextRegistry: new ContextRegistry(),
group: "inventory",
registry: new Registry(),
scenarioRegistry: new ScenarioRegistry(),
},
]);

await harness.callAsync("scenario", "");
await harness.callAsync("scenario", "billing");
await harness.callAsync("scenario", "billing setup extra");

expect(harness.output).toEqual([
"usage: .scenario <group> <path>",
"usage: .scenario <group> <path>",
"usage: .scenario <group> <path>",
]);
expect(harness.isReset()).toBe(true);
});

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

Expand Down
Loading