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/yummy-humans-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"counterfact": patch
---

Update REPL `.scenario` tab completion to suggest groups first in multi-API mode and group-scoped scenarios after selection.
2 changes: 2 additions & 0 deletions docs/features/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ When running multiple APIs in one process, qualify the command with the API grou
⬣> .scenario billing soldPets
```

Tab completion supports both modes: in single-API sessions, `.scenario <Tab>` suggests scenario paths/functions; in multi-API sessions, `.scenario <Tab>` suggests API groups first, then `.scenario <group> <Tab>` suggests scenario paths/functions for that group.

**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
122 changes: 95 additions & 27 deletions src/repl/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,43 +41,98 @@
function getScenarioCompletions(
line: string,
scenarioRegistry: ScenarioRegistry | undefined,
groupedScenarioRegistries?: Record<string, ScenarioRegistry | undefined>,
): [string[], string] | undefined {
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
function getPathCompletions(
partial: string,
registry: ScenarioRegistry | undefined,
): [string[], string] {
if (registry === undefined) {
return [[], partial];
}

if (!applyMatch) {
return undefined;
}
const slashIdx = partial.lastIndexOf("/");

const partial = applyMatch.groups?.["partial"] ?? "";
if (slashIdx === -1) {
const indexFunctions = registry.getExportedFunctionNames("index");
const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
const topLevelPrefixes = [
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
];
const allOptions = [...indexFunctions, ...topLevelPrefixes];
const matches = allOptions.filter((c) => c.startsWith(partial));

return [matches, partial];
}

const fileKey = partial.slice(0, slashIdx);
const funcPartial = partial.slice(slashIdx + 1);
const functions = registry.getExportedFunctionNames(fileKey);
const matches = functions
.filter((e) => e.startsWith(funcPartial))
.map((e) => `${fileKey}/${e}`);

if (scenarioRegistry === undefined) {
return [[], partial];
return [matches, partial];
}

const slashIdx = partial.lastIndexOf("/");
if (groupedScenarioRegistries !== undefined) {
if (!/^\.scenario(?:\s|$)/u.test(line)) {
return undefined;
}

if (slashIdx === -1) {
const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
const fileKeys = scenarioRegistry
.getFileKeys()
.filter((k) => k !== "index");
const topLevelPrefixes = [
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
];
const allOptions = [...indexFunctions, ...topLevelPrefixes];
const matches = allOptions.filter((c) => c.startsWith(partial));
const hasTrailingWhitespace = /\s$/u.test(line);
const args = line.trim().split(/\s+/u).slice(1);
const groupKeys = Object.keys(groupedScenarioRegistries);

return [matches, partial];
if (args.length === 0) {
return [groupKeys, ""];
}

if (args.length === 1 && !hasTrailingWhitespace) {
const groupPartial = args[0] ?? "";
const matches = groupKeys.filter((key) => key.startsWith(groupPartial));

return [matches, groupPartial];
}

const selectedGroup = args[0] ?? "";
const selectedRegistry = groupedScenarioRegistries[selectedGroup];

Check warning on line 99 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 (selectedRegistry === undefined) {
const scenarioPartial = hasTrailingWhitespace
? ""
: (args[args.length - 1] ?? "");

return [[], scenarioPartial];
}

if (args.length === 1 && hasTrailingWhitespace) {
return getPathCompletions("", selectedRegistry);
}

if (args.length === 2 && !hasTrailingWhitespace) {
const scenarioPartial = args[1];

if (scenarioPartial === undefined) {
return [[], ""];
}

return getPathCompletions(scenarioPartial, selectedRegistry);
}

// More than two args (or trailing whitespace after the second arg) means
// no additional `.scenario` arguments are valid in multi-API mode.
return [[], args[args.length - 1] ?? ""];
}

const fileKey = partial.slice(0, slashIdx);
const funcPartial = partial.slice(slashIdx + 1);
const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
const matches = functions
.filter((e) => e.startsWith(funcPartial))
.map((e) => `${fileKey}/${e}`);
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);

return [matches, partial];
if (!applyMatch) {
return undefined;
}

const partial = applyMatch.groups?.["partial"] ?? "";
return getPathCompletions(partial, scenarioRegistry);
}

function getRouteBuilderMethodCompletions(
Expand Down Expand Up @@ -142,11 +197,16 @@
fallback?: (line: string, callback: CompleterCallback) => void,
openApiDocument?: OpenApiDocument,
scenarioRegistry?: ScenarioRegistry,
groupedScenarioRegistries?: Record<string, ScenarioRegistry | undefined>,
) {
const routes = getRoutesForCompletion(registry, openApiDocument);

return (line: string, callback: CompleterCallback): void => {
const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry);
const scenarioCompletions = getScenarioCompletions(
line,
scenarioRegistry,
groupedScenarioRegistries,
);

if (scenarioCompletions !== undefined) {
callback(null, scenarioCompletions);
Expand Down Expand Up @@ -344,6 +404,14 @@
builtinCompleter,
rootBinding.openApiDocument,
rootBinding.scenarioRegistry,
isMultiApi
? Object.fromEntries(
groupedBindings.map((binding) => [
binding.key,
binding.scenarioRegistry,
]),
)
: undefined,
);

replServer.defineCommand("counterfact", {
Expand Down Expand Up @@ -499,7 +567,7 @@
return;
}

const fn = module[functionName];

Check warning on line 570 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 Down
101 changes: 101 additions & 0 deletions test/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,5 +1016,106 @@ describe("REPL", () => {

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

it("suggests API groups after `.scenario ` in multi-runner mode", async () => {
const billingRegistry = new ScenarioRegistry();
const inventoryRegistry = new ScenarioRegistry();
const registry = new Registry();
const completer = createCompleter(
registry,
undefined,
undefined,
undefined,
{
billing: billingRegistry,
inventory: inventoryRegistry,
},
);

const [completions, prefix] = await callCompleter(
completer,
".scenario ",
);

expect(prefix).toBe("");
expect(completions).toEqual(["billing", "inventory"]);

const [filteredCompletions, filteredPrefix] = await callCompleter(
completer,
".scenario bil",
);

expect(filteredPrefix).toBe("bil");
expect(filteredCompletions).toEqual(["billing"]);
});

it("suggests scenarios scoped to the selected API group in multi-runner mode", async () => {
const billingRegistry = new ScenarioRegistry();
const inventoryRegistry = new ScenarioRegistry();
const registry = new Registry();

billingRegistry.add("index", { setup() {}, reset() {} });
billingRegistry.add("pets/orders", { pending() {}, complete() {} });
inventoryRegistry.add("index", { seed() {} });

const completer = createCompleter(
registry,
undefined,
undefined,
undefined,
{
billing: billingRegistry,
inventory: inventoryRegistry,
},
);

const [groupCompletions, groupPrefix] = await callCompleter(
completer,
".scenario billing ",
);

expect(groupPrefix).toBe("");
expect(groupCompletions).toContain("setup");
expect(groupCompletions).toContain("reset");
expect(groupCompletions).toContain("pets/");
expect(groupCompletions).not.toContain("seed");

const [nestedCompletions, nestedPrefix] = await callCompleter(
completer,
".scenario billing pets/orders/p",
);

expect(nestedPrefix).toBe("pets/orders/p");
expect(nestedCompletions).toEqual(["pets/orders/pending"]);
});

it("returns empty completions for unknown groups in multi-runner mode", async () => {
const completer = createCompleter(
new Registry(),
undefined,
undefined,
undefined,
{
billing: new ScenarioRegistry(),
inventory: new ScenarioRegistry(),
},
);

const [completionsWithSpace, prefixWithSpace] = await callCompleter(
completer,
".scenario payments ",
);

expect(prefixWithSpace).toBe("");
expect(completionsWithSpace).toEqual([]);

const [completions, prefix] = await callCompleter(
completer,
".scenario payments set",
);

expect(prefix).toBe("set");
expect(completions).toEqual([]);
});
});
});
Loading