@sdods/core 0.3.2 → 0.5.2
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/evidence/index.d.ts +3 -0
- package/dist/evidence/index.js +3 -0
- package/dist/evidence/trace-redact.d.ts +74 -0
- package/dist/evidence/trace-redact.js +297 -0
- package/dist/evidence/zip.d.ts +36 -0
- package/dist/evidence/zip.js +148 -0
- 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 +9 -2
- 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/index.d.ts +1 -0
- package/dist/index.js +1 -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
package/dist/steps/api.steps.js
CHANGED
|
@@ -2,30 +2,41 @@ import { expect } from '@playwright/test';
|
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
4
|
import { coerce, getPath } from '../api/json-path.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderJson, renderStrict } from '../api/template.js';
|
|
6
6
|
import { pollUntil } from '../api/poll.js';
|
|
7
7
|
import { loadJsonSchema, loadZodSchema, validateAgainstOpenApi, validateJsonSchema, validateZod, } from '../api/validate.js';
|
|
8
8
|
import { SdodsError } from '../errors.js';
|
|
9
9
|
/* ── requests ─────────────────────────────────────────────────────────── */
|
|
10
10
|
When('I send a {method} request to {string}', async ({ api, apiContext, env }, method, path) => {
|
|
11
|
-
await api.send(method,
|
|
11
|
+
await api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars));
|
|
12
12
|
});
|
|
13
13
|
When('I send a {method} request to {string} with body:', async ({ api, apiContext, env }, method, path, body) => {
|
|
14
14
|
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
15
|
-
await api.send(method,
|
|
15
|
+
await api.send(method, renderStrict(path, ...scopes), { body: renderJson(body, ...scopes) });
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* The doc string goes out byte-for-byte: not parsed, not re-serialised, and not template-rendered
|
|
19
|
+
* (only the path is). This is how a malformed-input scenario sends `{ this is not json`; the
|
|
20
|
+
* `with body:` step parses a `json` doc string and would throw before sending.
|
|
21
|
+
* The content-type defaults to `application/json`; set another with `I set the request header`.
|
|
22
|
+
*/
|
|
23
|
+
When('I send a {method} request to {string} with the raw body:', async ({ api, apiContext, env }, method, path, body) => {
|
|
24
|
+
await api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars), {
|
|
25
|
+
body: Buffer.from(body, 'utf8'),
|
|
26
|
+
});
|
|
16
27
|
});
|
|
17
28
|
When('I send a {method} request to {string} with form:', async ({ api, apiContext, env }, method, path, table) => {
|
|
18
29
|
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
19
30
|
const form = {};
|
|
20
31
|
for (const [k, v] of table.raw())
|
|
21
|
-
form[k] =
|
|
22
|
-
await api.send(method,
|
|
32
|
+
form[k] = renderStrict(String(v ?? ''), ...scopes);
|
|
33
|
+
await api.send(method, renderStrict(path, ...scopes), { form });
|
|
23
34
|
});
|
|
24
35
|
Given('I set the request header {string} to {string}', async ({ apiContext, env }, name, value) => {
|
|
25
|
-
apiContext.headers.set(name,
|
|
36
|
+
apiContext.headers.set(name, renderStrict(value, apiContext.vars.toObject(), env.vars));
|
|
26
37
|
});
|
|
27
38
|
Given('I set the query parameter {string} to {string}', async ({ apiContext, env }, name, value) => {
|
|
28
|
-
apiContext.query.set(name,
|
|
39
|
+
apiContext.query.set(name, renderStrict(value, apiContext.vars.toObject(), env.vars));
|
|
29
40
|
});
|
|
30
41
|
Given('I clear the request headers and query parameters', async ({ apiContext }) => {
|
|
31
42
|
apiContext.resetRequestState();
|
|
@@ -40,8 +51,8 @@ Given('I authenticate with basic credentials {string} and {string}', async ({ ap
|
|
|
40
51
|
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
41
52
|
apiContext.auth = {
|
|
42
53
|
type: 'basic',
|
|
43
|
-
username:
|
|
44
|
-
password:
|
|
54
|
+
username: renderStrict(username, ...scopes),
|
|
55
|
+
password: renderStrict(password, ...scopes),
|
|
45
56
|
};
|
|
46
57
|
});
|
|
47
58
|
Given('I use no authentication', async ({ apiContext }) => {
|
|
@@ -51,6 +62,14 @@ Given('I use no authentication', async ({ apiContext }) => {
|
|
|
51
62
|
// refusal matters.
|
|
52
63
|
apiContext.auth = null;
|
|
53
64
|
});
|
|
65
|
+
/**
|
|
66
|
+
* Send the rest of the scenario's API calls through a request context with an empty cookie jar,
|
|
67
|
+
* so they carry only the credentials the scenario set. Without it, @ui/@hybrid calls also carry the
|
|
68
|
+
* leased role's session cookie, and "credential X alone is refused" can pass on the session.
|
|
69
|
+
*/
|
|
70
|
+
Given('I use an isolated API client', async ({ apiContext }) => {
|
|
71
|
+
apiContext.isolated = true;
|
|
72
|
+
});
|
|
54
73
|
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
55
74
|
Then('the response status should be {int}', async ({ apiContext }, status) => {
|
|
56
75
|
expect(apiContext.last().response.status, `expected HTTP ${status}, got ${apiContext.last().response.status} ${apiContext.last().response.statusText}`).toBe(status);
|
|
@@ -65,70 +84,88 @@ Then('the response status should be one of {string}', async ({ apiContext }, lis
|
|
|
65
84
|
Then('the response should contain {string}', async ({ apiContext, env }, text) => {
|
|
66
85
|
const body = apiContext.last().response.body;
|
|
67
86
|
const haystack = typeof body === 'string' ? body : JSON.stringify(body);
|
|
68
|
-
expect(haystack).toContain(
|
|
87
|
+
expect(haystack).toContain(renderStrict(text, apiContext.vars.toObject(), env.vars));
|
|
69
88
|
});
|
|
70
|
-
Then('the response JSON path {string} should equal {string}', async ({ apiContext, env },
|
|
89
|
+
Then('the response JSON path {string} should equal {string}', async ({ apiContext, env }, jsonPath, expected) => {
|
|
90
|
+
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
91
|
+
const path = renderStrict(jsonPath, ...scopes);
|
|
71
92
|
const actual = getPath(apiContext.last().response.body, path);
|
|
72
|
-
expect(actual, `JSON path ${path}`).toEqual(coerce(
|
|
93
|
+
expect(actual, `JSON path ${path}`).toEqual(coerce(renderStrict(expected, ...scopes)));
|
|
73
94
|
});
|
|
74
|
-
Then('the response JSON path {string} should exist', async ({ apiContext },
|
|
95
|
+
Then('the response JSON path {string} should exist', async ({ apiContext, env }, jsonPath) => {
|
|
96
|
+
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
75
97
|
expect(getPath(apiContext.last().response.body, path), `JSON path ${path}`).toBeDefined();
|
|
76
98
|
});
|
|
77
|
-
Then('the response JSON path {string} should match {string}', async ({ apiContext },
|
|
99
|
+
Then('the response JSON path {string} should match {string}', async ({ apiContext, env }, jsonPath, pattern) => {
|
|
100
|
+
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
101
|
+
const path = renderStrict(jsonPath, ...scopes);
|
|
78
102
|
const actual = getPath(apiContext.last().response.body, path);
|
|
79
|
-
expect(String(actual), `JSON path ${path}`).toMatch(new RegExp(pattern));
|
|
103
|
+
expect(String(actual), `JSON path ${path}`).toMatch(new RegExp(renderStrict(pattern, ...scopes)));
|
|
80
104
|
});
|
|
81
|
-
Then('the response JSON path {string} should have {int} items', async ({ apiContext },
|
|
105
|
+
Then('the response JSON path {string} should have {int} items', async ({ apiContext, env }, jsonPath, count) => {
|
|
106
|
+
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
82
107
|
const actual = getPath(apiContext.last().response.body, path);
|
|
83
108
|
expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBe(count);
|
|
84
109
|
});
|
|
85
|
-
Then('the response JSON path {string} should have at least {int} items', async ({ apiContext },
|
|
110
|
+
Then('the response JSON path {string} should have at least {int} items', async ({ apiContext, env }, jsonPath, count) => {
|
|
111
|
+
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
86
112
|
const actual = getPath(apiContext.last().response.body, path);
|
|
87
113
|
expect(Array.isArray(actual) ? actual.length : -1, `JSON path ${path} length`).toBeGreaterThanOrEqual(count);
|
|
88
114
|
});
|
|
89
115
|
Then('the response body should be an array', async ({ apiContext }) => {
|
|
90
116
|
expect(Array.isArray(apiContext.last().response.body)).toBe(true);
|
|
91
117
|
});
|
|
92
|
-
Then('the response header {string} should contain {string}', async ({ apiContext }, name, value) => {
|
|
93
|
-
|
|
118
|
+
Then('the response header {string} should contain {string}', async ({ apiContext, env }, name, value) => {
|
|
119
|
+
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
120
|
+
const header = renderStrict(name, ...scopes).toLowerCase();
|
|
121
|
+
expect(apiContext.last().response.headers[header] ?? '').toContain(renderStrict(value, ...scopes));
|
|
94
122
|
});
|
|
95
123
|
Then('the response time should be under {int} ms', async ({ apiContext }, maxMs) => {
|
|
96
124
|
expect(apiContext.last().response.responseTime).toBeLessThan(maxMs);
|
|
97
125
|
});
|
|
98
|
-
Then('the response should match the JSON schema {string}', async ({ apiContext, config },
|
|
126
|
+
Then('the response should match the JSON schema {string}', async ({ apiContext, config, env }, schemaFile) => {
|
|
127
|
+
const file = renderStrict(schemaFile, apiContext.vars.toObject(), env.vars);
|
|
99
128
|
const result = validateJsonSchema(apiContext.last().response.body, loadJsonSchema(config, file));
|
|
100
129
|
expect(result.ok, `schema ${file} violations:\n${result.errors.join('\n')}`).toBe(true);
|
|
101
130
|
});
|
|
102
|
-
Then('the response should match the zod schema {string}', async ({ apiContext, config },
|
|
131
|
+
Then('the response should match the zod schema {string}', async ({ apiContext, config, env }, zodSpec) => {
|
|
132
|
+
const spec = renderStrict(zodSpec, apiContext.vars.toObject(), env.vars);
|
|
103
133
|
const result = validateZod(apiContext.last().response.body, await loadZodSchema(config, spec));
|
|
104
134
|
expect(result.ok, `zod schema ${spec} violations:\n${result.errors.join('\n')}`).toBe(true);
|
|
105
135
|
});
|
|
106
|
-
Then('the response should match the OpenAPI schema for {method} {string}', async ({ apiContext, config }, method,
|
|
136
|
+
Then('the response should match the OpenAPI schema for {method} {string}', async ({ apiContext, config, env }, method, openApiPath) => {
|
|
137
|
+
// Single-brace `{id}` path parameters are not placeholders, so they pass through untouched.
|
|
138
|
+
const path = renderStrict(openApiPath, apiContext.vars.toObject(), env.vars);
|
|
107
139
|
const last = apiContext.last();
|
|
108
140
|
const result = await validateAgainstOpenApi(config, method, path, last.response.status, last.response.body);
|
|
109
141
|
expect(result.ok, `OpenAPI ${method} ${path} violations:\n${result.errors.join('\n')}`).toBe(true);
|
|
110
142
|
});
|
|
111
143
|
/* ── chaining & polling ───────────────────────────────────────────────── */
|
|
112
|
-
When('I save the response JSON path {string} as {string}', async ({ apiContext },
|
|
144
|
+
When('I save the response JSON path {string} as {string}', async ({ apiContext, env }, jsonPath, name) => {
|
|
145
|
+
// `name` is the variable being written, an identifier rather than a template.
|
|
146
|
+
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
113
147
|
const value = getPath(apiContext.last().response.body, path);
|
|
114
148
|
if (value === undefined)
|
|
115
149
|
throw new SdodsError('RUN_FAILED', `JSON path ${path} is undefined in the last response; cannot save as {{${name}}}.`);
|
|
116
150
|
apiContext.vars.set(name, value);
|
|
117
151
|
});
|
|
118
152
|
Given('I set the variable {string} to {string}', async ({ apiContext, env }, name, value) => {
|
|
119
|
-
apiContext.vars.set(name, coerce(
|
|
153
|
+
apiContext.vars.set(name, coerce(renderStrict(value, apiContext.vars.toObject(), env.vars)));
|
|
120
154
|
});
|
|
121
155
|
When('I poll {method} {string} until JSON path {string} equals {string} within {int} seconds', async ({ api, apiContext, env }, method, path, jsonPath, expected, seconds) => {
|
|
122
156
|
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
123
|
-
|
|
124
|
-
|
|
157
|
+
// Rendered before polling, so an unset variable fails at once rather than inside every attempt.
|
|
158
|
+
const want = coerce(renderStrict(expected, ...scopes));
|
|
159
|
+
const url = renderStrict(path, ...scopes);
|
|
160
|
+
const at = renderStrict(jsonPath, ...scopes);
|
|
161
|
+
await pollUntil(() => api.send(method, url), (snap) => JSON.stringify(getPath(snap.response.body, at)) === JSON.stringify(want), {
|
|
125
162
|
timeoutMs: seconds * 1000,
|
|
126
163
|
intervalMs: 1000,
|
|
127
164
|
description: `${method} ${path} → ${jsonPath} == ${expected}`,
|
|
128
165
|
});
|
|
129
166
|
});
|
|
130
167
|
When('I poll {method} {string} until the status is {int} within {int} seconds', async ({ api, apiContext, env }, method, path, status, seconds) => {
|
|
131
|
-
await pollUntil(() => api.send(method,
|
|
168
|
+
await pollUntil(() => api.send(method, renderStrict(path, apiContext.vars.toObject(), env.vars)), (snap) => snap.response.status === status, {
|
|
132
169
|
timeoutMs: seconds * 1000,
|
|
133
170
|
intervalMs: 1000,
|
|
134
171
|
description: `${method} ${path} → HTTP ${status}`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
3
|
-
import {
|
|
3
|
+
import { renderStrict } from '../api/template.js';
|
|
4
4
|
import { SdodsError } from '../errors.js';
|
|
5
5
|
/**
|
|
6
6
|
* Browser-state steps: cookies, web storage, viewport, colour scheme, locale and console.
|
|
@@ -10,7 +10,8 @@ import { SdodsError } from '../errors.js';
|
|
|
10
10
|
* step PROVES; where a step exists because of a specific trap, the trap is named.
|
|
11
11
|
*
|
|
12
12
|
* Two rules govern the whole file:
|
|
13
|
-
* * every string argument is interpolated through `
|
|
13
|
+
* * every string argument is interpolated through `renderStrict()`, so `{{vars}}` work everywhere
|
|
14
|
+
* and an unset one fails the step instead of being matched literally;
|
|
14
15
|
* * no assertion may pass over an empty set. A selector that matches nothing, a body that
|
|
15
16
|
* rendered no text, a recorder that was never armed and a page that never navigated are all
|
|
16
17
|
* failures, because a green step that observed nothing is worse than no step at all.
|
|
@@ -43,8 +44,8 @@ Given('I set the browser cookie {string} to {string}', async ({ page, apiContext
|
|
|
43
44
|
const scopes = scopesOf(apiContext, env);
|
|
44
45
|
await page.context().addCookies([
|
|
45
46
|
{
|
|
46
|
-
name:
|
|
47
|
-
value:
|
|
47
|
+
name: renderStrict(name, ...scopes),
|
|
48
|
+
value: renderStrict(value, ...scopes),
|
|
48
49
|
url: baseUrlOf(env, 'I set the browser cookie'),
|
|
49
50
|
},
|
|
50
51
|
]);
|
|
@@ -54,7 +55,7 @@ Given('I set the browser cookie {string} to {string}', async ({ page, apiContext
|
|
|
54
55
|
// read-filter-restore. Clearing the jar to drop a session cookie would also drop the consent and
|
|
55
56
|
// locale cookies the scenario set up, and the failure would look like a bug in the app.
|
|
56
57
|
Given('I clear the browser cookie {string}', async ({ page, apiContext, env }, name) => {
|
|
57
|
-
const wanted =
|
|
58
|
+
const wanted = renderStrict(name, ...scopesOf(apiContext, env));
|
|
58
59
|
const context = page.context();
|
|
59
60
|
const all = await context.cookies();
|
|
60
61
|
const survivors = all.filter((c) => c.name !== wanted);
|
|
@@ -71,19 +72,19 @@ Given('I clear all browser cookies', async ({ page }) => {
|
|
|
71
72
|
// `document.cookie`, so the browser context is the only place its value is observable at all.
|
|
72
73
|
Then('the browser cookie {string} should equal {string}', async ({ page, apiContext, env }, name, expected) => {
|
|
73
74
|
const scopes = scopesOf(apiContext, env);
|
|
74
|
-
const wanted =
|
|
75
|
+
const wanted = renderStrict(name, ...scopes);
|
|
75
76
|
const cookie = await findCookie(page, wanted);
|
|
76
77
|
expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
|
|
77
|
-
expect(cookie.value, `cookie "${wanted}"`).toBe(
|
|
78
|
+
expect(cookie.value, `cookie "${wanted}"`).toBe(renderStrict(expected, ...scopes));
|
|
78
79
|
});
|
|
79
80
|
// Proves the cookie was issued at all, for cases where the value is opaque or random.
|
|
80
81
|
Then('the browser cookie {string} should exist', async ({ page, apiContext, env }, name) => {
|
|
81
|
-
const wanted =
|
|
82
|
+
const wanted = renderStrict(name, ...scopesOf(apiContext, env));
|
|
82
83
|
expect(await findCookie(page, wanted), `cookie "${wanted}" is not in the jar`).toBeTruthy();
|
|
83
84
|
});
|
|
84
85
|
// Proves a logout, a rejected consent or a declined attribution left nothing behind.
|
|
85
86
|
Then('the browser cookie {string} should not exist', async ({ page, apiContext, env }, name) => {
|
|
86
|
-
const wanted =
|
|
87
|
+
const wanted = renderStrict(name, ...scopesOf(apiContext, env));
|
|
87
88
|
expect(await findCookie(page, wanted), `unexpected cookie "${wanted}"`).toBeUndefined();
|
|
88
89
|
});
|
|
89
90
|
/** The attributes Playwright actually reports. Anything else is an authoring mistake. */
|
|
@@ -94,8 +95,8 @@ const COOKIE_ATTRIBUTES = ['domain', 'path', 'expires', 'httpOnly', 'secure', 's
|
|
|
94
95
|
// string "undefined", so the step would pass while asserting nothing; the allowlist refuses it.
|
|
95
96
|
Then('the browser cookie {string} should carry {string} equal to {string}', async ({ page, apiContext, env }, name, attribute, value) => {
|
|
96
97
|
const scopes = scopesOf(apiContext, env);
|
|
97
|
-
const wanted =
|
|
98
|
-
const attr =
|
|
98
|
+
const wanted = renderStrict(name, ...scopes);
|
|
99
|
+
const attr = renderStrict(attribute, ...scopes);
|
|
99
100
|
if (!COOKIE_ATTRIBUTES.includes(attr))
|
|
100
101
|
throw new SdodsError('RUN_FAILED', `"${attr}" is not a cookie attribute.`, {
|
|
101
102
|
hint: `Use one of: ${COOKIE_ATTRIBUTES.join(', ')}.`,
|
|
@@ -103,7 +104,7 @@ Then('the browser cookie {string} should carry {string} equal to {string}', asyn
|
|
|
103
104
|
const cookie = await findCookie(page, wanted);
|
|
104
105
|
expect(cookie, `cookie "${wanted}" is not in the jar`).toBeTruthy();
|
|
105
106
|
const actual = cookie[attr];
|
|
106
|
-
expect(String(actual), `${wanted}.${attr}`).toBe(
|
|
107
|
+
expect(String(actual), `${wanted}.${attr}`).toBe(renderStrict(value, ...scopes));
|
|
107
108
|
});
|
|
108
109
|
async function findCookie(page, name) {
|
|
109
110
|
return (await page.context().cookies()).find((c) => c.name === name);
|
|
@@ -172,17 +173,17 @@ async function readStorage(page, kind, key) {
|
|
|
172
173
|
// banner — reaches it before the first paint rather than after it.
|
|
173
174
|
Given('I seed local storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
|
|
174
175
|
const scopes = scopesOf(apiContext, env);
|
|
175
|
-
await seedStorage(page, 'local',
|
|
176
|
+
await seedStorage(page, 'local', renderStrict(key, ...scopes), renderStrict(value, ...scopes));
|
|
176
177
|
});
|
|
177
178
|
// Same contract as local storage, for the per-tab values an app keeps out of the persistent store.
|
|
178
179
|
Given('I seed session storage {string} with {string}', async ({ page, apiContext, env }, key, value) => {
|
|
179
180
|
const scopes = scopesOf(apiContext, env);
|
|
180
|
-
await seedStorage(page, 'session',
|
|
181
|
+
await seedStorage(page, 'session', renderStrict(key, ...scopes), renderStrict(value, ...scopes));
|
|
181
182
|
});
|
|
182
183
|
// Proves the DEFAULT path: what the app does for a visitor with no stored preference. Removal is
|
|
183
184
|
// armed as an init script as well as applied in place, so the next navigation cannot resurrect it.
|
|
184
185
|
Given('I clear the local storage key {string}', async ({ page, apiContext, env }, key) => {
|
|
185
|
-
const wanted =
|
|
186
|
+
const wanted = renderStrict(key, ...scopesOf(apiContext, env));
|
|
186
187
|
await page.addInitScript((k) => {
|
|
187
188
|
try {
|
|
188
189
|
window.localStorage.removeItem(k);
|
|
@@ -228,25 +229,25 @@ Given('I clear all browser storage', async ({ page }) => {
|
|
|
228
229
|
// reaches storage looks identical on screen until the next page load.
|
|
229
230
|
Then('local storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
|
|
230
231
|
const scopes = scopesOf(apiContext, env);
|
|
231
|
-
const k =
|
|
232
|
-
expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBe(
|
|
232
|
+
const k = renderStrict(key, ...scopes);
|
|
233
|
+
expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBe(renderStrict(expected, ...scopes));
|
|
233
234
|
});
|
|
234
235
|
// Proves a sign-out, a reset or a decline actually removed the key rather than blanking the UI.
|
|
235
236
|
Then('local storage {string} should be absent', async ({ page, apiContext, env }, key) => {
|
|
236
|
-
const k =
|
|
237
|
+
const k = renderStrict(key, ...scopesOf(apiContext, env));
|
|
237
238
|
expect(await readStorage(page, 'local', k), `local storage "${k}"`).toBeNull();
|
|
238
239
|
});
|
|
239
240
|
// Per-tab equivalent of the local-storage absence assertion, so a tab-scoped value that outlives
|
|
240
241
|
// the flow which set it fails as loudly as a persisted one.
|
|
241
242
|
Then('session storage {string} should be absent', async ({ page, apiContext, env }, key) => {
|
|
242
|
-
const k =
|
|
243
|
+
const k = renderStrict(key, ...scopesOf(apiContext, env));
|
|
243
244
|
expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBeNull();
|
|
244
245
|
});
|
|
245
246
|
// Per-tab equivalent of the local-storage assertion.
|
|
246
247
|
Then('session storage {string} should equal {string}', async ({ page, apiContext, env }, key, expected) => {
|
|
247
248
|
const scopes = scopesOf(apiContext, env);
|
|
248
|
-
const k =
|
|
249
|
-
expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBe(
|
|
249
|
+
const k = renderStrict(key, ...scopes);
|
|
250
|
+
expect(await readStorage(page, 'session', k), `session storage "${k}"`).toBe(renderStrict(expected, ...scopes));
|
|
250
251
|
});
|
|
251
252
|
/* ── viewport and reflow ──────────────────────────────────────────────── */
|
|
252
253
|
// Proves a layout at a width the project's fixed viewport never visits. WCAG 2.2 reflow is a
|
|
@@ -300,7 +301,7 @@ Then('the page should not scroll horizontally', async ({ page }) => {
|
|
|
300
301
|
// to scroll internally must not push the page. Fails when the selector matches nothing, because a
|
|
301
302
|
// containment claim about an element that is not on the page is not a claim.
|
|
302
303
|
Then('the element matching {string} should not overflow horizontally', async ({ page, apiContext, env }, selector) => {
|
|
303
|
-
const sel =
|
|
304
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
304
305
|
const info = (await page.evaluate((s) => {
|
|
305
306
|
const el = document.querySelector(s);
|
|
306
307
|
if (!el)
|
|
@@ -316,7 +317,7 @@ const COLOUR_SCHEMES = ['light', 'dark', 'no-preference'];
|
|
|
316
317
|
// its own preference is seeded with `Given I seed local storage …` instead, and a complete dark-mode
|
|
317
318
|
// scenario usually needs both.
|
|
318
319
|
Given('I emulate the {string} colour scheme', async ({ page, apiContext, env }, scheme) => {
|
|
319
|
-
const wanted =
|
|
320
|
+
const wanted = renderStrict(scheme, ...scopesOf(apiContext, env));
|
|
320
321
|
if (!COLOUR_SCHEMES.includes(wanted))
|
|
321
322
|
throw new SdodsError('RUN_FAILED', `"${wanted}" is not a colour scheme.`, {
|
|
322
323
|
hint: `Use one of: ${COLOUR_SCHEMES.join(', ')}.`,
|
|
@@ -386,13 +387,13 @@ function expectLight(painted, subject) {
|
|
|
386
387
|
// Proves the CLASS was applied. Necessary but never sufficient: a `dark` class with no CSS behind
|
|
387
388
|
// it is not dark mode, which is why the painted-background steps exist alongside it.
|
|
388
389
|
Then('the html element should carry the {string} class', async ({ page, apiContext, env }, cls) => {
|
|
389
|
-
const wanted =
|
|
390
|
+
const wanted = renderStrict(cls, ...scopesOf(apiContext, env));
|
|
390
391
|
const classes = await page.evaluate(() => document.documentElement.className);
|
|
391
392
|
expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).toContain(wanted);
|
|
392
393
|
});
|
|
393
394
|
// Proves the theme class was REMOVED — the direction that catches a toggle which only ever adds.
|
|
394
395
|
Then('the html element should not carry the {string} class', async ({ page, apiContext, env }, cls) => {
|
|
395
|
-
const wanted =
|
|
396
|
+
const wanted = renderStrict(cls, ...scopesOf(apiContext, env));
|
|
396
397
|
const classes = await page.evaluate(() => document.documentElement.className);
|
|
397
398
|
expect(String(classes).split(/\s+/).filter(Boolean), `html class list is "${classes}"`).not.toContain(wanted);
|
|
398
399
|
});
|
|
@@ -408,7 +409,7 @@ Then('the painted page background should be light', async ({ page }) => {
|
|
|
408
409
|
// Proves ONE surface themed — a panel, a modal, a sidebar. Whole-page checks miss the component
|
|
409
410
|
// that kept a hardcoded white while everything around it went dark.
|
|
410
411
|
Then('the element matching {string} should paint a dark background', async ({ page, apiContext, env }, selector) => {
|
|
411
|
-
const sel =
|
|
412
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
412
413
|
expectDark(await paintedColour(page, sel, `the element matching "${sel}"`), `"${sel}"`);
|
|
413
414
|
});
|
|
414
415
|
/** Where the first-frame sample is parked. Stable by design: the assertion step reads it back. */
|
|
@@ -477,8 +478,8 @@ Given('I switch the interface locale to {string} using the {string} cookie', asy
|
|
|
477
478
|
requireNavigated(page, 'I switch the interface locale');
|
|
478
479
|
await page.context().addCookies([
|
|
479
480
|
{
|
|
480
|
-
name:
|
|
481
|
-
value:
|
|
481
|
+
name: renderStrict(cookieName, ...scopes),
|
|
482
|
+
value: renderStrict(locale, ...scopes),
|
|
482
483
|
url: baseUrlOf(env, 'I switch the interface locale'),
|
|
483
484
|
},
|
|
484
485
|
]);
|
|
@@ -491,14 +492,14 @@ Given('I switch the interface locale to {string} using the {string} cookie', asy
|
|
|
491
492
|
Given('I request the locale {string} with the Accept-Language header', async ({ page, apiContext, env }, locale) => {
|
|
492
493
|
requireNavigated(page, 'I request the locale with the Accept-Language header');
|
|
493
494
|
await page.setExtraHTTPHeaders({
|
|
494
|
-
'Accept-Language':
|
|
495
|
+
'Accept-Language': renderStrict(locale, ...scopesOf(apiContext, env)),
|
|
495
496
|
});
|
|
496
497
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
497
498
|
});
|
|
498
499
|
// Proves the document declared the language it served. Screen readers, hyphenation and every
|
|
499
500
|
// `:lang()` rule key off this, and it is the one machine-checkable trace of the negotiation.
|
|
500
501
|
Then('the html lang attribute should be {string}', async ({ page, apiContext, env }, lang) => {
|
|
501
|
-
const wanted =
|
|
502
|
+
const wanted = renderStrict(lang, ...scopesOf(apiContext, env));
|
|
502
503
|
const actual = await page.evaluate(() => document.documentElement.getAttribute('lang'));
|
|
503
504
|
expect(actual, 'html lang attribute').toBe(wanted);
|
|
504
505
|
});
|
|
@@ -623,7 +624,7 @@ Then('no console error should have been recorded', async ({ apiContext }) => {
|
|
|
623
624
|
// hydration mismatch, a blocked request — while tolerating noise the team has accepted.
|
|
624
625
|
Then('no console error should match {string}', async ({ apiContext, env }, pattern) => {
|
|
625
626
|
const recording = recordingFor(apiContext, 'no console error should match');
|
|
626
|
-
const rendered =
|
|
627
|
+
const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
|
|
627
628
|
const re = compilePattern(rendered, 'no console error should match');
|
|
628
629
|
expect(recording.errors.filter((line) => re.test(line)), `console errors matching /${rendered}/`).toEqual([]);
|
|
629
630
|
});
|
|
@@ -631,7 +632,7 @@ Then('no console error should match {string}', async ({ apiContext, env }, patte
|
|
|
631
632
|
// deprecated API — never at error level.
|
|
632
633
|
Then('no console warning should match {string}', async ({ apiContext, env }, pattern) => {
|
|
633
634
|
const recording = recordingFor(apiContext, 'no console warning should match');
|
|
634
|
-
const rendered =
|
|
635
|
+
const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
|
|
635
636
|
const re = compilePattern(rendered, 'no console warning should match');
|
|
636
637
|
expect(recording.warnings.filter((line) => re.test(line)), `console warnings matching /${rendered}/`).toEqual([]);
|
|
637
638
|
});
|
|
@@ -640,7 +641,7 @@ Then('no console warning should match {string}', async ({ apiContext, env }, pat
|
|
|
640
641
|
// a recorder that silently stopped working would leave every "no console error" step green.
|
|
641
642
|
Then('a console error matching {string} should have been recorded', async ({ apiContext, env }, pattern) => {
|
|
642
643
|
const recording = recordingFor(apiContext, 'a console error matching … should have been recorded');
|
|
643
|
-
const rendered =
|
|
644
|
+
const rendered = renderStrict(pattern, apiContext.vars.toObject(), env.vars);
|
|
644
645
|
const re = compilePattern(rendered, 'a console error matching … should have been recorded');
|
|
645
646
|
expect(recording.errors.filter((line) => re.test(line)).length, `no console error matched /${rendered}/. Recorded: ${recording.errors.slice(0, 10).join(' | ') || '(none)'}`).toBeGreaterThan(0);
|
|
646
647
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* Clock control.
|
|
@@ -40,7 +40,7 @@ Given('I install a fake clock', async ({ page }) => {
|
|
|
40
40
|
await page.clock.install();
|
|
41
41
|
});
|
|
42
42
|
Given('I install a fake clock set to {string}', async ({ page, apiContext, env }, when) => {
|
|
43
|
-
const at = resolveTime(
|
|
43
|
+
const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
|
|
44
44
|
await page.clock.install({ time: at });
|
|
45
45
|
});
|
|
46
46
|
/**
|
|
@@ -49,12 +49,12 @@ Given('I install a fake clock set to {string}', async ({ page, apiContext, env }
|
|
|
49
49
|
* hang the test rather than simulate it.
|
|
50
50
|
*/
|
|
51
51
|
Given('the clock jumps to {string}', async ({ page, apiContext, env }, when) => {
|
|
52
|
-
const at = resolveTime(
|
|
52
|
+
const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
|
|
53
53
|
await page.clock.setFixedTime(at);
|
|
54
54
|
});
|
|
55
55
|
/** Runs timers as it goes, so polling and countdowns actually fire. */
|
|
56
56
|
Given('the clock advances by {string}', async ({ page, apiContext, env }, amount) => {
|
|
57
|
-
const spec =
|
|
57
|
+
const spec = renderStrict(amount, ...scopesOf(apiContext, env)).trim();
|
|
58
58
|
const normalised = /^[+-]/.test(spec) ? spec : `+${spec}`;
|
|
59
59
|
const target = resolveTime(normalised, new Date(0));
|
|
60
60
|
await page.clock.runFor(target.getTime());
|
|
@@ -63,7 +63,7 @@ Given('the clock resumes', async ({ page }) => {
|
|
|
63
63
|
await page.clock.resume();
|
|
64
64
|
});
|
|
65
65
|
Then('the page clock should read {string}', async ({ page, apiContext, env }, iso) => {
|
|
66
|
-
const expected = resolveTime(
|
|
66
|
+
const expected = resolveTime(renderStrict(iso, ...scopesOf(apiContext, env)), new Date());
|
|
67
67
|
const actual = await page.evaluate(() => Date.now());
|
|
68
68
|
// A second of tolerance: the assertion is about which DAY or HOUR the page
|
|
69
69
|
// believes it is, and demanding millisecond equality would make it flaky for
|
package/dist/steps/data.steps.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, When } from '../fixtures/test.js';
|
|
4
4
|
import { tokenFileFor } from '../auth/capture.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderStrict } from '../api/template.js';
|
|
6
6
|
import { SdodsError } from '../errors.js';
|
|
7
7
|
import { Logger } from '../logger.js';
|
|
8
8
|
const log = new Logger('data');
|
|
@@ -29,6 +29,7 @@ Given('I use a leased user with role {string}', async ({ userPool, authCache, au
|
|
|
29
29
|
role: user.role,
|
|
30
30
|
...user.extra,
|
|
31
31
|
});
|
|
32
|
+
LEASED.set(apiContext, user);
|
|
32
33
|
if (sdods.layer !== 'api')
|
|
33
34
|
await authCache.apply({ context, page, user, auth, browser });
|
|
34
35
|
});
|
|
@@ -44,6 +45,7 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
44
45
|
userId: user.id,
|
|
45
46
|
role: user.role,
|
|
46
47
|
});
|
|
48
|
+
LEASED.set(apiContext, user);
|
|
47
49
|
});
|
|
48
50
|
/**
|
|
49
51
|
* API-layer variant: leases the account, exposes its credentials as variables,
|
|
@@ -68,6 +70,18 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
68
70
|
* meaningful and one mint is reused across a sharded run), then a live
|
|
69
71
|
* `token()` call. A strategy with no `token()` leaves auth unset and says so,
|
|
70
72
|
* rather than silently continuing anonymous.
|
|
73
|
+
*
|
|
74
|
+
* Attaching the token is itself a choice of credential class, though, and it
|
|
75
|
+
* must not be made behind the scenario's back (#87). A `custom` strategy's
|
|
76
|
+
* `token()` can mint a personal API key for an app whose scenarios also sign
|
|
77
|
+
* in with a session; attached silently, such a scenario sends BOTH, and "a key
|
|
78
|
+
* is refused on a session-only route" passes on whichever one the server reads
|
|
79
|
+
* first. So `auth.apiToken` decides: `implicit` attaches here, `explicit` only
|
|
80
|
+
* leases and the scenario says `I authenticate the API with the leased user's
|
|
81
|
+
* token`. Unset, `custom` is explicit and every other strategy keeps the
|
|
82
|
+
* implicit behaviour #18 introduced — for `token` and
|
|
83
|
+
* `oauth-client-credentials` the token is the only credential there is.
|
|
84
|
+
* Either way the attached credential is logged at info.
|
|
71
85
|
*/
|
|
72
86
|
Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
|
|
73
87
|
const user = await userPool.lease(role, $testInfo.parallelIndex);
|
|
@@ -78,17 +92,56 @@ Given('I use a leased user with role {string} for API calls', async ({ userPool,
|
|
|
78
92
|
role: user.role,
|
|
79
93
|
...user.extra,
|
|
80
94
|
});
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
95
|
+
LEASED.set(apiContext, user);
|
|
96
|
+
if (apiTokenMode(config, auth) === 'explicit') {
|
|
97
|
+
log.info(`leased ${user.username} (${role}) for API calls; no credential attached ` +
|
|
98
|
+
`(auth.apiToken: explicit). Add "I authenticate the API with the leased user's token" to send its token.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const found = await leasedUserToken(config, auth, user);
|
|
102
|
+
if (found) {
|
|
103
|
+
apiContext.auth = { type: 'bearer', token: found.token };
|
|
104
|
+
log.info(`API calls authenticate as ${user.username} (${role}): bearer token from ${found.source}.`);
|
|
105
|
+
}
|
|
84
106
|
else if (auth?.strategy && auth.strategy !== 'none') {
|
|
85
|
-
log.
|
|
107
|
+
log.info(`auth strategy "${auth.strategy}" provides no token() — API calls for ` +
|
|
86
108
|
`${user.username} (${role}) use the environment credential.`);
|
|
87
109
|
}
|
|
88
110
|
});
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
Given("I authenticate the API with the leased user's token", async ({ apiContext, auth, config }) => {
|
|
112
|
+
const user = LEASED.get(apiContext);
|
|
113
|
+
if (!user) {
|
|
114
|
+
throw new SdodsError('AUTH_FAILED', 'No user has been leased in this scenario yet.', {
|
|
115
|
+
hint: 'Lease one first with `Given I use a leased user with role "<role>" for API calls` (or `I use a leased user with role "<role>"`).',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const found = await leasedUserToken(config, auth, user);
|
|
119
|
+
if (!found) {
|
|
120
|
+
throw new SdodsError('AUTH_FAILED', `No API token for ${user.username} (${user.role}): no cached token file and the "${auth?.strategy ?? 'none'}" auth strategy's token() returned none.`, {
|
|
121
|
+
hint: `Run \`sdods auth capture --user ${user.role}\` to cache one, or implement token() in the project's defineAuth.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
apiContext.auth = { type: 'bearer', token: found.token };
|
|
125
|
+
log.info(`API calls authenticate as ${user.username} (${user.role}): bearer token from ${found.source}.`);
|
|
126
|
+
});
|
|
127
|
+
/** The user the scenario leased, so the explicit token step authenticates as that same account. */
|
|
128
|
+
const LEASED = new WeakMap();
|
|
129
|
+
function apiTokenMode(config, auth) {
|
|
130
|
+
return config.project.auth?.apiToken ?? (auth?.strategy === 'custom' ? 'explicit' : 'implicit');
|
|
131
|
+
}
|
|
132
|
+
/** The cached `sdods auth capture` token, else a live `token()` — with where it came from. */
|
|
133
|
+
async function leasedUserToken(config, auth, user) {
|
|
91
134
|
const file = tokenFileFor(config, user);
|
|
135
|
+
const cached = readCachedToken(file);
|
|
136
|
+
if (cached)
|
|
137
|
+
return { token: cached, source: `cached token file ${file}` };
|
|
138
|
+
const minted = await auth?.token?.({ config, user });
|
|
139
|
+
return minted
|
|
140
|
+
? { token: minted, source: `the "${auth?.strategy}" strategy's token()` }
|
|
141
|
+
: undefined;
|
|
142
|
+
}
|
|
143
|
+
/** The token `sdods auth capture` wrote for this user, when it is still there. */
|
|
144
|
+
function readCachedToken(file) {
|
|
92
145
|
if (!existsSync(file))
|
|
93
146
|
return undefined;
|
|
94
147
|
try {
|
|
@@ -102,7 +155,7 @@ function readCachedToken(config, user) {
|
|
|
102
155
|
}
|
|
103
156
|
/* ── cleanup ──────────────────────────────────────────────────────────── */
|
|
104
157
|
When('I register cleanup {method} {string}', async ({ data, api, apiContext, env }, method, path) => {
|
|
105
|
-
const rendered =
|
|
158
|
+
const rendered = renderStrict(path, apiContext.vars.toObject(), env.vars);
|
|
106
159
|
data.registerCleanup(async () => {
|
|
107
160
|
await api.send(method, rendered, { silent: true });
|
|
108
161
|
}, `${method} ${rendered}`);
|
package/dist/steps/db.steps.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { 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
|
* Database assertions over the existing Kysely worker fixture.
|
|
@@ -63,28 +63,28 @@ async function rowsWhere(db, table, clause) {
|
|
|
63
63
|
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
64
64
|
Then('the {string} table should have {int} row(s) where {string}', async ({ db, apiContext, env }, table, count, clause) => {
|
|
65
65
|
const scopes = scopesOf(apiContext, env);
|
|
66
|
-
const rows = await rowsWhere(requireDb(db),
|
|
66
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
67
67
|
expect(rows, `${table} where ${clause}`).toHaveLength(count);
|
|
68
68
|
});
|
|
69
69
|
Then('the {string} table should have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
70
70
|
const scopes = scopesOf(apiContext, env);
|
|
71
|
-
const rows = await rowsWhere(requireDb(db),
|
|
71
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
72
72
|
expect(rows.length, `${table} where ${clause}`).toBeGreaterThan(0);
|
|
73
73
|
});
|
|
74
74
|
Then('the {string} table should have no row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
75
75
|
const scopes = scopesOf(apiContext, env);
|
|
76
|
-
const rows = await rowsWhere(requireDb(db),
|
|
76
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
77
77
|
expect(rows, `${table} where ${clause}`).toHaveLength(0);
|
|
78
78
|
});
|
|
79
79
|
Then('the {string} column of the row where {string} in {string} should be {string}', async ({ db, apiContext, env }, column, clause, table, value) => {
|
|
80
80
|
const scopes = scopesOf(apiContext, env);
|
|
81
|
-
const rows = await rowsWhere(requireDb(db),
|
|
81
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
82
82
|
if (rows.length !== 1) {
|
|
83
83
|
throw new SdodsError('CONFIG_INVALID', `Expected exactly one row in ${table} where ${clause}, found ${rows.length}.`, {
|
|
84
84
|
hint: 'Narrow the condition. Asserting a column across several rows hides which one matched.',
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
-
expect(String(rows[0][identifier('column',
|
|
87
|
+
expect(String(rows[0][identifier('column', renderStrict(column, ...scopes))])).toBe(renderStrict(value, ...scopes));
|
|
88
88
|
});
|
|
89
89
|
/**
|
|
90
90
|
* Polling variant. A write that the API acknowledges is often applied
|
|
@@ -94,8 +94,8 @@ Then('the {string} column of the row where {string} in {string} should be {strin
|
|
|
94
94
|
When('I wait for the {string} table to have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
95
95
|
const scopes = scopesOf(apiContext, env);
|
|
96
96
|
const handle = requireDb(db);
|
|
97
|
-
const t =
|
|
98
|
-
const c =
|
|
97
|
+
const t = renderStrict(table, ...scopes);
|
|
98
|
+
const c = renderStrict(clause, ...scopes);
|
|
99
99
|
await expect
|
|
100
100
|
.poll(async () => (await rowsWhere(handle, t, c)).length, {
|
|
101
101
|
message: `${t} never got a row where ${c}`,
|