@typespec/spector 0.1.0-alpha.2 → 0.1.0-alpha.20-dev.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +37 -0
  3. package/dist/generated-defs/TypeSpec.Spector.ts-test.js +5 -2
  4. package/dist/generated-defs/TypeSpec.Spector.ts-test.js.map +1 -1
  5. package/dist/src/actions/helper.d.ts +8 -15
  6. package/dist/src/actions/helper.d.ts.map +1 -1
  7. package/dist/src/actions/helper.js +61 -69
  8. package/dist/src/actions/helper.js.map +1 -1
  9. package/dist/src/actions/serve.d.ts.map +1 -1
  10. package/dist/src/actions/serve.js +3 -4
  11. package/dist/src/actions/serve.js.map +1 -1
  12. package/dist/src/actions/server-test.d.ts +3 -5
  13. package/dist/src/actions/server-test.d.ts.map +1 -1
  14. package/dist/src/actions/server-test.js +108 -132
  15. package/dist/src/actions/server-test.js.map +1 -1
  16. package/dist/src/actions/upload-coverage-report.d.ts +2 -1
  17. package/dist/src/actions/upload-coverage-report.d.ts.map +1 -1
  18. package/dist/src/actions/upload-coverage-report.js +6 -2
  19. package/dist/src/actions/upload-coverage-report.js.map +1 -1
  20. package/dist/src/actions/upload-scenario-manifest.d.ts +3 -2
  21. package/dist/src/actions/upload-scenario-manifest.d.ts.map +1 -1
  22. package/dist/src/actions/upload-scenario-manifest.js +8 -5
  23. package/dist/src/actions/upload-scenario-manifest.js.map +1 -1
  24. package/dist/src/actions/validate-mock-apis.js +1 -1
  25. package/dist/src/actions/validate-mock-apis.js.map +1 -1
  26. package/dist/src/app/app.d.ts +1 -0
  27. package/dist/src/app/app.d.ts.map +1 -1
  28. package/dist/src/app/app.js +48 -54
  29. package/dist/src/app/app.js.map +1 -1
  30. package/dist/src/app/request-processor.d.ts +2 -2
  31. package/dist/src/app/request-processor.d.ts.map +1 -1
  32. package/dist/src/app/request-processor.js +10 -6
  33. package/dist/src/app/request-processor.js.map +1 -1
  34. package/dist/src/cli/cli.js +24 -15
  35. package/dist/src/cli/cli.js.map +1 -1
  36. package/dist/src/coverage/common.d.ts.map +1 -1
  37. package/dist/src/coverage/common.js +1 -0
  38. package/dist/src/coverage/common.js.map +1 -1
  39. package/dist/src/coverage/coverage-tracker.d.ts.map +1 -1
  40. package/dist/src/coverage/coverage-tracker.js.map +1 -1
  41. package/dist/src/lib/decorators.d.ts.map +1 -1
  42. package/dist/src/lib/decorators.js +0 -2
  43. package/dist/src/lib/decorators.js.map +1 -1
  44. package/dist/src/logger.d.ts +16 -2
  45. package/dist/src/logger.d.ts.map +1 -1
  46. package/dist/src/logger.js +27 -8
  47. package/dist/src/logger.js.map +1 -1
  48. package/dist/src/server/server.d.ts +1 -1
  49. package/dist/src/server/server.d.ts.map +1 -1
  50. package/dist/src/server/server.js +18 -5
  51. package/dist/src/server/server.js.map +1 -1
  52. package/dist/src/utils/body-utils.d.ts.map +1 -1
  53. package/dist/src/utils/request-utils.d.ts.map +1 -1
  54. package/docs/decorators.md +24 -0
  55. package/docs/using-spector.md +99 -0
  56. package/docs/writing-mock-apis.md +116 -0
  57. package/docs/writing-scenario-spec.md +49 -0
  58. package/generated-defs/TypeSpec.Spector.ts-test.ts +7 -2
  59. package/lib/main.tsp +1 -1
  60. package/package.json +24 -28
  61. package/src/actions/helper.ts +79 -95
  62. package/src/actions/serve.ts +3 -4
  63. package/src/actions/server-test.ts +132 -156
  64. package/src/actions/upload-coverage-report.ts +7 -1
  65. package/src/actions/upload-scenario-manifest.ts +11 -7
  66. package/src/actions/validate-mock-apis.ts +1 -1
  67. package/src/app/app.ts +71 -72
  68. package/src/app/request-processor.ts +16 -4
  69. package/src/cli/cli.ts +24 -15
  70. package/src/coverage/common.ts +1 -0
  71. package/src/coverage/coverage-tracker.ts +2 -2
  72. package/src/lib/decorators.ts +0 -2
  73. package/src/logger.ts +39 -8
  74. package/src/scenarios-resolver.ts +1 -1
  75. package/src/server/server.ts +21 -7
  76. package/temp/.tsbuildinfo +1 -1
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "child_process";
2
- import fetch from "node-fetch";
3
2
  import { resolve } from "path";
