@schmock/core 2.0.2 → 2.1.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.map +1 -1
- package/dist/builder.js +10 -6
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +55 -8
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +15 -12
- package/package.json +1 -1
- package/src/builder.ts +15 -6
- package/src/constants.ts +5 -0
- package/src/interceptor.ts +57 -9
- package/src/parser.test.ts +17 -0
- package/src/parser.ts +15 -13
- package/src/steps/developer-experience.steps.ts +18 -0
- package/src/steps/interceptor.steps.ts +54 -0
- package/src/steps/request-history.steps.ts +18 -0
package/dist/builder.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAwDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAanB,OAAO,CAAC,YAAY;IAZhC,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;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,OAAO,CAAC,SAAS,CAAoC;gBAEjC,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;
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAwDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAanB,OAAO,CAAC,YAAY;IAZhC,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;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,OAAO,CAAC,SAAS,CAAoC;gBAEjC,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;IA+EP,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,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAiBb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAqDrE,KAAK,IAAI,IAAI;IAab,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,gBAAgB,GAAG,OAAO,CAAC,eAAe;IAgBhE,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;IA+O5B;;;;OAIG;YACW,UAAU;CAezB"}
|
package/dist/builder.js
CHANGED
|
@@ -103,10 +103,11 @@ export class CallableMockInstance {
|
|
|
103
103
|
}
|
|
104
104
|
// Parse the route key to create pattern and extract parameters
|
|
105
105
|
const parsed = parseRouteKey(route);
|
|
106
|
-
// Check for duplicate routes
|
|
106
|
+
// Check for duplicate routes — first registration wins, subsequent ones are dropped
|
|
107
107
|
const existing = this.routes.find((r) => r.method === parsed.method && r.path === parsed.path);
|
|
108
108
|
if (existing) {
|
|
109
109
|
this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
|
|
110
|
+
return this;
|
|
110
111
|
}
|
|
111
112
|
// Compile the route
|
|
112
113
|
const compiledRoute = {
|
|
@@ -119,12 +120,9 @@ export class CallableMockInstance {
|
|
|
119
120
|
};
|
|
120
121
|
this.routes.push(compiledRoute);
|
|
121
122
|
// Store static routes (no params) in Map for O(1) lookup
|
|
122
|
-
// Only store the first registration — "first registration wins" semantics
|
|
123
123
|
if (parsed.params.length === 0) {
|
|
124
124
|
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
125
|
-
|
|
126
|
-
this.staticRoutes.set(key, compiledRoute);
|
|
127
|
-
}
|
|
125
|
+
this.staticRoutes.set(key, compiledRoute);
|
|
128
126
|
}
|
|
129
127
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
130
128
|
contentType: config.contentType,
|
|
@@ -419,7 +417,7 @@ export class CallableMockInstance {
|
|
|
419
417
|
const response = parseResponse(result, matchedRoute.config);
|
|
420
418
|
// Apply delay (route-level overrides global)
|
|
421
419
|
await this.applyDelay(matchedRoute.config.delay);
|
|
422
|
-
// Record request in history
|
|
420
|
+
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
423
421
|
this.requestHistory.push({
|
|
424
422
|
method,
|
|
425
423
|
path: requestPath,
|
|
@@ -430,6 +428,12 @@ export class CallableMockInstance {
|
|
|
430
428
|
timestamp: Date.now(),
|
|
431
429
|
response: { status: response.status, body: response.body },
|
|
432
430
|
});
|
|
431
|
+
const maxHistorySize = this.globalConfig.maxHistorySize;
|
|
432
|
+
if (typeof maxHistorySize === "number" &&
|
|
433
|
+
maxHistorySize >= 0 &&
|
|
434
|
+
this.requestHistory.length > maxHistorySize) {
|
|
435
|
+
this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
|
|
436
|
+
}
|
|
433
437
|
this.emit("request:end", {
|
|
434
438
|
method,
|
|
435
439
|
path: requestPath,
|
package/dist/constants.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare function isRouteNotFound(response: {
|
|
|
16
16
|
/**
|
|
17
17
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
18
18
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
19
|
+
*
|
|
20
|
+
* Known ambiguity: a length-2 numeric array whose first element happens to
|
|
21
|
+
* be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
|
|
22
|
+
* indistinguishable from a status tuple by shape alone. Prefer the explicit
|
|
23
|
+
* status() helper or return an object response when the data could collide.
|
|
19
24
|
*/
|
|
20
25
|
export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown, Record<string, string>];
|
|
21
26
|
//# sourceMappingURL=constants.d.ts.map
|
package/dist/constants.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAG7E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CASV;AAED
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAG7E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CASV;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQxE"}
|
package/dist/constants.js
CHANGED
|
@@ -40,6 +40,11 @@ export function isRouteNotFound(response) {
|
|
|
40
40
|
/**
|
|
41
41
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
42
42
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
43
|
+
*
|
|
44
|
+
* Known ambiguity: a length-2 numeric array whose first element happens to
|
|
45
|
+
* be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
|
|
46
|
+
* indistinguishable from a status tuple by shape alone. Prefer the explicit
|
|
47
|
+
* status() helper or return an object response when the data could collide.
|
|
43
48
|
*/
|
|
44
49
|
export function isStatusTuple(value) {
|
|
45
50
|
return (Array.isArray(value) &&
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AA2JA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,CACN,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,cAAc,CAAC,EAAE,OAAO,CAAC,cAAc,KACpC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC9B,OAAO,GAAE,OAAO,CAAC,gBAAqB,GACrC,OAAO,CAAC,eAAe,CA0JzB"}
|
package/dist/interceptor.js
CHANGED
|
@@ -19,6 +19,43 @@ function extractPathname(url) {
|
|
|
19
19
|
}
|
|
20
20
|
return urlWithoutQuery;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract origin (scheme + host + port) from a URL string, or null for
|
|
24
|
+
* relative URLs that don't carry one.
|
|
25
|
+
*/
|
|
26
|
+
function extractOrigin(url) {
|
|
27
|
+
if (!url.includes("://"))
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
return new URL(url).origin;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
38
|
+
* - "/api" → { origin: null, path: "/api" }
|
|
39
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
40
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
41
|
+
*
|
|
42
|
+
* Trailing slash is stripped from the path so the segment-boundary check
|
|
43
|
+
* works the same way for "/api" and "/api/".
|
|
44
|
+
*/
|
|
45
|
+
function parseBaseUrl(baseUrl) {
|
|
46
|
+
if (baseUrl.includes("://")) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(baseUrl);
|
|
49
|
+
const rawPath = parsed.pathname;
|
|
50
|
+
const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
|
|
51
|
+
return { origin: parsed.origin, path };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Fall through to path-only handling
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { origin: null, path: baseUrl.replace(/\/$/, "") };
|
|
58
|
+
}
|
|
22
59
|
/**
|
|
23
60
|
* Extract query parameters from a URL string.
|
|
24
61
|
*/
|
|
@@ -114,15 +151,25 @@ export function createFetchInterceptor(handle, options = {}) {
|
|
|
114
151
|
? input.href
|
|
115
152
|
: input;
|
|
116
153
|
const path = extractPathname(urlString);
|
|
117
|
-
// BaseUrl filter — non-matching requests go straight to real fetch
|
|
118
|
-
//
|
|
154
|
+
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
155
|
+
// Two modes:
|
|
156
|
+
// - origin form ("https://api.example.com/v1"): require matching
|
|
157
|
+
// origin AND matching path prefix.
|
|
158
|
+
// - path form ("/api"): match pathname prefix only.
|
|
159
|
+
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
119
160
|
if (baseUrl) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
|
|
162
|
+
if (baseOrigin) {
|
|
163
|
+
const reqOrigin = extractOrigin(urlString);
|
|
164
|
+
if (reqOrigin !== baseOrigin) {
|
|
165
|
+
return originalFetch(input, init);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (basePath) {
|
|
169
|
+
const isMatch = path === basePath || path.startsWith(`${basePath}/`);
|
|
170
|
+
if (!isMatch) {
|
|
171
|
+
return originalFetch(input, init);
|
|
172
|
+
}
|
|
126
173
|
}
|
|
127
174
|
}
|
|
128
175
|
// Build adapter request
|
package/dist/parser.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAyC3D"}
|
package/dist/parser.js
CHANGED
|
@@ -17,20 +17,23 @@ export function parseRouteKey(routeKey) {
|
|
|
17
17
|
throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
|
|
18
18
|
}
|
|
19
19
|
const [, method, path] = match;
|
|
20
|
-
//
|
|
20
|
+
// Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
|
|
21
|
+
// literals (".json", brackets, parens, etc.) terminate the name and can
|
|
22
|
+
// be escaped without bleeding into the parameter regex. Build the
|
|
23
|
+
// pattern by splitting the path on the param marker, escaping each
|
|
24
|
+
// literal segment, then substituting the capture group for each :name.
|
|
21
25
|
const params = [];
|
|
22
|
-
const paramPattern = /:([^/]+)/g;
|
|
23
|
-
let paramMatch;
|
|
24
|
-
paramMatch = paramPattern.exec(path);
|
|
25
|
-
while (paramMatch !== null) {
|
|
26
|
-
params.push(paramMatch[1]);
|
|
27
|
-
paramMatch = paramPattern.exec(path);
|
|
28
|
-
}
|
|
29
|
-
// Build regex pattern for matching
|
|
30
|
-
// Replace :param with capture groups
|
|
31
26
|
const regexPath = path
|
|
32
|
-
.
|
|
33
|
-
.
|
|
27
|
+
.split(/(:[a-zA-Z0-9_-]+)/g)
|
|
28
|
+
.map((segment) => {
|
|
29
|
+
const paramMatch = segment.match(/^:([a-zA-Z0-9_-]+)$/);
|
|
30
|
+
if (paramMatch) {
|
|
31
|
+
params.push(paramMatch[1]);
|
|
32
|
+
return "([^/]+)";
|
|
33
|
+
}
|
|
34
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
})
|
|
36
|
+
.join("");
|
|
34
37
|
const pattern = new RegExp(`^${regexPath}$`);
|
|
35
38
|
return {
|
|
36
39
|
method: toHttpMethod(method),
|
package/package.json
CHANGED
package/src/builder.ts
CHANGED
|
@@ -129,7 +129,7 @@ export class CallableMockInstance {
|
|
|
129
129
|
// Parse the route key to create pattern and extract parameters
|
|
130
130
|
const parsed = parseRouteKey(route);
|
|
131
131
|
|
|
132
|
-
// Check for duplicate routes
|
|
132
|
+
// Check for duplicate routes — first registration wins, subsequent ones are dropped
|
|
133
133
|
const existing = this.routes.find(
|
|
134
134
|
(r) => r.method === parsed.method && r.path === parsed.path,
|
|
135
135
|
);
|
|
@@ -138,6 +138,7 @@ export class CallableMockInstance {
|
|
|
138
138
|
"warning",
|
|
139
139
|
`Duplicate route: ${route} — first registration wins`,
|
|
140
140
|
);
|
|
141
|
+
return this;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
// Compile the route
|
|
@@ -153,12 +154,9 @@ export class CallableMockInstance {
|
|
|
153
154
|
this.routes.push(compiledRoute);
|
|
154
155
|
|
|
155
156
|
// Store static routes (no params) in Map for O(1) lookup
|
|
156
|
-
// Only store the first registration — "first registration wins" semantics
|
|
157
157
|
if (parsed.params.length === 0) {
|
|
158
158
|
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
159
|
-
|
|
160
|
-
this.staticRoutes.set(key, compiledRoute);
|
|
161
|
-
}
|
|
159
|
+
this.staticRoutes.set(key, compiledRoute);
|
|
162
160
|
}
|
|
163
161
|
|
|
164
162
|
this.logger.log("route", `Route defined: ${route}`, {
|
|
@@ -564,7 +562,7 @@ export class CallableMockInstance {
|
|
|
564
562
|
// Apply delay (route-level overrides global)
|
|
565
563
|
await this.applyDelay(matchedRoute.config.delay);
|
|
566
564
|
|
|
567
|
-
// Record request in history
|
|
565
|
+
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
568
566
|
this.requestHistory.push({
|
|
569
567
|
method,
|
|
570
568
|
path: requestPath,
|
|
@@ -575,6 +573,17 @@ export class CallableMockInstance {
|
|
|
575
573
|
timestamp: Date.now(),
|
|
576
574
|
response: { status: response.status, body: response.body },
|
|
577
575
|
});
|
|
576
|
+
const maxHistorySize = this.globalConfig.maxHistorySize;
|
|
577
|
+
if (
|
|
578
|
+
typeof maxHistorySize === "number" &&
|
|
579
|
+
maxHistorySize >= 0 &&
|
|
580
|
+
this.requestHistory.length > maxHistorySize
|
|
581
|
+
) {
|
|
582
|
+
this.requestHistory.splice(
|
|
583
|
+
0,
|
|
584
|
+
this.requestHistory.length - maxHistorySize,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
578
587
|
|
|
579
588
|
this.emit("request:end", {
|
|
580
589
|
method,
|
package/src/constants.ts
CHANGED
|
@@ -54,6 +54,11 @@ export function isRouteNotFound(response: {
|
|
|
54
54
|
/**
|
|
55
55
|
* Check if a value is a status tuple: [status, body] or [status, body, headers]
|
|
56
56
|
* Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
|
|
57
|
+
*
|
|
58
|
+
* Known ambiguity: a length-2 numeric array whose first element happens to
|
|
59
|
+
* be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
|
|
60
|
+
* indistinguishable from a status tuple by shape alone. Prefer the explicit
|
|
61
|
+
* status() helper or return an object response when the data could collide.
|
|
57
62
|
*/
|
|
58
63
|
export function isStatusTuple(
|
|
59
64
|
value: unknown,
|
package/src/interceptor.ts
CHANGED
|
@@ -24,6 +24,45 @@ function extractPathname(url: string): string {
|
|
|
24
24
|
return urlWithoutQuery;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Extract origin (scheme + host + port) from a URL string, or null for
|
|
29
|
+
* relative URLs that don't carry one.
|
|
30
|
+
*/
|
|
31
|
+
function extractOrigin(url: string): string | null {
|
|
32
|
+
if (!url.includes("://")) return null;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(url).origin;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
42
|
+
* - "/api" → { origin: null, path: "/api" }
|
|
43
|
+
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
44
|
+
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
45
|
+
*
|
|
46
|
+
* Trailing slash is stripped from the path so the segment-boundary check
|
|
47
|
+
* works the same way for "/api" and "/api/".
|
|
48
|
+
*/
|
|
49
|
+
function parseBaseUrl(baseUrl: string): {
|
|
50
|
+
origin: string | null;
|
|
51
|
+
path: string;
|
|
52
|
+
} {
|
|
53
|
+
if (baseUrl.includes("://")) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(baseUrl);
|
|
56
|
+
const rawPath = parsed.pathname;
|
|
57
|
+
const path = rawPath === "/" ? "" : rawPath.replace(/\/$/, "");
|
|
58
|
+
return { origin: parsed.origin, path };
|
|
59
|
+
} catch {
|
|
60
|
+
// Fall through to path-only handling
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { origin: null, path: baseUrl.replace(/\/$/, "") };
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/**
|
|
28
67
|
* Extract query parameters from a URL string.
|
|
29
68
|
*/
|
|
@@ -150,16 +189,25 @@ export function createFetchInterceptor(
|
|
|
150
189
|
|
|
151
190
|
const path = extractPathname(urlString);
|
|
152
191
|
|
|
153
|
-
// BaseUrl filter — non-matching requests go straight to real fetch
|
|
154
|
-
//
|
|
192
|
+
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
193
|
+
// Two modes:
|
|
194
|
+
// - origin form ("https://api.example.com/v1"): require matching
|
|
195
|
+
// origin AND matching path prefix.
|
|
196
|
+
// - path form ("/api"): match pathname prefix only.
|
|
197
|
+
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
155
198
|
if (baseUrl) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
199
|
+
const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
|
|
200
|
+
if (baseOrigin) {
|
|
201
|
+
const reqOrigin = extractOrigin(urlString);
|
|
202
|
+
if (reqOrigin !== baseOrigin) {
|
|
203
|
+
return originalFetch(input, init);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (basePath) {
|
|
207
|
+
const isMatch = path === basePath || path.startsWith(`${basePath}/`);
|
|
208
|
+
if (!isMatch) {
|
|
209
|
+
return originalFetch(input, init);
|
|
210
|
+
}
|
|
163
211
|
}
|
|
164
212
|
}
|
|
165
213
|
|
package/src/parser.test.ts
CHANGED
|
@@ -127,5 +127,22 @@ describe("parseRouteKey", () => {
|
|
|
127
127
|
expect(match).toBeTruthy();
|
|
128
128
|
expect(match?.[1]).toBe("report-2023.pdf");
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
it("stops the param name at a literal '.' suffix and escapes the dot", () => {
|
|
132
|
+
const route = parseRouteKey("GET /files/:name.json");
|
|
133
|
+
|
|
134
|
+
expect(route.params).toEqual(["name"]);
|
|
135
|
+
expect("/files/report.json".match(route.pattern)?.[1]).toBe("report");
|
|
136
|
+
expect("/files/report.json2".match(route.pattern)).toBeNull();
|
|
137
|
+
expect("/files/reportXjson".match(route.pattern)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("treats parens around params as literal characters", () => {
|
|
141
|
+
const route = parseRouteKey("GET /items/(:id)");
|
|
142
|
+
|
|
143
|
+
expect(route.params).toEqual(["id"]);
|
|
144
|
+
expect("/items/(42)".match(route.pattern)?.[1]).toBe("42");
|
|
145
|
+
expect("/items/42".match(route.pattern)).toBeNull();
|
|
146
|
+
});
|
|
130
147
|
});
|
|
131
148
|
});
|
package/src/parser.ts
CHANGED
|
@@ -34,22 +34,24 @@ export function parseRouteKey(routeKey: string): ParsedRoute {
|
|
|
34
34
|
|
|
35
35
|
const [, method, path] = match;
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
|
|
38
|
+
// literals (".json", brackets, parens, etc.) terminate the name and can
|
|
39
|
+
// be escaped without bleeding into the parameter regex. Build the
|
|
40
|
+
// pattern by splitting the path on the param marker, escaping each
|
|
41
|
+
// literal segment, then substituting the capture group for each :name.
|
|
38
42
|
const params: string[] = [];
|
|
39
|
-
const paramPattern = /:([^/]+)/g;
|
|
40
|
-
let paramMatch: RegExpExecArray | null;
|
|
41
43
|
|
|
42
|
-
paramMatch = paramPattern.exec(path);
|
|
43
|
-
while (paramMatch !== null) {
|
|
44
|
-
params.push(paramMatch[1]);
|
|
45
|
-
paramMatch = paramPattern.exec(path);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Build regex pattern for matching
|
|
49
|
-
// Replace :param with capture groups
|
|
50
44
|
const regexPath = path
|
|
51
|
-
.
|
|
52
|
-
.
|
|
45
|
+
.split(/(:[a-zA-Z0-9_-]+)/g)
|
|
46
|
+
.map((segment) => {
|
|
47
|
+
const paramMatch = segment.match(/^:([a-zA-Z0-9_-]+)$/);
|
|
48
|
+
if (paramMatch) {
|
|
49
|
+
params.push(paramMatch[1]);
|
|
50
|
+
return "([^/]+)";
|
|
51
|
+
}
|
|
52
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
})
|
|
54
|
+
.join("");
|
|
53
55
|
|
|
54
56
|
const pattern = new RegExp(`^${regexPath}$`);
|
|
55
57
|
|
|
@@ -183,4 +183,22 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
Scenario("Duplicate routes are not duplicated in getRoutes()", ({ Given, When, Then }) => {
|
|
187
|
+
let routesList: ReturnType<typeof mock.getRoutes>;
|
|
188
|
+
|
|
189
|
+
Given("I create a mock with two routes on {string} with different data", (_, route: string) => {
|
|
190
|
+
mock = schmock();
|
|
191
|
+
mock(route as Schmock.RouteKey, [{ id: 1 }]);
|
|
192
|
+
mock(route as Schmock.RouteKey, [{ id: 2 }]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
When("I call getRoutes", () => {
|
|
196
|
+
routesList = mock.getRoutes();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
Then("only one route entry is returned", () => {
|
|
200
|
+
expect(routesList).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
186
204
|
});
|
|
@@ -130,6 +130,60 @@ describeFeature(feature, ({ Scenario, AfterEachScenario }) => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
Scenario("Origin-form baseUrl intercepts matching origin only", ({ Given, When, Then, And }) => {
|
|
134
|
+
Given("a Schmock instance with route \"GET /api/users\" returning users", () => {
|
|
135
|
+
setup();
|
|
136
|
+
mock("GET /api/users", [{ id: 1 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
And("fetch is intercepted with baseUrl \"https://api.example.com\"", () => {
|
|
140
|
+
handle = mock.intercept({ baseUrl: "https://api.example.com" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
When("I fetch \"https://api.example.com/api/users\"", async () => {
|
|
144
|
+
fetchResponse = await fetch("https://api.example.com/api/users");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
Then("the response status should be {int}", (_, status: number) => {
|
|
148
|
+
expect(fetchResponse?.status).toBe(status);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
When("I fetch \"https://other.example.com/api/users\"", async () => {
|
|
152
|
+
await fetch("https://other.example.com/api/users");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
Then("the original fetch should have been called", () => {
|
|
156
|
+
expect(savedFetch).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
Scenario("Origin-form baseUrl with a path prefix requires both to match", ({ Given, When, Then, And }) => {
|
|
161
|
+
Given("a Schmock instance with route \"GET /v1/users\" returning users", () => {
|
|
162
|
+
setup();
|
|
163
|
+
mock("GET /v1/users", [{ id: 1 }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
And("fetch is intercepted with baseUrl \"https://api.example.com/v1\"", () => {
|
|
167
|
+
handle = mock.intercept({ baseUrl: "https://api.example.com/v1" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
When("I fetch \"https://api.example.com/v1/users\"", async () => {
|
|
171
|
+
fetchResponse = await fetch("https://api.example.com/v1/users");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
Then("the response status should be {int}", (_, status: number) => {
|
|
175
|
+
expect(fetchResponse?.status).toBe(status);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
When("I fetch \"https://api.example.com/users\"", async () => {
|
|
179
|
+
await fetch("https://api.example.com/users");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
Then("the original fetch should have been called", () => {
|
|
183
|
+
expect(savedFetch).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
133
187
|
Scenario("beforeRequest hook modifies the request", ({ Given, When, Then, And }) => {
|
|
134
188
|
Given("a Schmock instance with route \"GET /api/users\" that reads headers", () => {
|
|
135
189
|
setup();
|
|
@@ -255,4 +255,22 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
255
255
|
expect(mock.callCount()).toBe(0);
|
|
256
256
|
});
|
|
257
257
|
});
|
|
258
|
+
|
|
259
|
+
Scenario("maxHistorySize bounds the history with FIFO eviction", ({ Given, When, Then }) => {
|
|
260
|
+
Given("I create a mock with maxHistorySize {int} and a users route", (_, maxHistorySize: number) => {
|
|
261
|
+
mock = schmock({ maxHistorySize });
|
|
262
|
+
mock("GET /users", []);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
When("I issue {int} requests to {string}", async (_, count: number, request: string) => {
|
|
266
|
+
const [method, path] = request.split(" ");
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
await mock.handle(method as any, path);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
Then("the call count should be {int}", (_, expected: number) => {
|
|
273
|
+
expect(mock.callCount()).toBe(expected);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
258
276
|
});
|