@sungen/driver-mock 3.2.30 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-mock",
3
- "version": "3.2.30",
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.30"
16
+ "@sun-asterisk/sungen": "3.2.32"
17
17
  },
18
18
  "files": [
19
19
  "dist",
@@ -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). There is no `abort` on purpose: route.abort() is a network error
19
- * (ERR_FAILED), not a timeout — a mock named "timeout" that aborts makes tests
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
- status: number;
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 status = Number(obj.status);
95
- if (obj.status === undefined || !Number.isInteger(status) || status < 100 || status > 599) {
96
- throw new Error(`${where}: "status" is required and must be an HTTP status code (100-599)`);
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,