@schmock/core 1.2.2 → 1.3.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/dist/builder.d.ts +4 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +81 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/builder.ts +101 -1
- package/src/index.ts +3 -0
- package/src/server.test.ts +116 -0
- package/src/steps/standalone-server.steps.ts +233 -0
- package/src/types.ts +1 -0
package/dist/builder.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare class CallableMockInstance {
|
|
|
11
11
|
private logger;
|
|
12
12
|
private requestHistory;
|
|
13
13
|
private callableRef;
|
|
14
|
+
private server;
|
|
15
|
+
private serverInfo;
|
|
14
16
|
constructor(globalConfig?: Schmock.GlobalConfig);
|
|
15
17
|
defineRoute(route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig): this;
|
|
16
18
|
setCallableRef(ref: Schmock.CallableMockInstance): void;
|
|
@@ -22,6 +24,8 @@ export declare class CallableMockInstance {
|
|
|
22
24
|
reset(): void;
|
|
23
25
|
resetHistory(): void;
|
|
24
26
|
resetState(): void;
|
|
27
|
+
listen(port?: number, hostname?: string): Promise<Schmock.ServerInfo>;
|
|
28
|
+
close(): void;
|
|
25
29
|
handle(method: Schmock.HttpMethod, path: string, options?: Schmock.RequestOptions): Promise<Schmock.Response>;
|
|
26
30
|
/**
|
|
27
31
|
* Apply configured response delay
|
package/dist/builder.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AA4EA;;;;GAIG;AACH,qBAAa,oBAAoB;IAUnB,OAAO,CAAC,YAAY;IAThC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;gBAE/B,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAqFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,KAAK,IAAI,IAAI;IAcb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAmFrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAoL5B;;;;OAIG;YACW,UAAU;IAcxB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAwDrB;;;;;;;;;;OAUG;YACW,iBAAiB;IAqG/B;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IA0BjB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;CActB"}
|
package/dist/builder.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { isStatusTuple, toHttpMethod } from "./constants.js";
|
|
2
3
|
import { PluginError, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
|
|
3
4
|
import { parseRouteKey } from "./parser.js";
|
|
4
5
|
function errorMessage(error) {
|
|
@@ -57,6 +58,8 @@ export class CallableMockInstance {
|
|
|
57
58
|
logger;
|
|
58
59
|
requestHistory = [];
|
|
59
60
|
callableRef;
|
|
61
|
+
server;
|
|
62
|
+
serverInfo;
|
|
60
63
|
constructor(globalConfig = {}) {
|
|
61
64
|
this.globalConfig = globalConfig;
|
|
62
65
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
@@ -181,6 +184,7 @@ export class CallableMockInstance {
|
|
|
181
184
|
}
|
|
182
185
|
// ===== Reset / Lifecycle =====
|
|
183
186
|
reset() {
|
|
187
|
+
this.close();
|
|
184
188
|
this.routes = [];
|
|
185
189
|
this.staticRoutes.clear();
|
|
186
190
|
this.plugins = [];
|
|
@@ -204,6 +208,82 @@ export class CallableMockInstance {
|
|
|
204
208
|
}
|
|
205
209
|
this.logger.log("lifecycle", "State cleared");
|
|
206
210
|
}
|
|
211
|
+
// ===== Standalone Server =====
|
|
212
|
+
listen(port = 0, hostname = "127.0.0.1") {
|
|
213
|
+
if (this.server) {
|
|
214
|
+
throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
|
|
215
|
+
}
|
|
216
|
+
const httpServer = createServer((req, res) => {
|
|
217
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
218
|
+
const method = toHttpMethod(req.method ?? "GET");
|
|
219
|
+
const path = url.pathname;
|
|
220
|
+
const headers = {};
|
|
221
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
222
|
+
if (typeof value === "string") {
|
|
223
|
+
headers[key] = value;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const query = {};
|
|
227
|
+
url.searchParams.forEach((value, key) => {
|
|
228
|
+
query[key] = value;
|
|
229
|
+
});
|
|
230
|
+
const chunks = [];
|
|
231
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
232
|
+
req.on("end", () => {
|
|
233
|
+
const raw = Buffer.concat(chunks).toString();
|
|
234
|
+
let body;
|
|
235
|
+
const contentType = headers["content-type"] ?? "";
|
|
236
|
+
if (raw && contentType.includes("json")) {
|
|
237
|
+
try {
|
|
238
|
+
body = JSON.parse(raw);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
body = raw;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (raw) {
|
|
245
|
+
body = raw;
|
|
246
|
+
}
|
|
247
|
+
void this.handle(method, path, { headers, body, query }).then((schmockResponse) => {
|
|
248
|
+
const responseHeaders = {
|
|
249
|
+
...schmockResponse.headers,
|
|
250
|
+
};
|
|
251
|
+
if (!responseHeaders["content-type"] &&
|
|
252
|
+
schmockResponse.body !== undefined &&
|
|
253
|
+
typeof schmockResponse.body !== "string") {
|
|
254
|
+
responseHeaders["content-type"] = "application/json";
|
|
255
|
+
}
|
|
256
|
+
const responseBody = schmockResponse.body === undefined
|
|
257
|
+
? undefined
|
|
258
|
+
: typeof schmockResponse.body === "string"
|
|
259
|
+
? schmockResponse.body
|
|
260
|
+
: JSON.stringify(schmockResponse.body);
|
|
261
|
+
res.writeHead(schmockResponse.status, responseHeaders);
|
|
262
|
+
res.end(responseBody);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
this.server = httpServer;
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
httpServer.on("error", reject);
|
|
269
|
+
httpServer.listen(port, hostname, () => {
|
|
270
|
+
const addr = httpServer.address();
|
|
271
|
+
const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
|
|
272
|
+
this.serverInfo = { port: actualPort, hostname };
|
|
273
|
+
this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
|
|
274
|
+
resolve(this.serverInfo);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
close() {
|
|
279
|
+
if (!this.server) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.server.close();
|
|
283
|
+
this.server = undefined;
|
|
284
|
+
this.serverInfo = undefined;
|
|
285
|
+
this.logger.log("server", "Server stopped");
|
|
286
|
+
}
|
|
207
287
|
async handle(method, path, options) {
|
|
208
288
|
const requestId = crypto.randomUUID();
|
|
209
289
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
package/dist/index.d.ts
CHANGED
|
@@ -24,5 +24,5 @@
|
|
|
24
24
|
export declare function schmock(config?: Schmock.GlobalConfig): Schmock.CallableMockInstance;
|
|
25
25
|
export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
|
|
26
26
|
export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
|
|
27
|
-
export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteKey, StaticData, } from "./types.js";
|
|
27
|
+
export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteKey, ServerInfo, StaticData, } from "./types.js";
|
|
28
28
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmC9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,8 @@ export function schmock(config) {
|
|
|
42
42
|
reset: instance.reset.bind(instance),
|
|
43
43
|
resetHistory: instance.resetHistory.bind(instance),
|
|
44
44
|
resetState: instance.resetState.bind(instance),
|
|
45
|
+
listen: instance.listen.bind(instance),
|
|
46
|
+
close: instance.close.bind(instance),
|
|
45
47
|
});
|
|
46
48
|
instance.setCallableRef(callableInstance);
|
|
47
49
|
return callableInstance;
|
package/dist/types.d.ts
CHANGED
|
@@ -15,4 +15,5 @@ export type PluginContext = Schmock.PluginContext;
|
|
|
15
15
|
export type PluginResult = Schmock.PluginResult;
|
|
16
16
|
export type StaticData = Schmock.StaticData;
|
|
17
17
|
export type RequestRecord = Schmock.RequestRecord;
|
|
18
|
+
export type ServerInfo = Schmock.ServerInfo;
|
|
18
19
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schmock/core",
|
|
3
3
|
"description": "Core functionality for Schmock",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "bun build:lib && bun build:types",
|
|
22
|
-
"build:lib": "bun build --minify --outdir=dist src/index.ts",
|
|
22
|
+
"build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
|
|
23
23
|
"build:types": "rm -f tsconfig.tsbuildinfo && tsc --build",
|
|
24
24
|
"pretest": "rm -f src/*.js src/*.d.ts || true",
|
|
25
25
|
"test": "vitest",
|
package/src/builder.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { isStatusTuple, toHttpMethod } from "./constants.js";
|
|
2
4
|
import {
|
|
3
5
|
PluginError,
|
|
4
6
|
RouteDefinitionError,
|
|
@@ -84,6 +86,8 @@ export class CallableMockInstance {
|
|
|
84
86
|
private logger: DebugLogger;
|
|
85
87
|
private requestHistory: Schmock.RequestRecord[] = [];
|
|
86
88
|
private callableRef: Schmock.CallableMockInstance | undefined;
|
|
89
|
+
private server: Server | undefined;
|
|
90
|
+
private serverInfo: Schmock.ServerInfo | undefined;
|
|
87
91
|
|
|
88
92
|
constructor(private globalConfig: Schmock.GlobalConfig = {}) {
|
|
89
93
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
@@ -254,6 +258,7 @@ export class CallableMockInstance {
|
|
|
254
258
|
// ===== Reset / Lifecycle =====
|
|
255
259
|
|
|
256
260
|
reset(): void {
|
|
261
|
+
this.close();
|
|
257
262
|
this.routes = [];
|
|
258
263
|
this.staticRoutes.clear();
|
|
259
264
|
this.plugins = [];
|
|
@@ -280,6 +285,101 @@ export class CallableMockInstance {
|
|
|
280
285
|
this.logger.log("lifecycle", "State cleared");
|
|
281
286
|
}
|
|
282
287
|
|
|
288
|
+
// ===== Standalone Server =====
|
|
289
|
+
|
|
290
|
+
listen(port = 0, hostname = "127.0.0.1"): Promise<Schmock.ServerInfo> {
|
|
291
|
+
if (this.server) {
|
|
292
|
+
throw new SchmockError(
|
|
293
|
+
"Server is already running",
|
|
294
|
+
"SERVER_ALREADY_RUNNING",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const httpServer = createServer((req, res) => {
|
|
299
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
300
|
+
const method = toHttpMethod(req.method ?? "GET");
|
|
301
|
+
const path = url.pathname;
|
|
302
|
+
|
|
303
|
+
const headers: Record<string, string> = {};
|
|
304
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
305
|
+
if (typeof value === "string") {
|
|
306
|
+
headers[key] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const query: Record<string, string> = {};
|
|
311
|
+
url.searchParams.forEach((value, key) => {
|
|
312
|
+
query[key] = value;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const chunks: Buffer[] = [];
|
|
316
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
317
|
+
req.on("end", () => {
|
|
318
|
+
const raw = Buffer.concat(chunks).toString();
|
|
319
|
+
let body: unknown;
|
|
320
|
+
const contentType = headers["content-type"] ?? "";
|
|
321
|
+
if (raw && contentType.includes("json")) {
|
|
322
|
+
try {
|
|
323
|
+
body = JSON.parse(raw);
|
|
324
|
+
} catch {
|
|
325
|
+
body = raw;
|
|
326
|
+
}
|
|
327
|
+
} else if (raw) {
|
|
328
|
+
body = raw;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
void this.handle(method, path, { headers, body, query }).then(
|
|
332
|
+
(schmockResponse) => {
|
|
333
|
+
const responseHeaders: Record<string, string> = {
|
|
334
|
+
...schmockResponse.headers,
|
|
335
|
+
};
|
|
336
|
+
if (
|
|
337
|
+
!responseHeaders["content-type"] &&
|
|
338
|
+
schmockResponse.body !== undefined &&
|
|
339
|
+
typeof schmockResponse.body !== "string"
|
|
340
|
+
) {
|
|
341
|
+
responseHeaders["content-type"] = "application/json";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const responseBody =
|
|
345
|
+
schmockResponse.body === undefined
|
|
346
|
+
? undefined
|
|
347
|
+
: typeof schmockResponse.body === "string"
|
|
348
|
+
? schmockResponse.body
|
|
349
|
+
: JSON.stringify(schmockResponse.body);
|
|
350
|
+
|
|
351
|
+
res.writeHead(schmockResponse.status, responseHeaders);
|
|
352
|
+
res.end(responseBody);
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
this.server = httpServer;
|
|
359
|
+
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
httpServer.on("error", reject);
|
|
362
|
+
httpServer.listen(port, hostname, () => {
|
|
363
|
+
const addr = httpServer.address();
|
|
364
|
+
const actualPort =
|
|
365
|
+
addr !== null && typeof addr === "object" ? addr.port : port;
|
|
366
|
+
this.serverInfo = { port: actualPort, hostname };
|
|
367
|
+
this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
|
|
368
|
+
resolve(this.serverInfo);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
close(): void {
|
|
374
|
+
if (!this.server) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.server.close();
|
|
378
|
+
this.server = undefined;
|
|
379
|
+
this.serverInfo = undefined;
|
|
380
|
+
this.logger.log("server", "Server stopped");
|
|
381
|
+
}
|
|
382
|
+
|
|
283
383
|
async handle(
|
|
284
384
|
method: Schmock.HttpMethod,
|
|
285
385
|
path: string,
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,8 @@ export function schmock(
|
|
|
52
52
|
reset: instance.reset.bind(instance),
|
|
53
53
|
resetHistory: instance.resetHistory.bind(instance),
|
|
54
54
|
resetState: instance.resetState.bind(instance),
|
|
55
|
+
listen: instance.listen.bind(instance),
|
|
56
|
+
close: instance.close.bind(instance),
|
|
55
57
|
},
|
|
56
58
|
);
|
|
57
59
|
|
|
@@ -98,5 +100,6 @@ export type {
|
|
|
98
100
|
ResponseResult,
|
|
99
101
|
RouteConfig,
|
|
100
102
|
RouteKey,
|
|
103
|
+
ServerInfo,
|
|
101
104
|
StaticData,
|
|
102
105
|
} from "./types.js";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { schmock } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("Standalone Server", () => {
|
|
5
|
+
let mock: Schmock.CallableMockInstance;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
mock?.close();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns actual port when port=0", async () => {
|
|
12
|
+
mock = schmock();
|
|
13
|
+
mock("GET /test", { ok: true });
|
|
14
|
+
const info = await mock.listen(0);
|
|
15
|
+
expect(info.port).toBeGreaterThan(0);
|
|
16
|
+
expect(info.hostname).toBe("127.0.0.1");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("propagates request headers", async () => {
|
|
20
|
+
mock = schmock();
|
|
21
|
+
mock("GET /headers", ({ headers }) => ({
|
|
22
|
+
auth: headers.authorization,
|
|
23
|
+
}));
|
|
24
|
+
const info = await mock.listen(0);
|
|
25
|
+
|
|
26
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/headers`, {
|
|
27
|
+
headers: { authorization: "Bearer secret" },
|
|
28
|
+
});
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
expect(body.auth).toBe("Bearer secret");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles concurrent requests", async () => {
|
|
34
|
+
mock = schmock();
|
|
35
|
+
mock("GET /slow", () => ({ value: "done" }));
|
|
36
|
+
const info = await mock.listen(0);
|
|
37
|
+
|
|
38
|
+
const results = await Promise.all(
|
|
39
|
+
Array.from({ length: 10 }, () =>
|
|
40
|
+
fetch(`http://127.0.0.1:${info.port}/slow`).then((r) => r.json()),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
for (const body of results) {
|
|
45
|
+
expect(body.value).toBe("done");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parses text body when content-type is not JSON", async () => {
|
|
50
|
+
mock = schmock();
|
|
51
|
+
mock("POST /text", ({ body: reqBody }) => ({ received: reqBody }));
|
|
52
|
+
const info = await mock.listen(0);
|
|
53
|
+
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/text`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "content-type": "text/plain" },
|
|
57
|
+
body: "hello world",
|
|
58
|
+
});
|
|
59
|
+
const resBody = await res.json();
|
|
60
|
+
expect(resBody.received).toBe("hello world");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns 204 with no body for undefined response", async () => {
|
|
64
|
+
mock = schmock();
|
|
65
|
+
mock("DELETE /item", () => undefined);
|
|
66
|
+
const info = await mock.listen(0);
|
|
67
|
+
|
|
68
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/item`, {
|
|
69
|
+
method: "DELETE",
|
|
70
|
+
});
|
|
71
|
+
expect(res.status).toBe(204);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reset stops the server", async () => {
|
|
75
|
+
mock = schmock();
|
|
76
|
+
mock("GET /test", { ok: true });
|
|
77
|
+
const info = await mock.listen(0);
|
|
78
|
+
|
|
79
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/test`);
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
|
|
82
|
+
mock.reset();
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await fetch(`http://127.0.0.1:${info.port}/test`);
|
|
86
|
+
expect.unreachable("Should have thrown");
|
|
87
|
+
} catch {
|
|
88
|
+
// Expected: connection refused
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles route params in server mode", async () => {
|
|
93
|
+
mock = schmock();
|
|
94
|
+
mock("GET /users/:id", ({ params }) => ({ id: params.id }));
|
|
95
|
+
const info = await mock.listen(0);
|
|
96
|
+
|
|
97
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/users/42`);
|
|
98
|
+
const body = await res.json();
|
|
99
|
+
expect(body.id).toBe("42");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("double listen throws", async () => {
|
|
103
|
+
mock = schmock();
|
|
104
|
+
mock("GET /test", { ok: true });
|
|
105
|
+
await mock.listen(0);
|
|
106
|
+
expect(() => mock.listen(0)).toThrow("Server is already running");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("close is idempotent", async () => {
|
|
110
|
+
mock = schmock();
|
|
111
|
+
mock("GET /test", { ok: true });
|
|
112
|
+
await mock.listen(0);
|
|
113
|
+
mock.close();
|
|
114
|
+
expect(() => mock.close()).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
import { schmock } from "../index";
|
|
4
|
+
|
|
5
|
+
const feature = await loadFeature("../../features/standalone-server.feature");
|
|
6
|
+
|
|
7
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
8
|
+
let mock: Schmock.CallableMockInstance;
|
|
9
|
+
let serverInfo: Schmock.ServerInfo;
|
|
10
|
+
let httpResponse: Response;
|
|
11
|
+
|
|
12
|
+
function baseUrl(): string {
|
|
13
|
+
return `http://${serverInfo.hostname}:${serverInfo.port}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Scenario("Start and stop a simple server", ({ Given, When, Then, And }) => {
|
|
17
|
+
Given("I create a mock with a GET /hello route", () => {
|
|
18
|
+
mock = schmock();
|
|
19
|
+
mock("GET /hello", { message: "hello" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
When("I start the server on a random port", async () => {
|
|
23
|
+
serverInfo = await mock.listen(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
Then("the server should be running", () => {
|
|
27
|
+
expect(serverInfo.port).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
When("I fetch {string} from the server", async (_, route: string) => {
|
|
31
|
+
const [, path] = route.split(" ");
|
|
32
|
+
httpResponse = await fetch(`${baseUrl()}${path}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
Then("the HTTP response status should be {int}", (_, status: number) => {
|
|
36
|
+
expect(httpResponse.status).toBe(status);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
And(
|
|
40
|
+
"the HTTP response body should have {string} equal to {string}",
|
|
41
|
+
async (_, key: string, value: string) => {
|
|
42
|
+
const body = await httpResponse.json();
|
|
43
|
+
expect(body[key]).toBe(value);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
When("I stop the server", () => {
|
|
48
|
+
mock.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
Then("the server should not be running", async () => {
|
|
52
|
+
try {
|
|
53
|
+
await fetch(`${baseUrl()}/hello`);
|
|
54
|
+
expect.unreachable("Should not be able to connect");
|
|
55
|
+
} catch {
|
|
56
|
+
// Connection refused — server is down
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
Scenario("Handle POST with JSON body", ({ Given, When, Then, And }) => {
|
|
62
|
+
Given("I create a mock echoing POST at {string}", (_, path: string) => {
|
|
63
|
+
mock = schmock();
|
|
64
|
+
mock(`POST ${path}`, ({ body }) => body);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
When("I start the server on a random port", async () => {
|
|
68
|
+
serverInfo = await mock.listen(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
And(
|
|
72
|
+
"I fetch {string} with JSON body:",
|
|
73
|
+
async (_, route: string, docString: string) => {
|
|
74
|
+
const [method, path] = route.split(" ");
|
|
75
|
+
httpResponse = await fetch(`${baseUrl()}${path}`, {
|
|
76
|
+
method,
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: docString,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
Then(
|
|
84
|
+
"the response status from POST should be {int}",
|
|
85
|
+
(_, status: number) => {
|
|
86
|
+
expect(httpResponse.status).toBe(status);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
And(
|
|
91
|
+
"the response body from POST should have {string} equal to {string}",
|
|
92
|
+
async (_, key: string, value: string) => {
|
|
93
|
+
const body = await httpResponse.json();
|
|
94
|
+
expect(body[key]).toBe(value);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
When("I stop the server", () => {
|
|
99
|
+
mock.close();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
Scenario(
|
|
104
|
+
"Return 404 for unregistered routes",
|
|
105
|
+
({ Given, When, And, Then }) => {
|
|
106
|
+
Given("I create a mock with a GET /hello route", () => {
|
|
107
|
+
mock = schmock();
|
|
108
|
+
mock("GET /hello", { message: "hello" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
When("I start the server on a random port", async () => {
|
|
112
|
+
serverInfo = await mock.listen(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
And("I fetch {string} from the server", async (_, route: string) => {
|
|
116
|
+
const [, path] = route.split(" ");
|
|
117
|
+
httpResponse = await fetch(`${baseUrl()}${path}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
Then("the HTTP response status should be {int}", (_, status: number) => {
|
|
121
|
+
expect(httpResponse.status).toBe(status);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
When("I stop the server", () => {
|
|
125
|
+
mock.close();
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
Scenario("Query parameters are forwarded", ({ Given, When, And, Then }) => {
|
|
131
|
+
let parsedBody: Record<string, string>;
|
|
132
|
+
|
|
133
|
+
Given(
|
|
134
|
+
"I create a mock reflecting query params at {string}",
|
|
135
|
+
(_, route: string) => {
|
|
136
|
+
const [method, path] = route.split(" ");
|
|
137
|
+
mock = schmock();
|
|
138
|
+
mock(`${method} ${path}` as Schmock.RouteKey, ({ query }) => query);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
When("I start the server on a random port", async () => {
|
|
143
|
+
serverInfo = await mock.listen(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
And("I fetch {string} from the server", async (_, route: string) => {
|
|
147
|
+
const [, pathWithQuery] = route.split(" ");
|
|
148
|
+
httpResponse = await fetch(`${baseUrl()}${pathWithQuery}`);
|
|
149
|
+
parsedBody = await httpResponse.json();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
Then("the HTTP response status should be {int}", (_, status: number) => {
|
|
153
|
+
expect(httpResponse.status).toBe(status);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
And(
|
|
157
|
+
"the HTTP response body should have {string} equal to {string}",
|
|
158
|
+
(_, key: string, value: string) => {
|
|
159
|
+
expect(parsedBody[key]).toBe(value);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
And(
|
|
164
|
+
"the query param {string} should equal {string}",
|
|
165
|
+
(_, key: string, value: string) => {
|
|
166
|
+
expect(parsedBody[key]).toBe(value);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
When("I stop the server", () => {
|
|
171
|
+
mock.close();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
Scenario("Double listen throws an error", ({ Given, When, Then }) => {
|
|
176
|
+
Given("I create a mock with a GET /hello route", () => {
|
|
177
|
+
mock = schmock();
|
|
178
|
+
mock("GET /hello", { message: "hello" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
When("I start the server on a random port", async () => {
|
|
182
|
+
serverInfo = await mock.listen(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
Then("starting the server again should throw", () => {
|
|
186
|
+
expect(() => mock.listen(0)).toThrow("Server is already running");
|
|
187
|
+
mock.close();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
Scenario("Close is idempotent", ({ Given, When, And, Then }) => {
|
|
192
|
+
Given("I create a mock with a GET /hello route", () => {
|
|
193
|
+
mock = schmock();
|
|
194
|
+
mock("GET /hello", { message: "hello" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
When("I start the server on a random port", async () => {
|
|
198
|
+
serverInfo = await mock.listen(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
And("I stop the server", () => {
|
|
202
|
+
mock.close();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
Then("stopping the server again should not throw", () => {
|
|
206
|
+
expect(() => mock.close()).not.toThrow();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
Scenario("Reset stops the server", ({ Given, When, And, Then }) => {
|
|
211
|
+
Given("I create a mock with a GET /hello route", () => {
|
|
212
|
+
mock = schmock();
|
|
213
|
+
mock("GET /hello", { message: "hello" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
When("I start the server on a random port", async () => {
|
|
217
|
+
serverInfo = await mock.listen(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
And("I reset the mock", () => {
|
|
221
|
+
mock.reset();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
Then("the server should not be running", async () => {
|
|
225
|
+
try {
|
|
226
|
+
await fetch(`${baseUrl()}/hello`);
|
|
227
|
+
expect.unreachable("Should not be able to connect");
|
|
228
|
+
} catch {
|
|
229
|
+
// Connection refused — server is down
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/types.ts
CHANGED