@sdods/core 0.7.1 → 0.8.0

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 (53) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/index.d.ts +2 -0
  3. package/dist/analyze/index.js +2 -0
  4. package/dist/analyze/traceability-render.d.ts +5 -0
  5. package/dist/analyze/traceability-render.js +254 -0
  6. package/dist/analyze/traceability.d.ts +87 -0
  7. package/dist/analyze/traceability.js +559 -0
  8. package/dist/api/json-path.d.ts +11 -1
  9. package/dist/api/json-path.js +23 -1
  10. package/dist/config/emulation.d.ts +74 -0
  11. package/dist/config/emulation.js +154 -0
  12. package/dist/config/index.d.ts +1 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/config/runner.js +4 -1
  15. package/dist/config/tags.d.ts +1 -1
  16. package/dist/config/tags.js +7 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/fixtures/api-context.d.ts +19 -1
  19. package/dist/fixtures/api-context.js +28 -1
  20. package/dist/fixtures/test.js +36 -0
  21. package/dist/fixtures/types.d.ts +3 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lint/index.js +86 -0
  25. package/dist/load/index.d.ts +4 -0
  26. package/dist/load/index.js +4 -0
  27. package/dist/load/profile.d.ts +22 -0
  28. package/dist/load/profile.js +109 -0
  29. package/dist/load/run.d.ts +85 -0
  30. package/dist/load/run.js +255 -0
  31. package/dist/load/script.d.ts +27 -0
  32. package/dist/load/script.js +188 -0
  33. package/dist/mail/extract.d.ts +38 -0
  34. package/dist/mail/extract.js +106 -0
  35. package/dist/mail/index.d.ts +8 -0
  36. package/dist/mail/index.js +11 -0
  37. package/dist/mail/mailpit.d.ts +26 -0
  38. package/dist/mail/mailpit.js +124 -0
  39. package/dist/mail/types.d.ts +38 -0
  40. package/dist/mail/types.js +2 -0
  41. package/dist/matrix/index.d.ts +104 -0
  42. package/dist/matrix/index.js +486 -0
  43. package/dist/steps/api.steps.js +5 -7
  44. package/dist/steps/browser.steps.d.ts +12 -0
  45. package/dist/steps/browser.steps.js +154 -3
  46. package/dist/steps/canvas.steps.d.ts +9 -0
  47. package/dist/steps/canvas.steps.js +331 -0
  48. package/dist/steps/email.steps.d.ts +2 -0
  49. package/dist/steps/email.steps.js +182 -0
  50. package/dist/steps/hybrid.steps.js +2 -2
  51. package/dist/steps/index.d.ts +2 -0
  52. package/dist/steps/index.js +2 -0
  53. package/package.json +4 -3
@@ -1,6 +1,7 @@
1
- import { expect } from '@playwright/test';
1
+ import { devices, expect } from '@playwright/test';
2
2
  import { Given, Then, When } from '../fixtures/test.js';
3
3
  import { renderStrict } from '../api/template.js';
4
+ import { COLOR_SCHEMES, emulationTagProblem, findDevice, parseViewport, } from '../config/emulation.js';
4
5
  import { SdodsError } from '../errors.js';
