@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.
Files changed (56) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/api/client.d.ts +62 -1
  3. package/dist/api/client.js +189 -27
  4. package/dist/api/template.d.ts +15 -2
  5. package/dist/api/template.js +31 -13
  6. package/dist/auth/capture.js +4 -3
  7. package/dist/auth/index.js +3 -18
  8. package/dist/config/defaults.js +2 -0
  9. package/dist/config/resolve.d.ts +5 -1
  10. package/dist/config/resolve.js +16 -3
  11. package/dist/config/runner.d.ts +26 -1
  12. package/dist/config/runner.js +155 -24
  13. package/dist/config/tags.d.ts +14 -2
  14. package/dist/config/tags.js +27 -7
  15. package/dist/evidence/index.d.ts +3 -0
  16. package/dist/evidence/index.js +3 -0
  17. package/dist/evidence/trace-redact.d.ts +74 -0
  18. package/dist/evidence/trace-redact.js +297 -0
  19. package/dist/evidence/zip.d.ts +36 -0
  20. package/dist/evidence/zip.js +148 -0
  21. package/dist/fixtures/api-context.d.ts +7 -0
  22. package/dist/fixtures/api-context.js +7 -0
  23. package/dist/fixtures/auth.js +72 -5
  24. package/dist/fixtures/test.js +9 -2
  25. package/dist/heal/healer.d.ts +12 -1
  26. package/dist/heal/healer.js +80 -34
  27. package/dist/heal/strategies.d.ts +12 -2
  28. package/dist/heal/strategies.js +42 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/lint/index.d.ts +1 -0
  32. package/dist/lint/index.js +44 -3
  33. package/dist/lint/steps.d.ts +13 -0
  34. package/dist/lint/steps.js +333 -0
  35. package/dist/load-module.d.ts +7 -0
  36. package/dist/load-module.js +70 -0
  37. package/dist/logger.d.ts +1 -0
  38. package/dist/logger.js +9 -3
  39. package/dist/steps/a11y.steps.js +10 -10
  40. package/dist/steps/api.steps.js +64 -27
  41. package/dist/steps/browser.steps.js +34 -33
  42. package/dist/steps/clock.steps.js +5 -5
  43. package/dist/steps/data.steps.js +61 -8
  44. package/dist/steps/db.steps.js +8 -8
  45. package/dist/steps/dom.steps.js +49 -49
  46. package/dist/steps/hybrid.steps.js +9 -5
  47. package/dist/steps/iframe.steps.js +9 -9
  48. package/dist/steps/net.steps.d.ts +12 -7
  49. package/dist/steps/net.steps.js +78 -83
  50. package/dist/steps/perf.steps.js +6 -6
  51. package/dist/steps/tabs.steps.js +6 -6
  52. package/dist/steps/ui.steps.js +94 -45
  53. package/dist/steps/webhook.steps.js +3 -3
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +15 -1
  56. package/package.json +6 -4
@@ -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) => [apiContext.vars.toObject(), env.vars];
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, render(route, ...scopesOf(apiContext, env))), {
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 = render(name, ...scopesOf(apiContext, env));
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
- await heal.click(page.getByTestId(id), { testId: id, description: `test id "${id}"` });
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 = render(text, ...scopesOf(apiContext, env));
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 v = render(value, ...scopesOf(apiContext, env));
45
- await heal.fill(page.getByLabel(label), { label, placeholder: label, description: `${label} field` }, v);
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
- await heal.fill(page.getByTestId(id), { testId: id, description: `test id "${id}"` }, render(value, ...scopesOf(apiContext, env)));
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 = render(row.value ?? Object.values(row)[1] ?? '', ...scopesOf(apiContext, env));
54
- await heal.fill(page.getByLabel(field), { label: field, placeholder: field, description: `${field} field` }, value);
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
- await heal.select(page.getByLabel(label), { label, description: `${label} dropdown` }, option);
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 loc = await heal.resolve(page.getByLabel(label), { label, role: 'checkbox', name: label, description: `${label} checkbox` }, 'check');
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
- await page
70
- .getByLabel(label)
71
- .setInputFiles(isAbsolute(file) ? file : resolve(config.project.root, file));
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
- const t = render(text, ...scopesOf(apiContext, env));
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.getByText(render(text, ...scopesOf(apiContext, env)), { exact: false })).toHaveCount(0);
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
- await heal.expectVisible(page.getByRole(role, { name }), {
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} "${name}"`,
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
- await heal.expectVisible(page.getByTestId(id), { testId: id, description: `test id "${id}"` });
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
- await heal.expectText(page.getByTestId(id), { testId: id, description: `test id "${id}"` }, render(text, ...scopesOf(apiContext, env)));
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(part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
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(part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
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 { render } from '../api/template.js';
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 = render(text, apiContext.vars.toObject(), env.vars);
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[render(header, ...scopes).toLowerCase()]).toBe(render(value, ...scopes));
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 = "0.2.2";
1
+ export declare const VERSION: string;
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,16 @@
1
- export const VERSION = '0.2.2';
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.2",
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.58",
41
+ "@playwright/test": ">=1.63",
42
42
  "playwright-bdd": ">=9"
43
43
  },
44
44
  "dependencies": {
45
- "@sdods/contracts": "0.3.2",
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.3.2"
63
+ "@sdods/db": "0.5.2"
62
64
  },
63
65
  "homepage": "https://sdods.com",
64
66
  "bugs": {