@sdods/core 0.2.2 → 0.3.1
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/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/index.d.ts +0 -1
- package/dist/analyze/index.js +0 -1
- package/dist/analyze/propose.js +80 -38
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +19 -6
- package/dist/auth/index.js +23 -2
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +2 -2
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/scenario.js +1 -4
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/shots/hooks.js +0 -10
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/glob.d.ts +16 -1
- package/dist/steps/glob.js +40 -1
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +11 -1
- package/dist/steps/index.js +11 -1
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/package.json +3 -4
- package/dist/analyze/modules.d.ts +0 -74
- package/dist/analyze/modules.js +0 -353
- package/dist/config/playwright.d.ts +0 -37
- package/dist/config/playwright.js +0 -262
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import './params.js';
|
|
3
|
+
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
+
import { coerce, getPath } from '../api/json-path.js';
|
|
5
|
+
import { render } from '../api/template.js';
|
|
6
|
+
import { SdodsError } from '../errors.js';
|
|
7
|
+
const scopesOf = (apiContext, env) => [
|
|
8
|
+
apiContext.vars.toObject(),
|
|
9
|
+
env.vars,
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Render, then refuse a surviving `{{var}}`. Used for every argument whose non-resolution would
|
|
13
|
+
* produce a PASS rather than a failure: negative assertions, URL globs and request paths.
|
|
14
|
+
*/
|
|
15
|
+
function renderStrict(value, what, ...scopes) {
|
|
16
|
+
const out = render(value, ...scopes);
|
|
17
|
+
if (out.includes('{{')) {
|
|
18
|
+
throw new SdodsError('CONFIG_UNRESOLVED_VAR', `${what} did not resolve: "${out}".`, {
|
|
19
|
+
hint: 'No scenario variable or env var of that name exists yet. Save it first (for example with "I save the response JSON path ... as ..."), or fix the spelling — an unresolved placeholder here would make the assertion pass without testing anything.',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Comma-separated Gherkin list → trimmed, rendered, non-empty items. */
|
|
25
|
+
function renderList(raw, what, ...scopes) {
|
|
26
|
+
const items = raw
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((s) => renderStrict(s, what, ...scopes));
|
|
31
|
+
if (items.length === 0)
|
|
32
|
+
throw new SdodsError('RUN_FAILED', `${what} is empty.`, {
|
|
33
|
+
hint: 'List at least one comma-separated value; an empty list would make this assertion pass over nothing.',
|
|
34
|
+
});
|
|
35
|
+
return items;
|
|
36
|
+
}
|
|
37
|
+
/** A response body as searchable text. `null`/absent stays EMPTY, so an empty-body guard bites. */
|
|
38
|
+
function bodyText(body) {
|
|
39
|
+
if (body === null || body === undefined)
|
|
40
|
+
return '';
|
|
41
|
+
return typeof body === 'string' ? body : JSON.stringify(body);
|
|
42
|
+
}
|
|
43
|
+
/** Best-effort evidence for an @api scenario, which takes no screenshots. Never fails a step. */
|
|
44
|
+
async function attachEvidence(testInfo, name, payload) {
|
|
45
|
+
try {
|
|
46
|
+
await testInfo?.attach(name, {
|
|
47
|
+
body: JSON.stringify(payload, null, 2),
|
|
48
|
+
contentType: 'application/json',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* evidence is best-effort; losing it must not turn a green step red */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const RAW = new WeakMap();
|
|
56
|
+
const RAW_BODIES = new WeakMap();
|
|
57
|
+
function rawOf(apiContext) {
|
|
58
|
+
const snap = RAW.get(apiContext);
|
|
59
|
+
if (!snap)
|
|
60
|
+
throw new SdodsError('RUN_FAILED', 'No raw request has been sent in this scenario yet.', {
|
|
61
|
+
hint: 'Send one first with `When I send a GET request to "/path" without following redirects`. The built-in `I send a ... request` steps follow redirects and redact Set-Cookie, so their response is not visible to the raw assertions.',
|
|
62
|
+
});
|
|
63
|
+
return snap;
|
|
64
|
+
}
|
|
65
|
+
function savedBodies(apiContext) {
|
|
66
|
+
let bag = RAW_BODIES.get(apiContext);
|
|
67
|
+
if (!bag) {
|
|
68
|
+
bag = new Map();
|
|
69
|
+
RAW_BODIES.set(apiContext, bag);
|
|
70
|
+
}
|
|
71
|
+
return bag;
|
|
72
|
+
}
|
|
73
|
+
/** The auth the ApiClient would have applied, so a raw request is not silently anonymous. */
|
|
74
|
+
function envAuth(auth) {
|
|
75
|
+
switch (auth.type) {
|
|
76
|
+
case 'bearer':
|
|
77
|
+
return { type: 'bearer', token: auth.token };
|
|
78
|
+
case 'basic':
|
|
79
|
+
return { type: 'basic', username: auth.username, password: auth.password };
|
|
80
|
+
case 'header':
|
|
81
|
+
return { type: 'header', name: auth.name, value: auth.value };
|
|
82
|
+
default:
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Mirrors `ApiClient.buildHeaders` — env headers, then the scenario's pending headers, then auth.
|
|
88
|
+
* A raw request that dropped auth would report the anonymous redirect instead of the
|
|
89
|
+
* authenticated one, which is the same class of defect as a step that does not interpolate.
|
|
90
|
+
* `accept: application/json` is the default; override it with
|
|
91
|
+
* `Given I set the request header "accept" to "text/html"` when the gate under test negotiates
|
|
92
|
+
* on content type.
|
|
93
|
+
*/
|
|
94
|
+
function rawHeaders(apiContext, env, hasBody, isForm) {
|
|
95
|
+
const headers = { accept: 'application/json' };
|
|
96
|
+
for (const [k, v] of Object.entries(env.api.headers ?? {}))
|
|
97
|
+
headers[k.toLowerCase()] = v;
|
|
98
|
+
for (const [k, v] of apiContext.headers)
|
|
99
|
+
headers[k.toLowerCase()] = v;
|
|
100
|
+
if (hasBody && !isForm && !headers['content-type'])
|
|
101
|
+
headers['content-type'] = 'application/json';
|
|
102
|
+
const auth = apiContext.auth ?? envAuth(env.api.auth);
|
|
103
|
+
if (auth) {
|
|
104
|
+
if (auth.type === 'bearer')
|
|
105
|
+
headers.authorization = `Bearer ${auth.token}`;
|
|
106
|
+
else if (auth.type === 'basic')
|
|
107
|
+
headers.authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
|
|
108
|
+
else if (auth.type === 'header')
|
|
109
|
+
headers[auth.name.toLowerCase()] = auth.value;
|
|
110
|
+
}
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
|
113
|
+
/** Mirrors `ApiClient.resolveUrl`, so `Given I set the query parameter …` applies here too. */
|
|
114
|
+
function rawUrl(apiContext, env, pathOrUrl) {
|
|
115
|
+
const base = env.api.baseUrl.replace(/\/+$/, '');
|
|
116
|
+
const url = /^https?:\/\//.test(pathOrUrl)
|
|
117
|
+
? new URL(pathOrUrl)
|
|
118
|
+
: new URL(`${base}/${pathOrUrl.replace(/^\/+/, '')}`);
|
|
119
|
+
for (const [k, v] of apiContext.query)
|
|
120
|
+
url.searchParams.set(k, v);
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One request with `maxRedirects: 0`. The whole point is to keep the 3xx instead of chasing it,
|
|
125
|
+
* and to keep `Set-Cookie` unredacted — the two things the recorded ApiSnapshot cannot carry.
|
|
126
|
+
*/
|
|
127
|
+
export async function sendRaw(deps, method, pathOrUrl, opts = {}) {
|
|
128
|
+
const url = rawUrl(deps.apiContext, deps.env, pathOrUrl);
|
|
129
|
+
const headers = rawHeaders(deps.apiContext, deps.env, opts.body !== undefined || opts.form !== undefined, opts.form !== undefined);
|
|
130
|
+
const res = await deps.request.fetch(url, {
|
|
131
|
+
method,
|
|
132
|
+
headers,
|
|
133
|
+
data: opts.form ? undefined : opts.body,
|
|
134
|
+
form: opts.form,
|
|
135
|
+
maxRedirects: 0,
|
|
136
|
+
failOnStatusCode: false,
|
|
137
|
+
timeout: deps.config.project.timeouts.api,
|
|
138
|
+
});
|
|
139
|
+
const raw = {};
|
|
140
|
+
for (const [k, v] of Object.entries(res.headers()))
|
|
141
|
+
raw[k.toLowerCase()] = v;
|
|
142
|
+
const snap = {
|
|
143
|
+
method,
|
|
144
|
+
url,
|
|
145
|
+
requestBody: opts.body,
|
|
146
|
+
status: res.status(),
|
|
147
|
+
statusText: res.statusText(),
|
|
148
|
+
headers: raw,
|
|
149
|
+
setCookies: res
|
|
150
|
+
.headersArray()
|
|
151
|
+
.filter((h) => h.name.toLowerCase() === 'set-cookie')
|
|
152
|
+
.map((h) => h.value),
|
|
153
|
+
body: await res.text(),
|
|
154
|
+
};
|
|
155
|
+
RAW.set(deps.apiContext, snap);
|
|
156
|
+
// Keeping Set-Cookie readable by the ASSERTIONS is the point of this family; keeping it
|
|
157
|
+
// readable in the attached artefact is not. The report copy is redacted to exactly what
|
|
158
|
+
// ApiClient redacts, and the cookie NAMES carry everything a reader of the report needs.
|
|
159
|
+
await attachEvidence(deps.testInfo, `sdods/raw-${method.toLowerCase()}-${snap.status}`, {
|
|
160
|
+
request: { method, url, headers: redactForReport(headers), body: opts.body, form: opts.form },
|
|
161
|
+
response: {
|
|
162
|
+
status: snap.status,
|
|
163
|
+
statusText: snap.statusText,
|
|
164
|
+
headers: redactForReport(snap.headers),
|
|
165
|
+
setCookieNames: snap.setCookies.map((c) => c.split('=')[0]),
|
|
166
|
+
body: snap.body.slice(0, 4000),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
return snap;
|
|
170
|
+
}
|
|
171
|
+
/** The header set ApiClient redacts, applied to the reported copy only. */
|
|
172
|
+
const SECRET_HEADERS = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
|
|
173
|
+
export function redactForReport(headers) {
|
|
174
|
+
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, SECRET_HEADERS.test(k) ? '***' : v]));
|
|
175
|
+
}
|
|
176
|
+
When('I send a {method} request to {string} without following redirects', async ({ request, apiContext, env, config, $testInfo }, method, path) => {
|
|
177
|
+
// PROVES the edge itself answered. The built-in step would follow the 3xx and report the
|
|
178
|
+
// page it landed on, so an auth/locale/maintenance gate is unobservable through it.
|
|
179
|
+
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopesOf(apiContext, env)));
|
|
180
|
+
});
|
|
181
|
+
When('I send a {method} request to {string} without following redirects with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
182
|
+
// Body stays TEXT, never re-serialised JSON: the byte-identical comparison below is only an
|
|
183
|
+
// oracle for enumeration safety if nothing normalises whitespace or key order on the way in.
|
|
184
|
+
const scopes = scopesOf(apiContext, env);
|
|
185
|
+
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), { body: render(body, ...scopes) });
|
|
186
|
+
});
|
|
187
|
+
When('I send a {method} request to {string} without following redirects with form:', async ({ request, apiContext, env, config, $testInfo }, method, path, table) => {
|
|
188
|
+
// PROVES a form POST's gate. Most real redirect gates — sign-in, consent, locale switch —
|
|
189
|
+
// are urlencoded form posts, so a JSON-only raw request cannot reach the common case.
|
|
190
|
+
const scopes = scopesOf(apiContext, env);
|
|
191
|
+
const form = {};
|
|
192
|
+
for (const row of table.raw())
|
|
193
|
+
form[String(row[0] ?? '')] = render(String(row[1] ?? ''), ...scopes);
|
|
194
|
+
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), { form });
|
|
195
|
+
});
|
|
196
|
+
Then('the raw response status should be {int}', async ({ apiContext }, status) => {
|
|
197
|
+
// PROVES the gate fired at this hop. A 307 that a following client turns into a 200 is the
|
|
198
|
+
// single most common invisible-pass in an API suite.
|
|
199
|
+
const snap = rawOf(apiContext);
|
|
200
|
+
expect(snap.status, `${snap.method} ${snap.url}`).toBe(status);
|
|
201
|
+
});
|
|
202
|
+
Then('the raw response status should be one of {string}', async ({ apiContext }, list) => {
|
|
203
|
+
const allowed = list
|
|
204
|
+
.split(/[,\s]+/)
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.map(Number);
|
|
207
|
+
if (allowed.length === 0 || allowed.some(Number.isNaN))
|
|
208
|
+
throw new SdodsError('RUN_FAILED', `"${list}" is not a list of status codes.`, {
|
|
209
|
+
hint: 'Write them comma- or space-separated, for example "301, 302, 307".',
|
|
210
|
+
});
|
|
211
|
+
const snap = rawOf(apiContext);
|
|
212
|
+
expect(allowed, `${snap.method} ${snap.url} returned ${snap.status}`).toContain(snap.status);
|
|
213
|
+
});
|
|
214
|
+
Then('the raw response Location should be {string}', async ({ apiContext, env }, expected) => {
|
|
215
|
+
// Exact, not substring: the surviving return-to/callback parameter IS the assertion, and a
|
|
216
|
+
// `contains` check would pass on a truncated one that silently drops where the user came from.
|
|
217
|
+
const snap = rawOf(apiContext);
|
|
218
|
+
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toBe(render(expected, ...scopesOf(apiContext, env)));
|
|
219
|
+
});
|
|
220
|
+
Then('the raw response Location should contain {string}', async ({ apiContext, env }, expected) => {
|
|
221
|
+
const snap = rawOf(apiContext);
|
|
222
|
+
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toContain(render(expected, ...scopesOf(apiContext, env)));
|
|
223
|
+
});
|
|
224
|
+
Then('the raw response header {string} should contain {string}', async ({ apiContext, env }, name, value) => {
|
|
225
|
+
const scopes = scopesOf(apiContext, env);
|
|
226
|
+
const wanted = render(name, ...scopes);
|
|
227
|
+
const snap = rawOf(apiContext);
|
|
228
|
+
expect(snap.headers[wanted.toLowerCase()] ?? `(no ${wanted} header)`, `${wanted} of ${snap.url}`).toContain(render(value, ...scopes));
|
|
229
|
+
});
|
|
230
|
+
Then('the raw response should have no {string} header', async ({ apiContext, env }, name) => {
|
|
231
|
+
// The deny direction, and NOT the same as "does not contain": an `Allow` header present but
|
|
232
|
+
// empty, or a `Set-Cookie` present but rejected, are both absences a contains-check would miss.
|
|
233
|
+
const wanted = renderStrict(name, 'the header name', ...scopesOf(apiContext, env));
|
|
234
|
+
const snap = rawOf(apiContext);
|
|
235
|
+
expect(snap.headers[wanted.toLowerCase()], `unexpected ${wanted} header on ${snap.url}`).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
Then('the raw response body should contain {string}', async ({ apiContext, env }, text) => {
|
|
238
|
+
expect(rawOf(apiContext).body).toContain(render(text, ...scopesOf(apiContext, env)));
|
|
239
|
+
});
|
|
240
|
+
Then('the raw response should set the {string} cookie', async ({ apiContext, env }, name) => {
|
|
241
|
+
// PROVES the credential was actually issued. Unreachable through the built-in header step,
|
|
242
|
+
// which sees `set-cookie: ***` because the ApiSnapshot redacts it.
|
|
243
|
+
const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
244
|
+
const snap = rawOf(apiContext);
|
|
245
|
+
expect(snap.setCookies.some((c) => c.startsWith(`${wanted}=`)), `Set-Cookie for "${wanted}" (got ${JSON.stringify(snap.setCookies.map((c) => c.split('=')[0]))})`).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
Then('the raw response should not set the {string} cookie', async ({ apiContext, env }, name) => {
|
|
248
|
+
// PROVES a refusal left no credential behind. A `Max-Age=0` line is a deletion, not a grant,
|
|
249
|
+
// so it must not count as one — otherwise a correct sign-out would read as a session leak.
|
|
250
|
+
const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
251
|
+
const snap = rawOf(apiContext);
|
|
252
|
+
expect(snap.setCookies.filter((c) => c.startsWith(`${wanted}=`) && !isCleared(c)), `no Set-Cookie granting "${wanted}"`).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
Then('the raw response cookie {string} should carry {string}', async ({ apiContext, env }, name, attribute) => {
|
|
255
|
+
// HttpOnly / Secure / SameSite=Lax / Path=/ — the session contract every downstream gate
|
|
256
|
+
// reads. Matched case-insensitively because servers disagree on the casing of attributes.
|
|
257
|
+
const scopes = scopesOf(apiContext, env);
|
|
258
|
+
const wanted = renderStrict(name, 'the cookie name', ...scopes);
|
|
259
|
+
const attr = render(attribute, ...scopes);
|
|
260
|
+
const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
|
|
261
|
+
expect(cookie, `Set-Cookie for "${wanted}"`).toBeTruthy();
|
|
262
|
+
expect(String(cookie).toLowerCase(), `attributes of the "${wanted}" cookie`).toContain(attr.toLowerCase());
|
|
263
|
+
});
|
|
264
|
+
Then('the raw response cookie {string} should be cleared', async ({ apiContext, env }, name) => {
|
|
265
|
+
// PROVES sign-out server-side: a deletion is a Set-Cookie with an immediate expiry, which is
|
|
266
|
+
// what "the browser no longer holds the credential" actually means over the wire.
|
|
267
|
+
const wanted = renderStrict(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
268
|
+
const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
|
|
269
|
+
expect(cookie, `Set-Cookie clearing "${wanted}"`).toBeTruthy();
|
|
270
|
+
expect(isCleared(String(cookie)), `expiry of the "${wanted}" cookie`).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
function isCleared(cookie) {
|
|
273
|
+
return /Max-Age=0|Expires=Thu,\s*01[ -]Jan[ -]1970/i.test(cookie);
|
|
274
|
+
}
|
|
275
|
+
When('I save the raw response body as {string}', async ({ apiContext }, name) => {
|
|
276
|
+
savedBodies(apiContext).set(name, rawOf(apiContext).body);
|
|
277
|
+
});
|
|
278
|
+
Then('the raw response body should be identical to {string}', async ({ apiContext }, name) => {
|
|
279
|
+
// Byte-identical, not "both say no such account". An enumeration-safe endpoint that varies
|
|
280
|
+
// its wording, whitespace or field order between the two cases is still an oracle.
|
|
281
|
+
const saved = savedBodies(apiContext).get(name);
|
|
282
|
+
if (saved === undefined)
|
|
283
|
+
throw new SdodsError('RUN_FAILED', `No raw body was saved under "${name}".`, {
|
|
284
|
+
hint: 'Send the first request and store it with `When I save the raw response body as "<name>"` before comparing the second one against it.',
|
|
285
|
+
});
|
|
286
|
+
expect(rawOf(apiContext).body, `raw body vs. the one saved as "${name}"`).toBe(saved);
|
|
287
|
+
});
|
|
288
|
+
/* ── downloads ────────────────────────────────────────────────────────── */
|
|
289
|
+
const DOWNLOADS = new WeakMap();
|
|
290
|
+
const DOWNLOAD_TEXT = new WeakMap();
|
|
291
|
+
function downloadOf(page) {
|
|
292
|
+
const download = DOWNLOADS.get(page);
|
|
293
|
+
if (!download)
|
|
294
|
+
throw new SdodsError('RUN_FAILED', 'No download has been captured in this scenario yet.', {
|
|
295
|
+
hint: 'Capture one first with `When I click the "Export" button and capture the download`. A download must be captured at the moment the click happens — it cannot be read afterwards.',
|
|
296
|
+
});
|
|
297
|
+
return download;
|
|
298
|
+
}
|
|
299
|
+
async function downloadedText(page) {
|
|
300
|
+
const cached = DOWNLOAD_TEXT.get(page);
|
|
301
|
+
if (cached !== undefined)
|
|
302
|
+
return cached;
|
|
303
|
+
const download = downloadOf(page);
|
|
304
|
+
const file = await download.path();
|
|
305
|
+
if (!file) {
|
|
306
|
+
const failure = await download.failure();
|
|
307
|
+
throw new SdodsError('RUN_FAILED', `The download never completed${failure ? `: ${failure}` : ''}.`, {
|
|
308
|
+
hint: 'Playwright discards a download unless the browser context was created with `acceptDownloads: true` (the default), and reports null here when the transfer failed. Check the reason above before blaming the step.',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const { readFile } = await import('node:fs/promises');
|
|
312
|
+
const text = await readFile(file, 'utf8');
|
|
313
|
+
if (text.length === 0)
|
|
314
|
+
throw new SdodsError('RUN_FAILED', `The downloaded file "${download.suggestedFilename()}" is empty.`, {
|
|
315
|
+
hint: 'An empty export is a product failure, and every assertion below would pass vacuously over it, so this refuses rather than reporting green.',
|
|
316
|
+
});
|
|
317
|
+
DOWNLOAD_TEXT.set(page, text);
|
|
318
|
+
return text;
|
|
319
|
+
}
|
|
320
|
+
async function captureDownload(page, timeout, click) {
|
|
321
|
+
const [download] = await Promise.all([page.waitForEvent('download', { timeout }), click()]);
|
|
322
|
+
DOWNLOADS.set(page, download);
|
|
323
|
+
DOWNLOAD_TEXT.delete(page);
|
|
324
|
+
}
|
|
325
|
+
When('I click the {string} {role} and capture the download', async ({ page, heal, config, apiContext, env }, name, role) => {
|
|
326
|
+
// PROVES an export produced a file at all. Without this the whole export feature can only be
|
|
327
|
+
// asserted as "the click did not throw", which is true of a button wired to nothing.
|
|
328
|
+
const n = render(name, ...scopesOf(apiContext, env));
|
|
329
|
+
const loc = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'click');
|
|
330
|
+
await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
|
|
331
|
+
});
|
|
332
|
+
When('I click the element with test id {string} and capture the download', async ({ page, heal, config }, id) => {
|
|
333
|
+
const loc = await heal.resolve(page.getByTestId(id), { testId: id, description: `test id "${id}"` }, 'click');
|
|
334
|
+
await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
|
|
335
|
+
});
|
|
336
|
+
Then('the downloaded file name should end with {string}', async ({ page, apiContext, env }, suffix) => {
|
|
337
|
+
// `endsWith`, not `contains`: a ".json" that appears mid-name (report.json.txt) is the exact
|
|
338
|
+
// mis-typed export this assertion exists to catch.
|
|
339
|
+
const wanted = render(suffix, ...scopesOf(apiContext, env));
|
|
340
|
+
const actual = downloadOf(page).suggestedFilename();
|
|
341
|
+
expect(actual.endsWith(wanted), `download filename "${actual}" should end with "${wanted}"`).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
Then('the downloaded file name should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
344
|
+
expect(downloadOf(page).suggestedFilename(), 'download filename').toContain(render(part, ...scopesOf(apiContext, env)));
|
|
345
|
+
});
|
|
346
|
+
Then('the downloaded text first line should be {string}', async ({ page, apiContext, env }, expected) => {
|
|
347
|
+
// PROVES the header row of a CSV export matches the columns the UI promised. Exact, because a
|
|
348
|
+
// reordered or renamed column silently breaks every consumer of the file.
|
|
349
|
+
const text = await downloadedText(page);
|
|
350
|
+
expect(text.split(/\r?\n/)[0] ?? '', 'first line of the downloaded file').toBe(render(expected, ...scopesOf(apiContext, env)));
|
|
351
|
+
});
|
|
352
|
+
Then('the downloaded text should contain {string}', async ({ page, apiContext, env }, text) => {
|
|
353
|
+
expect(await downloadedText(page), 'downloaded file').toContain(render(text, ...scopesOf(apiContext, env)));
|
|
354
|
+
});
|
|
355
|
+
Then('the downloaded text should have at least {int} data rows', async ({ page }, min) => {
|
|
356
|
+
expect(dataRows(await downloadedText(page)), 'data rows in the downloaded file').toBeGreaterThanOrEqual(min);
|
|
357
|
+
});
|
|
358
|
+
Then('the downloaded text should have at most {int} data rows', async ({ page }, max) => {
|
|
359
|
+
// PROVES an export cap. Safe from vacuity because `downloadedText` refuses an empty file, so
|
|
360
|
+
// "at most N" can never be satisfied by nothing having been exported.
|
|
361
|
+
expect(dataRows(await downloadedText(page)), 'data rows in the downloaded file').toBeLessThanOrEqual(max);
|
|
362
|
+
});
|
|
363
|
+
/** Non-blank lines after the header row. */
|
|
364
|
+
function dataRows(text) {
|
|
365
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
366
|
+
return Math.max(0, lines.length - 1);
|
|
367
|
+
}
|
|
368
|
+
async function downloadedJson(page) {
|
|
369
|
+
const text = await downloadedText(page);
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(text);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
throw new SdodsError('RUN_FAILED', `The downloaded file is not JSON: ${e.message}`, {
|
|
375
|
+
hint: `First 200 characters: ${text.slice(0, 200)}`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
Then('the downloaded JSON path {string} should equal {string}', async ({ page, apiContext, env }, path, expected) => {
|
|
380
|
+
// PROVES the export/import round trip, not just that a file arrived: a scenario that can
|
|
381
|
+
// export but never read what it exported proves only that a click did not throw.
|
|
382
|
+
const actual = getPath(await downloadedJson(page), path);
|
|
383
|
+
expect(actual, `downloaded JSON path ${path}`).toEqual(coerce(render(expected, ...scopesOf(apiContext, env))));
|
|
384
|
+
});
|
|
385
|
+
Then('the downloaded JSON path {string} should have {int} items', async ({ page }, path, count) => {
|
|
386
|
+
// Arrays and keyed records both count, because exports model collections either way; anything
|
|
387
|
+
// else fails loudly rather than counting as zero.
|
|
388
|
+
const value = getPath(await downloadedJson(page), path);
|
|
389
|
+
if (Array.isArray(value)) {
|
|
390
|
+
expect(value, `downloaded JSON path ${path} length`).toHaveLength(count);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (value !== null && typeof value === 'object') {
|
|
394
|
+
expect(Object.keys(value), `downloaded JSON path ${path} key count`).toHaveLength(count);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
throw new SdodsError('RUN_FAILED', `Downloaded JSON path ${path} is not a collection.`, {
|
|
398
|
+
hint: `It is ${value === undefined ? 'absent' : typeof value}. Point the path at an array or an object whose keys are the items.`,
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
const STREAMS = new WeakMap();
|
|
402
|
+
const STREAM_TIMEOUT = new WeakMap();
|
|
403
|
+
/**
|
|
404
|
+
* Parse an SSE body into frames. Frames are separated by a blank line; `data:` lines within one
|
|
405
|
+
* frame join with a newline; a frame carrying only a comment (`: keepalive`) is not an event.
|
|
406
|
+
*/
|
|
407
|
+
export function parseEventStream(text) {
|
|
408
|
+
const events = [];
|
|
409
|
+
for (const frame of text.split(/\r?\n\r?\n/)) {
|
|
410
|
+
if (frame.trim() === '')
|
|
411
|
+
continue;
|
|
412
|
+
let name;
|
|
413
|
+
const data = [];
|
|
414
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
415
|
+
if (line.startsWith(':'))
|
|
416
|
+
continue;
|
|
417
|
+
if (line.startsWith('event:'))
|
|
418
|
+
name = line.slice(6).trim();
|
|
419
|
+
else if (line.startsWith('data:'))
|
|
420
|
+
data.push(line.slice(5).replace(/^ /, ''));
|
|
421
|
+
}
|
|
422
|
+
if (name === undefined && data.length === 0)
|
|
423
|
+
continue;
|
|
424
|
+
const payload = data.join('\n');
|
|
425
|
+
let json;
|
|
426
|
+
try {
|
|
427
|
+
json = payload.length ? JSON.parse(payload) : undefined;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
/* a non-JSON frame is still an event; its type is then the `event:` field alone */
|
|
431
|
+
}
|
|
432
|
+
const fromJson = json !== null && typeof json === 'object' && 'type' in json
|
|
433
|
+
? String(json.type)
|
|
434
|
+
: undefined;
|
|
435
|
+
const sentinel = /^\[[A-Z_]+\]$/.test(payload) ? payload : undefined;
|
|
436
|
+
events.push({ type: name ?? fromJson ?? sentinel, data: payload, json });
|
|
437
|
+
}
|
|
438
|
+
return { events, endedWithDelimiter: /\r?\n\r?\n\s*$/.test(text) };
|
|
439
|
+
}
|
|
440
|
+
function streamOf(apiContext) {
|
|
441
|
+
const capture = STREAMS.get(apiContext);
|
|
442
|
+
if (!capture)
|
|
443
|
+
throw new SdodsError('RUN_FAILED', 'No event stream has been read in this scenario yet.', {
|
|
444
|
+
hint: 'Read one first with `When I read the event stream from a POST request to "/chat" with body:`. The built-in `I send a ... request` steps buffer and JSON-parse the body, which a text/event-stream response is not.',
|
|
445
|
+
});
|
|
446
|
+
return capture;
|
|
447
|
+
}
|
|
448
|
+
async function readStream(deps, method, pathOrUrl, body) {
|
|
449
|
+
const headers = rawHeaders(deps.apiContext, deps.env, body !== undefined, false);
|
|
450
|
+
headers.accept = 'text/event-stream';
|
|
451
|
+
const url = rawUrl(deps.apiContext, deps.env, pathOrUrl);
|
|
452
|
+
const timeout = STREAM_TIMEOUT.get(deps.apiContext) ?? deps.config.project.timeouts.api;
|
|
453
|
+
let capture;
|
|
454
|
+
try {
|
|
455
|
+
const res = await deps.request.fetch(url, {
|
|
456
|
+
method,
|
|
457
|
+
headers,
|
|
458
|
+
data: body,
|
|
459
|
+
maxRedirects: 0,
|
|
460
|
+
failOnStatusCode: false,
|
|
461
|
+
timeout,
|
|
462
|
+
});
|
|
463
|
+
const text = await res.text();
|
|
464
|
+
capture = {
|
|
465
|
+
status: res.status(),
|
|
466
|
+
contentType: res.headers()['content-type'] ?? '',
|
|
467
|
+
...parseEventStream(text),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
// A stream that dies mid-flight surfaces here, not as a status — record the reason so the
|
|
472
|
+
// clean-termination step fails with it rather than with "no stream captured".
|
|
473
|
+
capture = {
|
|
474
|
+
status: 0,
|
|
475
|
+
contentType: '',
|
|
476
|
+
events: [],
|
|
477
|
+
endedWithDelimiter: false,
|
|
478
|
+
error: e.message,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
STREAMS.set(deps.apiContext, capture);
|
|
482
|
+
await attachEvidence(deps.testInfo, `sdods/stream-${method.toLowerCase()}-${capture.status}`, {
|
|
483
|
+
url,
|
|
484
|
+
status: capture.status,
|
|
485
|
+
contentType: capture.contentType,
|
|
486
|
+
eventCount: capture.events.length,
|
|
487
|
+
eventTypes: capture.events.map((e) => e.type),
|
|
488
|
+
endedWithDelimiter: capture.endedWithDelimiter,
|
|
489
|
+
error: capture.error,
|
|
490
|
+
});
|
|
491
|
+
return capture;
|
|
492
|
+
}
|
|
493
|
+
Given('I allow {int} seconds for the event stream', async ({ apiContext }, seconds) => {
|
|
494
|
+
// A long turn outlives `timeouts.api`, and that timeout would surface as a red step that looks
|
|
495
|
+
// exactly like a product bug — the worst kind of false failure.
|
|
496
|
+
STREAM_TIMEOUT.set(apiContext, seconds * 1000);
|
|
497
|
+
});
|
|
498
|
+
When('I read the event stream from a {method} request to {string}', async ({ request, apiContext, env, config, $testInfo }, method, path) => {
|
|
499
|
+
await readStream({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopesOf(apiContext, env)));
|
|
500
|
+
});
|
|
501
|
+
When('I read the event stream from a {method} request to {string} with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
502
|
+
const scopes = scopesOf(apiContext, env);
|
|
503
|
+
await readStream({ request, apiContext, env, config, testInfo: $testInfo }, method, renderStrict(path, 'the request path', ...scopes), render(body, ...scopes));
|
|
504
|
+
});
|
|
505
|
+
Then('the stream status should be {int}', async ({ apiContext }, status) => {
|
|
506
|
+
const capture = streamOf(apiContext);
|
|
507
|
+
expect(capture.status, capture.error ? `stream failed: ${capture.error}` : 'stream status').toBe(status);
|
|
508
|
+
});
|
|
509
|
+
Then('the stream content type should be {string}', async ({ apiContext, env }, expected) => {
|
|
510
|
+
// PROVES the endpoint actually streamed. An endpoint that quietly fell back to a buffered
|
|
511
|
+
// JSON response still returns 200, and every event assertion below would then fail
|
|
512
|
+
// confusingly.
|
|
513
|
+
expect(streamOf(apiContext).contentType, 'stream content type').toContain(render(expected, ...scopesOf(apiContext, env)));
|
|
514
|
+
});
|
|
515
|
+
Then('the stream should have at least {int} events', async ({ apiContext }, min) => {
|
|
516
|
+
if (min < 1)
|
|
517
|
+
throw new SdodsError('RUN_FAILED', 'Assert at least one event, not zero.', {
|
|
518
|
+
hint: '"at least 0 events" is satisfied by a stream that carried nothing; use `the stream should have terminated cleanly` if you mean the stream finished.',
|
|
519
|
+
});
|
|
520
|
+
expect(streamOf(apiContext).events.length, 'events parsed from the stream').toBeGreaterThanOrEqual(min);
|
|
521
|
+
});
|
|
522
|
+
Then('the first stream event type should be {string}', async ({ apiContext, env }, expected) => {
|
|
523
|
+
const { events } = streamOf(apiContext);
|
|
524
|
+
expect(events.length, 'the stream carried no events at all').toBeGreaterThan(0);
|
|
525
|
+
expect(events[0]?.type, 'first stream event type').toBe(render(expected, ...scopesOf(apiContext, env)));
|
|
526
|
+
});
|
|
527
|
+
Then('the last stream event type should be {string}', async ({ apiContext, env }, expected) => {
|
|
528
|
+
// PROVES the turn finished. A streaming endpoint returns 200 on its first byte, so the status
|
|
529
|
+
// says nothing about whether the model/producer ever reached its terminal event.
|
|
530
|
+
const { events } = streamOf(apiContext);
|
|
531
|
+
expect(events.length, 'the stream carried no events at all').toBeGreaterThan(0);
|
|
532
|
+
expect(events[events.length - 1]?.type, 'last stream event type — a 200 that never reaches this is a stream that died mid-flight').toBe(render(expected, ...scopesOf(apiContext, env)));
|
|
533
|
+
});
|
|
534
|
+
Then('the stream should have terminated cleanly', async ({ apiContext }) => {
|
|
535
|
+
// Three clauses, and all three are load-bearing: no transport error, at least one frame parsed
|
|
536
|
+
// (else a body of just "\n\n" would qualify), and a final frame boundary (a stream cut in the
|
|
537
|
+
// middle of a frame leaves no terminating blank line).
|
|
538
|
+
const capture = streamOf(apiContext);
|
|
539
|
+
expect(capture.error, 'the stream failed at the transport level').toBeUndefined();
|
|
540
|
+
expect(capture.events.length, 'the stream carried no events at all').toBeGreaterThan(0);
|
|
541
|
+
expect(capture.endedWithDelimiter, 'the body does not end on a frame boundary, so the stream was cut mid-event').toBe(true);
|
|
542
|
+
});
|
|
543
|
+
const REQUESTS = new WeakMap();
|
|
544
|
+
/**
|
|
545
|
+
* Playwright `page.route` glob semantics, restricted to the three operators that matter:
|
|
546
|
+
* `**` matches anything including `/`, `*` matches anything except `/`, `?` matches one
|
|
547
|
+
* non-`/` character. Anchored, like Playwright's own matcher. Kept deliberately compatible so a
|
|
548
|
+
* counting step and a `I mock {string} …` step can be given the same string.
|
|
549
|
+
*/
|
|
550
|
+
export function globToRegExp(glob) {
|
|
551
|
+
let out = '';
|
|
552
|
+
for (let i = 0; i < glob.length; i++) {
|
|
553
|
+
const c = glob[i];
|
|
554
|
+
if (c === '*') {
|
|
555
|
+
if (glob[i + 1] === '*') {
|
|
556
|
+
out += '.*';
|
|
557
|
+
i++;
|
|
558
|
+
}
|
|
559
|
+
else
|
|
560
|
+
out += '[^/]*';
|
|
561
|
+
}
|
|
562
|
+
else if (c === '?')
|
|
563
|
+
out += '[^/]';
|
|
564
|
+
else
|
|
565
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
566
|
+
}
|
|
567
|
+
return new RegExp(`^${out}$`);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Refuses a pattern that cannot match a URL. A bare fragment such as "analytics" matches nothing
|
|
571
|
+
* under glob rules, so "no requests were made" would pass for ever — the exact vacuous green this
|
|
572
|
+
* whole section exists to prevent.
|
|
573
|
+
*/
|
|
574
|
+
function urlGlob(raw, scopes) {
|
|
575
|
+
const pattern = renderStrict(raw, 'the URL pattern', ...scopes);
|
|
576
|
+
if (!/[*?]/.test(pattern) && !/^(https?:\/\/|\/)/.test(pattern))
|
|
577
|
+
throw new SdodsError('RUN_FAILED', `"${pattern}" cannot match a request URL.`, {
|
|
578
|
+
hint: 'These steps use Playwright route globs, matched against the whole URL. Write "**analytics**" for a fragment, "**/api/users" for a path, or a full URL.',
|
|
579
|
+
});
|
|
580
|
+
return globToRegExp(pattern);
|
|
581
|
+
}
|
|
582
|
+
function logOf(page, why) {
|
|
583
|
+
const log = REQUESTS.get(page);
|
|
584
|
+
if (!log)
|
|
585
|
+
throw new SdodsError('RUN_FAILED', `${why} before any request recording was started.`, {
|
|
586
|
+
hint: 'Add `Given I record outgoing browser requests` (or `When I start counting requests to "<glob>"`) BEFORE the action under test — a listener installed afterwards cannot see requests that already went out.',
|
|
587
|
+
});
|
|
588
|
+
return log;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Listens on `page.on('request')`, not `page.route`. Route handlers run in reverse registration
|
|
592
|
+
* order, so a mock registered after a counting route would fulfil the request and the counter
|
|
593
|
+
* would never fire — the count would read 0 and "the client does not poll" would pass vacuously.
|
|
594
|
+
* The request event fires whether the request is routed, fulfilled, aborted or served from the
|
|
595
|
+
* network, which is what "did the client issue it" actually means.
|
|
596
|
+
*/
|
|
597
|
+
function ensureLog(page) {
|
|
598
|
+
const existing = REQUESTS.get(page);
|
|
599
|
+
if (existing)
|
|
600
|
+
return existing;
|
|
601
|
+
const log = { entries: [], counters: new Map() };
|
|
602
|
+
REQUESTS.set(page, log);
|
|
603
|
+
page.on('request', (req) => log.entries.push({ method: req.method(), url: req.url() }));
|
|
604
|
+
return log;
|
|
605
|
+
}
|
|
606
|
+
Given('I record outgoing browser requests', async ({ page }) => {
|
|
607
|
+
ensureLog(page);
|
|
608
|
+
});
|
|
609
|
+
When('I start counting requests to {string}', async ({ page, apiContext, env }, glob) => {
|
|
610
|
+
// The counter is an OFFSET into the log, so "start counting" means "ignore what came before".
|
|
611
|
+
// Start it before navigating if the initial page load's requests are part of the count.
|
|
612
|
+
urlGlob(glob, scopesOf(apiContext, env));
|
|
613
|
+
const log = ensureLog(page);
|
|
614
|
+
log.counters.set(render(glob, ...scopesOf(apiContext, env)), log.entries.length);
|
|
615
|
+
});
|
|
616
|
+
function counted(page, glob, scopes, why) {
|
|
617
|
+
const rendered = renderStrict(glob, 'the URL pattern', ...scopes);
|
|
618
|
+
const log = logOf(page, why);
|
|
619
|
+
const from = log.counters.get(rendered);
|
|
620
|
+
if (from === undefined)
|
|
621
|
+
throw new SdodsError('RUN_FAILED', `No counter was started for "${rendered}".`, {
|
|
622
|
+
hint: 'Add `When I start counting requests to "<the same glob>"` before the action. The glob strings must match exactly — the counter is keyed on the string you wrote.',
|
|
623
|
+
});
|
|
624
|
+
const re = urlGlob(glob, scopes);
|
|
625
|
+
return log.entries.slice(from).filter((e) => re.test(e.url)).length;
|
|
626
|
+
}
|
|
627
|
+
Then('{int} requests should have been counted for {string}', async ({ page, apiContext, env }, expected, glob) => {
|
|
628
|
+
// PROVES an exact call budget — one fetch per keystroke-debounce window, one per mount.
|
|
629
|
+
expect(counted(page, glob, scopesOf(apiContext, env), 'a request count was asserted'), `requests matching ${glob}`).toBe(expected);
|
|
630
|
+
});
|
|
631
|
+
Then('at most {int} requests should have been counted for {string}', async ({ page, apiContext, env }, max, glob) => {
|
|
632
|
+
// PROVES the client does not poll. Bounded rather than exact, because a legitimate retry
|
|
633
|
+
// should not turn this red — but zero would, and that is the point of the counter start.
|
|
634
|
+
expect(counted(page, glob, scopesOf(apiContext, env), 'a request count was asserted'), `requests matching ${glob}`).toBeLessThanOrEqual(max);
|
|
635
|
+
});
|
|
636
|
+
Then('at least {int} requests should have been counted for {string}', async ({ page, apiContext, env }, min, glob) => {
|
|
637
|
+
expect(counted(page, glob, scopesOf(apiContext, env), 'a request count was asserted'), `requests matching ${glob}`).toBeGreaterThanOrEqual(min);
|
|
638
|
+
});
|
|
639
|
+
Then('no requests should have been counted for {string}', async ({ page, apiContext, env }, glob) => {
|
|
640
|
+
// PROVES a client-side guard refused before sending. Mocking the route instead would make the
|
|
641
|
+
// scenario pass whether or not the call went out, which is the assertion inverted.
|
|
642
|
+
expect(counted(page, glob, scopesOf(apiContext, env), 'a request count was asserted'), `requests matching ${glob}`).toBe(0);
|
|
643
|
+
});
|
|
644
|
+
Then('no request matching {string} should have been made', async ({ page, apiContext, env }, glob) => {
|
|
645
|
+
// PROVES nothing leaked to a third party. Spans the whole recording, not a counter window, so
|
|
646
|
+
// it also covers requests issued during the initial page load.
|
|
647
|
+
const scopes = scopesOf(apiContext, env);
|
|
648
|
+
const re = urlGlob(glob, scopes);
|
|
649
|
+
const log = logOf(page, 'an outgoing-request assertion ran');
|
|
650
|
+
const hits = log.entries.filter((e) => re.test(e.url)).map((e) => `${e.method} ${e.url}`);
|
|
651
|
+
expect(hits, `unexpected request(s) matching ${glob}`).toEqual([]);
|
|
652
|
+
});
|
|
653
|
+
Then('a request matching {string} should have been made', async ({ page, apiContext, env }, glob) => {
|
|
654
|
+
const scopes = scopesOf(apiContext, env);
|
|
655
|
+
const re = urlGlob(glob, scopes);
|
|
656
|
+
const log = logOf(page, 'an outgoing-request assertion ran');
|
|
657
|
+
expect(log.entries.filter((e) => re.test(e.url)).length, `no request matched ${glob}; recorded: ${log.entries
|
|
658
|
+
.slice(-5)
|
|
659
|
+
.map((e) => e.url)
|
|
660
|
+
.join(', ') || '(none)'}`).toBeGreaterThan(0);
|
|
661
|
+
});
|
|
662
|
+
/* ── cheap negatives ──────────────────────────────────────────────────── */
|
|
663
|
+
Then('the response should have no {string} header', async ({ apiContext, env }, name) => {
|
|
664
|
+
// Absence, which is NOT "does not contain": a header present with an empty or unexpected value
|
|
665
|
+
// satisfies a contains-check written as a negative, and this is the assertion behind
|
|
666
|
+
// "no cache header was set" and "the debug header never reaches production".
|
|
667
|
+
const wanted = renderStrict(name, 'the header name', ...scopesOf(apiContext, env));
|
|
668
|
+
expect(apiContext.last().response.headers[wanted.toLowerCase()], `unexpected ${wanted} header`).toBeUndefined();
|
|
669
|
+
});
|
|
670
|
+
Then('the response JSON path {string} should not exist', async ({ apiContext, env }, path) => {
|
|
671
|
+
// PROVES a field was withheld — the password hash, the internal id, the other tenant's row.
|
|
672
|
+
// Strict rendering matters most here: an unresolved path would be absent by construction.
|
|
673
|
+
const wanted = renderStrict(path, 'the JSON path', ...scopesOf(apiContext, env));
|
|
674
|
+
expect(getPath(apiContext.last().response.body, wanted), `JSON path ${wanted}`).toBeUndefined();
|
|
675
|
+
});
|
|
676
|
+
Then('the response should not contain any of {string}', async ({ apiContext, env }, list) => {
|
|
677
|
+
// Comma-separated; an empty list is refused, because "contains none of nothing" is the
|
|
678
|
+
// vacuous pass this step would otherwise be used to hide behind.
|
|
679
|
+
const wanted = renderList(list, 'the forbidden-strings list', ...scopesOf(apiContext, env));
|
|
680
|
+
const haystack = bodyText(apiContext.last().response.body);
|
|
681
|
+
if (haystack.trim().length === 0)
|
|
682
|
+
throw new SdodsError('RUN_FAILED', 'The response body is empty.', {
|
|
683
|
+
hint: 'Searching an empty body for forbidden strings can only ever pass; assert the status, or point this at the response that actually carries content.',
|
|
684
|
+
});
|
|
685
|
+
const found = wanted.filter((w) => haystack.includes(w));
|
|
686
|
+
expect(found, 'forbidden strings present in the response body').toEqual([]);
|
|
687
|
+
});
|
|
688
|
+
/**
|
|
689
|
+
* Shapes that are credentials wherever they appear. Deliberately narrow: each pattern is long
|
|
690
|
+
* and structured enough that a match is evidence, not a guess.
|
|
691
|
+
*/
|
|
692
|
+
const CREDENTIAL_PATTERNS = [
|
|
693
|
+
{ name: 'JSON Web Token', re: /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
|
|
694
|
+
{ name: 'PEM private key', re: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/ },
|
|
695
|
+
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
696
|
+
{ name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
697
|
+
{ name: 'Slack token', re: /\bxox[abprs]-[0-9A-Za-z-]{10,}/ },
|
|
698
|
+
{ name: 'GitHub token', re: /\bgh[pousr]_[0-9A-Za-z]{30,}/ },
|
|
699
|
+
{ name: 'Stripe secret key', re: /\bsk_(?:live|test)_[0-9A-Za-z]{16,}/ },
|
|
700
|
+
{
|
|
701
|
+
name: 'secret-named field with a value',
|
|
702
|
+
re: /"(?:password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|session[_-]?token)"\s*:\s*"(?!\*+"|\[REDACTED\]")[^"]{8,}"/i,
|
|
703
|
+
},
|
|
704
|
+
];
|
|
705
|
+
/** Every credential shape found in `text`, named. Exported so the patterns are testable. */
|
|
706
|
+
export function credentialFindings(text) {
|
|
707
|
+
return CREDENTIAL_PATTERNS.filter((p) => p.re.test(text)).map((p) => p.name);
|
|
708
|
+
}
|
|
709
|
+
function assertNoCredentials(text, where) {
|
|
710
|
+
if (text.trim().length === 0)
|
|
711
|
+
throw new SdodsError('RUN_FAILED', `The ${where} is empty.`, {
|
|
712
|
+
hint: 'A credential scan over an empty body proves nothing about the endpoint, so this refuses rather than reporting green. Assert the status or the absence of a cookie instead.',
|
|
713
|
+
});
|
|
714
|
+
expect(credentialFindings(text), `credential-shaped values in the ${where}`).toEqual([]);
|
|
715
|
+
}
|
|
716
|
+
Then('nothing in the response body should look like a credential', async ({ apiContext }) => {
|
|
717
|
+
// PROVES an over-serialised model did not leak a token into a response nobody reads closely.
|
|
718
|
+
// Body only: the ApiSnapshot already redacts `authorization`/`set-cookie`, so scanning its
|
|
719
|
+
// headers would assert over asterisks.
|
|
720
|
+
assertNoCredentials(bodyText(apiContext.last().response.body), 'response body');
|
|
721
|
+
});
|
|
722
|
+
Then('nothing in the raw response body should look like a credential', async ({ apiContext }) => {
|
|
723
|
+
// The raw variant, for a response whose headers were deliberately kept unredacted. Still body
|
|
724
|
+
// only: a `Set-Cookie` on a successful sign-in IS a credential by design, and flagging it would
|
|
725
|
+
// make this fail on every correct login.
|
|
726
|
+
assertNoCredentials(rawOf(apiContext).body, 'raw response body');
|
|
727
|
+
});
|
|
728
|
+
//# sourceMappingURL=net.steps.js.map
|