counterfact 2.6.0 → 2.8.1

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.
Files changed (98) hide show
  1. package/README.md +14 -207
  2. package/bin/README.md +24 -4
  3. package/bin/counterfact.js +54 -3
  4. package/dist/app.js +81 -28
  5. package/dist/counterfact-types/cookie-options.js +1 -0
  6. package/dist/counterfact-types/counterfact-response.js +7 -0
  7. package/dist/counterfact-types/example-names.js +1 -0
  8. package/dist/counterfact-types/example.js +1 -0
  9. package/dist/counterfact-types/generic-response-builder.js +1 -0
  10. package/dist/counterfact-types/http-status-code.js +1 -0
  11. package/dist/counterfact-types/if-has-key.js +1 -0
  12. package/dist/counterfact-types/index.js +0 -1
  13. package/dist/counterfact-types/maybe-promise.js +1 -0
  14. package/dist/counterfact-types/media-type.js +1 -0
  15. package/dist/counterfact-types/omit-all.js +1 -0
  16. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  17. package/dist/counterfact-types/open-api-content.js +1 -0
  18. package/dist/counterfact-types/open-api-operation.js +1 -0
  19. package/dist/counterfact-types/open-api-parameters.js +1 -0
  20. package/dist/counterfact-types/open-api-response.js +1 -0
  21. package/dist/counterfact-types/random-function.js +1 -0
  22. package/dist/counterfact-types/response-builder-factory.js +1 -0
  23. package/dist/counterfact-types/response-builder.js +1 -0
  24. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  25. package/dist/counterfact-types/wide-response-builder.js +1 -0
  26. package/dist/migrate/update-route-types.js +2 -3
  27. package/dist/repl/raw-http-client.js +19 -0
  28. package/dist/repl/repl.js +116 -4
  29. package/dist/repl/route-builder.js +68 -0
  30. package/dist/server/constants.js +8 -0
  31. package/dist/server/context-registry.js +70 -1
  32. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  33. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  34. package/dist/server/counterfact-types/example-names.ts +13 -0
  35. package/dist/server/counterfact-types/example.ts +10 -0
  36. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  37. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  38. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  39. package/dist/server/counterfact-types/index.ts +20 -338
  40. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  41. package/dist/server/counterfact-types/media-type.ts +6 -0
  42. package/dist/server/counterfact-types/omit-all.ts +11 -0
  43. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  44. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  45. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  46. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  47. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  48. package/dist/server/counterfact-types/random-function.ts +9 -0
  49. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  50. package/dist/server/counterfact-types/response-builder.ts +31 -0
  51. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  52. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  53. package/dist/server/create-koa-app.js +28 -24
  54. package/dist/server/determine-module-kind.js +13 -0
  55. package/dist/server/dispatcher.js +64 -5
  56. package/dist/server/file-discovery.js +20 -9
  57. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  58. package/dist/server/json-to-xml.js +11 -1
  59. package/dist/server/koa-middleware.js +25 -2
  60. package/dist/server/load-openapi-document.js +6 -0
  61. package/dist/server/module-dependency-graph.js +25 -0
  62. package/dist/server/module-loader.js +112 -17
  63. package/dist/server/module-tree.js +36 -0
  64. package/dist/server/openapi-document.js +69 -0
  65. package/dist/server/openapi-middleware.js +34 -5
  66. package/dist/server/openapi-watcher.js +35 -0
  67. package/dist/server/registry.js +89 -0
  68. package/dist/server/request-validator.js +3 -7
  69. package/dist/server/response-builder.js +18 -0
  70. package/dist/server/response-validator.js +58 -0
  71. package/dist/server/scenario-registry.js +55 -0
  72. package/dist/server/tools.js +29 -2
  73. package/dist/server/transpiler.js +23 -9
  74. package/dist/typescript-generator/code-generator.js +117 -4
  75. package/dist/typescript-generator/coder.js +80 -2
  76. package/dist/typescript-generator/operation-coder.js +13 -5
  77. package/dist/typescript-generator/operation-type-coder.js +40 -53
  78. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  79. package/dist/typescript-generator/prune.js +2 -1
  80. package/dist/typescript-generator/read-only-comments.js +1 -1
  81. package/dist/typescript-generator/repository.js +76 -20
  82. package/dist/typescript-generator/requirement.js +77 -1
  83. package/dist/typescript-generator/reserved-words.js +50 -0
  84. package/dist/typescript-generator/scenario-file-generator.js +235 -0
  85. package/dist/typescript-generator/script.js +70 -7
  86. package/dist/typescript-generator/specification.js +27 -0
  87. package/dist/util/ensure-directory-exists.js +7 -0
  88. package/dist/util/forward-slash-path.js +63 -0
  89. package/dist/util/load-config-file.js +44 -0
  90. package/dist/util/read-file.js +11 -0
  91. package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
  92. package/dist/util/windows-escape.js +18 -0
  93. package/package.json +9 -10
  94. package/dist/client/README.md +0 -14
  95. package/dist/client/index.html.hbs +0 -244
  96. package/dist/client/rapi-doc.html.hbs +0 -36
  97. package/dist/server/page-middleware.js +0 -23
  98. package/dist/typescript-generator/generate.js +0 -63