5
6
  /**
6
7
  * Browser-state steps: cookies, web storage, viewport, colour scheme, locale and console.
@@ -292,11 +293,14 @@ function probeHorizontalOverflow() {
292
293
  // TRAP: a blank document also does not scroll sideways. The body-children guard is what stops this
293
294
  // step going green on a page that failed to render, which is the one case it must not bless.
294
295
  Then('the page should not scroll horizontally', async ({ page }) => {
295
- requireNavigated(page, 'the page should not scroll horizontally');
296
+ await expectNoHorizontalScroll(page, 'the page should not scroll horizontally');
297
+ });
298
+ async function expectNoHorizontalScroll(page, step) {
299
+ requireNavigated(page, step);
296
300
  const report = (await page.evaluate(probeHorizontalOverflow));
297
301
  expect(report.bodyChildren, 'the document body is empty — a blank page cannot prove anything about reflow').toBeGreaterThan(0);
298
302
  expect(report.scrollWidth, `the document is ${report.scrollWidth}px wide inside a ${report.clientWidth}px viewport. Offenders: ${report.offenders.join(', ') || '(none measurable)'}`).toBeLessThanOrEqual(report.clientWidth + 1);
299
- });
303
+ }
300
304
  // Proves ONE container contains its own width — a table, a code block or a chart that is allowed
301
305
  // to scroll internally must not push the page. Fails when the selector matches nothing, because a
302
306
  // containment claim about an element that is not on the page is not a claim.
@@ -651,4 +655,151 @@ Then('no uncaught page error should have been recorded', async ({ apiContext })
651
655
  const recording = recordingFor(apiContext, 'no uncaught page error should have been recorded');
652
656
  expect(recording.pageErrors, 'uncaught page errors').toEqual([]);
653
657
  });
658
+ /* ── per-scenario emulation: the `I use …` family (#116) ─────────────── */
659
+ // Locale, timezone and device are browser-CONTEXT options: Playwright fixes them when the context
660
+ // is created and offers no way to change them afterwards. By the time the first step runs, the
661
+ // context is open, so these steps cannot apply a value. The tags `@locale:` `@timezone:` and
662
+ // `@device:` are resolved before the context opens; each step below passes when the context
663
+ // already has the value (it then states the scenario's intent in its text) and otherwise fails,
664
+ // naming the tag to use. Recreating the context was rejected: the page objects, the healer and the
665
+ // screenshot narrator already hold the original page, and would silently act on a closed one.
666
+ // Colour scheme and viewport CAN change on a live page, so those steps apply their value.
667
+ function contextFixed(what, tag, actual) {
668
+ return new SdodsError('RUN_FAILED',
669
+ // The tag is in the MESSAGE, not only the hint: a Playwright report shows the message alone.
670
+ `The browser context was created with ${what} ${actual === undefined ? '(the system default)' : `"${actual}"`}, and Playwright cannot change it on an open context — tag the scenario ${tag}.`, {
671
+ hint: `Tag the scenario ${tag} (or its Feature, or an Examples block) — the tag is applied before the context is created, and this step then passes.`,
672
+ });
673
+ }
674
+ // Proves the scenario runs under this locale: navigator.language, Intl formatting and the
675
+ // Accept-Language header all come from it. Passes only when `@locale:` (or the env's `use.locale`)
676
+ // already set it.
677
+ Given('I use the locale {string}', async ({ locale, apiContext, env }, value) => {
678
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
679
+ const problem = emulationTagProblem('locale', wanted);
680
+ if (problem)
681
+ throw new SdodsError('RUN_FAILED', problem);
682
+ const canon = (l) => Intl.getCanonicalLocales(l)[0];
683
+ if (!locale || canon(locale) !== canon(wanted))
684
+ throw contextFixed('locale', `@locale:${wanted}`, locale);
685
+ });
686
+ // Proves the scenario runs in this IANA time zone — the one that decides what "today" and every
687
+ // rendered timestamp mean. Passes only when `@timezone:` (or `use.timezoneId`) already set it.
688
+ Given('I use the timezone {string}', async ({ timezoneId, apiContext, env }, value) => {
689
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
690
+ const problem = emulationTagProblem('timezone', wanted);
691
+ if (problem)
692
+ throw new SdodsError('RUN_FAILED', problem);
693
+ if (timezoneId !== wanted)
694
+ throw contextFixed('time zone', `@timezone:${wanted}`, timezoneId);
695
+ });
696
+ // Proves the OS-level colour preference for the rest of the scenario. Applied on the live page, so
697
+ // it works mid-scenario; `@theme:` does the same before the first paint, which is what a
698
+ // flash-of-wrong-theme check needs.
699
+ Given('I use the {string} color scheme', async ({ page, apiContext, env }, scheme) => {
700
+ const wanted = renderStrict(scheme, ...scopesOf(apiContext, env));
701
+ if (!COLOR_SCHEMES.includes(wanted))
702
+ throw new SdodsError('RUN_FAILED', `"${wanted}" is not a color scheme.`, {
703
+ hint: `Use one of: ${COLOR_SCHEMES.join(', ')}.`,
704
+ });
705
+ await page.emulateMedia({ colorScheme: wanted });
706
+ });
707
+ // Resizes the live page. `@viewport:<W>x<H>` sets the size before the first navigation instead,
708
+ // which matters for apps that choose a layout once, on load.
709
+ Given('I use the viewport {int} by {int}', async ({ page }, width, height) => {
710
+ const size = parseViewport(`${width}x${height}`);
711
+ if (!size)
712
+ throw new SdodsError('RUN_FAILED', `Viewport ${width}x${height} is not a usable size.`, {
713
+ hint: 'Both dimensions must be positive integers, e.g. `Given I use the viewport 320 by 640`.',
714
+ });
715
+ await page.setViewportSize(size);
716
+ });
717
+ // Proves the scenario runs with this device's viewport, user agent, pixel ratio and touch. The
718
+ // engine stays the run target's browser. Passes when the context carries the device's user agent —
719
+ // from `@device:`, or from a mobile run target built on that descriptor (mobile-safari is iPhone 15).
720
+ Given('I use the device {string}', async ({ $sdodsEmulation, userAgent, apiContext, env }, value) => {
721
+ const wanted = renderStrict(value, ...scopesOf(apiContext, env));
722
+ const name = findDevice(wanted);
723
+ if (!name)
724
+ throw new SdodsError('RUN_FAILED', `"${wanted}" is not a Playwright device.`, {
725
+ hint: 'Use a descriptor name such as "iPhone 15" or "Pixel 7".',
726
+ });
727
+ if ($sdodsEmulation.device?.name !== name && userAgent !== devices[name]?.userAgent)
728
+ throw contextFixed('device', `@device:${name.replace(/\s+/g, '-')}`, $sdodsEmulation.device?.name ?? 'no device emulation');
729
+ });
730
+ // The WCAG 1.4.10 phrasing of `the page should not scroll horizontally` — same measurement, same
731
+ // blank-page guard. Pair it with `@viewport:320x640`.
732
+ Then('the page should reflow without horizontal scrolling', async ({ page }) => {
733
+ await expectNoHorizontalScroll(page, 'the page should reflow without horizontal scrolling');
734
+ });
735
+ /**
736
+ * What counts as an untranslated key in visible text, by default:
737
+ * * a whole text node shaped like a catalogue key — `nav.settings`, `checkout.summary.total`
738
+ * (the same rule as {@link looksLikeMessageKey}: file names, hostnames and versions excluded);
739
+ * * an unrendered interpolation placeholder — `Hello {{name}}`;
740
+ * * a library's own missing marker — `[missing "fr.nav.home" translation]` (i18n-js) or
741
+ * `translation missing: fr.nav.home` (Rails).
742
+ * A project whose keys look different passes its own pattern to the `matching {string}` step,
743
+ * which then replaces these defaults.
744
+ */
745
+ export const UNTRANSLATED_MARKERS = [
746
+ /\{\{\s*[^{}\s][^{}]*\}\}/,
747
+ /\[missing\b[^\]]*\]/i,
748
+ /\btranslation missing:/i,
749
+ ];
750
+ export function findUntranslated(texts, pattern) {
751
+ const found = new Set();
752
+ for (const raw of texts) {
753
+ const text = raw.trim();
754
+ if (!text)
755
+ continue;
756
+ const hit = pattern
757
+ ? pattern.test(text)
758
+ : (text.length < 80 && !/\s/.test(text) && looksLikeMessageKey(text)) ||
759
+ UNTRANSLATED_MARKERS.some((re) => re.test(text));
760
+ if (hit)
761
+ found.add(text.length > 120 ? `${text.slice(0, 117)}...` : text);
762
+ }
763
+ return [...found];
764
+ }
765
+ /** Every visible text node, whole, plus how many were examined. */
766
+ function collectVisibleTexts() {
767
+ const skip = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE'];
768
+ const texts = [];
769
+ if (!document.body)
770
+ return { sampled: 0, texts };
771
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
772
+ let node = walker.nextNode();
773
+ while (node) {
774
+ const parent = node.parentElement;
775
+ const text = (node.textContent ?? '').trim();
776
+ if (text && parent && skip.indexOf(parent.tagName) === -1) {
777
+ const el = parent;
778
+ const visible = typeof el.checkVisibility === 'function' ? el.checkVisibility() : el.offsetParent !== null;
779
+ if (visible)
780
+ texts.push(text);
781
+ }
782
+ node = walker.nextNode();
783
+ }
784
+ return { sampled: texts.length, texts };
785
+ }
786
+ async function expectNoUntranslated(page, step, pattern) {
787
+ requireNavigated(page, step);
788
+ const seen = (await page.evaluate(collectVisibleTexts));
789
+ // TRAP: a page that rendered no text has no untranslated keys either — fail instead of passing.
790
+ expect(seen.sampled, 'the page rendered no visible text at all, so this step examined nothing').toBeGreaterThan(0);
791
+ expect(findUntranslated(seen.texts, pattern), `untranslated keys rendered as visible text${pattern ? ` (matching /${pattern.source}/)` : ''}`).toEqual([]);
792
+ }
793
+ // Proves the catalogue resolved for this locale: no raw key, no unrendered `{{placeholder}}` and
794
+ // no library "missing translation" marker is visible. Run it under each `@locale:`.
795
+ Then('the page should have no untranslated keys', async ({ page }) => {
796
+ await expectNoUntranslated(page, 'the page should have no untranslated keys');
797
+ });
798
+ // The same check with the project's own definition of an untranslated key (a JavaScript regular
799
+ // expression tested against each visible text node), e.g. "^[A-Z0-9_]{3,}$" for SCREAMING_CASE keys.
800
+ Then('the page should have no untranslated keys matching {string}', async ({ page, apiContext, env }, pattern) => {
801
+ const step = 'the page should have no untranslated keys matching …';
802
+ const rendered = renderStrict(pattern, ...scopesOf(apiContext, env));
803
+ await expectNoUntranslated(page, step, compilePattern(rendered, step));
804
+ });
654
805
  //# sourceMappingURL=browser.steps.js.map
