@sdods/core 0.3.1 → 0.5.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/api/client.d.ts +62 -1
- package/dist/api/client.js +189 -27
- package/dist/api/template.d.ts +15 -2
- package/dist/api/template.js +31 -13
- package/dist/auth/capture.js +4 -3
- package/dist/auth/index.js +3 -18
- package/dist/config/defaults.js +2 -0
- package/dist/config/resolve.d.ts +5 -1
- package/dist/config/resolve.js +16 -3
- package/dist/config/runner.d.ts +26 -1
- package/dist/config/runner.js +155 -24
- package/dist/config/tags.d.ts +14 -2
- package/dist/config/tags.js +27 -7
- package/dist/fixtures/api-context.d.ts +7 -0
- package/dist/fixtures/api-context.js +7 -0
- package/dist/fixtures/auth.js +72 -5
- package/dist/fixtures/test.js +23 -6
- package/dist/heal/healer.d.ts +12 -1
- package/dist/heal/healer.js +80 -34
- package/dist/heal/strategies.d.ts +12 -2
- package/dist/heal/strategies.js +42 -0
- package/dist/lint/index.d.ts +1 -0
- package/dist/lint/index.js +44 -3
- package/dist/lint/steps.d.ts +13 -0
- package/dist/lint/steps.js +333 -0
- package/dist/load-module.d.ts +7 -0
- package/dist/load-module.js +70 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +9 -3
- package/dist/steps/a11y.steps.js +10 -10
- package/dist/steps/api.steps.js +64 -27
- package/dist/steps/browser.steps.js +34 -33
- package/dist/steps/clock.steps.js +5 -5
- package/dist/steps/data.steps.js +61 -8
- package/dist/steps/db.steps.js +8 -8
- package/dist/steps/dom.steps.js +49 -49
- package/dist/steps/hybrid.steps.js +9 -5
- package/dist/steps/iframe.steps.js +9 -9
- package/dist/steps/net.steps.d.ts +12 -7
- package/dist/steps/net.steps.js +78 -83
- package/dist/steps/perf.steps.js +6 -6
- package/dist/steps/tabs.steps.js +6 -6
- package/dist/steps/ui.steps.js +94 -45
- package/dist/steps/webhook.steps.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +15 -1
- package/package.json +6 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* Iframe steps.
|
|
@@ -31,7 +31,7 @@ function currentFrame(page) {
|
|
|
31
31
|
}
|
|
32
32
|
/* ── entering and leaving ─────────────────────────────────────────────── */
|
|
33
33
|
Given('I enter the frame {string}', async ({ page, apiContext, env }, selector) => {
|
|
34
|
-
const resolved =
|
|
34
|
+
const resolved = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
35
35
|
// A bare word is treated as a name/title/id, which is how frames are
|
|
36
36
|
// usually identified in markup; anything else is a CSS selector.
|
|
37
37
|
const css = /^[\w-]+$/.test(resolved)
|
|
@@ -53,32 +53,32 @@ When('I fill the frame field {string} with {string}', async ({ page, apiContext,
|
|
|
53
53
|
const scopes = scopesOf(apiContext, env);
|
|
54
54
|
const frame = currentFrame(page);
|
|
55
55
|
await frame
|
|
56
|
-
.getByLabel(
|
|
57
|
-
.or(frame.getByPlaceholder(
|
|
56
|
+
.getByLabel(renderStrict(label, ...scopes))
|
|
57
|
+
.or(frame.getByPlaceholder(renderStrict(label, ...scopes)))
|
|
58
58
|
.first()
|
|
59
|
-
.fill(
|
|
59
|
+
.fill(renderStrict(value, ...scopes));
|
|
60
60
|
});
|
|
61
61
|
When('I click the frame element {string}', async ({ page, apiContext, env }, selector) => {
|
|
62
62
|
await currentFrame(page)
|
|
63
|
-
.locator(
|
|
63
|
+
.locator(renderStrict(selector, ...scopesOf(apiContext, env)))
|
|
64
64
|
.first()
|
|
65
65
|
.click();
|
|
66
66
|
});
|
|
67
67
|
When('I click the frame {role} {string}', async ({ page, apiContext, env }, role, name) => {
|
|
68
68
|
await currentFrame(page)
|
|
69
|
-
.getByRole(role, { name:
|
|
69
|
+
.getByRole(role, { name: renderStrict(name, ...scopesOf(apiContext, env)) })
|
|
70
70
|
.first()
|
|
71
71
|
.click();
|
|
72
72
|
});
|
|
73
73
|
/* ── asserting inside the frame ───────────────────────────────────────── */
|
|
74
74
|
Then('the frame should contain the text {string}', async ({ page, apiContext, env }, text) => {
|
|
75
75
|
await expect(currentFrame(page)
|
|
76
|
-
.getByText(
|
|
76
|
+
.getByText(renderStrict(text, ...scopesOf(apiContext, env)))
|
|
77
77
|
.first()).toBeVisible();
|
|
78
78
|
});
|
|
79
79
|
Then('the frame element {string} should be visible', async ({ page, apiContext, env }, selector) => {
|
|
80
80
|
await expect(currentFrame(page)
|
|
81
|
-
.locator(
|
|
81
|
+
.locator(renderStrict(selector, ...scopesOf(apiContext, env)))
|
|
82
82
|
.first()).toBeVisible();
|
|
83
83
|
});
|
|
84
84
|
Then('the page should have {int} frame(s)', async ({ page }, count) => {
|
|
@@ -3,6 +3,7 @@ import type { APIRequestContext, TestInfo } from '@playwright/test';
|
|
|
3
3
|
import type { EnvConfig } from '@sdods/contracts';
|
|
4
4
|
import type { ResolvedConfig } from '../config/resolve.js';
|
|
5
5
|
import type { ApiContext } from '../fixtures/api-context.js';
|
|
6
|
+
import { type ApiClient } from '../api/client.js';
|
|
6
7
|
export interface RawSnapshot {
|
|
7
8
|
method: string;
|
|
8
9
|
url: string;
|
|
@@ -15,6 +16,15 @@ export interface RawSnapshot {
|
|
|
15
16
|
setCookies: string[];
|
|
16
17
|
body: string;
|
|
17
18
|
}
|
|
19
|
+
interface RawDeps {
|
|
20
|
+
request: APIRequestContext;
|
|
21
|
+
/** The scenario's ApiClient; supplies the isolated request context when one is in use. */
|
|
22
|
+
api?: Pick<ApiClient, 'requestContext'>;
|
|
23
|
+
apiContext: ApiContext;
|
|
24
|
+
env: EnvConfig;
|
|
25
|
+
config: ResolvedConfig;
|
|
26
|
+
testInfo?: Pick<TestInfo, 'attach'>;
|
|
27
|
+
}
|
|
18
28
|
export interface SendRawOptions {
|
|
19
29
|
body?: string;
|
|
20
30
|
form?: Record<string, string>;
|
|
@@ -23,13 +33,7 @@ export interface SendRawOptions {
|
|
|
23
33
|
* One request with `maxRedirects: 0`. The whole point is to keep the 3xx instead of chasing it,
|
|
24
34
|
* and to keep `Set-Cookie` unredacted — the two things the recorded ApiSnapshot cannot carry.
|
|
25
35
|
*/
|
|
26
|
-
export declare function sendRaw(deps:
|
|
27
|
-
request: APIRequestContext;
|
|
28
|
-
apiContext: ApiContext;
|
|
29
|
-
env: EnvConfig;
|
|
30
|
-
config: ResolvedConfig;
|
|
31
|
-
testInfo?: Pick<TestInfo, 'attach'>;
|
|
32
|
-
}, method: string, pathOrUrl: string, opts?: SendRawOptions): Promise<RawSnapshot>;
|
|
36
|
+
export declare function sendRaw(deps: RawDeps, method: string, pathOrUrl: string, opts?: SendRawOptions): Promise<RawSnapshot>;
|
|
33
37
|
export declare function redactForReport(headers: Record<string, string>): Record<string, string>;
|
|
34
38
|
export interface StreamEvent {
|
|
35
39
|
/** The SSE `event:` field, else the parsed JSON payload's `type`, else the raw sentinel. */
|
|
@@ -60,4 +64,5 @@ export declare function parseEventStream(text: string): Pick<StreamCapture, 'eve
|
|
|
60
64
|
export declare function globToRegExp(glob: string): RegExp;
|
|
61
65
|
/** Every credential shape found in `text`, named. Exported so the patterns are testable. */
|
|
62
66
|
export declare function credentialFindings(text: string): string[];
|
|
67
|
+
export {};
|
|
63
68
|
//# sourceMappingURL=net.steps.d.ts.map
|
package/dist/steps/net.steps.js
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
+
import { applyAuth, encodeBody, resolveAuth, } from '../api/client.js';
|
|
4
5
|
import { coerce, getPath } from '../api/json-path.js';
|
|
5
|
-
import { render } from '../api/template.js';
|
|
6
|
+
import { render, renderStrict } from '../api/template.js';
|
|
6
7
|
import { SdodsError } from '../errors.js';
|
|
7
8
|
const scopesOf = (apiContext, env) => [
|
|
8
9
|
apiContext.vars.toObject(),
|
|
9
10
|
env.vars,
|
|
10
11
|
];
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
12
|
+
/** `renderStrict`, with the message naming which argument held the unresolved variable. */
|
|
13
|
+
function renderArg(value, what, ...scopes) {
|
|
14
|
+
try {
|
|
15
|
+
return renderStrict(value, ...scopes);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e instanceof SdodsError)
|
|
19
|
+
e.message = `${what}: ${e.message}`;
|
|
20
|
+
throw e;
|
|
21
21
|
}
|
|
22
|
-
return out;
|
|
23
22
|
}
|
|
24
23
|
/** Comma-separated Gherkin list → trimmed, rendered, non-empty items. */
|
|
25
24
|
function renderList(raw, what, ...scopes) {
|
|
@@ -27,7 +26,7 @@ function renderList(raw, what, ...scopes) {
|
|
|
27
26
|
.split(',')
|
|
28
27
|
.map((s) => s.trim())
|
|
29
28
|
.filter(Boolean)
|
|
30
|
-
.map((s) =>
|
|
29
|
+
.map((s) => renderArg(s, what, ...scopes));
|
|
31
30
|
if (items.length === 0)
|
|
32
31
|
throw new SdodsError('RUN_FAILED', `${what} is empty.`, {
|
|
33
32
|
hint: 'List at least one comma-separated value; an empty list would make this assertion pass over nothing.',
|
|
@@ -70,28 +69,17 @@ function savedBodies(apiContext) {
|
|
|
70
69
|
}
|
|
71
70
|
return bag;
|
|
72
71
|
}
|
|
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
72
|
/**
|
|
87
|
-
* Mirrors `ApiClient.buildHeaders` — env headers, then the scenario's pending headers, then auth
|
|
88
|
-
*
|
|
89
|
-
*
|
|
73
|
+
* Mirrors `ApiClient.buildHeaders` — env headers, then the scenario's pending headers, then auth,
|
|
74
|
+
* resolved by the same function the client uses (so `I use no authentication` and every
|
|
75
|
+
* `env.api.auth` type behave identically here). A raw request that dropped auth would report the
|
|
76
|
+
* anonymous redirect instead of the authenticated one, which is the same class of defect as a step
|
|
77
|
+
* that does not interpolate.
|
|
90
78
|
* `accept: application/json` is the default; override it with
|
|
91
79
|
* `Given I set the request header "accept" to "text/html"` when the gate under test negotiates
|
|
92
80
|
* on content type.
|
|
93
81
|
*/
|
|
94
|
-
function rawHeaders(apiContext, env, hasBody, isForm) {
|
|
82
|
+
async function rawHeaders(apiContext, env, hasBody, isForm) {
|
|
95
83
|
const headers = { accept: 'application/json' };
|
|
96
84
|
for (const [k, v] of Object.entries(env.api.headers ?? {}))
|
|
97
85
|
headers[k.toLowerCase()] = v;
|
|
@@ -99,17 +87,20 @@ function rawHeaders(apiContext, env, hasBody, isForm) {
|
|
|
99
87
|
headers[k.toLowerCase()] = v;
|
|
100
88
|
if (hasBody && !isForm && !headers['content-type'])
|
|
101
89
|
headers['content-type'] = 'application/json';
|
|
102
|
-
|
|
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
|
-
}
|
|
90
|
+
applyAuth(headers, await resolveAuth(undefined, apiContext.auth, env.api.auth));
|
|
111
91
|
return headers;
|
|
112
92
|
}
|
|
93
|
+
/** The shared `request` fixture, or the isolated context after `I use an isolated API client`. */
|
|
94
|
+
async function rawRequest(deps) {
|
|
95
|
+
if (!deps.apiContext.isolated)
|
|
96
|
+
return deps.request;
|
|
97
|
+
if (!deps.api) {
|
|
98
|
+
throw new SdodsError('NOT_SUPPORTED', 'No isolated request context is available here.', {
|
|
99
|
+
hint: 'Raw requests reach the isolated client through the `api` fixture; pass it in the step fixtures.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return deps.api.requestContext();
|
|
103
|
+
}
|
|
113
104
|
/** Mirrors `ApiClient.resolveUrl`, so `Given I set the query parameter …` applies here too. */
|
|
114
105
|
function rawUrl(apiContext, env, pathOrUrl) {
|
|
115
106
|
const base = env.api.baseUrl.replace(/\/+$/, '');
|
|
@@ -126,11 +117,13 @@ function rawUrl(apiContext, env, pathOrUrl) {
|
|
|
126
117
|
*/
|
|
127
118
|
export async function sendRaw(deps, method, pathOrUrl, opts = {}) {
|
|
128
119
|
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
|
|
120
|
+
const headers = await rawHeaders(deps.apiContext, deps.env, opts.body !== undefined || opts.form !== undefined, opts.form !== undefined);
|
|
121
|
+
const request = await rawRequest(deps);
|
|
122
|
+
const res = await request.fetch(url, {
|
|
131
123
|
method,
|
|
132
124
|
headers,
|
|
133
|
-
|
|
125
|
+
// A Buffer, so Playwright does not JSON-encode a body that does not parse.
|
|
126
|
+
data: opts.form ? undefined : encodeBody(opts.body),
|
|
134
127
|
form: opts.form,
|
|
135
128
|
maxRedirects: 0,
|
|
136
129
|
failOnStatusCode: false,
|
|
@@ -173,25 +166,25 @@ const SECRET_HEADERS = /^(authorization|cookie|set-cookie|x-api-key|proxy-author
|
|
|
173
166
|
export function redactForReport(headers) {
|
|
174
167
|
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, SECRET_HEADERS.test(k) ? '***' : v]));
|
|
175
168
|
}
|
|
176
|
-
When('I send a {method} request to {string} without following redirects', async ({ request, apiContext, env, config, $testInfo }, method, path) => {
|
|
169
|
+
When('I send a {method} request to {string} without following redirects', async ({ request, api, apiContext, env, config, $testInfo }, method, path) => {
|
|
177
170
|
// PROVES the edge itself answered. The built-in step would follow the 3xx and report the
|
|
178
171
|
// page it landed on, so an auth/locale/maintenance gate is unobservable through it.
|
|
179
|
-
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method,
|
|
172
|
+
await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopesOf(apiContext, env)));
|
|
180
173
|
});
|
|
181
|
-
When('I send a {method} request to {string} without following redirects with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
174
|
+
When('I send a {method} request to {string} without following redirects with body:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
182
175
|
// Body stays TEXT, never re-serialised JSON: the byte-identical comparison below is only an
|
|
183
176
|
// oracle for enumeration safety if nothing normalises whitespace or key order on the way in.
|
|
184
177
|
const scopes = scopesOf(apiContext, env);
|
|
185
|
-
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method,
|
|
178
|
+
await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), { body: render(body, ...scopes) });
|
|
186
179
|
});
|
|
187
|
-
When('I send a {method} request to {string} without following redirects with form:', async ({ request, apiContext, env, config, $testInfo }, method, path, table) => {
|
|
180
|
+
When('I send a {method} request to {string} without following redirects with form:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, table) => {
|
|
188
181
|
// PROVES a form POST's gate. Most real redirect gates — sign-in, consent, locale switch —
|
|
189
182
|
// are urlencoded form posts, so a JSON-only raw request cannot reach the common case.
|
|
190
183
|
const scopes = scopesOf(apiContext, env);
|
|
191
184
|
const form = {};
|
|
192
185
|
for (const row of table.raw())
|
|
193
|
-
form[String(row[0] ?? '')] =
|
|
194
|
-
await sendRaw({ request, apiContext, env, config, testInfo: $testInfo }, method,
|
|
186
|
+
form[String(row[0] ?? '')] = renderStrict(String(row[1] ?? ''), ...scopes);
|
|
187
|
+
await sendRaw({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), { form });
|
|
195
188
|
});
|
|
196
189
|
Then('the raw response status should be {int}', async ({ apiContext }, status) => {
|
|
197
190
|
// PROVES the gate fired at this hop. A 307 that a following client turns into a 200 is the
|
|
@@ -215,39 +208,39 @@ Then('the raw response Location should be {string}', async ({ apiContext, env },
|
|
|
215
208
|
// Exact, not substring: the surviving return-to/callback parameter IS the assertion, and a
|
|
216
209
|
// `contains` check would pass on a truncated one that silently drops where the user came from.
|
|
217
210
|
const snap = rawOf(apiContext);
|
|
218
|
-
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toBe(
|
|
211
|
+
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
219
212
|
});
|
|
220
213
|
Then('the raw response Location should contain {string}', async ({ apiContext, env }, expected) => {
|
|
221
214
|
const snap = rawOf(apiContext);
|
|
222
|
-
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toContain(
|
|
215
|
+
expect(snap.headers.location ?? '(no Location header)', `Location of ${snap.url}`).toContain(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
223
216
|
});
|
|
224
217
|
Then('the raw response header {string} should contain {string}', async ({ apiContext, env }, name, value) => {
|
|
225
218
|
const scopes = scopesOf(apiContext, env);
|
|
226
|
-
const wanted =
|
|
219
|
+
const wanted = renderStrict(name, ...scopes);
|
|
227
220
|
const snap = rawOf(apiContext);
|
|
228
|
-
expect(snap.headers[wanted.toLowerCase()] ?? `(no ${wanted} header)`, `${wanted} of ${snap.url}`).toContain(
|
|
221
|
+
expect(snap.headers[wanted.toLowerCase()] ?? `(no ${wanted} header)`, `${wanted} of ${snap.url}`).toContain(renderStrict(value, ...scopes));
|
|
229
222
|
});
|
|
230
223
|
Then('the raw response should have no {string} header', async ({ apiContext, env }, name) => {
|
|
231
224
|
// The deny direction, and NOT the same as "does not contain": an `Allow` header present but
|
|
232
225
|
// empty, or a `Set-Cookie` present but rejected, are both absences a contains-check would miss.
|
|
233
|
-
const wanted =
|
|
226
|
+
const wanted = renderArg(name, 'the header name', ...scopesOf(apiContext, env));
|
|
234
227
|
const snap = rawOf(apiContext);
|
|
235
228
|
expect(snap.headers[wanted.toLowerCase()], `unexpected ${wanted} header on ${snap.url}`).toBeUndefined();
|
|
236
229
|
});
|
|
237
230
|
Then('the raw response body should contain {string}', async ({ apiContext, env }, text) => {
|
|
238
|
-
expect(rawOf(apiContext).body).toContain(
|
|
231
|
+
expect(rawOf(apiContext).body).toContain(renderStrict(text, ...scopesOf(apiContext, env)));
|
|
239
232
|
});
|
|
240
233
|
Then('the raw response should set the {string} cookie', async ({ apiContext, env }, name) => {
|
|
241
234
|
// PROVES the credential was actually issued. Unreachable through the built-in header step,
|
|
242
235
|
// which sees `set-cookie: ***` because the ApiSnapshot redacts it.
|
|
243
|
-
const wanted =
|
|
236
|
+
const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
244
237
|
const snap = rawOf(apiContext);
|
|
245
238
|
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
239
|
});
|
|
247
240
|
Then('the raw response should not set the {string} cookie', async ({ apiContext, env }, name) => {
|
|
248
241
|
// PROVES a refusal left no credential behind. A `Max-Age=0` line is a deletion, not a grant,
|
|
249
242
|
// so it must not count as one — otherwise a correct sign-out would read as a session leak.
|
|
250
|
-
const wanted =
|
|
243
|
+
const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
251
244
|
const snap = rawOf(apiContext);
|
|
252
245
|
expect(snap.setCookies.filter((c) => c.startsWith(`${wanted}=`) && !isCleared(c)), `no Set-Cookie granting "${wanted}"`).toEqual([]);
|
|
253
246
|
});
|
|
@@ -255,8 +248,8 @@ Then('the raw response cookie {string} should carry {string}', async ({ apiConte
|
|
|
255
248
|
// HttpOnly / Secure / SameSite=Lax / Path=/ — the session contract every downstream gate
|
|
256
249
|
// reads. Matched case-insensitively because servers disagree on the casing of attributes.
|
|
257
250
|
const scopes = scopesOf(apiContext, env);
|
|
258
|
-
const wanted =
|
|
259
|
-
const attr =
|
|
251
|
+
const wanted = renderArg(name, 'the cookie name', ...scopes);
|
|
252
|
+
const attr = renderStrict(attribute, ...scopes);
|
|
260
253
|
const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
|
|
261
254
|
expect(cookie, `Set-Cookie for "${wanted}"`).toBeTruthy();
|
|
262
255
|
expect(String(cookie).toLowerCase(), `attributes of the "${wanted}" cookie`).toContain(attr.toLowerCase());
|
|
@@ -264,7 +257,7 @@ Then('the raw response cookie {string} should carry {string}', async ({ apiConte
|
|
|
264
257
|
Then('the raw response cookie {string} should be cleared', async ({ apiContext, env }, name) => {
|
|
265
258
|
// PROVES sign-out server-side: a deletion is a Set-Cookie with an immediate expiry, which is
|
|
266
259
|
// what "the browser no longer holds the credential" actually means over the wire.
|
|
267
|
-
const wanted =
|
|
260
|
+
const wanted = renderArg(name, 'the cookie name', ...scopesOf(apiContext, env));
|
|
268
261
|
const cookie = rawOf(apiContext).setCookies.find((c) => c.startsWith(`${wanted}=`));
|
|
269
262
|
expect(cookie, `Set-Cookie clearing "${wanted}"`).toBeTruthy();
|
|
270
263
|
expect(isCleared(String(cookie)), `expiry of the "${wanted}" cookie`).toBe(true);
|
|
@@ -325,32 +318,33 @@ async function captureDownload(page, timeout, click) {
|
|
|
325
318
|
When('I click the {string} {role} and capture the download', async ({ page, heal, config, apiContext, env }, name, role) => {
|
|
326
319
|
// PROVES an export produced a file at all. Without this the whole export feature can only be
|
|
327
320
|
// asserted as "the click did not throw", which is true of a button wired to nothing.
|
|
328
|
-
const n =
|
|
321
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
329
322
|
const loc = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'click');
|
|
330
323
|
await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
|
|
331
324
|
});
|
|
332
|
-
When('I click the element with test id {string} and capture the download', async ({ page, heal, config }, id) => {
|
|
333
|
-
const
|
|
325
|
+
When('I click the element with test id {string} and capture the download', async ({ page, heal, config, apiContext, env }, id) => {
|
|
326
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
327
|
+
const loc = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'click');
|
|
334
328
|
await captureDownload(page, config.project.timeouts.navigation, () => loc.click());
|
|
335
329
|
});
|
|
336
330
|
Then('the downloaded file name should end with {string}', async ({ page, apiContext, env }, suffix) => {
|
|
337
331
|
// `endsWith`, not `contains`: a ".json" that appears mid-name (report.json.txt) is the exact
|
|
338
332
|
// mis-typed export this assertion exists to catch.
|
|
339
|
-
const wanted =
|
|
333
|
+
const wanted = renderStrict(suffix, ...scopesOf(apiContext, env));
|
|
340
334
|
const actual = downloadOf(page).suggestedFilename();
|
|
341
335
|
expect(actual.endsWith(wanted), `download filename "${actual}" should end with "${wanted}"`).toBe(true);
|
|
342
336
|
});
|
|
343
337
|
Then('the downloaded file name should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
344
|
-
expect(downloadOf(page).suggestedFilename(), 'download filename').toContain(
|
|
338
|
+
expect(downloadOf(page).suggestedFilename(), 'download filename').toContain(renderStrict(part, ...scopesOf(apiContext, env)));
|
|
345
339
|
});
|
|
346
340
|
Then('the downloaded text first line should be {string}', async ({ page, apiContext, env }, expected) => {
|
|
347
341
|
// PROVES the header row of a CSV export matches the columns the UI promised. Exact, because a
|
|
348
342
|
// reordered or renamed column silently breaks every consumer of the file.
|
|
349
343
|
const text = await downloadedText(page);
|
|
350
|
-
expect(text.split(/\r?\n/)[0] ?? '', 'first line of the downloaded file').toBe(
|
|
344
|
+
expect(text.split(/\r?\n/)[0] ?? '', 'first line of the downloaded file').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
351
345
|
});
|
|
352
346
|
Then('the downloaded text should contain {string}', async ({ page, apiContext, env }, text) => {
|
|
353
|
-
expect(await downloadedText(page), 'downloaded file').toContain(
|
|
347
|
+
expect(await downloadedText(page), 'downloaded file').toContain(renderStrict(text, ...scopesOf(apiContext, env)));
|
|
354
348
|
});
|
|
355
349
|
Then('the downloaded text should have at least {int} data rows', async ({ page }, min) => {
|
|
356
350
|
expect(dataRows(await downloadedText(page)), 'data rows in the downloaded file').toBeGreaterThanOrEqual(min);
|
|
@@ -380,7 +374,7 @@ Then('the downloaded JSON path {string} should equal {string}', async ({ page, a
|
|
|
380
374
|
// PROVES the export/import round trip, not just that a file arrived: a scenario that can
|
|
381
375
|
// export but never read what it exported proves only that a click did not throw.
|
|
382
376
|
const actual = getPath(await downloadedJson(page), path);
|
|
383
|
-
expect(actual, `downloaded JSON path ${path}`).toEqual(coerce(
|
|
377
|
+
expect(actual, `downloaded JSON path ${path}`).toEqual(coerce(renderStrict(expected, ...scopesOf(apiContext, env))));
|
|
384
378
|
});
|
|
385
379
|
Then('the downloaded JSON path {string} should have {int} items', async ({ page }, path, count) => {
|
|
386
380
|
// Arrays and keyed records both count, because exports model collections either way; anything
|
|
@@ -446,16 +440,17 @@ function streamOf(apiContext) {
|
|
|
446
440
|
return capture;
|
|
447
441
|
}
|
|
448
442
|
async function readStream(deps, method, pathOrUrl, body) {
|
|
449
|
-
const headers = rawHeaders(deps.apiContext, deps.env, body !== undefined, false);
|
|
443
|
+
const headers = await rawHeaders(deps.apiContext, deps.env, body !== undefined, false);
|
|
450
444
|
headers.accept = 'text/event-stream';
|
|
451
445
|
const url = rawUrl(deps.apiContext, deps.env, pathOrUrl);
|
|
452
446
|
const timeout = STREAM_TIMEOUT.get(deps.apiContext) ?? deps.config.project.timeouts.api;
|
|
447
|
+
const request = await rawRequest(deps);
|
|
453
448
|
let capture;
|
|
454
449
|
try {
|
|
455
|
-
const res = await
|
|
450
|
+
const res = await request.fetch(url, {
|
|
456
451
|
method,
|
|
457
452
|
headers,
|
|
458
|
-
data: body,
|
|
453
|
+
data: encodeBody(body),
|
|
459
454
|
maxRedirects: 0,
|
|
460
455
|
failOnStatusCode: false,
|
|
461
456
|
timeout,
|
|
@@ -495,12 +490,12 @@ Given('I allow {int} seconds for the event stream', async ({ apiContext }, secon
|
|
|
495
490
|
// exactly like a product bug — the worst kind of false failure.
|
|
496
491
|
STREAM_TIMEOUT.set(apiContext, seconds * 1000);
|
|
497
492
|
});
|
|
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,
|
|
493
|
+
When('I read the event stream from a {method} request to {string}', async ({ request, api, apiContext, env, config, $testInfo }, method, path) => {
|
|
494
|
+
await readStream({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopesOf(apiContext, env)));
|
|
500
495
|
});
|
|
501
|
-
When('I read the event stream from a {method} request to {string} with body:', async ({ request, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
496
|
+
When('I read the event stream from a {method} request to {string} with body:', async ({ request, api, apiContext, env, config, $testInfo }, method, path, body) => {
|
|
502
497
|
const scopes = scopesOf(apiContext, env);
|
|
503
|
-
await readStream({ request, apiContext, env, config, testInfo: $testInfo }, method,
|
|
498
|
+
await readStream({ request, api, apiContext, env, config, testInfo: $testInfo }, method, renderArg(path, 'the request path', ...scopes), render(body, ...scopes));
|
|
504
499
|
});
|
|
505
500
|
Then('the stream status should be {int}', async ({ apiContext }, status) => {
|
|
506
501
|
const capture = streamOf(apiContext);
|
|
@@ -510,7 +505,7 @@ Then('the stream content type should be {string}', async ({ apiContext, env }, e
|
|
|
510
505
|
// PROVES the endpoint actually streamed. An endpoint that quietly fell back to a buffered
|
|
511
506
|
// JSON response still returns 200, and every event assertion below would then fail
|
|
512
507
|
// confusingly.
|
|
513
|
-
expect(streamOf(apiContext).contentType, 'stream content type').toContain(
|
|
508
|
+
expect(streamOf(apiContext).contentType, 'stream content type').toContain(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
514
509
|
});
|
|
515
510
|
Then('the stream should have at least {int} events', async ({ apiContext }, min) => {
|
|
516
511
|
if (min < 1)
|
|
@@ -522,14 +517,14 @@ Then('the stream should have at least {int} events', async ({ apiContext }, min)
|
|
|
522
517
|
Then('the first stream event type should be {string}', async ({ apiContext, env }, expected) => {
|
|
523
518
|
const { events } = streamOf(apiContext);
|
|
524
519
|
expect(events.length, 'the stream carried no events at all').toBeGreaterThan(0);
|
|
525
|
-
expect(events[0]?.type, 'first stream event type').toBe(
|
|
520
|
+
expect(events[0]?.type, 'first stream event type').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
526
521
|
});
|
|
527
522
|
Then('the last stream event type should be {string}', async ({ apiContext, env }, expected) => {
|
|
528
523
|
// PROVES the turn finished. A streaming endpoint returns 200 on its first byte, so the status
|
|
529
524
|
// says nothing about whether the model/producer ever reached its terminal event.
|
|
530
525
|
const { events } = streamOf(apiContext);
|
|
531
526
|
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(
|
|
527
|
+
expect(events[events.length - 1]?.type, 'last stream event type — a 200 that never reaches this is a stream that died mid-flight').toBe(renderStrict(expected, ...scopesOf(apiContext, env)));
|
|
533
528
|
});
|
|
534
529
|
Then('the stream should have terminated cleanly', async ({ apiContext }) => {
|
|
535
530
|
// Three clauses, and all three are load-bearing: no transport error, at least one frame parsed
|
|
@@ -572,7 +567,7 @@ export function globToRegExp(glob) {
|
|
|
572
567
|
* whole section exists to prevent.
|
|
573
568
|
*/
|
|
574
569
|
function urlGlob(raw, scopes) {
|
|
575
|
-
const pattern =
|
|
570
|
+
const pattern = renderArg(raw, 'the URL pattern', ...scopes);
|
|
576
571
|
if (!/[*?]/.test(pattern) && !/^(https?:\/\/|\/)/.test(pattern))
|
|
577
572
|
throw new SdodsError('RUN_FAILED', `"${pattern}" cannot match a request URL.`, {
|
|
578
573
|
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.',
|
|
@@ -611,10 +606,10 @@ When('I start counting requests to {string}', async ({ page, apiContext, env },
|
|
|
611
606
|
// Start it before navigating if the initial page load's requests are part of the count.
|
|
612
607
|
urlGlob(glob, scopesOf(apiContext, env));
|
|
613
608
|
const log = ensureLog(page);
|
|
614
|
-
log.counters.set(
|
|
609
|
+
log.counters.set(renderStrict(glob, ...scopesOf(apiContext, env)), log.entries.length);
|
|
615
610
|
});
|
|
616
611
|
function counted(page, glob, scopes, why) {
|
|
617
|
-
const rendered =
|
|
612
|
+
const rendered = renderArg(glob, 'the URL pattern', ...scopes);
|
|
618
613
|
const log = logOf(page, why);
|
|
619
614
|
const from = log.counters.get(rendered);
|
|
620
615
|
if (from === undefined)
|
|
@@ -664,13 +659,13 @@ Then('the response should have no {string} header', async ({ apiContext, env },
|
|
|
664
659
|
// Absence, which is NOT "does not contain": a header present with an empty or unexpected value
|
|
665
660
|
// satisfies a contains-check written as a negative, and this is the assertion behind
|
|
666
661
|
// "no cache header was set" and "the debug header never reaches production".
|
|
667
|
-
const wanted =
|
|
662
|
+
const wanted = renderArg(name, 'the header name', ...scopesOf(apiContext, env));
|
|
668
663
|
expect(apiContext.last().response.headers[wanted.toLowerCase()], `unexpected ${wanted} header`).toBeUndefined();
|
|
669
664
|
});
|
|
670
665
|
Then('the response JSON path {string} should not exist', async ({ apiContext, env }, path) => {
|
|
671
666
|
// PROVES a field was withheld — the password hash, the internal id, the other tenant's row.
|
|
672
667
|
// Strict rendering matters most here: an unresolved path would be absent by construction.
|
|
673
|
-
const wanted =
|
|
668
|
+
const wanted = renderArg(path, 'the JSON path', ...scopesOf(apiContext, env));
|
|
674
669
|
expect(getPath(apiContext.last().response.body, wanted), `JSON path ${wanted}`).toBeUndefined();
|
|
675
670
|
});
|
|
676
671
|
Then('the response should not contain any of {string}', async ({ apiContext, env }, list) => {
|
package/dist/steps/perf.steps.js
CHANGED
|
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
|
|
|
3
3
|
import './params.js';
|
|
4
4
|
import { Then, When } from '../fixtures/test.js';
|
|
5
5
|
import { attachmentNames, pad2, scenarioFiles } from '@sdods/contracts';
|
|
6
|
-
import {
|
|
6
|
+
import { renderJson, renderStrict } from '../api/template.js';
|
|
7
7
|
import { SdodsError } from '../errors.js';
|
|
8
8
|
/**
|
|
9
9
|
* Performance budgets.
|
|
@@ -323,7 +323,7 @@ When('I record the page vitals', recordPageVitals);
|
|
|
323
323
|
*/
|
|
324
324
|
export const assertVitalWithinBudget = async ({ scenario, config, apiContext, env }, metricName) => {
|
|
325
325
|
const recording = lastRecording(scenario);
|
|
326
|
-
const metric = requireVitalKey(
|
|
326
|
+
const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
|
|
327
327
|
const budget = requireBudget(config, metric);
|
|
328
328
|
const value = measured(recording, metric);
|
|
329
329
|
expect(value, `${metric} measured ${value} ms on ${recording.url}, against a budget of ${budget} ms for environment "${config.env.name ?? 'unknown'}"`).toBeLessThanOrEqual(budget);
|
|
@@ -336,7 +336,7 @@ Then('the recorded {string} should be within its configured budget', assertVital
|
|
|
336
336
|
*/
|
|
337
337
|
export const assertVitalUnderThreshold = async ({ scenario, apiContext, env }, metricName, maxMs) => {
|
|
338
338
|
const recording = lastRecording(scenario);
|
|
339
|
-
const metric = requireVitalKey(
|
|
339
|
+
const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
|
|
340
340
|
const value = measured(recording, metric);
|
|
341
341
|
expect(value, `${metric} measured ${value} ms on ${recording.url}, against a scenario-local limit of ${maxMs} ms`).toBeLessThan(maxMs);
|
|
342
342
|
};
|
|
@@ -351,7 +351,7 @@ Then('the recorded {string} should be under {int} ms', assertVitalUnderThreshold
|
|
|
351
351
|
* recording with itself always passes.
|
|
352
352
|
*/
|
|
353
353
|
export const assertVitalNoWorseThanPrevious = async ({ scenario, apiContext, env }, metricName, toleranceMs) => {
|
|
354
|
-
const metric = requireVitalKey(
|
|
354
|
+
const metric = requireVitalKey(renderStrict(metricName, apiContext.vars.toObject(), env.vars));
|
|
355
355
|
const recordings = perfStore(scenario).recordings;
|
|
356
356
|
if (recordings.length < 2) {
|
|
357
357
|
throw new SdodsError('RUN_FAILED', `Only ${recordings.length} page-vitals recording(s) in this scenario; comparing needs two.`, {
|
|
@@ -372,7 +372,7 @@ Then('the recorded {string} should be no worse than the previous recording plus
|
|
|
372
372
|
* gap fails loudly, once, naming the key.
|
|
373
373
|
*/
|
|
374
374
|
export const assertBudgetsConfigured = async ({ config, apiContext, env }, names) => {
|
|
375
|
-
const wanted =
|
|
375
|
+
const wanted = renderStrict(names, apiContext.vars.toObject(), env.vars)
|
|
376
376
|
.split(/[,\s]+/)
|
|
377
377
|
.filter(Boolean);
|
|
378
378
|
if (wanted.length === 0) {
|
|
@@ -405,7 +405,7 @@ async function collectSamples(fx, method, pathTemplate, times, bodyTemplate) {
|
|
|
405
405
|
});
|
|
406
406
|
}
|
|
407
407
|
const scopes = [fx.apiContext.vars.toObject(), fx.env.vars];
|
|
408
|
-
const path =
|
|
408
|
+
const path = renderStrict(pathTemplate, ...scopes);
|
|
409
409
|
const body = bodyTemplate === undefined ? undefined : renderJson(bodyTemplate, ...scopes);
|
|
410
410
|
const samples = [];
|
|
411
411
|
for (let i = 0; i < times; i++) {
|
package/dist/steps/tabs.steps.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* Multi-tab and popup steps.
|
|
@@ -44,16 +44,16 @@ async function openedBy(page, action, what) {
|
|
|
44
44
|
}
|
|
45
45
|
/* ── opening ──────────────────────────────────────────────────────────── */
|
|
46
46
|
When('I open a new tab by clicking the {role} {string}', async ({ page, apiContext, env }, role, name) => {
|
|
47
|
-
const label =
|
|
47
|
+
const label = renderStrict(name, ...scopesOf(apiContext, env));
|
|
48
48
|
await openedBy(page, () => activePage(page).getByRole(role, { name: label }).first().click(), `clicking the ${role} "${label}"`);
|
|
49
49
|
});
|
|
50
50
|
When('I open a new tab by clicking the element {string}', async ({ page, apiContext, env }, selector) => {
|
|
51
|
-
const sel =
|
|
51
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
52
52
|
await openedBy(page, () => activePage(page).locator(sel).first().click(), `clicking "${sel}"`);
|
|
53
53
|
});
|
|
54
54
|
/* ── switching ────────────────────────────────────────────────────────── */
|
|
55
55
|
Given('I switch to the tab with URL containing {string}', async ({ page, apiContext, env }, part) => {
|
|
56
|
-
const needle =
|
|
56
|
+
const needle = renderStrict(part, ...scopesOf(apiContext, env));
|
|
57
57
|
const found = page
|
|
58
58
|
.context()
|
|
59
59
|
.pages()
|
|
@@ -96,14 +96,14 @@ Then('there should be {int} open tab(s)', async ({ page }, count) => {
|
|
|
96
96
|
.toBe(count);
|
|
97
97
|
});
|
|
98
98
|
Then('the current tab URL should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
99
|
-
const needle =
|
|
99
|
+
const needle = renderStrict(part, ...scopesOf(apiContext, env));
|
|
100
100
|
await expect
|
|
101
101
|
.poll(() => activePage(page).url(), { message: `the tab URL should contain "${needle}"` })
|
|
102
102
|
.toContain(needle);
|
|
103
103
|
});
|
|
104
104
|
Then('the current tab should contain the text {string}', async ({ page, apiContext, env }, text) => {
|
|
105
105
|
await expect(activePage(page)
|
|
106
|
-
.getByText(
|
|
106
|
+
.getByText(renderStrict(text, ...scopesOf(apiContext, env)))
|
|
107
107
|
.first()).toBeVisible();
|
|
108
108
|
});
|
|
109
109
|
//# sourceMappingURL=tabs.steps.js.map
|