@sungen/driver-mock 3.2.31 → 3.2.32
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/package.json +2 -2
- package/src/mock-catalog.ts +100 -13
- package/src/patterns/mock-patterns.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-mock",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.32",
|
|
4
4
|
"description": "Sungen Mock capability (Network Mocking) — the @mock tag, mocks.yaml catalog, mock-install/call-count steps, and the specs/mock.ts runtime template. Plugs into @sun-asterisk/sungen via the capability SPI.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@sun-asterisk/sungen": "3.2.
|
|
16
|
+
"@sun-asterisk/sungen": "3.2.32"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
package/src/mock-catalog.ts
CHANGED
|
@@ -7,35 +7,65 @@
|
|
|
7
7
|
* reservations-check-500:
|
|
8
8
|
* method: POST # required — HTTP verb or "*"
|
|
9
9
|
* url: "**\/api/reservations/check" # required — page.route glob; "re:" prefix = regex
|
|
10
|
-
* status: 500 # required
|
|
10
|
+
* status: 500 # required, unless `abort` is set
|
|
11
11
|
* headers: { content-type: application/json } # optional
|
|
12
12
|
* body: { message: "Internal Server Error" } # optional — inline, or a "{{var}}" test-data ref
|
|
13
13
|
* delay_ms: 5000 # optional — hold the response N ms (the timeout simulator)
|
|
14
14
|
*
|
|
15
|
+
* An entry describes ONE of three network outcomes, and the app under test takes a
|
|
16
|
+
* different branch for each — which is why none of them substitutes for another:
|
|
17
|
+
*
|
|
18
|
+
* status: the server ANSWERED. fetch() resolves, `res.ok` is false.
|
|
19
|
+
* abort: the request NEVER ARRIVED. fetch() rejects with `TypeError: Failed to
|
|
20
|
+
* fetch`, so the app's `catch` runs instead of its `!res.ok` branch.
|
|
21
|
+
* delay_ms: the server answered LATE. This is the timeout tool (#676).
|
|
22
|
+
*
|
|
23
|
+
* `abort:` takes `true` — the faithful spelling of "just fail it", normalized here to
|
|
24
|
+
* Playwright's own no-argument default `failed` — or one of ABORT_CODES. Note the
|
|
25
|
+
* browser hides the reason from page JS: every code surfaces to the app as the same
|
|
26
|
+
* opaque `TypeError`, so the code buys readability and trace detail, not behaviour.
|
|
27
|
+
*
|
|
28
|
+
* A mock named "timeout" that aborts makes tests green for the wrong reason. That is
|
|
29
|
+
* still true, and it is now enforced by name (see TIMEOUT_SHAPED_NAME) rather than by
|
|
30
|
+
* banning the key outright — which left a genuine network failure with no path at all.
|
|
31
|
+
*
|
|
15
32
|
* The loader ERRORS on an incomplete or unknown-keyed entry, naming the entry and
|
|
16
33
|
* the valid keys — a malformed mock must never silently vanish from the suite
|
|
17
34
|
* (the api-catalog loader's silent `continue` is a known trap, deliberately not
|
|
18
|
-
* copied).
|
|
19
|
-
* (
|
|
20
|
-
* green for the wrong reason. mocks.yaml is COMMITTED: credentials in bodies must
|
|
21
|
-
* be `${QA_*}` refs (the secret scan covers mock/*.yaml).
|
|
35
|
+
* copied). mocks.yaml is COMMITTED: credentials in bodies must be `${QA_*}` refs
|
|
36
|
+
* (the secret scan covers mock/*.yaml).
|
|
22
37
|
*/
|
|
23
38
|
|
|
24
39
|
import * as fs from 'fs';
|
|
25
40
|
import * as path from 'path';
|
|
26
41
|
import { parse as parseYaml } from 'yaml';
|
|
27
42
|
|
|
43
|
+
/** Playwright `route.abort()` errorCodes. `true` in YAML resolves to `failed`, its no-arg default. */
|
|
44
|
+
export const ABORT_CODES = [
|
|
45
|
+
'failed', 'aborted', 'accessdenied', 'addressunreachable', 'connectionaborted',
|
|
46
|
+
'connectionclosed', 'connectionfailed', 'connectionrefused', 'connectionreset',
|
|
47
|
+
'internetdisconnected', 'namenotresolved', 'timedout', 'blockedbyclient', 'blockedbyresponse',
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export type AbortErrorCode = (typeof ABORT_CODES)[number];
|
|
51
|
+
|
|
52
|
+
/** An entry whose NAME promises a timeout must not be satisfied by an abort — see the header. */
|
|
53
|
+
const TIMEOUT_SHAPED_NAME = /timeout|slow|hang/i;
|
|
54
|
+
|
|
28
55
|
export interface MockEntry {
|
|
29
56
|
name: string;
|
|
30
57
|
method: string;
|
|
31
58
|
url: string;
|
|
32
|
-
|
|
59
|
+
/** Absent exactly when `abort` is set — the two are mutually exclusive. */
|
|
60
|
+
status?: number;
|
|
61
|
+
/** Always a code once loaded: `abort: true` is normalized to 'failed' here. */
|
|
62
|
+
abort?: AbortErrorCode;
|
|
33
63
|
headers?: Record<string, string>;
|
|
34
64
|
body?: unknown;
|
|
35
65
|
delay_ms?: number;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
|
-
const VALID_KEYS = ['method', 'url', 'status', 'headers', 'body', 'delay_ms'];
|
|
68
|
+
const VALID_KEYS = ['method', 'url', 'status', 'abort', 'headers', 'body', 'delay_ms'];
|
|
39
69
|
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*'];
|
|
40
70
|
|
|
41
71
|
/** Absolute path of a unit's mock catalog (screens only in MVP — the locked unit shape). */
|
|
@@ -81,8 +111,7 @@ function validateEntry(name: string, value: unknown, file: string): MockEntry {
|
|
|
81
111
|
const obj = value as Record<string, unknown>;
|
|
82
112
|
const unknown = Object.keys(obj).filter((k) => !VALID_KEYS.includes(k));
|
|
83
113
|
if (unknown.length) {
|
|
84
|
-
throw new Error(`${where}: unknown key(s) ${unknown.join(', ')} — valid keys: ${VALID_KEYS.join(', ')}`
|
|
85
|
-
(unknown.includes('abort') ? ' (no `abort`: route.abort() is a network error, not a timeout — use a large delay_ms)' : ''));
|
|
114
|
+
throw new Error(`${where}: unknown key(s) ${unknown.join(', ')} — valid keys: ${VALID_KEYS.join(', ')}`);
|
|
86
115
|
}
|
|
87
116
|
const method = String(obj.method ?? '').toUpperCase();
|
|
88
117
|
if (!obj.method || !METHODS.includes(method)) {
|
|
@@ -91,9 +120,35 @@ function validateEntry(name: string, value: unknown, file: string): MockEntry {
|
|
|
91
120
|
if (!obj.url || typeof obj.url !== 'string') {
|
|
92
121
|
throw new Error(`${where}: "url" is required (a page.route glob, or "re:<pattern>" for a regex)`);
|
|
93
122
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
const abort = obj.abort === undefined ? undefined : resolveAbort(obj.abort, name, where);
|
|
124
|
+
|
|
125
|
+
// `status` and `abort` answer the same question — did the server respond? — with
|
|
126
|
+
// opposite answers. Picking one for the user would silently discard the other.
|
|
127
|
+
if (abort !== undefined && obj.status !== undefined) {
|
|
128
|
+
throw new Error(`${where}: "status" and "abort" are mutually exclusive — "status" is a response ` +
|
|
129
|
+
'the server sent, "abort" is a request that never arrived');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// An aborted request produces no response, so these describe a thing that never exists.
|
|
133
|
+
// Accepting them would compile a body the runtime silently drops — the "declared, plumbed
|
|
134
|
+
// and read by nothing" shape this loader refuses everywhere else. `delay_ms` is NOT in this
|
|
135
|
+
// list on purpose: a connection can legitimately drop late.
|
|
136
|
+
if (abort !== undefined) {
|
|
137
|
+
for (const key of ['headers', 'body'] as const) {
|
|
138
|
+
if (obj[key] !== undefined) {
|
|
139
|
+
throw new Error(`${where}: "${key}" cannot be used with "abort" — a request that ` +
|
|
140
|
+
'never arrives has no response to carry it');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let status: number | undefined;
|
|
146
|
+
if (abort === undefined) {
|
|
147
|
+
status = Number(obj.status);
|
|
148
|
+
if (obj.status === undefined || !Number.isInteger(status) || status < 100 || status > 599) {
|
|
149
|
+
throw new Error(`${where}: "status" is required and must be an HTTP status code (100-599)` +
|
|
150
|
+
' — or use "abort" for a request that never reaches the server');
|
|
151
|
+
}
|
|
97
152
|
}
|
|
98
153
|
if (obj.delay_ms !== undefined && (!Number.isFinite(Number(obj.delay_ms)) || Number(obj.delay_ms) < 0)) {
|
|
99
154
|
throw new Error(`${where}: "delay_ms" must be a non-negative number`);
|
|
@@ -105,13 +160,45 @@ function validateEntry(name: string, value: unknown, file: string): MockEntry {
|
|
|
105
160
|
name,
|
|
106
161
|
method,
|
|
107
162
|
url: obj.url,
|
|
108
|
-
status,
|
|
163
|
+
...(status === undefined ? {} : { status }),
|
|
164
|
+
...(abort === undefined ? {} : { abort }),
|
|
109
165
|
headers: obj.headers as Record<string, string> | undefined,
|
|
110
166
|
body: obj.body,
|
|
111
167
|
delay_ms: obj.delay_ms === undefined ? undefined : Number(obj.delay_ms),
|
|
112
168
|
};
|
|
113
169
|
}
|
|
114
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Normalize and validate one `abort:` value. Returns the errorCode the runtime will pass
|
|
173
|
+
* to `route.abort()`; throws naming the entry otherwise.
|
|
174
|
+
*/
|
|
175
|
+
function resolveAbort(value: unknown, name: string, where: string): AbortErrorCode {
|
|
176
|
+
// FIRST, before any value handling: the original rationale behind banning `abort` outright,
|
|
177
|
+
// kept as a name check. An entry that PROMISES a timeout and delivers a network error is
|
|
178
|
+
// green for the wrong reason — and `abort: true` under such a name is the likeliest way to
|
|
179
|
+
// write that defect, so this must run ahead of the `true` shorthand, not after it.
|
|
180
|
+
if (TIMEOUT_SHAPED_NAME.test(name)) {
|
|
181
|
+
throw new Error(`${where}: a timeout-shaped name must not be satisfied by an abort — use ` +
|
|
182
|
+
'"delay_ms" to hold the response; "abort" is for a request that never reaches the server');
|
|
183
|
+
}
|
|
184
|
+
if (value === true) return 'failed';
|
|
185
|
+
// `false` reads as "do not abort" — which is what omitting the key already says. Accepting
|
|
186
|
+
// it would leave an entry that looks disabled but still fulfils.
|
|
187
|
+
if (value === false) {
|
|
188
|
+
throw new Error(`${where}: "abort: false" is not a way to disable a mock — remove the "abort" key instead`);
|
|
189
|
+
}
|
|
190
|
+
// The sibling key is `status: 500`, so a number here is far more likely a misplaced HTTP
|
|
191
|
+
// status than a typo'd errorCode. Say that, rather than listing an enum it does not belong to.
|
|
192
|
+
if (typeof value === 'number') {
|
|
193
|
+
throw new Error(`${where}: "abort" takes true or an errorCode, not a number — for an HTTP ` +
|
|
194
|
+
`status use "status: ${value}"`);
|
|
195
|
+
}
|
|
196
|
+
if (typeof value !== 'string' || !(ABORT_CODES as readonly string[]).includes(value)) {
|
|
197
|
+
throw new Error(`${where}: "abort" must be true or one of ${ABORT_CODES.join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
return value as AbortErrorCode;
|
|
200
|
+
}
|
|
201
|
+
|
|
115
202
|
function rel(p: string, cwd: string): string {
|
|
116
203
|
return path.relative(cwd, p) || p;
|
|
117
204
|
}
|
|
@@ -45,8 +45,11 @@ function entryExpr(entry: MockEntry): string {
|
|
|
45
45
|
`name: ${JSON.stringify(entry.name)}`,
|
|
46
46
|
`method: ${JSON.stringify(entry.method)}`,
|
|
47
47
|
`url: ${JSON.stringify(entry.url)}`,
|
|
48
|
-
`status: ${entry.status}`,
|
|
49
48
|
];
|
|
49
|
+
// Exactly one of these is set — the loader refuses an entry carrying both (#676). The
|
|
50
|
+
// loader also normalized `abort: true` to a code, so this is always a string.
|
|
51
|
+
if (entry.abort !== undefined) parts.push(`abort: ${JSON.stringify(entry.abort)}`);
|
|
52
|
+
else parts.push(`status: ${entry.status}`);
|
|
50
53
|
if (entry.headers) parts.push(`headers: ${JSON.stringify(entry.headers)}`);
|
|
51
54
|
if (entry.delay_ms !== undefined) parts.push(`delay_ms: ${entry.delay_ms}`);
|
|
52
55
|
if (entry.body !== undefined) {
|
|
@@ -74,7 +77,7 @@ export const mockPatterns: StepPattern[] = [
|
|
|
74
77
|
const entry = resolveMock(name, context.screenName || context.featureName || '', process.cwd());
|
|
75
78
|
return {
|
|
76
79
|
code: `await mock.install(page, ${entryExpr(entry)});`,
|
|
77
|
-
comment: `Mock ${name}: ${entry.method} ${entry.url} → ${entry.status}${entry.delay_ms ? ` after ${entry.delay_ms}ms` : ''}`,
|
|
80
|
+
comment: `Mock ${name}: ${entry.method} ${entry.url} → ${entry.abort ? `aborts (${entry.abort})` : entry.status}${entry.delay_ms ? ` after ${entry.delay_ms}ms` : ''}`,
|
|
78
81
|
};
|
|
79
82
|
},
|
|
80
83
|
priority: 70,
|