@sdods/core 0.7.2 → 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.
- package/dist/.tsbuildinfo +1 -1
- 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/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/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- 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/test.js +36 -0
- package/dist/fixtures/types.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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 +104 -0
- package/dist/matrix/index.js +486 -0
- 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/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/package.json +4 -3
|
@@ -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,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,8 @@ 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';
|
|
21
23
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
22
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/steps/index.js
CHANGED
|
@@ -17,6 +17,8 @@ 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';
|
|
21
23
|
export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './glob.js';
|
|
22
24
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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>",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./analyze": "./dist/analyze/index.js",
|
|
22
22
|
"./recorder": "./dist/recorder/index.js",
|
|
23
23
|
"./har": "./dist/har/index.js",
|
|
24
|
+
"./load": "./dist/load/index.js",
|
|
24
25
|
"./auth": "./dist/auth/index.js",
|
|
25
26
|
"./insights": "./dist/insights/index.js",
|
|
26
27
|
"./reporters/dashboard": "./dist/reporters/dashboard.js",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"playwright-bdd": ">=9"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@sdods/contracts": "0.
|
|
46
|
+
"@sdods/contracts": "0.8.0",
|
|
46
47
|
"@cucumber/cucumber-expressions": "19.0.0",
|
|
47
48
|
"@cucumber/gherkin": "^42.0.1",
|
|
48
49
|
"@cucumber/messages": "^34.2.1",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"tsx": "^4.23.0",
|
|
61
62
|
"yaml": "^2.9.0",
|
|
62
63
|
"zod": "^4.5.4",
|
|
63
|
-
"@sdods/db": "0.
|
|
64
|
+
"@sdods/db": "0.8.0"
|
|
64
65
|
},
|
|
65
66
|
"homepage": "https://sdods.com",
|
|
66
67
|
"bugs": {
|