@@ -0,0 +1,9 @@
1
+ import { type CanvasConfig } from '@sdods/contracts';
2
+ import './params.js';
3
+ /**
4
+ * Fills a selector template. An unknown placeholder throws instead of being left in place: a
5
+ * selector with a literal `{nodeId}` in it matches nothing, which fails a positive step for the
6
+ * wrong reason and passes the negative edge step without testing anything.
7
+ */
8
+ export declare function expandCanvasSelector(key: keyof CanvasConfig, template: string, values: Record<string, string>, testIdAttribute: string): string;
9
+ //# sourceMappingURL=canvas.steps.d.ts.map
@@ -0,0 +1,331 @@
1
+ import { expect } from '@playwright/test';
2
+ import { CANVAS_DEFAULTS } from '@sdods/contracts';
3
+ import './params.js';
4
+ import { Then, When } from '../fixtures/test.js';
5
+ import { renderStrict } from '../api/template.js';
6
+ import { SdodsError } from '../errors.js';
7
+ const arg = ({ apiContext, env }, value) => renderStrict(value, apiContext.vars.toObject(), env.vars);
8
+ /** Pointer moves between press and release. Enough for every drag threshold we know of. */
9
+ const DRAG_STEPS = 12;
10
+ /** Size of the first move after the press, which starts a drag in libraries with a threshold. */
11
+ const NUDGE_PX = 4;
12
+ /** The editable control a field wrapper holds, when the field selector names a wrapper. */
13
+ const EDITABLE = 'input:not([type="hidden"]), textarea, select, [contenteditable=""], [contenteditable="true"]';
14
+ /** Places inside a node a press would NOT reach the node through: a drag or click there is eaten. */
15
+ const NOT_A_GRAB_POINT = `${EDITABLE}, button, a[href], .nodrag, .react-flow__handle`;
16
+ function canvasOf(config) {
17
+ return {
18
+ config: { ...CANVAS_DEFAULTS, ...(config.project.canvas ?? {}) },
19
+ testIdAttribute: config.project.testIdAttribute ?? 'data-testid',
20
+ };
21
+ }
22
+ /** A value inside a quoted CSS string: backslashes, both quotes and newlines escaped. */
23
+ function cssString(value) {
24
+ return value
25
+ .replace(/\\/g, '\\\\')
26
+ .replace(/"/g, '\\"')
27
+ .replace(/'/g, "\\'")
28
+ .replace(/\n/g, '\\a ');
29
+ }
30
+ /**
31
+ * Fills a selector template. An unknown placeholder throws instead of being left in place: a
32
+ * selector with a literal `{nodeId}` in it matches nothing, which fails a positive step for the
33
+ * wrong reason and passes the negative edge step without testing anything.
34
+ */
35
+ export function expandCanvasSelector(key, template, values, testIdAttribute) {
36
+ return template.replace(/\{(\w+)\}/g, (_whole, name) => {
37
+ if (name === 'testIdAttribute')
38
+ return testIdAttribute;
39
+ const value = values[name];
40
+ if (value === undefined) {
41
+ throw new SdodsError('CONFIG_INVALID', `canvas.${key} "${template}" uses {${name}}, which is not a placeholder for this selector.`, {
42
+ hint: `canvas.${key} may use ${Object.keys(values)
43
+ .map((v) => `{${v}}`)
44
+ .concat('{testIdAttribute}')
45
+ .join(', ')}.`,
46
+ });
47
+ }
48
+ return cssString(value);
49
+ });
50
+ }
51
+ function rootOf(page, c) {
52
+ return page.locator(expandCanvasSelector('root', c.config.root, {}, c.testIdAttribute));
53
+ }
54
+ /** Every node on the canvas (`canvas.nodes`), scoped inside the root. */
55
+ function allNodes(page, c) {
56
+ return rootOf(page, c).locator(expandCanvasSelector('nodes', c.config.nodes, {}, c.testIdAttribute));
57
+ }
58
+ function nodeLocator(page, c, id) {
59
+ return rootOf(page, c).locator(expandCanvasSelector('node', c.config.node, { id }, c.testIdAttribute));
60
+ }
61
+ /** Exactly one node with that id, or a failure that names it. */
62
+ async function oneNode(page, c, id) {
63
+ const node = nodeLocator(page, c, id);
64
+ await expect(node, `no single node "${id}" on the canvas (canvas.node)`).toHaveCount(1);
65
+ return node;
66
+ }
67
+ async function viewport(page) {
68
+ return page.viewportSize() ?? page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
69
+ }
70
+ async function boxOf(locator, what) {
71
+ const box = await locator.boundingBox();
72
+ if (!box || box.width === 0 || box.height === 0) {
73
+ throw new SdodsError('RUN_FAILED', `${what} has no size on screen, so it cannot be dragged.`, {
74
+ hint: 'It is in the DOM but not laid out: hidden, collapsed, or virtualised off the canvas.',
75
+ });
76
+ }
77
+ return box;
78
+ }
79
+ /** The centre of a drop target or a handle: the point a release or a connection aims at. */
80
+ async function centreOf(locator, what) {
81
+ await expect(locator, `${what} is not visible`).toBeVisible();
82
+ await locator.scrollIntoViewIfNeeded();
83
+ const box = await boxOf(locator, what);
84
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
85
+ }
86
+ /**
87
+ * A point on an element that a press reaches the ELEMENT through — used for every drag source and
88
+ * for selecting. The centre of a real workflow block is usually a text field, and editors
89
+ * deliberately ignore presses there (React Flow's `nodrag`), so a drag or click at the centre does
90
+ * nothing. Tries the centre, then the header strip, then the edges, and takes the first point whose
91
+ * hit target is the element or a plain part of it. A palette item with nothing inside it resolves
92
+ * to its centre.
93
+ */
94
+ async function grabPointOf(node, what) {
95
+ await expect(node, `${what} is not visible`).toBeVisible();
96
+ await node.scrollIntoViewIfNeeded();
97
+ const box = await boxOf(node, what);
98
+ const offset = await node.evaluate((el, skip) => {
99
+ const r = el.getBoundingClientRect();
100
+ const inset = Math.min(8, r.height / 4, r.width / 4);
101
+ const candidates = [
102
+ [r.width / 2, r.height / 2],
103
+ [r.width / 2, inset],
104
+ [inset * 2, inset],
105
+ [r.width - inset * 2, inset],
106
+ [r.width / 2, r.height - inset],
107
+ [inset, r.height / 2],
108
+ [r.width - inset, r.height / 2],
109
+ ];
110
+ for (const [x, y] of candidates) {
111
+ const hit = document.elementFromPoint(r.left + x, r.top + y);
112
+ if (!hit || !el.contains(hit))
113
+ continue;
114
+ const blocker = hit.closest(skip);
115
+ if (blocker && el.contains(blocker))
116
+ continue;
117
+ return { x, y, found: true };
118
+ }
119
+ return { x: r.width / 2, y: r.height / 2, found: false };
120
+ }, NOT_A_GRAB_POINT);
121
+ // Scale by the laid-out box, which is in page coordinates even inside a frame.
122
+ return { x: box.x + offset.x, y: box.y + offset.y };
123
+ }
124
+ /** One real pointer drag. The press is always released, even when a move throws. */
125
+ async function pointerDrag(page, from, to, what) {
126
+ const { width, height } = await viewport(page);
127
+ for (const [label, p] of [
128
+ ['starts', from],
129
+ ['ends', to],
130
+ ]) {
131
+ if (p.x < 0 || p.y < 0 || p.x > width || p.y > height) {
132
+ throw new SdodsError('RUN_FAILED', `Dragging ${what} ${label} at (${Math.round(p.x)}, ${Math.round(p.y)}), outside the viewport (${width}x${height}).`, {
133
+ hint: 'A pointer cannot be pressed or released off screen, so the drop would land short. Use a smaller offset, scroll or zoom the canvas first, or enlarge the viewport.',
134
+ });
135
+ }
136
+ }
137
+ const dx = to.x - from.x;
138
+ const dy = to.y - from.y;
139
+ const distance = Math.hypot(dx, dy);
140
+ await page.mouse.move(from.x, from.y);
141
+ await page.mouse.down();
142
+ try {
143
+ if (distance > NUDGE_PX) {
144
+ await page.mouse.move(from.x + (dx / distance) * NUDGE_PX, from.y + (dy / distance) * NUDGE_PX);
145
+ }
146
+ await page.mouse.move(to.x, to.y, { steps: DRAG_STEPS });
147
+ // The LAST move is what drop targets read (React Flow's connection target, HTML5 dragover).
148
+ await page.mouse.move(to.x, to.y);
149
+ }
150
+ finally {
151
+ await page.mouse.up();
152
+ }
153
+ }
154
+ function assertOffset(dx, dy, what) {
155
+ if (!Number.isInteger(dx) || !Number.isInteger(dy) || (dx === 0 && dy === 0)) {
156
+ throw new SdodsError('RUN_FAILED', `Moving ${what} by ${dx} and ${dy} is not a drag.`, {
157
+ hint: 'Offsets are whole screen pixels, right/down positive, and at least one must be non-zero: a press and release in place is a click, and editors treat it as one.',
158
+ });
159
+ }
160
+ }
161
+ /* ── dragging ─────────────────────────────────────────────────────────── */
162
+ // Adds a block from a palette, or moves anything onto a drop zone. The source is pressed on a
163
+ // plain part of it (see `grabPointOf`) and released at the target's centre.
164
+ When('I drag the element with test id {string} onto the element with test id {string}', async ({ page, apiContext, env }, sourceId, targetId) => {
165
+ const s = arg({ apiContext, env }, sourceId);
166
+ const t = arg({ apiContext, env }, targetId);
167
+ const target = page.getByTestId(t);
168
+ const source = page.getByTestId(s);
169
+ // Target first: scrolling the source into view last keeps the press point on screen.
170
+ await centreOf(target, `the element with test id "${t}"`);
171
+ const from = await grabPointOf(source, `the element with test id "${s}"`);
172
+ const to = await centreOf(target, `the element with test id "${t}"`);
173
+ await pointerDrag(page, from, to, `the element with test id "${s}"`);
174
+ });
175
+ // Moves an element by an offset in screen pixels (right/down positive). On a zoomed canvas the
176
+ // node moves by the offset divided by the zoom, in flow coordinates.
177
+ When('I drag the element with test id {string} by {int} and {int}', async ({ page, apiContext, env }, id, dx, dy) => {
178
+ const testId = arg({ apiContext, env }, id);
179
+ const what = `the element with test id "${testId}"`;
180
+ assertOffset(dx, dy, what);
181
+ const from = await grabPointOf(page.getByTestId(testId), what);
182
+ await pointerDrag(page, from, { x: from.x + dx, y: from.y + dy }, what);
183
+ });
184
+ // Moves a node addressed by its id rather than a test id — the one a workflow canvas actually has.
185
+ // Pressed on a plain part of the node, never on a field inside it (see `grabPointOf`).
186
+ When('I drag the node {string} by {int} and {int}', async ({ page, config, apiContext, env }, id, dx, dy) => {
187
+ const c = canvasOf(config);
188
+ const nodeId = arg({ apiContext, env }, id);
189
+ const what = `node "${nodeId}"`;
190
+ assertOffset(dx, dy, what);
191
+ const from = await grabPointOf(await oneNode(page, c, nodeId), what);
192
+ await pointerDrag(page, from, { x: from.x + dx, y: from.y + dy }, what);
193
+ });
194
+ /* ── connecting ───────────────────────────────────────────────────────── */
195
+ // Draws an edge the way a user does: press on the source handle, move onto the target handle,
196
+ // release. Both handles are looked up inside their own node, so two nodes that both have an "out"
197
+ // handle are never confused. Whether the editor ACCEPTED the connection is the next step's job:
198
+ // `the canvas should contain an edge from ... to ...`.
199
+ When('I connect the {string} handle of node {string} to the {string} handle of node {string}', async ({ page, config, apiContext, env }, sourceHandle, sourceNode, targetHandle, targetNode) => {
200
+ const c = canvasOf(config);
201
+ const v = { apiContext, env };
202
+ const [sh, sn, th, tn] = [sourceHandle, sourceNode, targetHandle, targetNode].map((s) => arg(v, s));
203
+ const handleIn = async (nodeId, handle) => {
204
+ const node = await oneNode(page, c, nodeId);
205
+ const h = node.locator(expandCanvasSelector('handle', c.config.handle, { handle }, c.testIdAttribute));
206
+ await expect(h, `the "${handle}" handle of node "${nodeId}" is not on the canvas (canvas.handle)`).toHaveCount(1);
207
+ return h;
208
+ };
209
+ const target = await handleIn(tn, th);
210
+ const source = await handleIn(sn, sh);
211
+ await centreOf(target, `the "${th}" handle of node "${tn}"`);
212
+ const from = await centreOf(source, `the "${sh}" handle of node "${sn}"`);
213
+ const to = await centreOf(target, `the "${th}" handle of node "${tn}"`);
214
+ await pointerDrag(page, from, to, `the "${sh}" handle of node "${sn}"`);
215
+ });
216
+ /* ── selecting and editing ────────────────────────────────────────────── */
217
+ // Selects a node by clicking a plain part of it, then waits until it matches `canvas.selected`
218
+ // (React Flow's `.selected` class by default), so a click the editor swallowed fails here rather
219
+ // than as a confusing failure in whatever the scenario does with the selection next.
220
+ When('I select the node {string}', async ({ page, config, apiContext, env }, id) => {
221
+ const c = canvasOf(config);
222
+ const nodeId = arg({ apiContext, env }, id);
223
+ const node = await oneNode(page, c, nodeId);
224
+ const at = await grabPointOf(node, `node "${nodeId}"`);
225
+ await page.mouse.click(at.x, at.y);
226
+ const marker = c.config.selected;
227
+ if (!marker)
228
+ return;
229
+ await expect
230
+ .poll(async () => node.evaluate((el, s) => el.matches(s), marker), {
231
+ message: `node "${nodeId}" is not selected after clicking it: it does not match ${marker} (canvas.selected)`,
232
+ })
233
+ .toBe(true);
234
+ });
235
+ // Fills a field that lives inside a node. `canvas.field` may name the control itself or a wrapper
236
+ // around it; for a wrapper, the first visible input, textarea, select or contenteditable inside it
237
+ // is used. A select is set by option value or label.
238
+ When('I set the {string} field of node {string} to {string}', async ({ page, config, apiContext, env }, field, id, value) => {
239
+ const c = canvasOf(config);
240
+ const v = { apiContext, env };
241
+ const name = arg(v, field);
242
+ const nodeId = arg(v, id);
243
+ const text = arg(v, value);
244
+ const node = await oneNode(page, c, nodeId);
245
+ const match = node
246
+ .locator(expandCanvasSelector('field', c.config.field, { field: name }, c.testIdAttribute))
247
+ .first();
248
+ await expect(match, `node "${nodeId}" has no "${name}" field (canvas.field)`).toBeAttached();
249
+ const isEditable = await match.evaluate((el, sel) => el.matches(sel), EDITABLE);
250
+ const control = isEditable ? match : match.locator(EDITABLE).filter({ visible: true }).first();
251
+ await expect(control, `the "${name}" field of node "${nodeId}" holds no visible editable control`).toBeVisible();
252
+ const tag = await control.evaluate((el) => el.tagName);
253
+ if (tag === 'SELECT')
254
+ await control.selectOption(text);
255
+ else
256
+ await control.fill(text);
257
+ });
258
+ /* ── assertions ───────────────────────────────────────────────────────── */
259
+ // Counts every node on the canvas (`canvas.nodes`). Exact, so a duplicated drop fails too.
260
+ Then('the canvas should contain {int} node(s)', async ({ page, config }, count) => {
261
+ if (!Number.isInteger(count) || count < 0) {
262
+ throw new SdodsError('RUN_FAILED', `"${count}" is not a number of nodes.`, {
263
+ hint: 'Node counts are whole numbers, 0 or more.',
264
+ });
265
+ }
266
+ const c = canvasOf(config);
267
+ await expect(allNodes(page, c)).toHaveCount(count);
268
+ });
269
+ // Proves the editor holds an edge from one node to the other, in that direction.
270
+ Then('the canvas should contain an edge from {string} to {string}', async ({ page, config, apiContext, env }, source, target) => {
271
+ const c = canvasOf(config);
272
+ const s = arg({ apiContext, env }, source);
273
+ const t = arg({ apiContext, env }, target);
274
+ const edges = rootOf(page, c).locator(expandCanvasSelector('edge', c.config.edge, { source: s, target: t }, c.testIdAttribute));
275
+ await expect
276
+ .poll(async () => edges.count(), {
277
+ message: `the canvas has no edge from "${s}" to "${t}" (canvas.edge)`,
278
+ })
279
+ .toBeGreaterThan(0);
280
+ });
281
+ // Proves an edge is gone, or was never drawn. Refuses a canvas with no visible nodes: on an editor
282
+ // that has not rendered, every edge in existence is absent, which is not an assertion. React Flow
283
+ // keeps unmeasured nodes in the DOM with `visibility: hidden` and draws edges only once the nodes
284
+ // are measured, so right after a load a node being attached proves nothing: the step waits until
285
+ // every node is visible and the editor has painted, and only then checks that the edge is absent.
286
+ Then('the canvas should not contain an edge from {string} to {string}', async ({ page, config, apiContext, env }, source, target) => {
287
+ const c = canvasOf(config);
288
+ const s = arg({ apiContext, env }, source);
289
+ const t = arg({ apiContext, env }, target);
290
+ const root = rootOf(page, c);
291
+ const nodes = allNodes(page, c);
292
+ try {
293
+ await expect(nodes.filter({ visible: true }).first()).toBeVisible();
294
+ }
295
+ catch {
296
+ throw new SdodsError('RUN_FAILED', 'The canvas has rendered no nodes, or none is visible.', {
297
+ hint: `Asserting there is no edge from "${s}" to "${t}" on an empty or unrendered canvas proves nothing: editors such as React Flow draw edges only after their nodes are measured and shown. Check canvas.root and canvas.nodes, or assert a node first.`,
298
+ });
299
+ }
300
+ await expect
301
+ .poll(async () => (await nodes.count()) - (await nodes.filter({ visible: true }).count()), {
302
+ message: `nodes on the canvas are still hidden (not yet measured), so its edges may not be drawn yet; the absent-edge check from "${s}" to "${t}" waits for them`,
303
+ })
304
+ .toBe(0);
305
+ // Edges are drawn in the render that follows the measurement: let the editor paint first.
306
+ await page.evaluate(() => new Promise((done) => {
307
+ const timer = setTimeout(done, 250);
308
+ requestAnimationFrame(() => requestAnimationFrame(() => {
309
+ clearTimeout(timer);
310
+ done();
311
+ }));
312
+ }));
313
+ const edges = root.locator(expandCanvasSelector('edge', c.config.edge, { source: s, target: t }, c.testIdAttribute));
314
+ await expect(edges, `the canvas has an edge from "${s}" to "${t}"`).toHaveCount(0);
315
+ });
316
+ // Proves the node is rendered and shown.
317
+ Then('the node {string} should be visible', async ({ page, config, apiContext, env }, id) => {
318
+ const c = canvasOf(config);
319
+ const nodeId = arg({ apiContext, env }, id);
320
+ await expect(nodeLocator(page, c, nodeId), `node "${nodeId}" is not visible`).toBeVisible();
321
+ });
322
+ // Proves THIS node shows the badge — a run state (running, success, error) or a validation marker.
323
+ Then('the node {string} should show the {string} badge', async ({ page, config, apiContext, env }, id, badge) => {
324
+ const c = canvasOf(config);
325
+ const nodeId = arg({ apiContext, env }, id);
326
+ const b = arg({ apiContext, env }, badge);
327
+ const node = await oneNode(page, c, nodeId);
328
+ const marker = node.locator(expandCanvasSelector('badge', c.config.badge, { badge: b }, c.testIdAttribute));
329
+ await expect(marker.first(), `node "${nodeId}" does not show the "${b}" badge (canvas.badge)`).toBeVisible();
330
+ });
331
+ //# sourceMappingURL=canvas.steps.js.map
@@ -0,0 +1,2 @@
1
+ import './params.js';
2
+ //# sourceMappingURL=email.steps.d.ts.map