counterfact 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -160
- package/bin/README.md +39 -14
- package/bin/counterfact.js +18 -539
- package/bin/ts-loader.mjs +1 -0
- package/dist/api-runner.js +202 -0
- package/dist/app.js +102 -114
- package/dist/cli/banner.js +81 -0
- package/dist/cli/check-for-updates.js +45 -0
- package/dist/cli/run.js +304 -0
- package/dist/cli/telemetry.js +50 -0
- package/dist/migrate/paths-to-routes.js +1 -0
- package/dist/migrate/update-route-types.js +3 -3
- package/dist/msw.js +78 -0
- package/dist/repl/raw-http-client.js +22 -1
- package/dist/repl/repl.js +250 -63
- package/dist/repl/route-builder.js +68 -0
- package/dist/server/constants.js +8 -0
- package/dist/server/context-registry.js +54 -1
- package/dist/server/determine-module-kind.js +14 -0
- package/dist/server/dispatcher.js +46 -0
- package/dist/server/file-discovery.js +21 -9
- package/dist/server/is-proxy-enabled-for-path.js +12 -0
- package/dist/server/json-to-xml.js +10 -0
- package/dist/server/load-openapi-document.js +4 -11
- package/dist/server/module-dependency-graph.js +25 -0
- package/dist/server/module-loader.js +52 -21
- package/dist/server/module-tree.js +36 -0
- package/dist/server/openapi-document.js +69 -0
- package/dist/server/registry.js +89 -0
- package/dist/server/response-builder.js +15 -0
- package/dist/server/scenario-registry.js +26 -0
- package/dist/server/tools.js +27 -0
- package/dist/server/transpiler.js +24 -9
- package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
- package/dist/server/web-server/create-koa-app.js +68 -0
- package/dist/server/web-server/openapi-middleware.js +34 -0
- package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
- package/dist/typescript-generator/code-generator.js +118 -4
- package/dist/typescript-generator/coder.js +76 -0
- package/dist/typescript-generator/operation-coder.js +12 -4
- package/dist/typescript-generator/operation-type-coder.js +39 -4
- package/dist/typescript-generator/parameters-type-coder.js +2 -4
- package/dist/typescript-generator/prune.js +3 -1
- package/dist/typescript-generator/repository.js +77 -20
- package/dist/typescript-generator/requirement.js +69 -0
- package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
- package/dist/typescript-generator/script.js +70 -7
- package/dist/typescript-generator/specification.js +27 -0
- package/dist/util/ensure-directory-exists.js +8 -0
- package/dist/util/forward-slash-path.js +63 -0
- package/dist/util/load-config-file.js +2 -2
- package/dist/util/read-file.js +27 -2
- package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
- package/dist/util/windows-escape.js +18 -0
- package/package.json +5 -4
- package/dist/server/create-koa-app.js +0 -42
- package/dist/server/openapi-middleware.js +0 -19
package/dist/repl/repl.js
CHANGED
|
@@ -15,61 +15,125 @@ const ROUTE_BUILDER_METHODS = [
|
|
|
15
15
|
"ready(",
|
|
16
16
|
"send(",
|
|
17
17
|
];
|
|
18
|
+
function getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries) {
|
|
19
|
+
function getPathCompletions(partial, registry) {
|
|
20
|
+
if (registry === undefined) {
|
|
21
|
+
return [[], partial];
|
|
22
|
+
}
|
|
23
|
+
const slashIdx = partial.lastIndexOf("/");
|
|
24
|
+
if (slashIdx === -1) {
|
|
25
|
+
const indexFunctions = registry.getExportedFunctionNames("index");
|
|
26
|
+
const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
|
|
27
|
+
const topLevelPrefixes = [
|
|
28
|
+
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
|
|
29
|
+
];
|
|
30
|
+
const allOptions = [...indexFunctions, ...topLevelPrefixes];
|
|
31
|
+
const matches = allOptions.filter((c) => c.startsWith(partial));
|
|
32
|
+
return [matches, partial];
|
|
33
|
+
}
|
|
34
|
+
const fileKey = partial.slice(0, slashIdx);
|
|
35
|
+
const funcPartial = partial.slice(slashIdx + 1);
|
|
36
|
+
const functions = registry.getExportedFunctionNames(fileKey);
|
|
37
|
+
const matches = functions
|
|
38
|
+
.filter((e) => e.startsWith(funcPartial))
|
|
39
|
+
.map((e) => `${fileKey}/${e}`);
|
|
40
|
+
return [matches, partial];
|
|
41
|
+
}
|
|
42
|
+
if (groupedScenarioRegistries !== undefined) {
|
|
43
|
+
if (!/^\.scenario(?:\s|$)/u.test(line)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const hasTrailingWhitespace = /\s$/u.test(line);
|
|
47
|
+
const args = line.trim().split(/\s+/u).slice(1);
|
|
48
|
+
const groupKeys = Object.keys(groupedScenarioRegistries);
|
|
49
|
+
if (args.length === 0) {
|
|
50
|
+
return [groupKeys, ""];
|
|
51
|
+
}
|
|
52
|
+
if (args.length === 1 && !hasTrailingWhitespace) {
|
|
53
|
+
const groupPartial = args[0] ?? "";
|
|
54
|
+
const matches = groupKeys.filter((key) => key.startsWith(groupPartial));
|
|
55
|
+
return [matches, groupPartial];
|
|
56
|
+
}
|
|
57
|
+
const selectedGroup = args[0] ?? "";
|
|
58
|
+
const selectedRegistry = groupedScenarioRegistries[selectedGroup];
|
|
59
|
+
if (selectedRegistry === undefined) {
|
|
60
|
+
const scenarioPartial = hasTrailingWhitespace
|
|
61
|
+
? ""
|
|
62
|
+
: (args[args.length - 1] ?? "");
|
|
63
|
+
return [[], scenarioPartial];
|
|
64
|
+
}
|
|
65
|
+
if (args.length === 1 && hasTrailingWhitespace) {
|
|
66
|
+
return getPathCompletions("", selectedRegistry);
|
|
67
|
+
}
|
|
68
|
+
if (args.length === 2 && !hasTrailingWhitespace) {
|
|
69
|
+
const scenarioPartial = args[1];
|
|
70
|
+
if (scenarioPartial === undefined) {
|
|
71
|
+
return [[], ""];
|
|
72
|
+
}
|
|
73
|
+
return getPathCompletions(scenarioPartial, selectedRegistry);
|
|
74
|
+
}
|
|
75
|
+
// More than two args (or trailing whitespace after the second arg) means
|
|
76
|
+
// no additional `.scenario` arguments are valid in multi-API mode.
|
|
77
|
+
return [[], args[args.length - 1] ?? ""];
|
|
78
|
+
}
|
|
79
|
+
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
|
|
80
|
+
if (!applyMatch) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const partial = applyMatch.groups?.["partial"] ?? "";
|
|
84
|
+
return getPathCompletions(partial, scenarioRegistry);
|
|
85
|
+
}
|
|
86
|
+
function getRouteBuilderMethodCompletions(line) {
|
|
87
|
+
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
|
|
88
|
+
if (!builderMatch) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const partial = builderMatch.groups?.["partial"] ?? "";
|
|
92
|
+
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
|
|
93
|
+
return [matches, partial];
|
|
94
|
+
}
|
|
95
|
+
function getRoutesForCompletion(registry, openApiDocument) {
|
|
96
|
+
const openApiPaths = openApiDocument
|
|
97
|
+
? Object.keys(openApiDocument.paths)
|
|
98
|
+
: [];
|
|
99
|
+
if (openApiPaths.length > 0) {
|
|
100
|
+
return openApiPaths;
|
|
101
|
+
}
|
|
102
|
+
return registry.routes.map((route) => route.path);
|
|
103
|
+
}
|
|
104
|
+
function getRouteCompletions(line, routes) {
|
|
105
|
+
const routeMatch = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
|
|
106
|
+
if (!routeMatch) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const partial = routeMatch.groups?.["partial"] ?? "";
|
|
110
|
+
const matches = routes.filter((route) => route.startsWith(partial));
|
|
111
|
+
return [matches, partial];
|
|
112
|
+
}
|
|
18
113
|
/**
|
|
19
114
|
* Creates a tab-completion function for the REPL.
|
|
20
115
|
*
|
|
21
116
|
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
|
|
22
117
|
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
|
|
23
|
-
* @param
|
|
118
|
+
* @param openApiDocument - Optional OpenAPI document used as the source of route completions when available.
|
|
119
|
+
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
|
|
24
120
|
* exported function names and file-key prefixes from the loaded scenario modules.
|
|
25
121
|
*/
|
|
26
|
-
export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
122
|
+
export function createCompleter(registry, fallback, openApiDocument, scenarioRegistry, groupedScenarioRegistries) {
|
|
123
|
+
const routes = getRoutesForCompletion(registry, openApiDocument);
|
|
27
124
|
return (line, callback) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const partial = applyMatch.groups?.["partial"] ?? "";
|
|
32
|
-
if (scenarioRegistry !== undefined) {
|
|
33
|
-
const slashIdx = partial.lastIndexOf("/");
|
|
34
|
-
if (slashIdx === -1) {
|
|
35
|
-
// No slash: complete exports from "index" key + top-level file prefixes
|
|
36
|
-
const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
|
|
37
|
-
const fileKeys = scenarioRegistry
|
|
38
|
-
.getFileKeys()
|
|
39
|
-
.filter((k) => k !== "index");
|
|
40
|
-
const topLevelPrefixes = [
|
|
41
|
-
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
|
|
42
|
-
];
|
|
43
|
-
const allOptions = [...indexFunctions, ...topLevelPrefixes];
|
|
44
|
-
const matches = allOptions.filter((c) => c.startsWith(partial));
|
|
45
|
-
callback(null, [matches, partial]);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
// Has slash: complete exports from the named file key
|
|
49
|
-
const fileKey = partial.slice(0, slashIdx);
|
|
50
|
-
const funcPartial = partial.slice(slashIdx + 1);
|
|
51
|
-
const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
|
|
52
|
-
const matches = functions
|
|
53
|
-
.filter((e) => e.startsWith(funcPartial))
|
|
54
|
-
.map((e) => `${fileKey}/${e}`);
|
|
55
|
-
callback(null, [matches, partial]);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
callback(null, [[], partial]);
|
|
60
|
-
}
|
|
125
|
+
const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries);
|
|
126
|
+
if (scenarioCompletions !== undefined) {
|
|
127
|
+
callback(null, scenarioCompletions);
|
|
61
128
|
return;
|
|
62
129
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const partial = builderMatch.groups?.["partial"] ?? "";
|
|
67
|
-
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
|
|
68
|
-
callback(null, [matches, partial]);
|
|
130
|
+
const routeBuilderCompletions = getRouteBuilderMethodCompletions(line);
|
|
131
|
+
if (routeBuilderCompletions !== undefined) {
|
|
132
|
+
callback(null, routeBuilderCompletions);
|
|
69
133
|
return;
|
|
70
134
|
}
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
135
|
+
const routeCompletions = getRouteCompletions(line, routes);
|
|
136
|
+
if (routeCompletions === undefined) {
|
|
73
137
|
if (fallback) {
|
|
74
138
|
fallback(line, callback);
|
|
75
139
|
}
|
|
@@ -78,13 +142,74 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
|
|
|
78
142
|
}
|
|
79
143
|
return;
|
|
80
144
|
}
|
|
81
|
-
|
|
82
|
-
const routes = registry.routes.map((route) => route.path);
|
|
83
|
-
const matches = routes.filter((route) => route.startsWith(partial));
|
|
84
|
-
callback(null, [matches, partial]);
|
|
145
|
+
callback(null, routeCompletions);
|
|
85
146
|
};
|
|
86
147
|
}
|
|
87
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Launches the interactive Counterfact REPL.
|
|
150
|
+
*
|
|
151
|
+
* The REPL is a standard Node.js REPL augmented with:
|
|
152
|
+
* - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
|
|
153
|
+
* - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
|
|
154
|
+
* - `route(path)` — creates a {@link RouteBuilder} for the given path.
|
|
155
|
+
* - `.counterfact` — help command.
|
|
156
|
+
* - `.proxy` — proxy configuration command.
|
|
157
|
+
* - `.scenario` — runs a named scenario function from the scenarios directory.
|
|
158
|
+
*
|
|
159
|
+
* @param contextRegistry - The live context registry.
|
|
160
|
+
* @param registry - The route registry (used for tab completion).
|
|
161
|
+
* @param config - Server configuration.
|
|
162
|
+
* @param print - Output function; defaults to writing to `stdout`.
|
|
163
|
+
* @param openApiDocument - Optional OpenAPI document for tab completion.
|
|
164
|
+
* @param scenarioRegistry - Optional scenario registry for `.scenario` support.
|
|
165
|
+
* @returns The configured Node.js REPL server instance.
|
|
166
|
+
*/
|
|
167
|
+
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry, apiBindings) {
|
|
168
|
+
const bindings = apiBindings === undefined || apiBindings.length === 0
|
|
169
|
+
? [
|
|
170
|
+
{
|
|
171
|
+
contextRegistry,
|
|
172
|
+
group: "",
|
|
173
|
+
openApiDocument,
|
|
174
|
+
registry,
|
|
175
|
+
scenarioRegistry,
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: apiBindings;
|
|
179
|
+
const isMultiApi = bindings.length > 1;
|
|
180
|
+
const groupedBindings = bindings.map((binding) => ({
|
|
181
|
+
...binding,
|
|
182
|
+
key: binding.group.trim(),
|
|
183
|
+
}));
|
|
184
|
+
if (isMultiApi) {
|
|
185
|
+
const invalidBindings = groupedBindings.filter((binding) => binding.key === "");
|
|
186
|
+
if (invalidBindings.length > 0) {
|
|
187
|
+
throw new Error("Each API binding must define a non-empty group when multiple APIs are configured.");
|
|
188
|
+
}
|
|
189
|
+
const seenGroups = new Set();
|
|
190
|
+
const duplicateGroups = new Set();
|
|
191
|
+
for (const binding of groupedBindings) {
|
|
192
|
+
if (seenGroups.has(binding.key)) {
|
|
193
|
+
duplicateGroups.add(binding.key);
|
|
194
|
+
}
|
|
195
|
+
seenGroups.add(binding.key);
|
|
196
|
+
}
|
|
197
|
+
if (duplicateGroups.size > 0) {
|
|
198
|
+
throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const rootBinding = groupedBindings[0];
|
|
202
|
+
if (rootBinding === undefined) {
|
|
203
|
+
throw new Error("startRepl requires at least one API binding");
|
|
204
|
+
}
|
|
205
|
+
const groupedLoadContext = Object.fromEntries(groupedBindings.map((binding) => [
|
|
206
|
+
binding.key,
|
|
207
|
+
(path) => binding.contextRegistry.find(path),
|
|
208
|
+
]));
|
|
209
|
+
const groupedRoute = Object.fromEntries(groupedBindings.map((binding) => [
|
|
210
|
+
binding.key,
|
|
211
|
+
createRouteFunction(config.port, "localhost", binding.openApiDocument),
|
|
212
|
+
]));
|
|
88
213
|
function printProxyStatus() {
|
|
89
214
|
if (config.proxyUrl === "") {
|
|
90
215
|
print("The proxy URL is not set.");
|
|
@@ -123,12 +248,17 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
123
248
|
}
|
|
124
249
|
}
|
|
125
250
|
const replServer = repl.start({
|
|
126
|
-
prompt: "⬣> ",
|
|
251
|
+
prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
|
|
127
252
|
});
|
|
128
253
|
const builtinCompleter = replServer.completer;
|
|
129
254
|
// completer is typed as readonly in @types/node but is writable at runtime
|
|
130
255
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
131
|
-
replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry
|
|
256
|
+
replServer.completer = createCompleter(rootBinding.registry, builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, isMultiApi
|
|
257
|
+
? Object.fromEntries(groupedBindings.map((binding) => [
|
|
258
|
+
binding.key,
|
|
259
|
+
binding.scenarioRegistry,
|
|
260
|
+
]))
|
|
261
|
+
: undefined);
|
|
132
262
|
replServer.defineCommand("counterfact", {
|
|
133
263
|
action() {
|
|
134
264
|
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
|
|
@@ -167,17 +297,63 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
167
297
|
},
|
|
168
298
|
help: 'proxy configuration (".proxy help" for details)',
|
|
169
299
|
});
|
|
170
|
-
replServer.context.loadContext =
|
|
171
|
-
|
|
300
|
+
replServer.context.loadContext = isMultiApi
|
|
301
|
+
? groupedLoadContext
|
|
302
|
+
: groupedLoadContext[rootBinding.key];
|
|
303
|
+
replServer.context.context = isMultiApi
|
|
304
|
+
? Object.fromEntries(groupedBindings.map((binding) => [
|
|
305
|
+
binding.key,
|
|
306
|
+
binding.contextRegistry.find("/"),
|
|
307
|
+
]))
|
|
308
|
+
: rootBinding.contextRegistry.find("/");
|
|
172
309
|
replServer.context.client = new RawHttpClient("localhost", config.port);
|
|
173
310
|
replServer.context.RawHttpClient = RawHttpClient;
|
|
174
|
-
replServer.context.route =
|
|
175
|
-
|
|
176
|
-
|
|
311
|
+
replServer.context.route = isMultiApi
|
|
312
|
+
? groupedRoute
|
|
313
|
+
: groupedRoute[rootBinding.key];
|
|
314
|
+
replServer.context.routes = isMultiApi
|
|
315
|
+
? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
|
|
316
|
+
: {};
|
|
317
|
+
replServer.defineCommand("scenario", {
|
|
177
318
|
async action(text) {
|
|
178
|
-
const
|
|
319
|
+
const trimmedText = text.trim();
|
|
320
|
+
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
|
|
321
|
+
const usage = isMultiApi
|
|
322
|
+
? "usage: .scenario <group> <path>"
|
|
323
|
+
: "usage: .scenario <path>";
|
|
324
|
+
const { selectedBinding, scenarioPath } = (() => {
|
|
325
|
+
if (!isMultiApi) {
|
|
326
|
+
if (trimmedText === "") {
|
|
327
|
+
return { scenarioPath: undefined, selectedBinding: undefined };
|
|
328
|
+
}
|
|
329
|
+
return { scenarioPath: trimmedText, selectedBinding: rootBinding };
|
|
330
|
+
}
|
|
331
|
+
if (parsedArgs.length !== 2) {
|
|
332
|
+
return { scenarioPath: undefined, selectedBinding: undefined };
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
scenarioPath: parsedArgs[1],
|
|
336
|
+
selectedBinding: groupedBindings.find((binding) => binding.key === parsedArgs[0]),
|
|
337
|
+
};
|
|
338
|
+
})();
|
|
339
|
+
if (selectedBinding === undefined || scenarioPath === undefined) {
|
|
340
|
+
if (isMultiApi &&
|
|
341
|
+
scenarioPath !== undefined &&
|
|
342
|
+
selectedBinding === undefined) {
|
|
343
|
+
const groupName = parsedArgs[0] ?? "";
|
|
344
|
+
const availableGroups = groupedBindings.map((binding) => binding.key);
|
|
345
|
+
print(`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
print(usage);
|
|
349
|
+
}
|
|
350
|
+
this.clearBufferedCommand();
|
|
351
|
+
this.displayPrompt();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const parts = scenarioPath.split("/").filter(Boolean);
|
|
179
355
|
if (parts.length === 0) {
|
|
180
|
-
print(
|
|
356
|
+
print(usage);
|
|
181
357
|
this.clearBufferedCommand();
|
|
182
358
|
this.displayPrompt();
|
|
183
359
|
return;
|
|
@@ -190,7 +366,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
190
366
|
}
|
|
191
367
|
const functionName = parts[parts.length - 1] ?? "";
|
|
192
368
|
const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
|
|
193
|
-
const module = scenarioRegistry?.getModule(fileKey);
|
|
369
|
+
const module = selectedBinding.scenarioRegistry?.getModule(fileKey);
|
|
194
370
|
if (module === undefined) {
|
|
195
371
|
print(`Error: Could not find scenario file "${fileKey}"`);
|
|
196
372
|
this.clearBufferedCommand();
|
|
@@ -205,11 +381,20 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
205
381
|
return;
|
|
206
382
|
}
|
|
207
383
|
try {
|
|
384
|
+
const selectedRoutes = isMultiApi
|
|
385
|
+
? replServer.context["routes"][selectedBinding.key]
|
|
386
|
+
: replServer.context["routes"];
|
|
387
|
+
if (isMultiApi && selectedRoutes === undefined) {
|
|
388
|
+
print(`Error: Could not resolve routes for API group "${selectedBinding.key}"`);
|
|
389
|
+
this.clearBufferedCommand();
|
|
390
|
+
this.displayPrompt();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
208
393
|
const applyContext = {
|
|
209
|
-
context:
|
|
210
|
-
loadContext:
|
|
211
|
-
route:
|
|
212
|
-
routes:
|
|
394
|
+
context: selectedBinding.contextRegistry.find("/"),
|
|
395
|
+
loadContext: ((path) => selectedBinding.contextRegistry.find(path)),
|
|
396
|
+
route: groupedRoute[selectedBinding.key],
|
|
397
|
+
routes: selectedRoutes,
|
|
213
398
|
};
|
|
214
399
|
await fn(applyContext);
|
|
215
400
|
print(`Applied ${text.trim()}`);
|
|
@@ -220,7 +405,9 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
|
|
|
220
405
|
this.clearBufferedCommand();
|
|
221
406
|
this.displayPrompt();
|
|
222
407
|
},
|
|
223
|
-
help:
|
|
408
|
+
help: isMultiApi
|
|
409
|
+
? 'apply a scenario script (".scenario <group> <path>" calls the named export from that group\'s scenarios/)'
|
|
410
|
+
: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
|
|
224
411
|
});
|
|
225
412
|
return replServer;
|
|
226
413
|
}
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { RawHttpClient } from "./raw-http-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Immutable fluent builder for constructing and sending HTTP requests from the
|
|
4
|
+
* Counterfact REPL.
|
|
5
|
+
*
|
|
6
|
+
* Each builder method returns a **new** `RouteBuilder` instance with the
|
|
7
|
+
* updated field — the original is never mutated. When all required parameters
|
|
8
|
+
* are set, call {@link send} to execute the request.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // Inside the REPL:
|
|
12
|
+
* route("/pets/{petId}").method("get").path({ petId: 1 }).send();
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
2
15
|
export class RouteBuilder {
|
|
3
16
|
routePath;
|
|
4
17
|
_body;
|
|
@@ -47,29 +60,63 @@ export class RouteBuilder {
|
|
|
47
60
|
queryParams: overrides.queryParams ?? this._queryParams,
|
|
48
61
|
});
|
|
49
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns a new builder with the HTTP method set.
|
|
65
|
+
*
|
|
66
|
+
* @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
|
|
67
|
+
*/
|
|
50
68
|
method(method) {
|
|
51
69
|
return this.clone({ method: method.toUpperCase() });
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns a new builder with additional path parameters merged in.
|
|
73
|
+
*
|
|
74
|
+
* @param params - Key/value map of path variable names to values.
|
|
75
|
+
*/
|
|
53
76
|
path(params) {
|
|
54
77
|
return this.clone({ pathParams: { ...this._pathParams, ...params } });
|
|
55
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a new builder with additional query parameters merged in.
|
|
81
|
+
*
|
|
82
|
+
* @param params - Key/value map of query parameter names to values.
|
|
83
|
+
*/
|
|
56
84
|
query(params) {
|
|
57
85
|
return this.clone({ queryParams: { ...this._queryParams, ...params } });
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns a new builder with additional request headers merged in.
|
|
89
|
+
*
|
|
90
|
+
* @param params - Key/value map of header names to values.
|
|
91
|
+
*/
|
|
59
92
|
headers(params) {
|
|
60
93
|
return this.clone({ headerParams: { ...this._headerParams, ...params } });
|
|
61
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns a new builder with the request body set.
|
|
97
|
+
*
|
|
98
|
+
* @param body - The request body (will be serialised to JSON or sent as-is).
|
|
99
|
+
*/
|
|
62
100
|
body(body) {
|
|
63
101
|
return this.clone({ body });
|
|
64
102
|
}
|
|
65
103
|
getOperation() {
|
|
66
104
|
return this._operation;
|
|
67
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Returns `true` when a method is set and no required parameters are
|
|
108
|
+
* missing.
|
|
109
|
+
*/
|
|
68
110
|
ready() {
|
|
69
111
|
if (!this._method)
|
|
70
112
|
return false;
|
|
71
113
|
return this.missing() === undefined;
|
|
72
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Returns a {@link MissingParams} object describing all required parameters
|
|
117
|
+
* that have not yet been set, or `undefined` when nothing is missing (or
|
|
118
|
+
* when the operation has no parameters).
|
|
119
|
+
*/
|
|
73
120
|
missing() {
|
|
74
121
|
const operation = this.getOperation();
|
|
75
122
|
if (!operation?.parameters)
|
|
@@ -98,6 +145,10 @@ export class RouteBuilder {
|
|
|
98
145
|
return undefined;
|
|
99
146
|
return missingParams;
|
|
100
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns a human-readable help string describing the operation, its
|
|
150
|
+
* parameters, and the expected responses.
|
|
151
|
+
*/
|
|
101
152
|
help() {
|
|
102
153
|
const method = this._method ?? "[no method set]";
|
|
103
154
|
const operation = this.getOperation();
|
|
@@ -168,6 +219,13 @@ export class RouteBuilder {
|
|
|
168
219
|
}
|
|
169
220
|
return lines.join("\n");
|
|
170
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Executes the HTTP request and returns the parsed response body.
|
|
224
|
+
*
|
|
225
|
+
* @throws When no HTTP method has been set.
|
|
226
|
+
* @throws When required parameters are missing.
|
|
227
|
+
* @throws When an unsupported HTTP method is used.
|
|
228
|
+
*/
|
|
171
229
|
async send() {
|
|
172
230
|
if (!this._method) {
|
|
173
231
|
throw new Error('No HTTP method set. Use .method("get") to set the method.');
|
|
@@ -265,6 +323,16 @@ export class RouteBuilder {
|
|
|
265
323
|
return lines.join("\n");
|
|
266
324
|
}
|
|
267
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Creates a factory function that constructs a {@link RouteBuilder} for a
|
|
328
|
+
* given route path, pre-configured with the server's host, port, and OpenAPI
|
|
329
|
+
* document.
|
|
330
|
+
*
|
|
331
|
+
* @param port - The port the Counterfact server is listening on.
|
|
332
|
+
* @param host - The server hostname (default `"localhost"`).
|
|
333
|
+
* @param openApiDocument - Optional OpenAPI document for parameter introspection.
|
|
334
|
+
* @returns A function `(routePath: string) => RouteBuilder`.
|
|
335
|
+
*/
|
|
268
336
|
export function createRouteFunction(port, host, openApiDocument) {
|
|
269
337
|
return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
|
|
270
338
|
}
|
package/dist/server/constants.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default options passed to every chokidar watcher in Counterfact.
|
|
3
|
+
*
|
|
4
|
+
* - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
|
|
5
|
+
* files already present when the watcher starts.
|
|
6
|
+
* - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
|
|
7
|
+
* on Windows; polling is more reliable there.
|
|
8
|
+
*/
|
|
1
9
|
export const CHOKIDAR_OPTIONS = {
|
|
2
10
|
ignoreInitial: true,
|
|
3
11
|
usePolling: process.platform === "win32",
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A context object that lives at a specific route path and is shared across
|
|
3
|
+
* all requests to that path (and its descendants).
|
|
4
|
+
*
|
|
5
|
+
* Route handlers receive this object as `$.context` and may freely add or
|
|
6
|
+
* modify properties to maintain state between requests.
|
|
7
|
+
*/
|
|
1
8
|
export class Context {
|
|
2
9
|
constructor() { }
|
|
3
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns the parent path of a route path by stripping the last segment.
|
|
13
|
+
*
|
|
14
|
+
* @param path - A route path such as `"/pets/1"`.
|
|
15
|
+
* @returns The parent path (e.g. `"/pets"`), or `"/"` for top-level paths.
|
|
16
|
+
*/
|
|
4
17
|
export function parentPath(path) {
|
|
5
18
|
return String(path.split("/").slice(0, -1).join("/")) || "/";
|
|
6
19
|
}
|
|
@@ -29,6 +42,19 @@ function cloneForCache(value) {
|
|
|
29
42
|
}
|
|
30
43
|
return clone;
|
|
31
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Registry of per-path {@link Context} objects that persist state across
|
|
47
|
+
* requests.
|
|
48
|
+
*
|
|
49
|
+
* The registry is case-insensitive for path lookups and uses a write-through
|
|
50
|
+
* cache to detect which context properties have changed between hot-reloads.
|
|
51
|
+
* It extends {@link EventTarget} so that listeners can react to structural
|
|
52
|
+
* changes (e.g. to regenerate type files):
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* contextRegistry.addEventListener("context-changed", () => { ... });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
32
58
|
export class ContextRegistry extends EventTarget {
|
|
33
59
|
entries = new Map();
|
|
34
60
|
cache = new Map();
|
|
@@ -46,6 +72,13 @@ export class ContextRegistry extends EventTarget {
|
|
|
46
72
|
}
|
|
47
73
|
return undefined;
|
|
48
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Registers a new context for `path`, replacing any existing one, and
|
|
77
|
+
* dispatches a `"context-changed"` event so listeners can react.
|
|
78
|
+
*
|
|
79
|
+
* @param path - The route path (e.g. `"/pets"`).
|
|
80
|
+
* @param context - The context object to store.
|
|
81
|
+
*/
|
|
49
82
|
add(path, context) {
|
|
50
83
|
this.entries.set(path, context);
|
|
51
84
|
this.cache.set(path, cloneForCache(context));
|
|
@@ -53,7 +86,7 @@ export class ContextRegistry extends EventTarget {
|
|
|
53
86
|
}
|
|
54
87
|
/**
|
|
55
88
|
* Removes the context entry for the given path and dispatches a
|
|
56
|
-
* "context-changed" event so that listeners (e.g. the
|
|
89
|
+
* "context-changed" event so that listeners (e.g. the _.context type
|
|
57
90
|
* generator) can regenerate type files in response to the removal.
|
|
58
91
|
*
|
|
59
92
|
* @param path - The route path whose context entry should be deleted
|
|
@@ -65,10 +98,28 @@ export class ContextRegistry extends EventTarget {
|
|
|
65
98
|
this.seen.delete(path);
|
|
66
99
|
this.dispatchEvent(new Event("context-changed"));
|
|
67
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Finds the context for `path`, walking up the path hierarchy until a
|
|
103
|
+
* context is found. Falls back to `"/"` which always has a context.
|
|
104
|
+
*
|
|
105
|
+
* @param path - The route path to look up.
|
|
106
|
+
* @returns The nearest ancestor context (or the root context).
|
|
107
|
+
*/
|
|
68
108
|
find(path) {
|
|
69
109
|
return (this.getContextIgnoreCase(this.entries, path) ??
|
|
70
110
|
this.find(parentPath(path)));
|
|
71
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Merges `updatedContext` into the existing context for `path`.
|
|
114
|
+
*
|
|
115
|
+
* On the first call for a path the context is added directly. On subsequent
|
|
116
|
+
* calls only properties whose values differ from the cached snapshot are
|
|
117
|
+
* applied, preserving live mutations made by route handlers between reloads.
|
|
118
|
+
*
|
|
119
|
+
* @param path - The route path (e.g. `"/pets"`).
|
|
120
|
+
* @param updatedContext - The new context instance (typically freshly
|
|
121
|
+
* constructed from the reloaded `_.context.ts` file).
|
|
122
|
+
*/
|
|
72
123
|
update(path, updatedContext) {
|
|
73
124
|
if (updatedContext === undefined) {
|
|
74
125
|
return;
|
|
@@ -87,9 +138,11 @@ export class ContextRegistry extends EventTarget {
|
|
|
87
138
|
Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
|
|
88
139
|
this.cache.set(path, cloneForCache(updatedContext));
|
|
89
140
|
}
|
|
141
|
+
/** Returns all registered route paths as an array. */
|
|
90
142
|
getAllPaths() {
|
|
91
143
|
return Array.from(this.entries.keys());
|
|
92
144
|
}
|
|
145
|
+
/** Returns a plain object mapping every registered path to its context. */
|
|
93
146
|
getAllContexts() {
|
|
94
147
|
const result = {};
|
|
95
148
|
for (const [path, context] of this.entries.entries()) {
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
/* eslint-disable security/detect-non-literal-fs-filename -- module kind detection only probes package.json while walking parent directories. */
|
|
4
5
|
const DEFAULT_MODULE_KIND = "commonjs";
|
|
6
|
+
/**
|
|
7
|
+
* Determines whether a module file should be treated as CommonJS or ESM.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order (matches Node.js conventions):
|
|
10
|
+
* 1. `.cjs` extension → `"commonjs"`.
|
|
11
|
+
* 2. `.mjs` or `.ts` extension → `"module"`.
|
|
12
|
+
* 3. Walk up the directory tree looking for a `package.json` with a `"type"`
|
|
13
|
+
* field.
|
|
14
|
+
* 4. Falls back to `"commonjs"` at the filesystem root.
|
|
15
|
+
*
|
|
16
|
+
* @param modulePath - Absolute or relative path to the module file.
|
|
17
|
+
* @returns `"commonjs"` or `"module"`.
|
|
18
|
+
*/
|
|
5
19
|
export async function determineModuleKind(modulePath) {
|
|
6
20
|
if (modulePath.endsWith(".cjs")) {
|
|
7
21
|
return "commonjs";
|