diff --git a/.changeset/yummy-humans-swim.md b/.changeset/yummy-humans-swim.md new file mode 100644 index 00000000..6575e83e --- /dev/null +++ b/.changeset/yummy-humans-swim.md @@ -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. diff --git a/docs/features/repl.md b/docs/features/repl.md index 25a5755f..16bd17e0 100644 --- a/docs/features/repl.md +++ b/docs/features/repl.md @@ -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 ` suggests scenario paths/functions; in multi-API sessions, `.scenario ` suggests API groups first, then `.scenario ` 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 `/scenarios/` (with `index.ts` as the default file). | Command | File | Function | diff --git a/src/repl/repl.ts b/src/repl/repl.ts index 25ebe6bb..0c4145e4 100644 --- a/src/repl/repl.ts +++ b/src/repl/repl.ts @@ -41,43 +41,98 @@ const ROUTE_BUILDER_METHODS = [ function getScenarioCompletions( line: string, scenarioRegistry: ScenarioRegistry | undefined, + groupedScenarioRegistries?: Record, ): [string[], string] | undefined { - const applyMatch = line.match(/^\.scenario\s+(?\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]; + + 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+(?\S*)$/u); - return [matches, partial]; + if (!applyMatch) { + return undefined; + } + + const partial = applyMatch.groups?.["partial"] ?? ""; + return getPathCompletions(partial, scenarioRegistry); } function getRouteBuilderMethodCompletions( @@ -142,11 +197,16 @@ export function createCompleter( fallback?: (line: string, callback: CompleterCallback) => void, openApiDocument?: OpenApiDocument, scenarioRegistry?: ScenarioRegistry, + groupedScenarioRegistries?: Record, ) { 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); @@ -344,6 +404,14 @@ export function startRepl( builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, + isMultiApi + ? Object.fromEntries( + groupedBindings.map((binding) => [ + binding.key, + binding.scenarioRegistry, + ]), + ) + : undefined, ); replServer.defineCommand("counterfact", { diff --git a/test/repl/repl.test.ts b/test/repl/repl.test.ts index 15f4d99d..7f042dd2 100644 --- a/test/repl/repl.test.ts +++ b/test/repl/repl.test.ts @@ -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([]); + }); }); });