@@ -1,6 +1,20 @@
1
1
  function isDirectory(test) {
2
2
  return test !== undefined;
3
3
  }
4
+ /**
5
+ * Trie-based tree that maps URL path segments to route-handler modules.
6
+ *
7
+ * Each node in the tree represents one URL segment. Segments whose names are
8
+ * wrapped in curly braces (e.g. `{petId}`) are treated as wildcards and can
9
+ * match any value in that position.
10
+ *
11
+ * The tree supports:
12
+ * - **Exact matches** — literal URL segments take precedence over wildcards.
13
+ * - **Wildcard matches** — `{param}` segments capture the matched value as a
14
+ * path variable.
15
+ * - **Ambiguous-wildcard detection** — when multiple wildcards exist at the
16
+ * same level the match is flagged as `ambiguous` and an error is logged.
17
+ */
4
18
  export class ModuleTree {
5
19
  root = {
6
20
  directories: new Map(),
@@ -58,6 +72,12 @@ export class ModuleTree {
58
72
  }
59
73
  }
60
74
  }
75
+ /**
76
+ * Registers a module at the given URL pattern.
77
+ *
78
+ * @param url - The route URL pattern (e.g. `"/pets/{petId}"`).
79
+ * @param module - The route-handler module to associate with the URL.
80
+ */
61
81
  add(url, module) {
62
82
  this.addModuleToDirectory(this.root, url.split("/").slice(1), module);
63
83
  }
@@ -75,6 +95,11 @@ export class ModuleTree {
75
95
  }
76
96
  this.removeModuleFromDirectory(directory.directories.get(segment.toLowerCase()), remainingSegments);
77
97
  }
98
+ /**
99
+ * Removes the module registered at `url`.
100
+ *
101
+ * @param url - The route URL pattern to deregister.
102
+ */
78
103
  remove(url) {
79
104
  const segments = url.split("/").slice(1);
80
105
  this.removeModuleFromDirectory(this.root, segments);
@@ -168,9 +193,20 @@ export class ModuleTree {
168
193
  }
169
194
  return wildcardMatches[0];
170
195
  }
196
+ /**
197
+ * Finds the best-matching module for `url` and `method`.
198
+ *
199
+ * Traverses the trie, preferring exact matches over wildcards at each
200
+ * segment. Returns `undefined` when no match is found.
201
+ *
202
+ * @param url - The incoming request URL.
203
+ * @param method - The HTTP method (used to validate wildcard matches).
204
+ * @returns A {@link Match} object, or `undefined` when nothing matches.
205
+ */
171
206
  match(url, method) {
172
207
  return this.matchWithinDirectory(this.root, url.split("/").slice(1), {}, "", method);
173
208
  }
