@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/ui.steps.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
-
import { render, renderJson } from '../api/template.js';
|
|
4
|
+
import { render, renderJson, renderStrict } from '../api/template.js';
|
|
5
|
+
import { withActionRole } from '../heal/strategies.js';
|
|
5
6
|
import { SdodsError } from '../errors.js';
|
|
6
|
-
const scopesOf = (apiContext, env) => [
|
|
7
|
+
const scopesOf = (apiContext, env) => [
|
|
8
|
+
apiContext.vars.toObject(),
|
|
9
|
+
env.vars,
|
|
10
|
+
];
|
|
11
|
+
/** Render one step argument strictly. */
|
|
12
|
+
const arg = ({ apiContext, env }, value) => renderStrict(value, ...scopesOf(apiContext, env));
|
|
13
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
14
|
function routePath(config, nameOrPath) {
|
|
8
15
|
const routes = config.project.routes;
|
|
9
16
|
if (nameOrPath in routes)
|
|
@@ -14,9 +21,21 @@ function routePath(config, nameOrPath) {
|
|
|
14
21
|
hint: `Known routes: ${Object.keys(routes).join(', ') || '(none)'}.`,
|
|
15
22
|
});
|
|
16
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The control a label names, for `action`. An exact label beats a partial one, and a control
|
|
26
|
+
* whose role fits the action beats anything else the label also reaches: `getByLabel('Password')`
|
|
27
|
+
* on its own also matches a "Show password" toggle, and the fill then dies on a strict-mode
|
|
28
|
+
* violation. The unfiltered forms stay as the last resort, for controls with no role at all such
|
|
29
|
+
* as a contenteditable editor.
|
|
30
|
+
*/
|
|
31
|
+
function labelled(page, heal, label, action) {
|
|
32
|
+
const exact = page.getByLabel(label, { exact: true });
|
|
33
|
+
const loose = page.getByLabel(label);
|
|
34
|
+
return heal.prefer(withActionRole(page, exact, action), withActionRole(page, loose, action), exact, loose);
|
|
35
|
+
}
|
|
17
36
|
/* ── navigation ───────────────────────────────────────────────────────── */
|
|
18
37
|
Given('I navigate to the {string} page', async ({ page, config, apiContext, env }, route) => {
|
|
19
|
-
await page.goto(routePath(config,
|
|
38
|
+
await page.goto(routePath(config, arg({ apiContext, env }, route)), {
|
|
20
39
|
waitUntil: 'domcontentloaded',
|
|
21
40
|
});
|
|
22
41
|
});
|
|
@@ -25,7 +44,7 @@ Given('I reload the page', async ({ page }) => {
|
|
|
25
44
|
});
|
|
26
45
|
/* ── interactions (heal-aware) ────────────────────────────────────────── */
|
|
27
46
|
When('I click the {string} {role}', async ({ page, heal, apiContext, env }, name, role) => {
|
|
28
|
-
const n =
|
|
47
|
+
const n = arg({ apiContext, env }, name);
|
|
29
48
|
await heal.click(page.getByRole(role, { name: n }), {
|
|
30
49
|
role: role,
|
|
31
50
|
name: n,
|
|
@@ -33,89 +52,119 @@ When('I click the {string} {role}', async ({ page, heal, apiContext, env }, name
|
|
|
33
52
|
description: `${role} "${n}"`,
|
|
34
53
|
});
|
|
35
54
|
});
|
|
36
|
-
When('I click the element with test id {string}', async ({ page, heal }, id) => {
|
|
37
|
-
|
|
55
|
+
When('I click the element with test id {string}', async ({ page, heal, apiContext, env }, id) => {
|
|
56
|
+
const testId = arg({ apiContext, env }, id);
|
|
57
|
+
await heal.click(page.getByTestId(testId), { testId, description: `test id "${testId}"` });
|
|
38
58
|
});
|
|
39
59
|
When('I click the text {string}', async ({ page, heal, apiContext, env }, text) => {
|
|
40
|
-
const t =
|
|
60
|
+
const t = arg({ apiContext, env }, text);
|
|
41
61
|
await heal.click(page.getByText(t, { exact: true }), { text: t, description: `text "${t}"` });
|
|
42
62
|
});
|
|
43
63
|
When('I fill the {string} field with {string}', async ({ page, heal, apiContext, env }, label, value) => {
|
|
44
|
-
const
|
|
45
|
-
await heal.fill(page
|
|
64
|
+
const l = arg({ apiContext, env }, label);
|
|
65
|
+
await heal.fill(await labelled(page, heal, l, 'fill'), { label: l, placeholder: l, description: `${l} field` }, arg({ apiContext, env }, value));
|
|
46
66
|
});
|
|
47
67
|
When('I fill the element with test id {string} with {string}', async ({ page, heal, apiContext, env }, id, value) => {
|
|
48
|
-
|
|
68
|
+
const testId = arg({ apiContext, env }, id);
|
|
69
|
+
await heal.fill(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, arg({ apiContext, env }, value));
|
|
49
70
|
});
|
|
50
71
|
When('I fill the form:', async ({ page, heal, apiContext, env }, table) => {
|
|
51
72
|
for (const row of table.hashes()) {
|
|
52
|
-
const field = row.field ?? row.label ?? Object.values(row)[0];
|
|
53
|
-
const value =
|
|
54
|
-
await heal.fill(page
|
|
73
|
+
const field = arg({ apiContext, env }, row.field ?? row.label ?? Object.values(row)[0]);
|
|
74
|
+
const value = arg({ apiContext, env }, row.value ?? Object.values(row)[1] ?? '');
|
|
75
|
+
await heal.fill(await labelled(page, heal, field, 'fill'), { label: field, placeholder: field, description: `${field} field` }, value);
|
|
55
76
|
}
|
|
56
77
|
});
|
|
57
|
-
When('I select {string} in the {string} dropdown', async ({ page, heal }, option, label) => {
|
|
58
|
-
|
|
78
|
+
When('I select {string} in the {string} dropdown', async ({ page, heal, apiContext, env }, option, label) => {
|
|
79
|
+
const l = arg({ apiContext, env }, label);
|
|
80
|
+
await heal.select(await labelled(page, heal, l, 'select'), { label: l, description: `${l} dropdown` }, arg({ apiContext, env }, option));
|
|
59
81
|
});
|
|
60
|
-
When('I check the {string} checkbox', async ({ page, heal }, label) => {
|
|
61
|
-
const
|
|
82
|
+
When('I check the {string} checkbox', async ({ page, heal, apiContext, env }, label) => {
|
|
83
|
+
const l = arg({ apiContext, env }, label);
|
|
84
|
+
const loc = await heal.resolve(await labelled(page, heal, l, 'check'), { label: l, role: 'checkbox', name: l, description: `${l} checkbox` }, 'check');
|
|
62
85
|
await loc.check();
|
|
63
86
|
});
|
|
64
|
-
When('I press {string}', async ({ page }, key) => {
|
|
65
|
-
await page.keyboard.press(key);
|
|
87
|
+
When('I press {string}', async ({ page, apiContext, env }, key) => {
|
|
88
|
+
await page.keyboard.press(arg({ apiContext, env }, key));
|
|
66
89
|
});
|
|
67
|
-
When('I upload {string} to the {string} field', async ({ page, config }, file, label) => {
|
|
90
|
+
When('I upload {string} to the {string} field', async ({ page, heal, config, apiContext, env }, file, label) => {
|
|
68
91
|
const { resolve, isAbsolute } = await import('node:path');
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
const l = arg({ apiContext, env }, label);
|
|
93
|
+
const f = arg({ apiContext, env }, file);
|
|
94
|
+
// A file input has no role to filter on, so only the exact-label preference applies.
|
|
95
|
+
const input = await heal.prefer(page.getByLabel(l, { exact: true }), page.getByLabel(l));
|
|
96
|
+
await input.setInputFiles(isAbsolute(f) ? f : resolve(config.project.root, f));
|
|
72
97
|
});
|
|
73
98
|
When('I wait for {int} seconds', async ({ page }, seconds) => {
|
|
74
99
|
await page.waitForTimeout(seconds * 1000);
|
|
75
100
|
});
|
|
76
101
|
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
102
|
+
/*
|
|
103
|
+
* The text pair means "a visible element carries this text" / "no visible element does". A closed
|
|
104
|
+
* FAQ answer or a collapsed panel stays in the DOM, so the negative step filters on visibility
|
|
105
|
+
* rather than counting DOM matches, and the positive step looks past hidden matches instead of
|
|
106
|
+
* taking whichever comes first. `.filter({ visible: true })` + `toHaveCount(0)` is strict-mode
|
|
107
|
+
* safe and retries until the text is gone. The `exact` variants match the whole text of an
|
|
108
|
+
* element (whitespace-normalised, case-sensitive), so "already" does not collide with "you already
|
|
109
|
+
* serve". `the page should not contain the text` keeps the DOM-absence check for hidden text.
|
|
110
|
+
*/
|
|
111
|
+
const visibleText = (page, text, exact) => page.getByText(text, { exact }).filter({ visible: true });
|
|
77
112
|
Then('I should see the text {string}', async ({ page, apiContext, env }, text) => {
|
|
78
|
-
|
|
79
|
-
await expect(page.getByText(t, { exact: false }).first()).toBeVisible();
|
|
113
|
+
await expect(visibleText(page, arg({ apiContext, env }, text), false).first()).toBeVisible();
|
|
80
114
|
});
|
|
81
115
|
Then('I should not see the text {string}', async ({ page, apiContext, env }, text) => {
|
|
82
|
-
await expect(page
|
|
116
|
+
await expect(visibleText(page, arg({ apiContext, env }, text), false)).toHaveCount(0);
|
|
117
|
+
});
|
|
118
|
+
Then('I should see the exact text {string}', async ({ page, apiContext, env }, text) => {
|
|
119
|
+
await expect(visibleText(page, arg({ apiContext, env }, text), true).first()).toBeVisible();
|
|
120
|
+
});
|
|
121
|
+
Then('I should not see the exact text {string}', async ({ page, apiContext, env }, text) => {
|
|
122
|
+
await expect(visibleText(page, arg({ apiContext, env }, text), true)).toHaveCount(0);
|
|
123
|
+
});
|
|
124
|
+
Then('the page should not contain the text {string}', async ({ page, apiContext, env }, text) => {
|
|
125
|
+
await expect(page.getByText(arg({ apiContext, env }, text), { exact: false })).toHaveCount(0);
|
|
83
126
|
});
|
|
84
|
-
Then('the {string} {role} should be visible', async ({ page, heal }, name, role) => {
|
|
85
|
-
|
|
127
|
+
Then('the {string} {role} should be visible', async ({ page, heal, apiContext, env }, name, role) => {
|
|
128
|
+
const n = arg({ apiContext, env }, name);
|
|
129
|
+
await heal.expectVisible(page.getByRole(role, { name: n }), {
|
|
86
130
|
role: role,
|
|
87
|
-
name,
|
|
88
|
-
description: `${role} "${
|
|
131
|
+
name: n,
|
|
132
|
+
description: `${role} "${n}"`,
|
|
89
133
|
});
|
|
90
134
|
});
|
|
91
|
-
Then('the element with test id {string} should be visible', async ({ page, heal }, id) => {
|
|
92
|
-
|
|
135
|
+
Then('the element with test id {string} should be visible', async ({ page, heal, apiContext, env }, id) => {
|
|
136
|
+
const testId = arg({ apiContext, env }, id);
|
|
137
|
+
await heal.expectVisible(page.getByTestId(testId), {
|
|
138
|
+
testId,
|
|
139
|
+
description: `test id "${testId}"`,
|
|
140
|
+
});
|
|
93
141
|
});
|
|
94
142
|
Then('the element with test id {string} should contain {string}', async ({ page, heal, apiContext, env }, id, text) => {
|
|
95
|
-
|
|
143
|
+
const testId = arg({ apiContext, env }, id);
|
|
144
|
+
await heal.expectText(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, arg({ apiContext, env }, text));
|
|
96
145
|
});
|
|
97
|
-
Then('the page URL should contain {string}', async ({ page }, part) => {
|
|
98
|
-
await expect(page).toHaveURL(new RegExp(
|
|
146
|
+
Then('the page URL should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
147
|
+
await expect(page).toHaveURL(new RegExp(escapeRegExp(arg({ apiContext, env }, part))));
|
|
99
148
|
});
|
|
100
|
-
Then('the page title should contain {string}', async ({ page }, part) => {
|
|
101
|
-
await expect(page).toHaveTitle(new RegExp(
|
|
149
|
+
Then('the page title should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
150
|
+
await expect(page).toHaveTitle(new RegExp(escapeRegExp(arg({ apiContext, env }, part))));
|
|
102
151
|
});
|
|
103
|
-
Then('the page should match the visual baseline {string}', async ({ shots, $bddContext }, name) => {
|
|
104
|
-
await shots.visual(name, $bddContext.stepIndex);
|
|
152
|
+
Then('the page should match the visual baseline {string}', async ({ shots, $bddContext, apiContext, env }, name) => {
|
|
153
|
+
await shots.visual(arg({ apiContext, env }, name), $bddContext.stepIndex);
|
|
105
154
|
});
|
|
106
155
|
/* ── network mocking ──────────────────────────────────────────────────── */
|
|
107
156
|
When('I mock {string} with JSON:', async ({ page, apiContext, env }, urlGlob, json) => {
|
|
108
157
|
const body = renderJson(json, ...scopesOf(apiContext, env));
|
|
109
|
-
await page.route(urlGlob, (route) => route.fulfill({ json: body }));
|
|
158
|
+
await page.route(arg({ apiContext, env }, urlGlob), (route) => route.fulfill({ json: body }));
|
|
110
159
|
});
|
|
111
160
|
When('I mock {string} with HTML:', async ({ page, apiContext, env }, urlGlob, html) => {
|
|
112
161
|
const body = render(html, ...scopesOf(apiContext, env));
|
|
113
|
-
await page.route(urlGlob, (route) => route.fulfill({ status: 200, contentType: 'text/html', body }));
|
|
162
|
+
await page.route(arg({ apiContext, env }, urlGlob), (route) => route.fulfill({ status: 200, contentType: 'text/html', body }));
|
|
114
163
|
});
|
|
115
|
-
When('I mock {string} with status {int}', async ({ page }, urlGlob, status) => {
|
|
116
|
-
await page.route(urlGlob, (route) => route.fulfill({ status, body: '' }));
|
|
164
|
+
When('I mock {string} with status {int}', async ({ page, apiContext, env }, urlGlob, status) => {
|
|
165
|
+
await page.route(arg({ apiContext, env }, urlGlob), (route) => route.fulfill({ status, body: '' }));
|
|
117
166
|
});
|
|
118
|
-
When('I abort requests to {string}', async ({ page }, urlGlob) => {
|
|
119
|
-
await page.route(urlGlob, (route) => route.abort());
|
|
167
|
+
When('I abort requests to {string}', async ({ page, apiContext, env }, urlGlob) => {
|
|
168
|
+
await page.route(arg({ apiContext, env }, urlGlob), (route) => route.abort());
|
|
120
169
|
});
|
|
121
170
|
//# sourceMappingURL=ui.steps.js.map
|
|
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { expect } from '@playwright/test';
|
|
3
3
|
import './params.js';
|
|
4
4
|
import { AfterScenario, Given, Then, When } from '../fixtures/test.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderStrict } from '../api/template.js';
|
|
6
6
|
import { SdodsError } from '../errors.js';
|
|
7
7
|
const receivers = new Map();
|
|
8
8
|
function key(apiContext) {
|
|
@@ -93,7 +93,7 @@ Then('the callback receiver should receive a delivery', async ({ apiContext }) =
|
|
|
93
93
|
});
|
|
94
94
|
Then('the callback receiver should receive a delivery containing {string}', async ({ apiContext, env }, text) => {
|
|
95
95
|
const r = requireReceiver(key(apiContext));
|
|
96
|
-
const needle =
|
|
96
|
+
const needle = renderStrict(text, apiContext.vars.toObject(), env.vars);
|
|
97
97
|
await expect
|
|
98
98
|
.poll(() => r.deliveries.some((d) => d.body.includes(needle)), {
|
|
99
99
|
message: `no callback delivery contained "${needle}"`,
|
|
@@ -110,7 +110,7 @@ Then('the last callback delivery should have the header {string} set to {string}
|
|
|
110
110
|
env.vars,
|
|
111
111
|
];
|
|
112
112
|
const last = r.deliveries[r.deliveries.length - 1];
|
|
113
|
-
expect(last.headers[
|
|
113
|
+
expect(last.headers[renderStrict(header, ...scopes).toLowerCase()]).toBe(renderStrict(value, ...scopes));
|
|
114
114
|
});
|
|
115
115
|
/**
|
|
116
116
|
* Torn down after EVERY scenario, including a failing one — hence a hook rather
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION
|
|
1
|
+
export declare const VERSION: string;
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
/**
|
|
3
|
+
* The SDODS version, read from this package's own manifest.
|
|
4
|
+
*
|
|
5
|
+
* Hand-maintaining this constant drifted: it said 0.2.2 while every publishable package was at
|
|
6
|
+
* 0.3.2. That is load-bearing rather than cosmetic — `sdods init` writes `^${VERSION}` into every
|
|
7
|
+
* scaffolded workspace, and a caret on a 0.x version does not cross the minor, so `^0.2.2` pinned
|
|
8
|
+
* those workspaces (the desktop app's included) to a 0.2.x CLI that predated the commands the web
|
|
9
|
+
* UI calls.
|
|
10
|
+
*
|
|
11
|
+
* Resolves to `packages/core/package.json` from both `src/version.ts` and `dist/version.js`, since
|
|
12
|
+
* `dist` sits one level below the package root.
|
|
13
|
+
*/
|
|
14
|
+
const manifest = createRequire(import.meta.url)('../package.json');
|
|
15
|
+
export const VERSION = manifest.version;
|
|
2
16
|
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SDODS <admin@sdods.com>",
|
|
@@ -38,11 +38,12 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@playwright/test": ">=1.
|
|
41
|
+
"@playwright/test": ">=1.63",
|
|
42
42
|
"playwright-bdd": ">=9"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@sdods/contracts": "0.
|
|
45
|
+
"@sdods/contracts": "0.5.2",
|
|
46
|
+
"@cucumber/cucumber-expressions": "19.0.0",
|
|
46
47
|
"@cucumber/gherkin": "^42.0.1",
|
|
47
48
|
"@cucumber/messages": "^34.2.1",
|
|
48
49
|
"@faker-js/faker": "^10.6.0",
|
|
@@ -56,9 +57,10 @@
|
|
|
56
57
|
"jsonpath-plus": "^10.4.0",
|
|
57
58
|
"picocolors": "^1.1.1",
|
|
58
59
|
"ts-morph": "^28.0.0",
|
|
60
|
+
"tsx": "^4.23.0",
|
|
59
61
|
"yaml": "^2.9.0",
|
|
60
62
|
"zod": "^4.5.4",
|
|
61
|
-
"@sdods/db": "0.
|
|
63
|
+
"@sdods/db": "0.5.2"
|
|
62
64
|
},
|
|
63
65
|
"homepage": "https://sdods.com",
|
|
64
66
|
"bugs": {
|