@sdods/core 0.3.2 → 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 +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/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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
/**
|
|
3
|
+
* Import a TypeScript module that belongs to a project (`steps/auth.ts` and friends) from outside
|
|
4
|
+
* the Playwright runner.
|
|
5
|
+
*
|
|
6
|
+
* `sdods run` loads project files through Playwright's TS loader, which transpiles full
|
|
7
|
+
* TypeScript, remaps `import './helper.js'` to `helper.ts`, and treats a file as ESM or CommonJS
|
|
8
|
+
* by its package type. A bare `import()` from a CLI command hands the file to Node's native type
|
|
9
|
+
* stripping instead, which does none of the remapping — so a helper imported the conventional way
|
|
10
|
+
* worked under `sdods run` and crashed `sdods auth capture` with "Cannot find module .../helper.js"
|
|
11
|
+
* (#86). In the workspace the gap was invisible, because bin/sdods.js registers tsx globally.
|
|
12
|
+
*
|
|
13
|
+
* tsx gives the same three behaviours as Playwright's loader. Both its ESM and CommonJS hooks are
|
|
14
|
+
* registered — a project without `"type": "module"` loads `.ts` as CommonJS, and a CommonJS
|
|
15
|
+
* `require('./helper.js')` only goes through tsx's CJS hook — and removed again once the import
|
|
16
|
+
* settles, so nothing else in the process is affected. Registrations are reference-counted so
|
|
17
|
+
* overlapping imports do not unregister each other's hooks mid-load.
|
|
18
|
+
*/
|
|
19
|
+
let active = 0;
|
|
20
|
+
let hooks;
|
|
21
|
+
async function acquire() {
|
|
22
|
+
active++;
|
|
23
|
+
hooks ??= (async () => {
|
|
24
|
+
const [esm, cjs] = await Promise.all([import('tsx/esm/api'), import('tsx/cjs/api')]);
|
|
25
|
+
const offEsm = esm.register();
|
|
26
|
+
const offCjs = cjs.register();
|
|
27
|
+
return async () => {
|
|
28
|
+
offCjs();
|
|
29
|
+
await offEsm();
|
|
30
|
+
};
|
|
31
|
+
})();
|
|
32
|
+
try {
|
|
33
|
+
await hooks;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
// Do not leave a rejected registration cached for every later import.
|
|
37
|
+
if (--active === 0)
|
|
38
|
+
hooks = undefined;
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function releaseOne() {
|
|
43
|
+
if (--active > 0 || !hooks)
|
|
44
|
+
return;
|
|
45
|
+
const off = hooks;
|
|
46
|
+
hooks = undefined;
|
|
47
|
+
await (await off)();
|
|
48
|
+
}
|
|
49
|
+
export async function importProjectModule(file) {
|
|
50
|
+
await acquire();
|
|
51
|
+
try {
|
|
52
|
+
return (await import(pathToFileURL(file).href));
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await releaseOne();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A named export from a module loaded by {@link importProjectModule}. A CommonJS-format file (no
|
|
60
|
+
* `"type": "module"` in the project) arrives with its exports under `default`.
|
|
61
|
+
*/
|
|
62
|
+
export function namedExport(mod, name) {
|
|
63
|
+
if (mod[name] !== undefined)
|
|
64
|
+
return mod[name];
|
|
65
|
+
const def = mod.default;
|
|
66
|
+
if (def && typeof def === 'object' && def[name] !== undefined)
|
|
67
|
+
return def[name];
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=load-module.js.map
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'step' | 'debug';
|
|
2
|
+
export declare function isSecretKey(name: string): boolean;
|
|
2
3
|
export declare function setLogLevel(level: LogLevel): void;
|
|
3
4
|
export declare function setLogJson(enabled: boolean): void;
|
|
4
5
|
export declare function getLogLevel(): LogLevel;
|
package/dist/logger.js
CHANGED
|
@@ -9,7 +9,11 @@ const ORDER = {
|
|
|
9
9
|
};
|
|
10
10
|
let globalLevel = process.env.SDODS_LOG_LEVEL || 'info';
|
|
11
11
|
let jsonMode = process.env.SDODS_LOG_JSON === '1';
|
|
12
|
-
|
|
12
|
+
/** Key names whose string values are credentials: config fields, header names, log fields. */
|
|
13
|
+
const SECRET_KEY = /(passw(or)?d|secret|token|api[-_]?key|[-_]key$|authorization|cookie|credential)/i;
|
|
14
|
+
export function isSecretKey(name) {
|
|
15
|
+
return SECRET_KEY.test(name);
|
|
16
|
+
}
|
|
13
17
|
export function setLogLevel(level) {
|
|
14
18
|
globalLevel = level;
|
|
15
19
|
}
|
|
@@ -26,9 +30,11 @@ export function redact(value, depth = 0) {
|
|
|
26
30
|
return value.map((v) => redact(v, depth + 1));
|
|
27
31
|
if (value && typeof value === 'object') {
|
|
28
32
|
const out = {};
|
|
33
|
+
// Header auth (`{ type: header, name, value }`) keeps its secret under a neutral key.
|
|
34
|
+
const headerAuth = value.type === 'header';
|
|
29
35
|
for (const [k, v] of Object.entries(value)) {
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
const secret = SECRET_KEY.test(k) || (headerAuth && k === 'value');
|
|
37
|
+
out[k] = secret && typeof v === 'string' && v.length > 0 ? '***' : redact(v, depth + 1);
|
|
32
38
|
}
|
|
33
39
|
return out;
|
|
34
40
|
}
|
package/dist/steps/a11y.steps.js
CHANGED
|
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
|
|
|
3
3
|
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
4
4
|
import './params.js';
|
|
5
5
|
import { Then } from '../fixtures/test.js';
|
|
6
|
-
import {
|
|
6
|
+
import { renderStrict } from '../api/template.js';
|
|
7
7
|
import { SdodsError } from '../errors.js';
|
|
8
8
|
import { Logger } from '../logger.js';
|
|
9
9
|
/**
|
|
@@ -449,7 +449,7 @@ Then('the page should have no accessibility violations', async ({ page, scenario
|
|
|
449
449
|
* an empty context happily and reports zero violations, which is indistinguishable from a pass.
|
|
450
450
|
*/
|
|
451
451
|
Then('the page should have no accessibility violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
|
|
452
|
-
const sel =
|
|
452
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
453
453
|
await requireRegion(page, sel);
|
|
454
454
|
const results = await runAxe(page, { include: sel });
|
|
455
455
|
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
@@ -462,7 +462,7 @@ Then('the page should have no accessibility violations within {string}', async (
|
|
|
462
462
|
* nothing — "or worse than banana" would otherwise be the greenest step in the suite.
|
|
463
463
|
*/
|
|
464
464
|
Then('the page should have no accessibility violations of impact {string} or worse', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level) => {
|
|
465
|
-
const floor = parseImpactFloor(
|
|
465
|
+
const floor = parseImpactFloor(renderStrict(level, ...scopesOf(apiContext, env)));
|
|
466
466
|
const results = await runAxe(page, {});
|
|
467
467
|
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
468
468
|
assertAxeChecked(results, `on ${page.url()}`);
|
|
@@ -472,8 +472,8 @@ Then('the page should have no accessibility violations of impact {string} or wor
|
|
|
472
472
|
/** PROVES a region carries no violation at or above a severity — the scoped form of the above. */
|
|
473
473
|
Then('the page should have no accessibility violations of impact {string} or worse within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level, selector) => {
|
|
474
474
|
const scopes = scopesOf(apiContext, env);
|
|
475
|
-
const floor = parseImpactFloor(
|
|
476
|
-
const sel =
|
|
475
|
+
const floor = parseImpactFloor(renderStrict(level, ...scopes));
|
|
476
|
+
const sel = renderStrict(selector, ...scopes);
|
|
477
477
|
await requireRegion(page, sel);
|
|
478
478
|
const results = await runAxe(page, { include: sel });
|
|
479
479
|
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
@@ -489,7 +489,7 @@ Then('the page should have no accessibility violations of impact {string} or wor
|
|
|
489
489
|
* step fails on either, because a rule that never ran cannot have passed.
|
|
490
490
|
*/
|
|
491
491
|
Then('the page should have no accessibility violations of rule {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, ruleId) => {
|
|
492
|
-
const rule =
|
|
492
|
+
const rule = renderStrict(ruleId, ...scopesOf(apiContext, env));
|
|
493
493
|
const results = await runAxe(page, { rules: [rule] });
|
|
494
494
|
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
495
495
|
const bucket = assertRuleRan(results, rule, `on ${page.url()}`);
|
|
@@ -510,7 +510,7 @@ Then('the page should have no colour-contrast violations', async ({ page, scenar
|
|
|
510
510
|
});
|
|
511
511
|
/** PROVES one region's text meets AA contrast — for auditing a themed component in isolation. */
|
|
512
512
|
Then('the page should have no colour-contrast violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
|
|
513
|
-
const sel =
|
|
513
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
514
514
|
await requireRegion(page, sel);
|
|
515
515
|
const results = await runAxe(page, { include: sel, rules: ['color-contrast'] });
|
|
516
516
|
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
@@ -556,7 +556,7 @@ Then('every image on the page should carry an alt attribute', async ({ page }) =
|
|
|
556
556
|
});
|
|
557
557
|
/** PROVES every image inside one region declares its alt intent — the scoped form of the above. */
|
|
558
558
|
Then('every image within {string} should carry an alt attribute', async ({ page, apiContext, env }, selector) => {
|
|
559
|
-
const sel =
|
|
559
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
560
560
|
const gathered = await page.evaluate(gatherImages, sel);
|
|
561
561
|
const images = assertGathered(assertRegion(gathered, sel), 'img elements', `within "${sel}"`);
|
|
562
562
|
const missing = imagesWithoutAlt(images);
|
|
@@ -571,7 +571,7 @@ Then('every image within {string} should carry an alt attribute', async ({ page,
|
|
|
571
571
|
* itself — axe sees a non-empty name and passes it.
|
|
572
572
|
*/
|
|
573
573
|
Then('every icon-only control within {string} should expose an accessible name', async ({ page, apiContext, env }, selector) => {
|
|
574
|
-
const sel =
|
|
574
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
575
575
|
const gathered = await page.evaluate(gatherControls, sel);
|
|
576
576
|
const controls = assertRegion(gathered, sel);
|
|
577
577
|
const iconOnly = assertGathered(iconOnlyControls(controls), 'icon-only controls', `within "${sel}" (${controls.length} control(s) there render text)`);
|
|
@@ -588,7 +588,7 @@ Then('every icon-only control within {string} should expose an accessible name',
|
|
|
588
588
|
* enter keyboard modality and never blurs between elements.
|
|
589
589
|
*/
|
|
590
590
|
Then('every focusable element within {string} should show a visible focus indicator', async ({ page, apiContext, env }, selector) => {
|
|
591
|
-
const sel =
|
|
591
|
+
const sel = renderStrict(selector, ...scopesOf(apiContext, env));
|
|
592
592
|
await page.keyboard.press('Tab');
|
|
593
593
|
const gathered = await page.evaluate(gatherFocusIndicators, sel);
|
|
594
594
|
const focusables = assertGathered(assertRegion(gathered, sel), 'focusable elements', `within "${sel}"`);
|
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
|
});
|