209
+ /** Returns all registered routes sorted alphabetically by path. */
174
210
  get routes() {
175
211
  const routes = [];
176
212
  function traverse(directory, path) {
@@ -0,0 +1,69 @@
1
+ import { watch } from "chokidar";
2
+ import createDebug from "debug";
3
+ import { dereference } from "@apidevtools/json-schema-ref-parser";
4
+ import { waitForEvent } from "../util/wait-for-event.js";
5
+ import { CHOKIDAR_OPTIONS } from "./constants.js";
6
+ const debug = createDebug("counterfact:server:openapi-document");
7
+ /**
8
+ * Represents a loaded OpenAPI document. Knows the location of its source
9
+ * file, can read the file and initialize itself, can watch for file-system
10
+ * changes, and dispatches a `"reload"` event (via `EventTarget`) whenever
11
+ * the document is reloaded from disk.
12
+ */
13
+ export class OpenApiDocument extends EventTarget {
14
+ /** The path or URL of the OpenAPI source file. */
15
+ source;
16
+ basePath;
17
+ paths = {};
18
+ produces;
19
+ watcher;
20
+ constructor(source) {
21
+ super();
22
+ this.source = source;
23
+ }
24
+ /**
25
+ * Reads the source file and populates the document's properties.
26
+ * Must be called at least once before the document data is accessible.
27
+ */
28
+ async load() {
29
+ try {
30
+ const data = (await dereference(this.source));
31
+ this.basePath = data.basePath;
32
+ this.paths = data.paths;
33
+ this.produces = data.produces;
34
+ }
35
+ catch (error) {
36
+ debug("could not load OpenAPI document from %s: %o", this.source, error);
37
+ const details = error instanceof Error ? error.message : String(error);
38
+ throw new Error(`Could not load the OpenAPI spec from "${this.source}".\n${details}`, { cause: error });
39
+ }
40
+ }
41
+ /**
42
+ * Starts watching the source file for changes. When a change is detected
43
+ * the document reloads itself and dispatches a `"reload"` event.
44
+ *
45
+ * Has no effect when the source is `"_"` or a remote URL.
46
+ */
47
+ async watch() {
48
+ if (this.source === "_" || this.source.startsWith("http")) {
49
+ return;
50
+ }
51
+ this.watcher = watch(this.source, CHOKIDAR_OPTIONS).on("change", () => {
52
+ void (async () => {
53
+ try {
54
+ await this.load();
55
+ debug("reloaded OpenAPI document from %s", this.source);
56
+ this.dispatchEvent(new Event("reload"));
57
+ }
58
+ catch (error) {
59
+ debug("failed to reload OpenAPI document from %s: %o", this.source, error);
60
+ }
61
+ })();
62
+ });
63
+ await waitForEvent(this.watcher, "ready");
64
+ }
65
+ /** Stops watching the source file. */
66
+ async stopWatching() {
67
+ await this.watcher?.close();
68
+ }
69
+ }
@@ -1,16 +1,45 @@
1
1
  import { bundle } from "@apidevtools/json-schema-ref-parser";
2
2
  import { dump } from "js-yaml";
3
- export function openapiMiddleware(openApiPath, url) {
3
+ /**
4
+ * Returns a Koa middleware that serves bundled OpenAPI documents as YAML.
5
+ *
6
+ * When `documents` has exactly one entry the document is served at
7
+ * `/counterfact/openapi` (backward-compatible behaviour).
8
+ *
9
+ * When `documents` has more than one entry each document is served at
10
+ * `/counterfact/openapi/{id}` where `id` comes from the corresponding entry.
11
+ *
12
+ * Every served document is augmented with a `servers` entry (OpenAPI 3.x) and
13
+ * a `host` field (OpenAPI 2.x / Swagger) so that the Swagger UI can send
14
+ * requests to the running Counterfact instance.
15
+ *
16
+ * @param documents - Array of document descriptors. Each entry must provide
17
+ * `path` (file path or URL to the source OpenAPI document) and `baseUrl`
18
+ * (the base URL to inject, e.g. `"//localhost:3100/api"`). An optional `id`
19
+ * string is used to build the per-document URL when more than one document
20
+ * is present.
21
+ * @returns A Koa middleware function.
22
+ */
23
+ export function openapiMiddleware(documents) {
4
24
  return async (ctx, next) => {
5
- if (ctx.URL.pathname === "/counterfact/openapi") {
6
- const openApiDocument = (await bundle(openApiPath));
25
+ let matched;
26
+ if (documents.length === 1) {
27
+ if (ctx.URL.pathname === "/counterfact/openapi") {
28
+ matched = documents[0];
29
+ }
30
+ }
31
+ else {
32
+ matched = documents.find((doc) => ctx.URL.pathname === `/counterfact/openapi/${doc.id}`);
33
+ }
34
+ if (matched) {
35
+ const openApiDocument = (await bundle(matched.path));
7
36
  openApiDocument.servers ??= [];
8
37
  openApiDocument.servers.unshift({
9
38
  description: "Counterfact",
10
- url,
39
+ url: matched.baseUrl,
11
40
  });
12
41
  // OpenApi 2 support:
13
- openApiDocument.host = url;
42
+ openApiDocument.host = matched.baseUrl;
14
43
  ctx.body = dump(openApiDocument);
15
44
  return;
16
45
  }
@@ -0,0 +1,35 @@
1
+ import { watch } from "chokidar";
2
+ import createDebug from "debug";
3
+ import { waitForEvent } from "../util/wait-for-event.js";
4
+ import { CHOKIDAR_OPTIONS } from "./constants.js";
5
+ import { loadOpenApiDocument } from "./load-openapi-document.js";
6
+ const debug = createDebug("counterfact:server:openapi-watcher");
7
+ export class OpenApiWatcher {
8
+ openApiPath;
9
+ dispatcher;
10
+ watcher;
11
+ constructor(openApiPath, dispatcher) {
12
+ this.openApiPath = openApiPath;
13
+ this.dispatcher = dispatcher;
14
+ }
15
+ async watch() {
16
+ if (this.openApiPath === "_" || this.openApiPath.startsWith("http")) {
17
+ return;
18
+ }
19
+ this.watcher = watch(this.openApiPath, CHOKIDAR_OPTIONS).on("change", () => {
20
+ void (async () => {
21
+ try {
22
+ this.dispatcher.openApiDocument = await loadOpenApiDocument(this.openApiPath);
23
+ debug("reloaded OpenAPI document from %s", this.openApiPath);
24
+ }
25
+ catch (error) {
26
+ debug("failed to reload OpenAPI document from %s: %o", this.openApiPath, error);
27
+ }
28
+ })();
29
+ });
30
+ await waitForEvent(this.watcher, "ready");
31
+ }
32
+ async stopWatching() {
33
+ await this.watcher?.close();
34
+ }
35
+ }
@@ -11,6 +11,16 @@ const ALL_HTTP_METHODS = [
11
11
  "PUT",
12
12
  "TRACE",
13
13
  ];
14
+ /**
15
+ * Casts a string URL/header/query parameter value to the type declared in the
16
+ * OpenAPI spec.
17
+ *
18
+ * @param value - The raw parameter value (may already be the correct type when
19
+ * the HTTP framework has pre-parsed it).
20
+ * @param type - The OpenAPI primitive type string (`"integer"`, `"number"`,
21
+ * `"boolean"`, or anything else to leave as a string).
22
+ * @returns The value coerced to the appropriate JavaScript type.
23
+ */
14
24
  function castParameter(value, type) {
15
25
  if (typeof value !== "string") {
16
26
  return value;
@@ -26,6 +36,13 @@ function castParameter(value, type) {
26
36
  }
27
37
  return value;
28
38
  }
39
+ /**
40
+ * Applies {@link castParameter} to every value in a parameters map.
41
+ *
42
+ * @param parameters - Key/value map of raw parameter values.
43
+ * @param parameterTypes - Map from parameter name to its OpenAPI type string.
44
+ * @returns A new object with the same keys and cast values.
45
+ */
29
46
  function castParameters(parameters = {}, parameterTypes = new Map()) {
30
47
  const copy = {};
31
48
  Object.entries(parameters).forEach(([key, value]) => {
@@ -33,27 +50,70 @@ function castParameters(parameters = {}, parameterTypes = new Map()) {
33
50
  });
34
51
  return copy;
35
52
  }
53
+ /**
54
+ * Central route registry that maps URL patterns to route-handler modules.
55
+ *
56
+ * Routes are stored in a {@link ModuleTree} that supports wildcard path
57
+ * segments (e.g. `{petId}`). The registry also maintains an ordered chain of
58
+ * middleware functions that wrap every route handler execution.
59
+ */
36
60
  export class Registry {
37
61
  moduleTree = new ModuleTree();
38
62
  middlewares = new Map();
39
63
  constructor() {
40
64
  this.middlewares.set("", ($, respondTo) => respondTo($));
41
65
  }
66
+ /** Returns all registered routes as a flat array of `{ path, methods }` objects. */
42
67
  get routes() {
43
68
  return this.moduleTree.routes;
44
69
  }
70
+ /**
71
+ * Registers (or replaces) the module for a URL pattern.
72
+ *
73
+ * @param url - The URL pattern (e.g. `/pets/{petId}`).
74
+ * @param module - The route-handler module exposing HTTP-method functions.
75
+ */
45
76
  add(url, module) {
46
77
  this.moduleTree.add(url, module);
47
78
  }
79
+ /**
80
+ * Registers a middleware function that wraps every handler under `url`.
81
+ *
82
+ * Middleware receives `($, respondTo)` where `respondTo` is the next handler
83
+ * in the chain. Setting `url` to `"/"` makes the middleware global.
84
+ *
85
+ * @param url - The path prefix at which this middleware applies.
86
+ * @param callback - The middleware function.
87
+ */
48
88
  addMiddleware(url, callback) {
49
89
  this.middlewares.set(url === "/" ? "" : url, callback);
50
90
  }
91
+ /**
92
+ * Removes the module registered at `url`.
93
+ *
94
+ * @param url - The URL pattern to deregister.
95
+ */
51
96
  remove(url) {
52
97
  this.moduleTree.remove(url);
53
98
  }
99
+ /**
100
+ * Returns `true` when a handler for `method` is registered at `url`.
101
+ *
102
+ * @param method - HTTP method (e.g. `"GET"`).
103
+ * @param url - The request URL.
104
+ */
54
105
  exists(method, url) {
55
106
  return Boolean(this.handler(url, method).module?.[method]);
56
107
  }
108
+ /**
109
+ * Finds the best-matching module and extracts path-variable bindings for a
110
+ * given URL and HTTP method.
111
+ *
112
+ * @param url - The incoming request URL.
113
+ * @param method - The HTTP method.
114
+ * @returns An object with `module`, `path` (variable bindings),
115
+ * `matchedPath`, and `ambiguous` flag.
116
+ */
57
117
  handler(url, method) {
58
118
  const match = this.moduleTree.match(url, method);
59
119
  return {
@@ -63,12 +123,41 @@ export class Registry {
63
123
  path: match?.pathVariables ?? {},
64
124
  };
65
125
  }
126
+ /**
127
+ * Returns `true` when the URL matches a registered module for at least one
128
+ * HTTP method other than `excludeMethod`.
129
+ *
130
+ * Used to decide whether to respond with 405 Method Not Allowed.
131
+ *
132
+ * @param url - The request URL.
133
+ * @param excludeMethod - The method to exclude from the check.
134
+ */
66
135
  pathExistsWithAnyMethod(url, excludeMethod) {
67
136
  return ALL_HTTP_METHODS.filter((method) => method !== excludeMethod).some((method) => this.moduleTree.match(url, method) !== undefined);
68
137
  }
138
+ /**
139
+ * Returns a comma-separated list of HTTP methods that have a registered
140
+ * handler at `url`. Used to populate the `Allow` response header for 405
141
+ * responses.
142
+ *
143
+ * @param url - The request URL.
144
+ */
69
145
  allowedMethods(url) {
70
146
  return ALL_HTTP_METHODS.filter((method) => Boolean(this.moduleTree.match(url, method)?.module?.[method])).join(", ");
71
147
  }
148
+ /**
149
+ * Returns an async function that executes the registered handler for
150
+ * `httpRequestMethod` at `url`, wrapped by all applicable middleware.
151
+ *
152
+ * Path, query, and header parameter values are cast to their declared types
153
+ * before being forwarded to the handler. The returned function always
154
+ * resolves to a {@link CounterfactResponseObject}.
155
+ *
156
+ * @param httpRequestMethod - The HTTP method to look up.
157
+ * @param url - The incoming request URL (before path-variable substitution).
158
+ * @param parameterTypes - Optional maps from parameter name to OpenAPI type
159
+ * for each of `header`, `path`, and `query`.
160
+ */
72
161
  endpoint(httpRequestMethod, url, parameterTypes = {}) {
73
162
  const handler = this.handler(url, httpRequestMethod);
74
163
  debug("handler for %s: %o", url, handler);
@@ -1,7 +1,7 @@
1
1
  import Ajv from "ajv";
2
2
  const ajv = new Ajv({
3
3
  allErrors: true,
4
- unknownFormats: "ignore",
4
+ strict: false,
5
5
  coerceTypes: false,
6
6
  });
7
7
  function findMissingRequired(parameters, location, values) {
@@ -30,9 +30,7 @@ export function validateRequest(operation, request) {
30
30
  const valid = ajv.validate(schema, request.body);
31
31
  if (!valid && ajv.errors) {
32
32
  for (const error of ajv.errors) {
33
- const path = error.instancePath ??
34
- error.dataPath ??
35
- "";
33
+ const path = error.instancePath ?? "";
36
34
  errors.push(`body${path} ${error.message ?? "is invalid"}`);
37
35
  }
38
36
  }
@@ -47,9 +45,7 @@ export function validateRequest(operation, request) {
47
45
  const valid = ajv.validate(bodyParam.schema, request.body);
48
46
  if (!valid && ajv.errors) {
49
47
  for (const error of ajv.errors) {
50
- const path = error.instancePath ??
51
- error.dataPath ??
52
- "";
48
+ const path = error.instancePath ?? "";
53
49
  errors.push(`body${path} ${error.message ?? "is invalid"}`);
54
50
  }
55
51
  }
@@ -56,6 +56,21 @@ function unknownStatusCodeResponse(statusCode) {
56
56
  status: 500,
57
57
  };
58
58
  }
59
+ /**
60
+ * Creates the `$.response` builder proxy made available to route handlers.
61
+ *
62
+ * The proxy maps HTTP status codes to a fluent builder object. Example usage
63
+ * in a route handler:
64
+ *
65
+ * ```ts
66
+ * return $.response[200].json({ id: 1, name: "Fluffy" });
67
+ * ```
68
+ *
69
+ * @param operation - The OpenAPI operation descriptor used for schema-driven
70
+ * random generation and example resolution.
71
+ * @param config - Server configuration (e.g. `alwaysFakeOptionals`).
72
+ * @returns A {@link ResponseBuilder} proxy keyed by HTTP status code.
73
+ */
59
74
  export function createResponseBuilder(operation, config) {
60
75
  return new Proxy({}, {
61
76
  get: (target, statusCode) => ({
@@ -112,6 +127,9 @@ export function createResponseBuilder(operation, config) {
112
127
  ],
113
128
  };
114
129
  },
130
+ empty() {
131
+ return { ...this, content: undefined };
132
+ },
115
133
  example(name) {
116
134
  if (operation.produces) {
117
135
  return unknownStatusCodeResponse(this.status);
@@ -0,0 +1,58 @@
1
+ import Ajv from "ajv";
2
+ const ajv = new Ajv({
3
+ allErrors: true,
4
+ strict: false,
5
+ coerceTypes: false,
6
+ });
7
+ export function validateResponse(operation, response) {
8
+ if (!operation) {
9
+ return { errors: [], valid: true };
10
+ }
11
+ const errors = [];
12
+ const statusKey = response.status !== undefined ? String(response.status) : undefined;
13
+ const responseSpec = (statusKey !== undefined ? operation.responses[statusKey] : undefined) ??
14
+ operation.responses.default;
15
+ if (!responseSpec) {
16
+ return { errors: [], valid: true };
17
+ }
18
+ const specHeaders = responseSpec.headers ?? {};
19
+ const actualHeaders = response.headers ?? {};
20
+ for (const [name, headerSpec] of Object.entries(specHeaders)) {
21
+ const actualValue = actualHeaders[name] ?? actualHeaders[name.toLowerCase()];
22
+ if (headerSpec.required === true && actualValue === undefined) {
23
+ errors.push(`response header '${name}' is required`);
24
+ continue;
25
+ }
26
+ if (actualValue !== undefined && headerSpec.schema !== undefined) {
27
+ const coercedValue = typeof actualValue === "string"
28
+ ? coerceHeaderValue(actualValue, headerSpec.schema)
29
+ : actualValue;
30
+ const valid = ajv.validate(headerSpec.schema, coercedValue);
31
+ if (!valid && ajv.errors) {
32
+ for (const error of ajv.errors) {
33
+ const path = error.instancePath ?? "";
34
+ errors.push(`response header '${name}'${path} ${error.message ?? "is invalid"}`);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ return {
40
+ errors,
41
+ valid: errors.length === 0,
42
+ };
43
+ }
44
+ function coerceHeaderValue(value, schema) {
45
+ const type = schema.type;
46
+ if (type === "integer" || type === "number") {
47
+ const num = Number(value);
48
+ return Number.isNaN(num) ? value : num;
49
+ }
50
+ if (type === "boolean") {
51
+ if (value === "true")
52
+ return true;
53
+ if (value === "false")
54
+ return false;
55
+ return value;
56
+ }
57
+ return value;
58
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Registry of loaded scenario modules.
3
+ *
4
+ * Scenario modules are plain JavaScript/TypeScript files that export named
5
+ * functions. Each module is keyed by a slash-delimited path relative to the
6
+ * `scenarios/` directory (e.g. `"index"`, `"sub/reset"`).
7
+ *
8
+ * The registry is used by the REPL's `.scenario` command and by tab-completion
9
+ * to enumerate available scenarios and their exported function names.
10
+ */
11
+ export class ScenarioRegistry {
12
+ modules = new Map();
13
+ /**
14
+ * Registers (or replaces) a scenario module.
15
+ *
16
+ * @param key - Slash-delimited file key (e.g. `"index"`, `"auth/setup"`).
17
+ * @param module - The module's exported values.
18
+ */
19
+ add(key, module) {
20
+ this.modules.set(key, module);
21
+ }
22
+ /**
23
+ * Removes the scenario module for `key`.
24
+ *
25
+ * @param key - The file key to remove.
26
+ */
27
+ remove(key) {
28
+ this.modules.delete(key);
29
+ }
30
+ /**
31
+ * Returns the module for `fileKey`, or `undefined` if not registered.
32
+ *
33
+ * @param fileKey - The file key to look up.
34
+ */
35
+ getModule(fileKey) {
36
+ return this.modules.get(fileKey);
37
+ }
38
+ /**
39
+ * Returns the names of all exported functions for the given file key.
40
+ * Used for tab completion.
41
+ */
42
+ getExportedFunctionNames(fileKey) {
43
+ const module = this.modules.get(fileKey);
44
+ if (!module)
45
+ return [];
46
+ return Object.keys(module).filter((k) => typeof module[k] === "function");
47
+ }
48
+ /**
49
+ * Returns all loaded file keys (e.g. "index", "myscript", "sub/script").
50
+ * Used for tab completion to enumerate available scenario files.
51
+ */
52
+ getFileKeys() {
53
+ return [...this.modules.keys()];
54
+ }
55
+ }
@@ -1,24 +1,51 @@
1
1
  import { generate } from "json-schema-faker";
2
+ /**
3
+ * A collection of utility helpers made available to route handlers via
4
+ * `$.tools`.
5
+ *
6
+ * Provides random selection, content-type acceptance checking, and
7
+ * schema-based random data generation.
8
+ */
2
9
  export class Tools {
3
10
  headers;
4
11
  constructor({ headers = {}, } = {}) {
5
12
  this.headers = headers;
6
13
  }
14
+ /**
15
+ * Returns a randomly selected element from `array`.
16
+ *
17
+ * @param array - The array to pick from.
18
+ * @returns A random element.
19
+ */
7
20
  oneOf(array) {
8
21
  return array[Math.floor(Math.random() * array.length)];
9
22
  }
23
+ /**
24
+ * Returns `true` when the request's `Accept` header is compatible with
25
+ * `contentType`.
26
+ *
27
+ * A missing or empty `Accept` header is treated as `*/*` (accepts anything).
28
+ *
29
+ * @param contentType - The MIME type to check (e.g. `"application/json"`).
30
+ */
10
31
  accepts(contentType) {
11
- const acceptHeader = this.headers.Accept;
32
+ const acceptHeader = Object.entries(this.headers).find(([key]) => key.toLowerCase() === "accept")?.[1];
12
33
  if (acceptHeader === "" || acceptHeader === undefined) {
13
34
  return true;
14
35
  }
15
36
  const acceptTypes = String(acceptHeader).split(",");
16
37
  return acceptTypes.some((acceptType) => {
17
- const [type, subtype] = acceptType.split("/");
38
+ const [type, subtype] = acceptType.trim().split("/");
18
39
  return ((type === "*" || type === contentType.split("/")[0]) &&
19
40
  (subtype === "*" || subtype === contentType.split("/")[1]));
20
41
  });
21
42
  }
43
+ /**
44
+ * Generates a random value that satisfies `schema` using json-schema-faker.
45
+ *
46
+ * @param schema - A JSON Schema object.
47
+ * @returns A promise that resolves to a generated value.
48
+ */
22
49
  randomFromSchema(schema) {
23
50
  return generate(schema, { useExamplesValue: true, fillProperties: false });
24
51
  }
@@ -1,14 +1,25 @@
1
1
  // Stryker disable all
2
2
  import { once } from "node:events";
3
3
  import fs from "node:fs/promises";
4
- import nodePath from "node:path";
5
4
  import { watch as chokidarWatch } from "chokidar";
6
5
  import createDebug from "debug";
7
6
  import ts from "typescript";
8
7
  import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
8
+ import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
9
9
  import { CHOKIDAR_OPTIONS } from "./constants.js";
10
10
  import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";
11
11
  const debug = createDebug("counterfact:server:transpiler");
12
+ /**
13
+ * Watches TypeScript source files in `sourcePath` and compiles them to
14
+ * JavaScript in `destinationPath` using the TypeScript compiler API.
15
+ *
16
+ * Used when the runtime cannot execute TypeScript natively (i.e. Node.js
17
+ * without the `--experimental-strip-types` flag). Each file is compiled
18
+ * independently (no type-checking) for maximum speed.
19
+ *
20
+ * Emits DOM-style events: `"write"` after a successful transpile, `"delete"`
21
+ * after a source file is removed, and `"error"` on write or compilation errors.
22
+ */
12
23
  export class Transpiler extends EventTarget {
13
24
  sourcePath;
14
25
  destinationPath;
@@ -23,6 +34,11 @@ export class Transpiler extends EventTarget {
23
34
  get extension() {
24
35
  return this.moduleKind.toLowerCase() === "commonjs" ? ".cjs" : ".js";
25
36
  }
37
+ /**
38
+ * Starts the file-system watcher and transpiles all existing files in the
39
+ * source path. Resolves once the initial scan and all pending transpiles
40
+ * are complete.
41
+ */
26
42
  async watch() {
27
43
  debug("transpiler: watch");
28
44
  this.watcher = chokidarWatch(this.sourcePath, {
@@ -36,11 +52,10 @@ export class Transpiler extends EventTarget {
36
52
  const JS_EXTENSIONS = ["js", "mjs", "ts", "mts"];
37
53
  if (!JS_EXTENSIONS.some((extension) => sourcePathOriginal.endsWith(`.${extension}`)))
38
54
  return;
39
- const sourcePath = sourcePathOriginal.replaceAll("\\", "/");
40
- const destinationPath = sourcePath
55
+ const sourcePath = toForwardSlashPath(sourcePathOriginal);
56
+ const destinationPath = toForwardSlashPath(sourcePath
41
57
  .replace(this.sourcePath, this.destinationPath)
42
- .replaceAll("\\", "/")
43
- .replace(".ts", this.extension);
58
+ .replace(".ts", this.extension));
44
59
  if (["add", "change"].includes(eventName)) {
45
60
  transpiles.push(this.transpileFile(eventName, sourcePath, destinationPath));
46
61
  }
@@ -61,6 +76,7 @@ export class Transpiler extends EventTarget {
61
76
  await once(this.watcher, "ready");
62
77
  await Promise.all(transpiles);
63
78
  }
79
+ /** Closes the file-system watcher. */
64
80
  async stopWatching() {
65
81
  await this.watcher?.close();
66
82
  }
@@ -81,11 +97,9 @@ export class Transpiler extends EventTarget {
81
97
  }
82
98
  }
83
99
  const result = transpileOutput.outputText;
84
- const fullDestination = nodePath
85
- .join(sourcePath
100
+ const fullDestination = pathJoin(sourcePath
86
101
  .replace(this.sourcePath, this.destinationPath)
87
- .replace(".ts", this.extension))
88
- .replaceAll("\\", "/");
102
+ .replace(".ts", this.extension));
89
103
  const resultWithTransformedFileExtensions = convertFileExtensionsToCjs(result);
90
104
  try {
91
105
  await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);