4
3
  import { MockApiApp } from "../app/app.js";
5
4
  import { AdminUrls } from "../constants.js";
@@ -39,14 +38,14 @@ export async function startInBackground(config: ServeConfig) {
39
38
  const [nodeExe, entrypoint] = process.argv;
40
39
  logger.info(`Starting server in background at port ${config.port}`);
41
40
  const scenariosPath = Array.isArray(config.scenariosPath)
42
- ? config.scenariosPath.join(" ")
43
- : config.scenariosPath;
41
+ ? config.scenariosPath
42
+ : [config.scenariosPath];
44
43
  const cp = spawn(
45
44
  nodeExe,
46
45
  [
47
46
  entrypoint,
48
47
  "serve",
49
- scenariosPath,
48
+ ...scenariosPath,
50
49
  "--port",
51
50
  config.port.toString(),
52
51
  "--coverageFile",
@@ -1,157 +1,114 @@
1
- import { MockApiDefinition } from "@typespec/spec-api";
2
- import * as fs from "fs";
3
- import * as path from "path";
1
+ import {
2
+ expandDyns,
3
+ MockApiDefinition,
4
+ MockBody,
5
+ ResolverConfig,
6
+ ValidationError,
7
+ } from "@typespec/spec-api";
8
+ import deepEqual from "deep-equal";
9
+ import micromatch from "micromatch";
10
+ import { inspect } from "node:util";
4
11
  import pc from "picocolors";
5
12
  import { logger } from "../logger.js";
6
13
  import { loadScenarioMockApis } from "../scenarios-resolver.js";
7
- import { makeServiceCall, uint8ArrayToString } from "./helper.js";
14
+ import { makeServiceCall } from "./helper.js";
8
15
 
9
16
  const DEFAULT_BASE_URL = "http://localhost:3000";
10
17
 
11
18
  export interface ServerTestDiagnostics {
12
- scenario_name: string;
13
- status: "success" | "failure";
14
- message: any;
19
+ scenarioName: string;
20
+ message: string;
15
21
  }
16
22
 
17
23
  class ServerTestsGenerator {
18
24
  private name: string = "";
19
25
  private mockApiDefinition: MockApiDefinition;
20
26
  private serverBasePath: string = "";
27
+ private resolverConfig: ResolverConfig;
21
28
 
22
29
  constructor(name: string, mockApiDefinition: MockApiDefinition, serverBasePath: string) {
23
30
  this.name = name;
24
31
  this.mockApiDefinition = mockApiDefinition;
25
32
  this.serverBasePath = serverBasePath;
26
- }
27
-
28
- private getConfigObj() {
29
- let config = {};
30
- if (this.mockApiDefinition.request.status) {
31
- const validStatusCode = this.mockApiDefinition.request.status;
32
- config = {
33
- validateStatus: function (status: number) {
34
- return (status >= 200 && status < 300) || validStatusCode === status;
35
- },
36
- };
37
- }
38
- if (this.mockApiDefinition.request.params) {
39
- config = {
40
- ...config,
41
- params: this.mockApiDefinition.request.params,
42
- };
43
- }
44
- if (this.mockApiDefinition.request.headers) {
45
- config = {
46
- ...config,
47
- headers: this.mockApiDefinition.request.headers,
48
- };
49
- }
50
- if (
51
- ["head", "get", "delete"].includes(this.mockApiDefinition.method) &&
52
- this.mockApiDefinition.request.body
53
- ) {
54
- config = {
55
- ...config,
56
- data: this.mockApiDefinition.request.body,
57
- };
58
- }
59
- return config;
33
+ this.resolverConfig = {
34
+ baseUrl: serverBasePath,
35
+ };
60
36
  }
61
37
 
62
38
  public async executeScenario() {
63
- logger.info(`Executing ${this.name} endpoint - Method: ${this.mockApiDefinition.method}`);
64
-
65
- const response = await makeServiceCall(this.mockApiDefinition.method, {
66
- endPoint: `${this.serverBasePath}${this.mockApiDefinition.uri}`,
67
- options: {
68
- requestBody: this.mockApiDefinition.request.body,
69
- files: this.mockApiDefinition.request.files,
70
- config: this.getConfigObj(),
39
+ log(`Executing ${this.name} endpoint - Method: ${this.mockApiDefinition.method}`);
40
+
41
+ const response = await makeServiceCall(
42
+ {
43
+ method: this.mockApiDefinition.method,
44
+ url: `${this.serverBasePath}${this.mockApiDefinition.uri}`,
45
+ body: this.mockApiDefinition.request?.body,
46
+ headers: this.mockApiDefinition.request?.headers,
47
+ query: this.mockApiDefinition.request?.query,
48
+ pathParams: this.mockApiDefinition.request?.pathParams,
71
49
  },
72
- });
50
+ this.resolverConfig,
51
+ );
73
52
 
74
53
  if (this.mockApiDefinition.response.status !== response.status) {
75
- logger.error(`Status code mismatch for ${this.name} endpoint`);
76
- logger.error(
77
- `Expected: ${this.mockApiDefinition.response.status} - Actual: ${response.status}`,
54
+ throw new ValidationError(
55
+ "Status code mismatch",
56
+ this.mockApiDefinition.response.status,
57
+ response.status,
78
58
  );
79
- throw new Error(`Status code mismatch for ${this.name} endpoint`);
80
59
  }
60
+
81
61
  if (this.mockApiDefinition.response.body) {
82
- if (this.mockApiDefinition.response.body.contentType === "application/xml") {
83
- if (
84
- JSON.stringify(this.mockApiDefinition.response.body.rawContent) !==
85
- JSON.stringify(response.data)
86
- ) {
87
- logger.error(`Response data mismatch for ${this.name} endpoint`);
88
- logger.error(
89
- `Expected: ${this.mockApiDefinition.response.body["rawContent"]} - Actual: ${response.data}`,
90
- );
91
- throw new Error(`Response data mismatch for ${this.name} endpoint`);
92
- }
93
- } else if (Buffer.isBuffer(this.mockApiDefinition.response.body.rawContent)) {
94
- if (
95
- this.mockApiDefinition.request.headers &&
96
- this.mockApiDefinition.request.headers["accept"] === "application/json"
97
- ) {
98
- if (
99
- response.data.content !==
100
- this.mockApiDefinition.response.body.rawContent.toString("base64")
101
- ) {
102
- throw new Error(`Response data mismatch for ${this.name} endpoint`);
103
- }
104
- } else {
105
- if (
106
- uint8ArrayToString(response.data, "utf-8") !==
107
- this.mockApiDefinition.response.body.rawContent.toString()
108
- ) {
109
- throw new Error(`Response data mismatch for ${this.name} endpoint`);
110
- }
111
- }
112
- } else if (this.mockApiDefinition.response.body.contentType === "text/plain") {
113
- if (this.mockApiDefinition.response.body.rawContent !== response.data) {
114
- logger.error(`Response data mismatch for ${this.name} endpoint`);
115
- logger.error(
116
- `Expected: ${this.mockApiDefinition.response.body} - Actual: ${response.data}`,
117
- );
118
- throw new Error(`Response data mismatch for ${this.name} endpoint`);
119
- }
120
- } else {
121
- const responseData = JSON.stringify(response.data);
122
- if (
123
- this.mockApiDefinition.response.body.rawContent !==
124
- responseData.replace(this.serverBasePath, "")
125
- ) {
126
- logger.error(`Response data mismatch for ${this.name} endpoint`);
127
- logger.error(
128
- `Expected: ${this.mockApiDefinition.response.body} - Actual: ${response.data}`,
129
- );
130
- throw new Error(`Response data mismatch for ${this.name} endpoint`);
131
- }
132
- }
62
+ await this.#validateBody(response, this.mockApiDefinition.response.body);
133
63
  }
64
+
134
65
  if (this.mockApiDefinition.response.headers) {
135
- for (const key in this.mockApiDefinition.response.headers) {
136
- if (
137
- this.mockApiDefinition.response.headers[key] !==
138
- response.headers[key].replace(this.serverBasePath, "")
139
- ) {
140
- logger.error(`Response headers mismatch for ${this.name} endpoint`);
141
- logger.error(
142
- `Expected: ${this.mockApiDefinition.response.headers[key]} - Actual: ${response.headers[key]}`,
66
+ const headers = expandDyns(this.mockApiDefinition.response.headers, this.resolverConfig);
67
+ for (const key in headers) {
68
+ if (headers[key] !== response.headers.get(key)) {
69
+ throw new ValidationError(
70
+ `Response headers mismatch`,
71
+ headers[key],
72
+ response.headers.get(key),
143
73
  );
144
- throw new Error(`Response headers mismatch for ${this.name} endpoint`);
145
74
  }
146
75
  }
147
76
  }
148
77
  }
78
+
79
+ async #validateBody(response: Response, body: MockBody) {
80
+ if (Buffer.isBuffer(body.rawContent)) {
81
+ const responseData = Buffer.from(await response.arrayBuffer());
82
+ if (!deepEqual(responseData, body.rawContent)) {
83
+ throw new ValidationError(`Raw body mismatch`, body.rawContent, responseData);
84
+ }
85
+ } else {
86
+ const responseData = await response.text();
87
+ const raw =
88
+ typeof body.rawContent === "string"
89
+ ? body.rawContent
90
+ : body.rawContent?.serialize(this.resolverConfig);
91
+ switch (body.contentType) {
92
+ case "application/xml":
93
+ case "text/plain":
94
+ if (body.rawContent !== responseData) {
95
+ throw new ValidationError("Response data mismatch", raw, responseData);
96
+ }
97
+ break;
98
+ case "application/json":
99
+ const expected = JSON.parse(raw as any);
100
+ const actual = JSON.parse(responseData);
101
+ if (!deepEqual(actual, expected, { strict: true })) {
102
+ throw new ValidationError("Response data mismatch", expected, actual);
103
+ }
104
+ }
105
+ }
106
+ }
149
107
  }
150
108
 
151
109
  export interface ServerTestOptions {
152
110
  baseUrl?: string;
153
- runSingleScenario?: string;
154
- runScenariosFromFile?: string;
111
+ filter?: string;
155
112
  }
156
113
 
157
114
  async function delay(ms: number) {
@@ -159,7 +116,7 @@ async function delay(ms: number) {
159
116
  }
160
117
 
161
118
  async function waitForServer(baseUrl: string) {
162
- logger.info(`Executing server tests with base URL: ${baseUrl}`);
119
+ logger.debug(`Executing server tests with base URL: ${baseUrl}`);
163
120
  let retry = 0;
164
121
 
165
122
  while (retry < 3) {
@@ -178,40 +135,44 @@ async function waitForServer(baseUrl: string) {
178
135
  export async function serverTest(scenariosPath: string, options: ServerTestOptions = {}) {
179
136
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
180
137
  await waitForServer(baseUrl);
181
- // 1. Get Testcases to run
182
- const testCasesToRun: string[] = [];
183
- if (options.runSingleScenario) {
184
- testCasesToRun.push(options.runSingleScenario);
185
- } else if (options.runScenariosFromFile) {
186
- const data = fs.readFileSync(path.resolve(options.runScenariosFromFile), "utf8");
187
- const lines = data.split("\n");
188
- lines.forEach((line) => {
189
- testCasesToRun.push(line.trim());
190
- });
191
- }
192
- // 2. Load all the scenarios
193
138
  const scenarios = await loadScenarioMockApis(scenariosPath);
194
- const success_diagnostics: ServerTestDiagnostics[] = [];
195
- const failure_diagnostics: ServerTestDiagnostics[] = [];
196
-
139
+ const successfullScenarios: { name: string }[] = [];
140
+ const failureDiagnostics: ServerTestDiagnostics[] = [];
141
+
142
+ const allScenarioEntries = Object.entries(scenarios);
143
+ const scenarioEntries = allScenarioEntries.filter(([name]) => {
144
+ const pathlikeName = name.replaceAll("_", "/").toLowerCase();
145
+ const filter = options.filter?.toLowerCase();
146
+ if (filter && !micromatch.isMatch(pathlikeName, filter)) {
147
+ logger.debug(`Skipping scenario: ${pathlikeName}, does not match filter: ${filter}`);
148
+ return false;
149
+ }
150
+ return true;
151
+ });
197
152
  // 3. Execute each scenario
198
- for (const [name, scenario] of Object.entries(scenarios)) {
153
+ for (const [name, scenario] of scenarioEntries) {
199
154
  if (!Array.isArray(scenario.apis)) continue;
200
155
  for (const api of scenario.apis) {
201
156
  if (api.kind !== "MockApiDefinition") continue;
202
- if (testCasesToRun.length === 0 || testCasesToRun.includes(name)) {
203
- const obj: ServerTestsGenerator = new ServerTestsGenerator(name, api, baseUrl);
204
- try {
205
- await obj.executeScenario();
206
- success_diagnostics.push({
207
- scenario_name: name,
208
- status: "success",
209
- message: "executed successfully",
157
+ const obj: ServerTestsGenerator = new ServerTestsGenerator(name, api, baseUrl);
158
+ try {
159
+ await obj.executeScenario();
160
+ successfullScenarios.push({
161
+ name,
162
+ });
163
+ } catch (e: any) {
164
+ if (e instanceof ValidationError) {
165
+ failureDiagnostics.push({
166
+ scenarioName: name,
167
+ message: [
168
+ `Validation failed: ${e.message}:`,
169
+ ` Expected:\n ${inspect(e.expected)}`,
170
+ ` Actual:\n ${inspect(e.actual)}`,
171
+ ].join("\n"),
210
172
  });
211
- } catch (e: any) {
212
- failure_diagnostics.push({
213
- scenario_name: name,
214
- status: "failure",
173
+ } else {
174
+ failureDiagnostics.push({
175
+ scenarioName: name,
215
176
  message: `code = ${e.code} \n message = ${e.message} \n name = ${e.name} \n stack = ${e.stack} \n status = ${e.status}`,
216
177
  });
217
178
  }
@@ -220,20 +181,35 @@ export async function serverTest(scenariosPath: string, options: ServerTestOptio
220
181
  }
221
182
 
222
183
  // 4. Print diagnostics
223
- logger.info("Server Tests Diagnostics Summary");
184
+ log("");
185
+ log("Server Tests Diagnostics Summary");
186
+
187
+ if (successfullScenarios.length === 0 && failureDiagnostics.length === 0) {
188
+ logger.error("No scenarios were executed");
189
+ process.exit(-1);
190
+ }
224
191
 
225
- if (success_diagnostics.length > 0) logger.info("Success Scenarios");
226
- success_diagnostics.forEach((diagnostic) => {
227
- logger.info(`${pc.green("✓")} Scenario: ${diagnostic.scenario_name} - ${diagnostic.message}`);
192
+ if (successfullScenarios.length > 0) log("Successfull scenarios");
193
+ successfullScenarios.forEach((diagnostic) => {
194
+ log(`${pc.green("✓")} Scenario: ${pc.cyan(diagnostic.name)}`);
228
195
  });
229
196
 
230
- if (failure_diagnostics.length > 0) logger.error("Failure Scenarios");
231
- if (failure_diagnostics.length > 0) {
232
- logger.error("Failed Scenario details");
233
- failure_diagnostics.forEach((diagnostic) => {
234
- logger.error(`${pc.red("✘")} Scenario: ${diagnostic.scenario_name}`);
235
- logger.error(`${diagnostic.message}`);
197
+ if (failureDiagnostics.length > 0) {
198
+ log("Failed scenarios");
199
+ failureDiagnostics.forEach((diagnostic) => {
200
+ log(`${pc.red("✘")} Scenario: ${pc.cyan(diagnostic.scenarioName)}`);
201
+ log(`${diagnostic.message}`);
236
202
  });
237
- process.exit(-1);
238
203
  }
204
+ log(pc.bold(pc.green(`✓ ${scenarioEntries.length} passed`)));
205
+ if (failureDiagnostics.length > 0) {
206
+ log(pc.red(`✘ ${failureDiagnostics.length} failed`));
207
+ }
208
+
209
+ process.exit(failureDiagnostics.length > 0 ? 1 : 0);
210
+ }
211
+
212
+ function log(message: string) {
213
+ // eslint-disable-next-line no-console
214
+ console.log(message);
239
215
  }
@@ -11,6 +11,7 @@ export interface UploadCoverageReportConfig {
11
11
  generatorVersion: string;
12
12
  generatorCommit?: string;
13
13
  generatorMode: string;
14
+ containerName: string;
14
15
  }
15
16
 
16
17
  export async function uploadCoverageReport({
@@ -20,11 +21,16 @@ export async function uploadCoverageReport({
20
21
  generatorVersion,
21
22
  generatorCommit: geenratorCommit,
22
23
  generatorMode,
24
+ containerName,
23
25
  }: UploadCoverageReportConfig) {
24
26
  const content = await readFile(coverageFile);
25
27
  const coverage: CoverageReport = JSON.parse(content.toString());
26
28
 
27
- const client = new SpecCoverageClient(storageAccountName, new AzureCliCredential());
29
+ const client = new SpecCoverageClient(storageAccountName, {
30
+ credential: new AzureCliCredential(),
31
+ containerName,
32
+ });
33
+ await client.createIfNotExists();
28
34
  const generatorMetadata: GeneratorMetadata = {
29
35
  name: generatorName,
30
36
  version: generatorVersion,
@@ -9,27 +9,31 @@ import { logger } from "../logger.js";
9
9
  export interface UploadScenarioManifestConfig {
10
10
  scenariosPaths: string[];
11
11
  storageAccountName: string;
12
- setName: string;
12
+ setNames: string[];
13
+ containerName: string;
13
14
  }
14
15
 
15
16
  export async function uploadScenarioManifest({
16
17
  scenariosPaths,
17
18
  storageAccountName,
18
- setName,
19
+ setNames,
20
+ containerName,
19
21
  }: UploadScenarioManifestConfig) {
20
22
  const manifests = [];
21
- for (const scenariosPath of scenariosPaths) {
22
- const path = resolve(process.cwd(), scenariosPath);
23
+ for (let idx = 0; idx < scenariosPaths.length; idx++) {
24
+ const path = resolve(process.cwd(), scenariosPaths[idx]);
23
25
  logger.info(`Computing scenario manifest for ${path}`);
24
- const [manifest, diagnostics] = await computeScenarioManifest(path, setName);
26
+ const [manifest, diagnostics] = await computeScenarioManifest(path, setNames[idx]);
25
27
  if (manifest === undefined || diagnostics.length > 0) {
26
28
  process.exit(-1);
27
29
  }
28
30
  manifests.push(manifest);
29
31
  }
30
-
31
32
  await writeFile("manifest.json", JSON.stringify(manifests, null, 2));
32
- const client = new SpecCoverageClient(storageAccountName, new AzureCliCredential());
33
+ const client = new SpecCoverageClient(storageAccountName, {
34
+ credential: new AzureCliCredential(),
35
+ containerName,
36
+ });
33
37
  await client.createIfNotExists();
34
38
  await client.manifest.upload(manifests);
35
39
 
@@ -41,7 +41,7 @@ export async function validateMockApis({
41
41
  );
42
42
 
43
43
  if (programDiagnostics.length > 0) {
44
- specCompiler.logDiagnostics(programDiagnostics, { log: logger.error });
44
+ specCompiler.logDiagnostics(programDiagnostics, specCompiler.NodeHost.logSink);
45
45
  diagnostics.reportDiagnostic({
46
46
  message: `Scenario ${name} is invalid.`,
47
47
  });
package/src/app/app.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { MockApiDefinition, MockRequest, RequestExt, ScenarioMockApi } from "@typespec/spec-api";
1
+ import {
2
+ expandDyns,
3
+ MockApiDefinition,
4
+ MockBody,
5
+ MockMultipartBody,
6
+ MockRequest,
7
+ RequestExt,
8
+ ResolverConfig,
9
+ ScenarioMockApi,
10
+ } from "@typespec/spec-api";
2
11
  import { ScenariosMetadata } from "@typespec/spec-coverage-sdk";
3
12
  import { Response, Router } from "express";
4
13
  import { getScenarioMetadata } from "../coverage/common.js";
@@ -19,6 +28,7 @@ export class MockApiApp {
19
28
  private router = Router();
20
29
  private server: MockApiServer;
21
30
  private coverageTracker: CoverageTracker;
31
+ private resolverConfig!: ResolverConfig;
22
32
 
23
33
  constructor(private config: ApiMockAppConfig) {
24
34
  this.server = new MockApiServer({ port: config.port });
@@ -54,79 +64,77 @@ export class MockApiApp {
54
64
  });
55
65
 
56
66
  this.server.use("/", this.router);
57
- this.server.start();
67
+ // Getting the resolved port as setting 0 in the config will have express resolve on of the available ports
68
+ const port = await this.server.start();
69
+ this.resolverConfig = {
70
+ baseUrl: `http://localhost:${port}`,
71
+ };
58
72
  }
59
73
 
60
74
  private registerScenario(name: string, scenario: ScenarioMockApi) {
61
75
  for (const endpoint of scenario.apis) {
62
- if (endpoint.kind !== "MockApiDefinition") {
63
- this.router.route(endpoint.uri)[endpoint.method]((req: RequestExt, res: Response) => {
64
- processRequest(
65
- this.coverageTracker,
66
- name,
67
- endpoint.uri,
68
- req,
69
- res,
70
- endpoint.handler,
71
- ).catch((e) => {
72
- logger.error("Unexpected request error", e);
73
- res.status(500).end();
74
- });
75
- });
76
- } else {
77
- if (!endpoint.handler) {
78
- endpoint.handler = createHandler(endpoint);
79
- }
80
- this.router.route(endpoint.uri)[endpoint.method]((req: RequestExt, res: Response) => {
81
- processRequest(
82
- this.coverageTracker,
83
- name,
84
- endpoint.uri,
85
- req,
86
- res,
87
- endpoint.handler!,
88
- ).catch((e) => {
89
- logger.error("Unexpected request error", e);
90
- res.status(500).end();
91
- });
92
- });
76
+ if (!endpoint.handler) {
77
+ endpoint.handler = createHandler(endpoint, this.resolverConfig);
93
78
  }
79
+ this.router.route(endpoint.uri)[endpoint.method]((req: RequestExt, res: Response) => {
80
+ processRequest(
81
+ this.coverageTracker,
82
+ name,
83
+ endpoint.uri,
84
+ req,
85
+ res,
86
+ endpoint.handler!,
87
+ this.resolverConfig,
88
+ ).catch((e) => {
89
+ logger.error("Unexpected request error", e);
90
+ res.status(500).end();
91
+ });
92
+ });
94
93
  }
95
94
  }
96
95
  }
97
96
 
98
- function isObject(value: any): boolean {
99
- return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ function validateBody(
98
+ req: MockRequest,
99
+ body: MockBody | MockMultipartBody,
100
+ config: ResolverConfig,
101
+ ) {
102
+ if ("kind" in body) {
103
+ // custom handler for now.
104
+ } else {
105
+ if (Buffer.isBuffer(body.rawContent)) {
106
+ req.expect.rawBodyEquals(body.rawContent);
107
+ } else {
108
+ const raw =
109
+ typeof body.rawContent === "string" ? body.rawContent : body.rawContent?.serialize(config);
110
+ switch (body.contentType) {
111
+ case "application/json":
112
+ req.expect.coercedBodyEquals(JSON.parse(raw as any));
113
+ break;
114
+ case "application/xml":
115
+ req.expect.xmlBodyEquals(
116
+ (raw as any).replace(`<?xml version='1.0' encoding='UTF-8'?>`, ""),
117
+ );
118
+ break;
119
+ default:
120
+ req.expect.rawBodyEquals(raw);
121
+ }
122
+ }
123
+ }
100
124
  }
101
125
 
102
- function createHandler(apiDefinition: MockApiDefinition) {
126
+ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) {
103
127
  return (req: MockRequest) => {
128
+ const body = apiDefinition.request?.body;
104
129
  // Validate body if present in the request
105
- if (apiDefinition.request.body) {
106
- if (
107
- apiDefinition.request.headers &&
108
- apiDefinition.request.headers["Content-Type"] === "application/xml"
109
- ) {
110
- req.expect.xmlBodyEquals(
111
- apiDefinition.request.body.rawContent.replace(
112
- `<?xml version='1.0' encoding='UTF-8'?>`,
113
- "",
114
- ),
115
- );
116
- } else {
117
- if (isObject(apiDefinition.request.body)) {
118
- Object.entries(apiDefinition.request.body).forEach(([key, value]) => {
119
- req.expect.deepEqual(req.body[key], value);
120
- });
121
- } else {
122
- req.expect.coercedBodyEquals(apiDefinition.request.body);
123
- }
124
- }
130
+ if (body) {
131
+ validateBody(req, body, config);
125
132
  }
126
133
 
127
134
  // Validate headers if present in the request
128
- if (apiDefinition.request.headers) {
129
- Object.entries(apiDefinition.request.headers).forEach(([key, value]) => {
135
+ if (apiDefinition.request?.headers) {
136
+ const headers = expandDyns(apiDefinition.request.headers, config);
137
+ Object.entries(headers).forEach(([key, value]) => {
130
138
  if (key.toLowerCase() !== "content-type") {
131
139
  if (Array.isArray(value)) {
132
140
  req.expect.deepEqual(req.headers[key], value);
@@ -137,21 +145,12 @@ function createHandler(apiDefinition: MockApiDefinition) {
137
145
  });
138
146
  }
139
147
 
140
- // Validate query params if present in the request
141
- if (apiDefinition.request.params) {
142
- Object.entries(apiDefinition.request.params).forEach(([key, value]) => {
143
- if (!req.query[key]) {
144
- if (Array.isArray(value)) {
145
- req.expect.deepEqual(req.params[key], value);
146
- } else {
147
- req.expect.deepEqual(req.params[key], String(value));
148
- }
148
+ if (apiDefinition.request?.query) {
149
+ Object.entries(apiDefinition.request.query).forEach(([key, value]) => {
150
+ if (Array.isArray(value)) {
151
+ req.expect.deepEqual(req.query[key], value);
149
152
  } else {
150
- if (Array.isArray(value)) {
151
- req.expect.deepEqual(req.query[key], value);
152
- } else {
153
- req.expect.containsQueryParam(key, String(value));
154
- }
153
+ req.expect.containsQueryParam(key, String(value));
155
154
  }
156
155
  });
157
156
  }