@sdods/core 0.7.2 → 0.9.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -3
|
@@ -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
|
package/dist/steps/data.steps.js
CHANGED
|
@@ -3,6 +3,7 @@ import './params.js';
|
|
|
3
3
|
import { Given, When } from '../fixtures/test.js';
|
|
4
4
|
import { tokenFileFor } from '../auth/capture.js';
|
|
5
5
|
import { renderStrict } from '../api/template.js';
|
|
6
|
+
import { scenarioLeaseClock } from '../data/user-pool.js';
|
|
6
7
|
import { SdodsError } from '../errors.js';
|
|
7
8
|
import { Logger } from '../logger.js';
|
|
8
9
|
const log = new Logger('data');
|
|
@@ -20,8 +21,8 @@ Given('I generate a {string} from the factory as {string}', async ({ data, apiCo
|
|
|
20
21
|
apiContext.vars.set(as, await data.factory(factory));
|
|
21
22
|
});
|
|
22
23
|
/* ── user pool ────────────────────────────────────────────────────────── */
|
|
23
|
-
Given('I use a leased user with role {string}', async ({ userPool, authCache, auth, sdods, apiContext, page, context, browser, $testInfo }, role) => {
|
|
24
|
-
const user = await userPool
|
|
24
|
+
Given('I use a leased user with role {string}', async ({ userPool, authCache, auth, sdods, apiContext, page, context, browser, config, $testInfo }, role) => {
|
|
25
|
+
const user = await leaseInScenario(userPool, config, role, $testInfo);
|
|
25
26
|
apiContext.vars.setAll({
|
|
26
27
|
username: user.username,
|
|
27
28
|
password: user.password,
|
|
@@ -33,12 +34,12 @@ Given('I use a leased user with role {string}', async ({ userPool, authCache, au
|
|
|
33
34
|
if (sdods.layer !== 'api')
|
|
34
35
|
await authCache.apply({ context, page, user, auth, browser });
|
|
35
36
|
});
|
|
36
|
-
Given('I use the user {string} from the pool', async ({ userPool, apiContext, $testInfo }, username) => {
|
|
37
|
+
Given('I use the user {string} from the pool', async ({ userPool, apiContext, config, $testInfo }, username) => {
|
|
37
38
|
const status = await userPool.status();
|
|
38
39
|
const entry = status.find((u) => u.username === username);
|
|
39
40
|
if (!entry)
|
|
40
41
|
throw new SdodsError('DATASET_ROW_NOT_FOUND', `No pool user named "${username}".`);
|
|
41
|
-
const user = await userPool
|
|
42
|
+
const user = await leaseInScenario(userPool, config, entry.role, $testInfo);
|
|
42
43
|
apiContext.vars.setAll({
|
|
43
44
|
username: user.username,
|
|
44
45
|
password: user.password,
|
|
@@ -84,7 +85,7 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
84
85
|
* Either way the attached credential is logged at info.
|
|
85
86
|
*/
|
|
86
87
|
Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
|
|
87
|
-
const user = await userPool
|
|
88
|
+
const user = await leaseInScenario(userPool, config, role, $testInfo);
|
|
88
89
|
apiContext.vars.setAll({
|
|
89
90
|
username: user.username,
|
|
90
91
|
password: user.password,
|
|
@@ -126,6 +127,17 @@ Given("I authenticate the API with the leased user's token", async ({ apiContext
|
|
|
126
127
|
});
|
|
127
128
|
/** The user the scenario leased, so the explicit token step authenticates as that same account. */
|
|
128
129
|
const LEASED = new WeakMap();
|
|
130
|
+
/**
|
|
131
|
+
* Lease for this scenario. With `leaseScope: scenario` the wait for a free account is added to the
|
|
132
|
+
* scenario's timeout, and `$sdodsScenarioLeases` releases the account when the scenario ends.
|
|
133
|
+
*/
|
|
134
|
+
async function leaseInScenario(userPool, config, role, testInfo) {
|
|
135
|
+
// `data` is always present on a resolved config; unit tests hand steps a partial one.
|
|
136
|
+
const clock = scenarioLeaseClock(config.project.data?.userPool, testInfo);
|
|
137
|
+
const user = await userPool.lease(role, testInfo.parallelIndex);
|
|
138
|
+
clock.settle();
|
|
139
|
+
return user;
|
|
140
|
+
}
|
|
129
141
|
function apiTokenMode(config, auth) {
|
|
130
142
|
return config.project.auth?.apiToken ?? (auth?.strategy === 'custom' ? 'explicit' : 'implicit');
|
|
131
143
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import './params.js';
|
|
3
|
+
import { BeforeScenario, Then, When } from '../fixtures/test.js';
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
|
+
import { SdodsError } from '../errors.js';
|
|
6
|
+
import { createMailInbox } from '../mail/index.js';
|
|
7
|
+
import { extractLinks, findCode, findLinkMatching, findLinkNamed, readableText, } from '../mail/extract.js';
|
|
8
|
+
// Keyed by the per-scenario ApiContext, so state never outlives or crosses a scenario.
|
|
9
|
+
const states = new WeakMap();
|
|
10
|
+
function stateFor(apiContext) {
|
|
11
|
+
let s = states.get(apiContext);
|
|
12
|
+
if (!s) {
|
|
13
|
+
s = { since: Date.now(), cleared: new Map(), pinned: new Map() };
|
|
14
|
+
states.set(apiContext, s);
|
|
15
|
+
}
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Opens the window when the scenario starts rather than at the first email step: in the typical
|
|
20
|
+
* flow the application sends the message during a UI step, before any email step has run.
|
|
21
|
+
*/
|
|
22
|
+
BeforeScenario({ name: 'sdods:mail:window' }, async ({ apiContext }) => {
|
|
23
|
+
stateFor(apiContext);
|
|
24
|
+
});
|
|
25
|
+
function requireMail(env) {
|
|
26
|
+
if (!env.mail) {
|
|
27
|
+
throw new SdodsError('MAIL_REQUIRED', 'This scenario reads email but no mail catcher is configured.', {
|
|
28
|
+
hint: 'Declare `mail: { provider: mailpit, url: http://localhost:8025 }` in the environment yaml, and point the application under test at the same Mailpit for SMTP (port 1025 by default).',
|
|
29
|
+
docsPath: '/docs/reference/env-yaml',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
provider: env.mail.provider ?? 'mailpit',
|
|
34
|
+
url: env.mail.url,
|
|
35
|
+
auth: env.mail.auth,
|
|
36
|
+
pollIntervalMs: env.mail.pollIntervalMs ?? 1000,
|
|
37
|
+
clockSkewMs: env.mail.clockSkewMs ?? 2000,
|
|
38
|
+
timeoutSeconds: env.mail.timeoutSeconds ?? 15,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const scopesOf = ({ apiContext, env }) => [apiContext.vars.toObject(), env.vars];
|
|
42
|
+
const arg = (fx, value) => renderStrict(value, ...scopesOf(fx));
|
|
43
|
+
const addressArg = (fx, value) => arg(fx, value).trim().toLowerCase();
|
|
44
|
+
function windowStart(fx, config, address) {
|
|
45
|
+
const s = stateFor(fx.apiContext);
|
|
46
|
+
return new Date(Math.max(s.since, s.cleared.get(address) ?? 0) - config.clockSkewMs);
|
|
47
|
+
}
|
|
48
|
+
/** Search until something matches or the deadline passes; `[]` on timeout. */
|
|
49
|
+
async function pollSearch(inbox, query, timeoutMs, intervalMs) {
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const found = await inbox.search(query);
|
|
53
|
+
if (found.length > 0)
|
|
54
|
+
return found;
|
|
55
|
+
const left = deadline - Date.now();
|
|
56
|
+
if (left <= 0)
|
|
57
|
+
return [];
|
|
58
|
+
await new Promise((r) => setTimeout(r, Math.min(intervalMs, left)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function describeSeen(seen) {
|
|
62
|
+
if (seen.length === 0)
|
|
63
|
+
return 'No email at all reached that address in this scenario.';
|
|
64
|
+
const list = seen
|
|
65
|
+
.slice(0, 10)
|
|
66
|
+
.map((m) => ` - "${m.subject}" from ${m.from.address} at ${m.receivedAt.toISOString()}`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
return `Emails that did reach it in this scenario (newest first):\n${list}`;
|
|
69
|
+
}
|
|
70
|
+
/** The message "the latest email to <address>" refers to. See the header for the rules. */
|
|
71
|
+
async function latestEmail(fx, rawAddress) {
|
|
72
|
+
const config = requireMail(fx.env);
|
|
73
|
+
const inbox = createMailInbox(config);
|
|
74
|
+
const address = addressArg(fx, rawAddress);
|
|
75
|
+
const pinned = stateFor(fx.apiContext).pinned.get(address);
|
|
76
|
+
if (pinned)
|
|
77
|
+
return inbox.get(pinned);
|
|
78
|
+
const since = windowStart(fx, config, address);
|
|
79
|
+
const found = await pollSearch(inbox, { to: address, since }, config.timeoutSeconds * 1000, config.pollIntervalMs);
|
|
80
|
+
if (found.length === 0) {
|
|
81
|
+
throw new SdodsError('MAIL_NOT_RECEIVED', `No email to ${address} arrived within ${config.timeoutSeconds} s.`, {
|
|
82
|
+
hint: 'Only mail received after the scenario started (or after `I clear the inbox for` that address) counts. Check that the application sends SMTP to the Mailpit in `mail.url`, or wait explicitly with `the inbox for … should receive an email with subject containing … within … seconds`.',
|
|
83
|
+
details: { address, since: since.toISOString(), mailpit: config.url },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return inbox.get(found[0].id);
|
|
87
|
+
}
|
|
88
|
+
When('I clear the inbox for {string}', async ({ apiContext, env }, rawAddress) => {
|
|
89
|
+
const fx = { apiContext, env };
|
|
90
|
+
const config = requireMail(env);
|
|
91
|
+
const address = addressArg(fx, rawAddress);
|
|
92
|
+
await createMailInbox(config).clear(address);
|
|
93
|
+
const s = stateFor(apiContext);
|
|
94
|
+
s.cleared.set(address, Date.now());
|
|
95
|
+
s.pinned.delete(address);
|
|
96
|
+
});
|
|
97
|
+
Then('the inbox for {string} should receive an email with subject containing {string} within {int} seconds', async ({ apiContext, env }, rawAddress, rawSubject, seconds) => {
|
|
98
|
+
const fx = { apiContext, env };
|
|
99
|
+
const config = requireMail(env);
|
|
100
|
+
const inbox = createMailInbox(config);
|
|
101
|
+
const address = addressArg(fx, rawAddress);
|
|
102
|
+
const subject = arg(fx, rawSubject);
|
|
103
|
+
const since = windowStart(fx, config, address);
|
|
104
|
+
const found = await pollSearch(inbox, { to: address, subjectContains: subject, since }, seconds * 1000, config.pollIntervalMs);
|
|
105
|
+
if (found.length === 0) {
|
|
106
|
+
// "Nothing arrived" and "something with the wrong subject arrived" are different bugs, so
|
|
107
|
+
// the failure says which one this is.
|
|
108
|
+
const seen = await inbox.search({ to: address, since });
|
|
109
|
+
throw new SdodsError('MAIL_NOT_RECEIVED', `No email to ${address} with subject containing "${subject}" arrived within ${seconds} s.\n${describeSeen(seen)}`, {
|
|
110
|
+
hint: 'The subject match is a case-sensitive substring. Only mail received after the scenario started (or after `I clear the inbox for` that address) counts; check that the application sends SMTP to the Mailpit in `mail.url`.',
|
|
111
|
+
details: {
|
|
112
|
+
address,
|
|
113
|
+
subject,
|
|
114
|
+
since: since.toISOString(),
|
|
115
|
+
seen: seen.map((m) => ({
|
|
116
|
+
subject: m.subject,
|
|
117
|
+
from: m.from.address,
|
|
118
|
+
receivedAt: m.receivedAt.toISOString(),
|
|
119
|
+
})),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
stateFor(apiContext).pinned.set(address, found[0].id);
|
|
124
|
+
});
|
|
125
|
+
Then('the latest email to {string} should contain the text {string}', async ({ apiContext, env }, rawAddress, rawText) => {
|
|
126
|
+
const fx = { apiContext, env };
|
|
127
|
+
const message = await latestEmail(fx, rawAddress);
|
|
128
|
+
const needle = arg(fx, rawText);
|
|
129
|
+
expect(readableText(message), `the latest email to ${message.to.map((t) => t.address).join(', ')} ("${message.subject}")`).toContain(needle);
|
|
130
|
+
});
|
|
131
|
+
Then('the latest email to {string} should have been sent from {string}', async ({ apiContext, env }, rawAddress, rawFrom) => {
|
|
132
|
+
const fx = { apiContext, env };
|
|
133
|
+
const message = await latestEmail(fx, rawAddress);
|
|
134
|
+
const wanted = arg(fx, rawFrom).trim();
|
|
135
|
+
const { name, address } = message.from;
|
|
136
|
+
const candidates = [address, name, `${name} <${address}>`].map((s) => s.toLowerCase());
|
|
137
|
+
if (!candidates.includes(wanted.toLowerCase())) {
|
|
138
|
+
expect(name ? `${name} <${address}>` : address, `sender of "${message.subject}" (compare the address, the display name, or "Name <address>")`).toBe(wanted);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
When('I save the link matching {string} from the latest email to {string} as {string}', async ({ apiContext, env }, rawPattern, rawAddress, name) => {
|
|
142
|
+
const fx = { apiContext, env };
|
|
143
|
+
const message = await latestEmail(fx, rawAddress);
|
|
144
|
+
const pattern = arg(fx, rawPattern);
|
|
145
|
+
const link = findLinkMatching(message, pattern);
|
|
146
|
+
if (!link)
|
|
147
|
+
throw noLink(message, `matching ${pattern}`);
|
|
148
|
+
// `name` is the variable being written, an identifier rather than a template.
|
|
149
|
+
apiContext.vars.set(name, link.href);
|
|
150
|
+
});
|
|
151
|
+
When('I save the code matching {string} from the latest email to {string} as {string}', async ({ apiContext, env }, rawPattern, rawAddress, name) => {
|
|
152
|
+
const fx = { apiContext, env };
|
|
153
|
+
const message = await latestEmail(fx, rawAddress);
|
|
154
|
+
const pattern = arg(fx, rawPattern);
|
|
155
|
+
const code = findCode(message, pattern);
|
|
156
|
+
if (code === undefined) {
|
|
157
|
+
throw new SdodsError('RUN_FAILED', `Nothing in "${message.subject}" matches ${pattern}.`, {
|
|
158
|
+
hint: 'The pattern is a regular expression over the text part (or the HTML part rendered to text). With a capture group, group 1 is saved: `code: (\\d{6})`.',
|
|
159
|
+
details: { subject: message.subject, text: readableText(message).slice(0, 1000) },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
apiContext.vars.set(name, code);
|
|
163
|
+
});
|
|
164
|
+
When('I open the link {string} from the latest email to {string}', async ({ apiContext, env, page }, rawName, rawAddress) => {
|
|
165
|
+
const fx = { apiContext, env };
|
|
166
|
+
const message = await latestEmail(fx, rawAddress);
|
|
167
|
+
const name = arg(fx, rawName);
|
|
168
|
+
const link = findLinkNamed(message, name);
|
|
169
|
+
if (!link)
|
|
170
|
+
throw noLink(message, `named "${name}"`);
|
|
171
|
+
await page.goto(link.href, { waitUntil: 'domcontentloaded' });
|
|
172
|
+
});
|
|
173
|
+
function noLink(message, what) {
|
|
174
|
+
const links = extractLinks(message);
|
|
175
|
+
return new SdodsError('RUN_FAILED', `No link ${what} in "${message.subject}".`, {
|
|
176
|
+
hint: links.length
|
|
177
|
+
? `Links in that email:\n${links.map((l) => ` - ${l.text ? `"${l.text}" ` : ''}${l.href}`).join('\n')}`
|
|
178
|
+
: 'That email has no links at all: no <a href> in the HTML part and no http(s) URL in the text part.',
|
|
179
|
+
details: { subject: message.subject, links },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=email.steps.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Registers the screenshot-narrative, finalize, browser-skip and
|
|
1
|
+
// Registers the screenshot-narrative, finalize, browser-skip, HAR, @a11y and @perf hooks when bddgen loads the core step glob.
|
|
2
2
|
import '../shots/hooks.js';
|
|
3
|
+
// After the screenshot hooks: after-hooks run in reverse order, so these run before sdods:finalize.
|
|
4
|
+
import '../quality/hooks.js';
|
|
3
5
|
import { registerHarHooks } from '../har/hooks.js';
|
|
4
6
|
import { Before } from '../fixtures/test.js';
|
|
5
7
|
registerHarHooks({ Before });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Then, When } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { matchedPath } from '../api/json-path.js';
|
|
5
5
|
import { renderJson, renderStrict } from '../api/template.js';
|
|
6
6
|
/* Hybrid glue: seed through the API, verify in the browser. */
|
|
7
7
|
When('I seed via {method} {string} with body:', async ({ api, apiContext, env }, method, path, body) => {
|
|
@@ -10,7 +10,7 @@ When('I seed via {method} {string} with body:', async ({ api, apiContext, env },
|
|
|
10
10
|
});
|
|
11
11
|
Then('the UI should show the text from JSON path {string}', async ({ page, apiContext, env }, jsonPath) => {
|
|
12
12
|
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
13
|
-
const value =
|
|
13
|
+
const value = matchedPath(apiContext.last().response.body, path);
|
|
14
14
|
await expect(page.getByText(String(value), { exact: false }).first()).toBeVisible();
|
|
15
15
|
});
|
|
16
16
|
When('I replay HAR {string} for {string}', async ({ page, config, apiContext, env }, harName, glob) => {
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ import './browser.steps.js';
|
|
|
17
17
|
import './dom.steps.js';
|
|
18
18
|
import './net.steps.js';
|
|
19
19
|
import './perf.steps.js';
|
|
20
|
+
import './email.steps.js';
|
|
21
|
+
import './canvas.steps.js';
|
|
20
22
|
import '../shots/hooks.js';
|
|
23
|
+
import '../quality/hooks.js';
|
|
21
24
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
22
25
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/steps/index.js
CHANGED
|
@@ -17,6 +17,9 @@ import './browser.steps.js';
|
|
|
17
17
|
import './dom.steps.js';
|
|
18
18
|
import './net.steps.js';
|
|
19
19
|
import './perf.steps.js';
|
|
20
|
+
import './email.steps.js';
|
|
21
|
+
import './canvas.steps.js';
|
|
20
22
|
import '../shots/hooks.js';
|
|
23
|
+
import '../quality/hooks.js';
|
|
21
24
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
22
25
|
//# sourceMappingURL=index.js.map
|