@trylayout/qa 0.1.5 → 0.2.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/README.md +89 -34
- package/build/cli/layoutQa.js +217 -23
- package/build/flows.d.ts +27 -11
- package/build/flows.js +87 -23
- package/build/report.js +44 -18
- package/build/runner.js +31 -12
- package/build/types.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/Layout-App/layout-qa/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Layout QA is a
|
|
7
|
+
Layout QA is a browser QA protocol and runner for frontend changes. It runs deterministic flows against a local or CI-served URL, captures screenshots at meaningful checkpoints, checks browser health, and writes a static HTML report.
|
|
8
8
|
|
|
9
9
|
The core loop is intentionally local:
|
|
10
10
|
|
|
@@ -35,14 +35,14 @@ npx layout-qa run --target-url http://localhost:5173 --scenario happy_path --ope
|
|
|
35
35
|
|
|
36
36
|
## Why This Exists
|
|
37
37
|
|
|
38
|
-
Frontend agents can move faster when they have a visual feedback loop they can run themselves. Layout gives the
|
|
38
|
+
Frontend agents and developers can move faster when they have a visual feedback loop they can run themselves. Layout gives the repo a small protocol:
|
|
39
39
|
|
|
40
|
-
- Wire deterministic API/auth responses behind a
|
|
40
|
+
- Wire deterministic API/auth responses behind a QA env flag such as `LAYOUT_QA=1` or `VITE_LAYOUT_QA=1`.
|
|
41
41
|
- Switch response states with `localStorage["layout.qa.scenario"]`.
|
|
42
|
-
- Declare high-value browser flows in `.layout/qa
|
|
43
|
-
- Run the CLI locally and inspect the generated screenshots/report.
|
|
42
|
+
- Declare high-value browser flows in `.layout/qa.json`.
|
|
43
|
+
- Run the CLI locally or in GitHub Actions and inspect the generated screenshots/report.
|
|
44
44
|
|
|
45
|
-
The goal is not to replace Playwright. The goal is to make the browser QA loop simple enough for a coding agent to
|
|
45
|
+
The goal is not to replace Playwright. The goal is to make the browser QA loop simple enough for a team or coding agent to run before frontend changes merge.
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
@@ -79,10 +79,10 @@ Create a starter flow manifest:
|
|
|
79
79
|
npx @trylayout/qa init
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
Start your app with whatever
|
|
82
|
+
Start your app with whatever QA flag your project uses:
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
|
|
85
|
+
LAYOUT_QA=1 VITE_LAYOUT_QA=1 npm run dev
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Run a scenario:
|
|
@@ -120,42 +120,42 @@ Options:
|
|
|
120
120
|
```text
|
|
121
121
|
--target-url <url> URL of the running frontend to test.
|
|
122
122
|
--scenario <name> Scenario to activate. Defaults to happy_path.
|
|
123
|
-
--flows <path> Flow manifest path. Defaults to .layout/qa
|
|
123
|
+
--flows <path> Flow manifest path. Defaults to .layout/qa.json.
|
|
124
124
|
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
125
125
|
--viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
|
|
126
126
|
--timeout <ms> Browser run timeout. Defaults to 60000.
|
|
127
127
|
--headed Show the browser instead of running headless.
|
|
128
128
|
--open Open the generated local HTML report after the run.
|
|
129
129
|
--json Print machine-readable JSON.
|
|
130
|
+
--upload-url <url> Upload completed run JSON/screenshots to Layout.
|
|
131
|
+
--upload-token <token> Project upload token for hosted Layout reports.
|
|
132
|
+
--repo <name> Repository full name, e.g. owner/repo.
|
|
133
|
+
--branch <name> Branch name for report metadata.
|
|
134
|
+
--commit-sha <sha> Commit SHA for report metadata.
|
|
135
|
+
--pr-number <number> Pull request number for report metadata.
|
|
136
|
+
--run-source <value> local or github_actions. Defaults from environment.
|
|
130
137
|
--force Overwrite an existing flow file during init.
|
|
131
138
|
```
|
|
132
139
|
|
|
133
140
|
## Flow Manifest
|
|
134
141
|
|
|
135
|
-
Default path: `.layout/qa
|
|
142
|
+
Default path: `.layout/qa.json`.
|
|
136
143
|
|
|
137
144
|
```json
|
|
138
145
|
{
|
|
139
|
-
"
|
|
146
|
+
"version": 1,
|
|
147
|
+
"baseUrl": "$LAYOUT_BASE_URL",
|
|
148
|
+
"viewports": ["desktop"],
|
|
140
149
|
"flows": [
|
|
141
150
|
{
|
|
142
151
|
"id": "workspace_smoke",
|
|
143
|
-
"
|
|
144
|
-
"startUrl": "/",
|
|
152
|
+
"label": "Workspace smoke",
|
|
145
153
|
"scenarios": ["happy_path"],
|
|
146
154
|
"steps": [
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"screenshot": true
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
"id": "open_settings",
|
|
155
|
-
"type": "click",
|
|
156
|
-
"text": "Settings",
|
|
157
|
-
"screenshot": true
|
|
158
|
-
}
|
|
155
|
+
{"visit": "/"},
|
|
156
|
+
{"screenshot": "Workspace loaded", "expect": {"text": ["Dashboard"]}},
|
|
157
|
+
{"click": "[data-layout-qa='open-settings']"},
|
|
158
|
+
{"screenshot": "Settings open", "expect": {"text": ["Settings"]}}
|
|
159
159
|
]
|
|
160
160
|
}
|
|
161
161
|
]
|
|
@@ -164,28 +164,36 @@ Default path: `.layout/qa-flows.json`.
|
|
|
164
164
|
|
|
165
165
|
Top-level fields:
|
|
166
166
|
|
|
167
|
-
- `
|
|
167
|
+
- `version`: currently `1`.
|
|
168
|
+
- `baseUrl`: optional reference value for hosted/CI integrations. The CLI still uses `--target-url` as the source of truth.
|
|
169
|
+
- `viewports`: optional default viewport labels for hosted/CI integrations.
|
|
168
170
|
- `flows`: array of flow definitions.
|
|
169
171
|
|
|
170
172
|
Flow fields:
|
|
171
173
|
|
|
172
174
|
- `id`: stable machine-readable flow id.
|
|
173
|
-
- `
|
|
174
|
-
- `startUrl`: path or absolute URL where the flow starts.
|
|
175
|
+
- `label`: human-readable report title.
|
|
175
176
|
- `scenarios`: scenario names this flow can run against. Use an empty array to allow all scenarios.
|
|
176
177
|
- `steps`: ordered browser steps.
|
|
177
178
|
|
|
178
179
|
Step fields:
|
|
179
180
|
|
|
180
181
|
- `id`: stable machine-readable step id.
|
|
181
|
-
- `type`: step type.
|
|
182
|
+
- `type`: explicit step type for advanced steps.
|
|
182
183
|
- `label`: optional human-readable report label.
|
|
183
184
|
- `screenshot`: set `true` to capture a screenshot after the step.
|
|
185
|
+
- `expect`: optional assertions attached to a step.
|
|
184
186
|
- `timeoutMs`: optional per-step timeout.
|
|
185
187
|
- `tolerance`: optional pixel tolerance for layout assertions.
|
|
186
188
|
- `minWidth`, `maxWidth`, `minHeight`, `maxHeight`: optional `assert_box` constraints.
|
|
187
189
|
|
|
188
|
-
Supported
|
|
190
|
+
Supported shorthand steps:
|
|
191
|
+
|
|
192
|
+
- `{"visit": "/path"}`: navigate to a path.
|
|
193
|
+
- `{"click": "[data-layout-qa='action']"}`: click a selector. If the string does not look like a selector, it is treated as visible text.
|
|
194
|
+
- `{"screenshot": "Human label"}`: capture a screenshot checkpoint.
|
|
195
|
+
|
|
196
|
+
Supported explicit step types:
|
|
189
197
|
|
|
190
198
|
- `goto`: navigate to `url`.
|
|
191
199
|
- `click`: click by `selector` or visible `text`.
|
|
@@ -198,8 +206,25 @@ Supported step types:
|
|
|
198
206
|
- `assert_box`: require a `selector` or visible `text` to satisfy width/height constraints.
|
|
199
207
|
- `screenshot`: capture a screenshot checkpoint.
|
|
200
208
|
|
|
209
|
+
Supported expectations:
|
|
210
|
+
|
|
211
|
+
- `{"expect": {"text": ["Visible copy"]}}`: require visible text after the step.
|
|
212
|
+
- `{"expect": {"noConsoleErrors": true}}`: require no console/page errors observed so far.
|
|
213
|
+
|
|
201
214
|
Examples:
|
|
202
215
|
|
|
216
|
+
```json
|
|
217
|
+
{ "visit": "/checkout" }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{ "click": "[data-layout-qa='simulate-payment-timeout']" }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{ "screenshot": "Payment timeout recovery", "expect": { "text": ["Payment failed", "Try again"] } }
|
|
226
|
+
```
|
|
227
|
+
|
|
203
228
|
```json
|
|
204
229
|
{ "id": "open_settings", "type": "click", "text": "Settings" }
|
|
205
230
|
```
|
|
@@ -249,6 +274,34 @@ Presets:
|
|
|
249
274
|
|
|
250
275
|
The selected viewport is written to `result.json`, shown in the HTML report, and included in the run directory name.
|
|
251
276
|
|
|
277
|
+
## Hosted Reports
|
|
278
|
+
|
|
279
|
+
The CLI is local-first. If you have a Layout project upload token, the same run can upload screenshots and report metadata to a hosted Layout report:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npx @trylayout/qa run \
|
|
283
|
+
--target-url http://localhost:5173 \
|
|
284
|
+
--upload-url https://trylayout.com/api/v1/qa/uploads \
|
|
285
|
+
--upload-token "$LAYOUT_UPLOAD_TOKEN" \
|
|
286
|
+
--repo owner/repo \
|
|
287
|
+
--branch "$BRANCH_NAME" \
|
|
288
|
+
--commit-sha "$COMMIT_SHA"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Environment fallbacks:
|
|
292
|
+
|
|
293
|
+
- `LAYOUT_UPLOAD_URL`
|
|
294
|
+
- `LAYOUT_UPLOAD_TOKEN`
|
|
295
|
+
- `LAYOUT_REPOSITORY`
|
|
296
|
+
- `LAYOUT_BRANCH`
|
|
297
|
+
- `LAYOUT_COMMIT_SHA`
|
|
298
|
+
- `LAYOUT_PR_NUMBER`
|
|
299
|
+
- `LAYOUT_RUN_SOURCE`
|
|
300
|
+
|
|
301
|
+
In GitHub Actions, the CLI also reads `GITHUB_REPOSITORY`, `GITHUB_HEAD_REF`, `GITHUB_REF_NAME`, `GITHUB_SHA`, `GITHUB_REF`, and `GITHUB_EVENT_PATH` when explicit flags are not provided.
|
|
302
|
+
|
|
303
|
+
If either upload flag is provided, both `--upload-url` and `--upload-token` are required. Upload failures make the CLI exit nonzero.
|
|
304
|
+
|
|
252
305
|
## Scenarios
|
|
253
306
|
|
|
254
307
|
Before the app loads, the runner sets:
|
|
@@ -280,7 +333,7 @@ Rules:
|
|
|
280
333
|
- Do not add a standalone mock server.
|
|
281
334
|
- Do not require a hosted Layout service.
|
|
282
335
|
- Keep all deterministic response fixtures local to this app.
|
|
283
|
-
- Gate deterministic API/auth responses behind a
|
|
336
|
+
- Gate deterministic API/auth responses behind a QA env flag such as LAYOUT_QA=1, VITE_LAYOUT_QA=1, NEXT_PUBLIC_LAYOUT_QA=1, or the framework-appropriate equivalent.
|
|
284
337
|
- Use localStorage["layout.qa.scenario"] to select at least happy_path, empty, and error response states.
|
|
285
338
|
- Hide any local QA switcher or debug controls when sessionStorage["layout.qa.runner"] === "1".
|
|
286
339
|
|
|
@@ -288,7 +341,7 @@ Implementation:
|
|
|
288
341
|
- Add deterministic API fixtures for the highest-value frontend route.
|
|
289
342
|
- If the app has a central auth/session abstraction, add a deterministic QA user only when the Layout QA env flag is enabled.
|
|
290
343
|
- If auth is scattered or provider-SDK-only, leave a clear note in the PR/code comments and start with public or logged-out flows.
|
|
291
|
-
- Add .layout/qa
|
|
344
|
+
- Add .layout/qa.json with one smoke flow for the most important page.
|
|
292
345
|
- Prefer visible text and stable selectors.
|
|
293
346
|
- Add screenshot checkpoints after meaningful user-visible states.
|
|
294
347
|
|
|
@@ -317,8 +370,10 @@ jobs:
|
|
|
317
370
|
cache: npm
|
|
318
371
|
- run: npm ci
|
|
319
372
|
- run: npx playwright install chromium
|
|
320
|
-
- run:
|
|
321
|
-
- run: npx @trylayout/qa run --target-url http://127.0.0.1:5173 --scenario happy_path
|
|
373
|
+
- run: LAYOUT_QA=1 VITE_LAYOUT_QA=1 npm run dev -- --host 127.0.0.1 --port 5173 &
|
|
374
|
+
- run: npx @trylayout/qa run --target-url http://127.0.0.1:5173 --scenario happy_path --run-source github_actions --upload-url https://trylayout.com/api/v1/qa/uploads --upload-token "$LAYOUT_UPLOAD_TOKEN"
|
|
375
|
+
env:
|
|
376
|
+
LAYOUT_UPLOAD_TOKEN: ${{ secrets.LAYOUT_UPLOAD_TOKEN }}
|
|
322
377
|
- uses: actions/upload-artifact@v4
|
|
323
378
|
if: always()
|
|
324
379
|
with:
|
package/build/cli/layoutQa.js
CHANGED
|
@@ -6,6 +6,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const http_1 = require("http");
|
|
10
|
+
const https_1 = require("https");
|
|
11
|
+
const url_1 = require("url");
|
|
9
12
|
const flows_1 = require("../flows");
|
|
10
13
|
const runner_1 = require("../runner");
|
|
11
14
|
const report_1 = require("../report");
|
|
@@ -21,19 +24,26 @@ Usage:
|
|
|
21
24
|
npx layout-qa run --target-url <url> [options]
|
|
22
25
|
|
|
23
26
|
Commands:
|
|
24
|
-
init Write a starter .layout/qa
|
|
27
|
+
init Write a starter .layout/qa.json.
|
|
25
28
|
run Run browser QA and write a local HTML report.
|
|
26
29
|
|
|
27
30
|
Options:
|
|
28
31
|
--target-url <url> URL of the running frontend to test.
|
|
29
32
|
--scenario <name> Scenario to activate. Defaults to happy_path.
|
|
30
|
-
--flows <path> Flow manifest path. Defaults to .layout/qa
|
|
33
|
+
--flows <path> Flow manifest path. Defaults to .layout/qa.json.
|
|
31
34
|
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
32
35
|
--viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
|
|
33
36
|
--timeout <ms> Browser run timeout. Defaults to LAYOUT_QA_TEST_TIMEOUT_MS or 60000.
|
|
34
37
|
--headed Show the browser instead of running headless.
|
|
35
38
|
--open Open the generated local HTML report after the run.
|
|
36
39
|
--json Print machine-readable JSON.
|
|
40
|
+
--upload-url <url> Upload completed run JSON/screenshots to Layout.
|
|
41
|
+
--upload-token <token> Project upload token for hosted Layout reports.
|
|
42
|
+
--repo <name> Repository full name, e.g. owner/repo.
|
|
43
|
+
--branch <name> Branch name for report metadata.
|
|
44
|
+
--commit-sha <sha> Commit SHA for report metadata.
|
|
45
|
+
--pr-number <number> Pull request number for report metadata.
|
|
46
|
+
--run-source <value> local or github_actions. Defaults from environment.
|
|
37
47
|
--force Overwrite an existing flow file during init.
|
|
38
48
|
--help Show this help.
|
|
39
49
|
`);
|
|
@@ -47,10 +57,34 @@ function readFlag(args, name) {
|
|
|
47
57
|
function hasFlag(args, name) {
|
|
48
58
|
return args.includes(name);
|
|
49
59
|
}
|
|
60
|
+
function envValue(name) {
|
|
61
|
+
return process.env[name] || '';
|
|
62
|
+
}
|
|
63
|
+
async function githubEventPullRequestNumber() {
|
|
64
|
+
const eventPath = envValue('GITHUB_EVENT_PATH');
|
|
65
|
+
if (!eventPath)
|
|
66
|
+
return '';
|
|
67
|
+
const content = await promises_1.default.readFile(eventPath, 'utf8').catch(() => '');
|
|
68
|
+
if (!content)
|
|
69
|
+
return '';
|
|
70
|
+
const event = JSON.parse(content);
|
|
71
|
+
return event.pull_request?.number ? String(event.pull_request.number) : '';
|
|
72
|
+
}
|
|
73
|
+
function inferGithubPrNumber() {
|
|
74
|
+
const ref = envValue('GITHUB_REF');
|
|
75
|
+
const match = ref.match(/^refs\/pull\/(\d+)\//);
|
|
76
|
+
return match?.[1] || '';
|
|
77
|
+
}
|
|
78
|
+
function inferBranch() {
|
|
79
|
+
return envValue('GITHUB_HEAD_REF') || envValue('GITHUB_REF_NAME');
|
|
80
|
+
}
|
|
50
81
|
function parseArgs(args) {
|
|
51
82
|
const command = args[0] && !args[0].startsWith('--') ? args[0] : 'help';
|
|
52
83
|
const timeoutValue = readFlag(args, '--timeout');
|
|
53
84
|
const parsedTimeoutMs = timeoutValue ? Number(timeoutValue) : undefined;
|
|
85
|
+
const rawRunSource = readFlag(args, '--run-source') ||
|
|
86
|
+
envValue('LAYOUT_RUN_SOURCE') ||
|
|
87
|
+
(envValue('GITHUB_ACTIONS') === 'true' ? 'github_actions' : 'local');
|
|
54
88
|
if (timeoutValue &&
|
|
55
89
|
(!Number.isFinite(parsedTimeoutMs) || Number(parsedTimeoutMs) <= 0)) {
|
|
56
90
|
throw new Error('--timeout must be a positive number of milliseconds.');
|
|
@@ -61,6 +95,19 @@ function parseArgs(args) {
|
|
|
61
95
|
scenario: readFlag(args, '--scenario') || 'happy_path',
|
|
62
96
|
flowsPath: readFlag(args, '--flows'),
|
|
63
97
|
outDir: readFlag(args, '--out'),
|
|
98
|
+
uploadUrl: readFlag(args, '--upload-url') || envValue('LAYOUT_UPLOAD_URL'),
|
|
99
|
+
uploadToken: readFlag(args, '--upload-token') || envValue('LAYOUT_UPLOAD_TOKEN'),
|
|
100
|
+
repo: readFlag(args, '--repo') ||
|
|
101
|
+
envValue('LAYOUT_REPOSITORY') ||
|
|
102
|
+
envValue('GITHUB_REPOSITORY'),
|
|
103
|
+
branch: readFlag(args, '--branch') || envValue('LAYOUT_BRANCH') || inferBranch(),
|
|
104
|
+
commitSha: readFlag(args, '--commit-sha') ||
|
|
105
|
+
envValue('LAYOUT_COMMIT_SHA') ||
|
|
106
|
+
envValue('GITHUB_SHA'),
|
|
107
|
+
prNumber: readFlag(args, '--pr-number') ||
|
|
108
|
+
envValue('LAYOUT_PR_NUMBER') ||
|
|
109
|
+
inferGithubPrNumber(),
|
|
110
|
+
runSource: rawRunSource === 'github_actions' ? 'github_actions' : 'local',
|
|
64
111
|
viewport: (0, viewports_1.parseViewport)(readFlag(args, '--viewport')),
|
|
65
112
|
timeoutMs: parsedTimeoutMs,
|
|
66
113
|
headed: hasFlag(args, '--headed'),
|
|
@@ -92,6 +139,7 @@ function statusIcon(passed) {
|
|
|
92
139
|
}
|
|
93
140
|
function printHumanSummary(input) {
|
|
94
141
|
const passed = (0, runner_1.isQaRunPassed)(input.result);
|
|
142
|
+
const flows = resultFlows(input.result);
|
|
95
143
|
process.stdout.write(`\nLayout QA ${passed ? 'passed' : 'failed'}\n` +
|
|
96
144
|
`Scenario: ${input.scenario}\n` +
|
|
97
145
|
`Target: ${input.targetUrl}\n` +
|
|
@@ -99,14 +147,15 @@ function printHumanSummary(input) {
|
|
|
99
147
|
? (0, viewports_1.formatViewport)(input.result.viewport)
|
|
100
148
|
: 'unavailable'}\n` +
|
|
101
149
|
`Final URL: ${input.result.finalUrl || 'unavailable'}\n` +
|
|
102
|
-
`
|
|
150
|
+
`Flows: ${flows.length || 0}\n` +
|
|
103
151
|
`Manifest: ${input.manifestFound ? input.manifestPath : 'not found; default smoke'}\n\n`);
|
|
104
152
|
for (const check of input.result.checks) {
|
|
105
153
|
process.stdout.write(`${statusIcon(check.passed)} ${check.label}${check.detail ? ` - ${check.detail}` : ''}\n`);
|
|
106
154
|
}
|
|
107
|
-
|
|
155
|
+
for (const flow of flows) {
|
|
108
156
|
process.stdout.write('\nFlow steps:\n');
|
|
109
|
-
|
|
157
|
+
process.stdout.write(`${flow.name} (${flow.source})\n`);
|
|
158
|
+
for (const step of flow.steps) {
|
|
110
159
|
process.stdout.write(`${statusIcon(step.status === 'passed')} ${step.label || step.id}${step.detail ? ` - ${step.detail}` : ''}\n`);
|
|
111
160
|
}
|
|
112
161
|
}
|
|
@@ -126,35 +175,168 @@ function printHumanSummary(input) {
|
|
|
126
175
|
function resultForConsole(result) {
|
|
127
176
|
const clean = JSON.parse(JSON.stringify(result));
|
|
128
177
|
delete clean.screenshotDataUrl;
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
178
|
+
const scrubFlow = (flow) => {
|
|
179
|
+
for (const step of flow?.steps || []) {
|
|
180
|
+
delete step.screenshotDataUrl;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
scrubFlow(clean.flow);
|
|
184
|
+
const flows = clean.flows;
|
|
185
|
+
for (const flow of flows || []) {
|
|
186
|
+
scrubFlow(flow);
|
|
132
187
|
}
|
|
133
188
|
return clean;
|
|
134
189
|
}
|
|
190
|
+
function resultFlows(result) {
|
|
191
|
+
return result.flows?.length
|
|
192
|
+
? result.flows
|
|
193
|
+
: result.flow
|
|
194
|
+
? [result.flow]
|
|
195
|
+
: [];
|
|
196
|
+
}
|
|
197
|
+
function combineFlowRunResults(results) {
|
|
198
|
+
if (results.length === 1) {
|
|
199
|
+
const [result] = results;
|
|
200
|
+
return {
|
|
201
|
+
...result,
|
|
202
|
+
flows: resultFlows(result),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const flows = results
|
|
206
|
+
.map(result => result.flow)
|
|
207
|
+
.filter((flow) => Boolean(flow));
|
|
208
|
+
const lastResult = results[results.length - 1];
|
|
209
|
+
const firstFailed = results.find(result => !(0, runner_1.isQaRunPassed)(result));
|
|
210
|
+
return {
|
|
211
|
+
finalUrl: lastResult.finalUrl,
|
|
212
|
+
title: lastResult.title,
|
|
213
|
+
scenarioActive: lastResult.scenarioActive,
|
|
214
|
+
controlsPresent: lastResult.controlsPresent,
|
|
215
|
+
screenshotDataUrl: lastResult.screenshotDataUrl,
|
|
216
|
+
screenshotBytes: lastResult.screenshotBytes,
|
|
217
|
+
bodyTextSample: results.map(result => result.bodyTextSample || '').join('\n\n'),
|
|
218
|
+
viewport: lastResult.viewport,
|
|
219
|
+
checks: results.flatMap(result => {
|
|
220
|
+
const flowName = result.flow?.name || 'Flow';
|
|
221
|
+
const flowId = result.flow?.id || 'flow';
|
|
222
|
+
return result.checks.map(check => ({
|
|
223
|
+
...check,
|
|
224
|
+
id: `${flowId}_${check.id}`,
|
|
225
|
+
label: `${flowName}: ${check.label}`,
|
|
226
|
+
}));
|
|
227
|
+
}),
|
|
228
|
+
issues: results.flatMap(result => result.issues),
|
|
229
|
+
flow: flows[0],
|
|
230
|
+
flows,
|
|
231
|
+
nextAction: firstFailed?.nextAction || lastResult.nextAction,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function readFileAsDataUrl(filePath) {
|
|
235
|
+
return promises_1.default.readFile(filePath).then(buffer => {
|
|
236
|
+
const ext = path_1.default.extname(filePath).toLowerCase();
|
|
237
|
+
const mimeType = ext === '.html'
|
|
238
|
+
? 'text/html'
|
|
239
|
+
: ext === '.json'
|
|
240
|
+
? 'application/json'
|
|
241
|
+
: 'image/jpeg';
|
|
242
|
+
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function postJson(input) {
|
|
246
|
+
const target = new url_1.URL(input.url);
|
|
247
|
+
const request = target.protocol === 'https:' ? https_1.request : http_1.request;
|
|
248
|
+
const body = JSON.stringify(input.body);
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
const req = request({
|
|
251
|
+
protocol: target.protocol,
|
|
252
|
+
hostname: target.hostname,
|
|
253
|
+
port: target.port,
|
|
254
|
+
path: `${target.pathname}${target.search}`,
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: {
|
|
257
|
+
Authorization: `Bearer ${input.token}`,
|
|
258
|
+
'Content-Type': 'application/json',
|
|
259
|
+
'Content-Length': Buffer.byteLength(body),
|
|
260
|
+
'User-Agent': '@trylayout/qa',
|
|
261
|
+
},
|
|
262
|
+
}, res => {
|
|
263
|
+
let responseBody = '';
|
|
264
|
+
res.setEncoding('utf8');
|
|
265
|
+
res.on('data', chunk => {
|
|
266
|
+
responseBody += chunk;
|
|
267
|
+
});
|
|
268
|
+
res.on('end', () => {
|
|
269
|
+
const parsed = responseBody
|
|
270
|
+
? JSON.parse(responseBody)
|
|
271
|
+
: {};
|
|
272
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
273
|
+
reject(new Error(`Upload failed (${res.statusCode || 'unknown'}): ${parsed.message || parsed.error || responseBody}`));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
resolve(parsed);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
req.on('error', reject);
|
|
280
|
+
req.write(body);
|
|
281
|
+
req.end();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async function uploadRun(input) {
|
|
285
|
+
if (!input.options.uploadUrl && !input.options.uploadToken)
|
|
286
|
+
return null;
|
|
287
|
+
if (!input.options.uploadUrl || !input.options.uploadToken) {
|
|
288
|
+
throw new Error('--upload-url and --upload-token must be provided together.');
|
|
289
|
+
}
|
|
290
|
+
const prNumber = input.options.prNumber || (await githubEventPullRequestNumber());
|
|
291
|
+
const reportDataUrl = await readFileAsDataUrl(input.artifacts.reportPath);
|
|
292
|
+
return postJson({
|
|
293
|
+
url: input.options.uploadUrl,
|
|
294
|
+
token: input.options.uploadToken,
|
|
295
|
+
body: {
|
|
296
|
+
status: input.passed ? 'passed' : 'failed',
|
|
297
|
+
runSource: input.options.runSource,
|
|
298
|
+
repository: input.options.repo,
|
|
299
|
+
branch: input.options.branch,
|
|
300
|
+
commitSha: input.options.commitSha,
|
|
301
|
+
prNumber: prNumber ? Number(prNumber) : undefined,
|
|
302
|
+
scenario: input.options.scenario,
|
|
303
|
+
targetUrl: input.options.targetUrl,
|
|
304
|
+
manifestPath: input.manifestPath,
|
|
305
|
+
manifestFound: input.manifestFound,
|
|
306
|
+
result: input.result,
|
|
307
|
+
report: {
|
|
308
|
+
fileName: 'index.html',
|
|
309
|
+
dataUrl: reportDataUrl,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
135
314
|
async function runCommand(options) {
|
|
136
315
|
if (!options.targetUrl) {
|
|
137
316
|
throw new Error('--target-url is required.');
|
|
138
317
|
}
|
|
139
|
-
const {
|
|
318
|
+
const { flows, manifestPath, manifestFound } = await (0, flows_1.loadFlows)({
|
|
140
319
|
flowsPath: options.flowsPath,
|
|
141
320
|
scenario: options.scenario,
|
|
142
321
|
});
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
322
|
+
const results = [];
|
|
323
|
+
for (const flow of flows) {
|
|
324
|
+
try {
|
|
325
|
+
results.push(await (0, runner_1.runLayoutQaBrowser)({
|
|
326
|
+
targetUrl: options.targetUrl,
|
|
327
|
+
scenario: options.scenario,
|
|
328
|
+
flow,
|
|
329
|
+
timeoutMs: options.timeoutMs || (0, flows_1.getTestTimeoutMs)(),
|
|
330
|
+
headless: !options.headed,
|
|
331
|
+
viewport: options.viewport,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
+
results.push((0, runner_1.buildRunnerErrorResult)(message, options.viewport));
|
|
337
|
+
}
|
|
157
338
|
}
|
|
339
|
+
const result = combineFlowRunResults(results);
|
|
158
340
|
const artifacts = await (0, report_1.writeArtifacts)({
|
|
159
341
|
outDir: options.outDir,
|
|
160
342
|
scenario: options.scenario,
|
|
@@ -164,6 +346,14 @@ async function runCommand(options) {
|
|
|
164
346
|
result,
|
|
165
347
|
});
|
|
166
348
|
const passed = (0, runner_1.isQaRunPassed)(result);
|
|
349
|
+
const uploadResponse = await uploadRun({
|
|
350
|
+
options,
|
|
351
|
+
result,
|
|
352
|
+
artifacts,
|
|
353
|
+
manifestPath,
|
|
354
|
+
manifestFound,
|
|
355
|
+
passed,
|
|
356
|
+
});
|
|
167
357
|
if (options.json) {
|
|
168
358
|
process.stdout.write(`${JSON.stringify({
|
|
169
359
|
status: passed ? 'passed' : 'failed',
|
|
@@ -173,6 +363,7 @@ async function runCommand(options) {
|
|
|
173
363
|
manifestPath,
|
|
174
364
|
manifestFound,
|
|
175
365
|
artifacts,
|
|
366
|
+
upload: uploadResponse,
|
|
176
367
|
result: resultForConsole(result),
|
|
177
368
|
}, null, 2)}\n`);
|
|
178
369
|
}
|
|
@@ -189,6 +380,9 @@ async function runCommand(options) {
|
|
|
189
380
|
if (options.open) {
|
|
190
381
|
await (0, report_1.openReport)(artifacts.reportPath);
|
|
191
382
|
}
|
|
383
|
+
if (uploadResponse && !options.json) {
|
|
384
|
+
process.stdout.write(`Uploaded: ${String(uploadResponse.reportUrl || uploadResponse.runId)}\n`);
|
|
385
|
+
}
|
|
192
386
|
process.exitCode = passed ? 0 : 1;
|
|
193
387
|
}
|
|
194
388
|
async function main() {
|
package/build/flows.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { LoadedQaFlow, QaFlowDefinition } from './types';
|
|
2
2
|
export declare const DEFAULT_TEST_TIMEOUT_MS: number;
|
|
3
3
|
export declare const SCREENSHOT_LIMIT_BYTES: number;
|
|
4
|
-
export declare const FLOW_MANIFEST_PATH = ".layout/qa
|
|
4
|
+
export declare const FLOW_MANIFEST_PATH = ".layout/qa.json";
|
|
5
5
|
export declare const QA_DOCS_URL = "https://github.com/Layout-App/layout-qa#readme";
|
|
6
6
|
export declare function getTestTimeoutMs(): number;
|
|
7
|
-
export declare function selectFlowFromManifest(raw: unknown, scenario: string): QaFlowDefinition
|
|
7
|
+
export declare function selectFlowFromManifest(raw: unknown, scenario: string): QaFlowDefinition;
|
|
8
|
+
export declare function selectFlowsFromManifest(raw: unknown, scenario: string): QaFlowDefinition[];
|
|
8
9
|
export declare function parseFlowManifestContent(content: string, scenario: string): LoadedQaFlow | null;
|
|
10
|
+
export declare function parseFlowsManifestContent(content: string, scenario: string): LoadedQaFlow[];
|
|
9
11
|
export declare function defaultFlow(): LoadedQaFlow;
|
|
10
12
|
export declare function resolveDefaultPath(defaultPath: string): Promise<string>;
|
|
11
13
|
export declare function loadFlow(input: {
|
|
@@ -16,18 +18,32 @@ export declare function loadFlow(input: {
|
|
|
16
18
|
manifestPath: string;
|
|
17
19
|
manifestFound: boolean;
|
|
18
20
|
}>;
|
|
21
|
+
export declare function loadFlows(input: {
|
|
22
|
+
flowsPath: string;
|
|
23
|
+
scenario: string;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
flows: LoadedQaFlow[];
|
|
26
|
+
manifestPath: string;
|
|
27
|
+
manifestFound: boolean;
|
|
28
|
+
}>;
|
|
19
29
|
export declare function starterFlowManifest(): {
|
|
20
|
-
|
|
30
|
+
version: number;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
viewports: string[];
|
|
21
33
|
flows: {
|
|
22
34
|
id: string;
|
|
23
|
-
|
|
24
|
-
startUrl: string;
|
|
35
|
+
label: string;
|
|
25
36
|
scenarios: string[];
|
|
26
|
-
steps: {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
steps: ({
|
|
38
|
+
visit: string;
|
|
39
|
+
screenshot?: undefined;
|
|
40
|
+
expect?: undefined;
|
|
41
|
+
} | {
|
|
42
|
+
screenshot: string;
|
|
43
|
+
expect: {
|
|
44
|
+
noConsoleErrors: boolean;
|
|
45
|
+
};
|
|
46
|
+
visit?: undefined;
|
|
47
|
+
})[];
|
|
32
48
|
}[];
|
|
33
49
|
};
|
package/build/flows.js
CHANGED
|
@@ -6,16 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.QA_DOCS_URL = exports.FLOW_MANIFEST_PATH = exports.SCREENSHOT_LIMIT_BYTES = exports.DEFAULT_TEST_TIMEOUT_MS = void 0;
|
|
7
7
|
exports.getTestTimeoutMs = getTestTimeoutMs;
|
|
8
8
|
exports.selectFlowFromManifest = selectFlowFromManifest;
|
|
9
|
+
exports.selectFlowsFromManifest = selectFlowsFromManifest;
|
|
9
10
|
exports.parseFlowManifestContent = parseFlowManifestContent;
|
|
11
|
+
exports.parseFlowsManifestContent = parseFlowsManifestContent;
|
|
10
12
|
exports.defaultFlow = defaultFlow;
|
|
11
13
|
exports.resolveDefaultPath = resolveDefaultPath;
|
|
12
14
|
exports.loadFlow = loadFlow;
|
|
15
|
+
exports.loadFlows = loadFlows;
|
|
13
16
|
exports.starterFlowManifest = starterFlowManifest;
|
|
14
17
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
15
18
|
const path_1 = __importDefault(require("path"));
|
|
16
19
|
exports.DEFAULT_TEST_TIMEOUT_MS = 60 * 1000;
|
|
17
20
|
exports.SCREENSHOT_LIMIT_BYTES = 300 * 1024;
|
|
18
|
-
exports.FLOW_MANIFEST_PATH = '.layout/qa
|
|
21
|
+
exports.FLOW_MANIFEST_PATH = '.layout/qa.json';
|
|
19
22
|
exports.QA_DOCS_URL = 'https://github.com/Layout-App/layout-qa#readme';
|
|
20
23
|
function getTestTimeoutMs() {
|
|
21
24
|
const value = Number(process.env.LAYOUT_QA_TEST_TIMEOUT_MS);
|
|
@@ -40,24 +43,66 @@ function stringArray(value) {
|
|
|
40
43
|
? value.filter(item => typeof item === 'string')
|
|
41
44
|
: [];
|
|
42
45
|
}
|
|
46
|
+
function expectTextArray(value) {
|
|
47
|
+
if (!isRecord(value))
|
|
48
|
+
return [];
|
|
49
|
+
const text = value.text;
|
|
50
|
+
if (typeof text === 'string')
|
|
51
|
+
return [text];
|
|
52
|
+
return stringArray(text);
|
|
53
|
+
}
|
|
54
|
+
function isLikelySelector(value) {
|
|
55
|
+
return /^(#|\.|\[)/.test(value) || /[#.[\]:>~+]/.test(value);
|
|
56
|
+
}
|
|
57
|
+
function shortcutStep(value, index) {
|
|
58
|
+
if (typeof value.visit === 'string') {
|
|
59
|
+
return {
|
|
60
|
+
type: 'goto',
|
|
61
|
+
url: value.visit.trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (typeof value.click === 'string') {
|
|
65
|
+
const target = value.click.trim();
|
|
66
|
+
return {
|
|
67
|
+
type: 'click',
|
|
68
|
+
...(isLikelySelector(target) ? { selector: target } : { text: target }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (typeof value.screenshot === 'string' || value.screenshot === true) {
|
|
72
|
+
return {
|
|
73
|
+
type: 'screenshot',
|
|
74
|
+
label: typeof value.screenshot === 'string'
|
|
75
|
+
? value.screenshot.trim()
|
|
76
|
+
: `Screenshot ${index + 1}`,
|
|
77
|
+
screenshot: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
43
82
|
function normalizeFlowStep(value, index) {
|
|
44
83
|
if (!isRecord(value))
|
|
45
84
|
return null;
|
|
46
|
-
const
|
|
85
|
+
const shortcut = shortcutStep(value, index);
|
|
86
|
+
const type = stringValue(value.type || shortcut?.type).trim();
|
|
47
87
|
if (!type)
|
|
48
88
|
return null;
|
|
89
|
+
const expect = isRecord(value.expect) ? value.expect : {};
|
|
90
|
+
const clickTarget = stringValue(value.click).trim();
|
|
49
91
|
return {
|
|
50
92
|
id: stringValue(value.id).trim() ||
|
|
51
93
|
`${type.replace(/[^a-zA-Z0-9_-]+/g, '_')}_${index + 1}`,
|
|
52
|
-
type,
|
|
53
|
-
label: stringValue(value.label || value.name).trim(),
|
|
54
|
-
text: stringValue(value.text).trim(),
|
|
55
|
-
|
|
94
|
+
type: type === 'visit' ? 'goto' : type,
|
|
95
|
+
label: stringValue(value.label || value.name || shortcut?.label).trim(),
|
|
96
|
+
text: stringValue(value.text || shortcut?.text).trim(),
|
|
97
|
+
expectText: expectTextArray(value.expect),
|
|
98
|
+
expectNoConsoleErrors: isRecord(expect) && expect.noConsoleErrors === true ? true : undefined,
|
|
99
|
+
selector: stringValue(value.selector || shortcut?.selector).trim() ||
|
|
100
|
+
(clickTarget && isLikelySelector(clickTarget) ? clickTarget : ''),
|
|
56
101
|
value: stringValue(value.value),
|
|
57
|
-
url: stringValue(value.url || value.path).trim(),
|
|
102
|
+
url: stringValue(value.url || value.path || value.visit || shortcut?.url).trim(),
|
|
58
103
|
contains: stringValue(value.contains).trim(),
|
|
59
104
|
exact: booleanValue(value.exact),
|
|
60
|
-
screenshot: booleanValue(value.screenshot, type === 'screenshot'),
|
|
105
|
+
screenshot: booleanValue(value.screenshot, type === 'screenshot' || shortcut?.screenshot === true),
|
|
61
106
|
timeoutMs: numberValue(value.timeoutMs),
|
|
62
107
|
tolerance: numberValue(value.tolerance),
|
|
63
108
|
minWidth: numberValue(value.minWidth),
|
|
@@ -78,26 +123,33 @@ function normalizeFlow(value, index) {
|
|
|
78
123
|
const id = stringValue(value.id).trim() || `flow_${index + 1}`;
|
|
79
124
|
return {
|
|
80
125
|
id,
|
|
81
|
-
name: stringValue(value.name).trim() || id,
|
|
126
|
+
name: stringValue(value.label || value.name).trim() || id,
|
|
82
127
|
startUrl: stringValue(value.startUrl).trim() || '/',
|
|
83
128
|
scenarios: stringArray(value.scenarios),
|
|
84
129
|
steps,
|
|
85
130
|
};
|
|
86
131
|
}
|
|
87
132
|
function selectFlowFromManifest(raw, scenario) {
|
|
133
|
+
return selectFlowsFromManifest(raw, scenario)[0] || null;
|
|
134
|
+
}
|
|
135
|
+
function selectFlowsFromManifest(raw, scenario) {
|
|
88
136
|
if (!isRecord(raw) || !Array.isArray(raw.flows))
|
|
89
|
-
return
|
|
137
|
+
return [];
|
|
90
138
|
const flows = raw.flows
|
|
91
139
|
.map((flow, index) => normalizeFlow(flow, index))
|
|
92
140
|
.filter((flow) => Boolean(flow));
|
|
93
141
|
if (flows.length === 0)
|
|
94
|
-
return
|
|
95
|
-
|
|
142
|
+
return [];
|
|
143
|
+
const selected = flows.filter(flow => flow.scenarios.length === 0 || flow.scenarios.includes(scenario));
|
|
144
|
+
return selected.length ? selected : [flows[0]];
|
|
96
145
|
}
|
|
97
146
|
function parseFlowManifestContent(content, scenario) {
|
|
98
147
|
const flow = selectFlowFromManifest(JSON.parse(content), scenario);
|
|
99
148
|
return flow ? { ...flow, source: 'manifest' } : null;
|
|
100
149
|
}
|
|
150
|
+
function parseFlowsManifestContent(content, scenario) {
|
|
151
|
+
return selectFlowsFromManifest(JSON.parse(content), scenario).map(flow => ({ ...flow, source: 'manifest' }));
|
|
152
|
+
}
|
|
101
153
|
function defaultFlow() {
|
|
102
154
|
return {
|
|
103
155
|
id: 'target_smoke',
|
|
@@ -139,6 +191,14 @@ async function resolveDefaultPath(defaultPath) {
|
|
|
139
191
|
return cwdPath;
|
|
140
192
|
}
|
|
141
193
|
async function loadFlow(input) {
|
|
194
|
+
const loaded = await loadFlows(input);
|
|
195
|
+
return {
|
|
196
|
+
flow: loaded.flows[0],
|
|
197
|
+
manifestPath: loaded.manifestPath,
|
|
198
|
+
manifestFound: loaded.manifestFound,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async function loadFlows(input) {
|
|
142
202
|
const manifestPath = input.flowsPath
|
|
143
203
|
? path_1.default.resolve(process.cwd(), input.flowsPath)
|
|
144
204
|
: await resolveDefaultPath(exports.FLOW_MANIFEST_PATH);
|
|
@@ -150,33 +210,37 @@ async function loadFlow(input) {
|
|
|
150
210
|
});
|
|
151
211
|
if (!content) {
|
|
152
212
|
return {
|
|
153
|
-
|
|
213
|
+
flows: [defaultFlow()],
|
|
154
214
|
manifestPath,
|
|
155
215
|
manifestFound: false,
|
|
156
216
|
};
|
|
157
217
|
}
|
|
158
|
-
const
|
|
218
|
+
const flows = parseFlowsManifestContent(content, input.scenario);
|
|
159
219
|
return {
|
|
160
|
-
|
|
220
|
+
flows: flows.length ? flows : [defaultFlow()],
|
|
161
221
|
manifestPath,
|
|
162
|
-
manifestFound:
|
|
222
|
+
manifestFound: flows.length > 0,
|
|
163
223
|
};
|
|
164
224
|
}
|
|
165
225
|
function starterFlowManifest() {
|
|
166
226
|
return {
|
|
167
|
-
|
|
227
|
+
version: 1,
|
|
228
|
+
baseUrl: '$LAYOUT_BASE_URL',
|
|
229
|
+
viewports: ['desktop'],
|
|
168
230
|
flows: [
|
|
169
231
|
{
|
|
170
232
|
id: 'smoke',
|
|
171
|
-
|
|
172
|
-
startUrl: '/',
|
|
233
|
+
label: 'Smoke',
|
|
173
234
|
scenarios: ['happy_path'],
|
|
174
235
|
steps: [
|
|
175
236
|
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
screenshot:
|
|
237
|
+
visit: '/',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
screenshot: 'Initial screen',
|
|
241
|
+
expect: {
|
|
242
|
+
noConsoleErrors: true,
|
|
243
|
+
},
|
|
180
244
|
},
|
|
181
245
|
],
|
|
182
246
|
},
|
package/build/report.js
CHANGED
|
@@ -19,6 +19,9 @@ function safeName(value) {
|
|
|
19
19
|
function stepScreenshotFileName(index, stepId) {
|
|
20
20
|
return `${String(index + 1).padStart(2, '0')}-${safeName(stepId)}.jpg`;
|
|
21
21
|
}
|
|
22
|
+
function flowStepScreenshotFileName(input) {
|
|
23
|
+
return `${safeName(input.flow.id || 'flow')}-${stepScreenshotFileName(input.stepIndex, input.stepId)}`;
|
|
24
|
+
}
|
|
22
25
|
async function writeDataUrl(filePath, dataUrl) {
|
|
23
26
|
const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
|
|
24
27
|
if (!match)
|
|
@@ -29,8 +32,7 @@ async function writeDataUrl(filePath, dataUrl) {
|
|
|
29
32
|
async function writeStepScreenshot(input) {
|
|
30
33
|
if (!input.step.screenshotDataUrl)
|
|
31
34
|
return '';
|
|
32
|
-
const
|
|
33
|
-
const filePath = path_1.default.join(input.screenshotsDir, fileName);
|
|
35
|
+
const filePath = path_1.default.join(input.screenshotsDir, input.fileName);
|
|
34
36
|
const written = await writeDataUrl(filePath, input.step.screenshotDataUrl);
|
|
35
37
|
return written ? filePath : '';
|
|
36
38
|
}
|
|
@@ -79,14 +81,25 @@ function renderIssueList(result) {
|
|
|
79
81
|
</li>`)
|
|
80
82
|
.join('')}</ul>`;
|
|
81
83
|
}
|
|
84
|
+
function resultFlows(result) {
|
|
85
|
+
return result.flows?.length
|
|
86
|
+
? result.flows
|
|
87
|
+
: result.flow
|
|
88
|
+
? [result.flow]
|
|
89
|
+
: [];
|
|
90
|
+
}
|
|
82
91
|
function renderStepList(input) {
|
|
83
|
-
const steps = input.
|
|
92
|
+
const steps = input.flow.steps || [];
|
|
84
93
|
if (steps.length === 0)
|
|
85
94
|
return '<p class="empty">No flow steps declared.</p>';
|
|
86
95
|
return steps
|
|
87
96
|
.map((step, index) => {
|
|
88
97
|
const screenshotHref = step.screenshotDataUrl
|
|
89
|
-
? relativeHref(input.runDir, path_1.default.join(input.runDir, 'screenshots',
|
|
98
|
+
? relativeHref(input.runDir, path_1.default.join(input.runDir, 'screenshots', flowStepScreenshotFileName({
|
|
99
|
+
flow: input.flow,
|
|
100
|
+
stepIndex: index,
|
|
101
|
+
stepId: step.id,
|
|
102
|
+
})))
|
|
90
103
|
: '';
|
|
91
104
|
return `<article class="step">
|
|
92
105
|
<header class="step-header">
|
|
@@ -111,7 +124,12 @@ function renderReport(input) {
|
|
|
111
124
|
? relativeHref(input.runDir, path_1.default.join(input.runDir, 'screenshots', 'final.jpg'))
|
|
112
125
|
: '';
|
|
113
126
|
const resultHref = relativeHref(input.runDir, input.resultPath);
|
|
114
|
-
const
|
|
127
|
+
const flows = resultFlows(input.result);
|
|
128
|
+
const flowSummary = flows.length === 0
|
|
129
|
+
? 'None'
|
|
130
|
+
: flows.length === 1
|
|
131
|
+
? flows[0].name
|
|
132
|
+
: `${flows.length} flows`;
|
|
115
133
|
const viewport = input.result.viewport;
|
|
116
134
|
const failedCheckCount = input.result.checks.filter(check => !check.passed).length;
|
|
117
135
|
return `<!doctype html>
|
|
@@ -283,7 +301,7 @@ function renderReport(input) {
|
|
|
283
301
|
|
|
284
302
|
<dl class="summary">
|
|
285
303
|
<div class="metric"><dt>Scenario</dt><dd>${escapeHtml(input.scenario)}</dd></div>
|
|
286
|
-
<div class="metric"><dt>
|
|
304
|
+
<div class="metric"><dt>Flows</dt><dd>${escapeHtml(flowSummary)}</dd></div>
|
|
287
305
|
<div class="metric"><dt>Target URL</dt><dd>${escapeHtml(input.targetUrl)}</dd></div>
|
|
288
306
|
<div class="metric"><dt>Viewport</dt><dd>${escapeHtml(viewport ? (0, viewports_1.formatViewport)(viewport) : 'unavailable')}</dd></div>
|
|
289
307
|
<div class="metric"><dt>Final URL</dt><dd>${escapeHtml(input.result.finalUrl || 'unavailable')}</dd></div>
|
|
@@ -294,10 +312,12 @@ function renderReport(input) {
|
|
|
294
312
|
<ul class="stack">${renderCheckList(input.result)}</ul>
|
|
295
313
|
</section>
|
|
296
314
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
315
|
+
${flows
|
|
316
|
+
.map(flow => `<section>
|
|
317
|
+
<h2>${escapeHtml(flow.name || flow.id)}</h2>
|
|
318
|
+
${renderStepList({ runDir: input.runDir, flow })}
|
|
319
|
+
</section>`)
|
|
320
|
+
.join('')}
|
|
301
321
|
|
|
302
322
|
${finalScreenshotHref
|
|
303
323
|
? `<section>
|
|
@@ -347,14 +367,20 @@ async function writeArtifacts(input) {
|
|
|
347
367
|
screenshots.push(finalPath);
|
|
348
368
|
}
|
|
349
369
|
}
|
|
350
|
-
for (const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
for (const flow of resultFlows(input.result)) {
|
|
371
|
+
for (const [index, step] of flow.steps.entries()) {
|
|
372
|
+
const screenshotPath = await writeStepScreenshot({
|
|
373
|
+
screenshotsDir,
|
|
374
|
+
fileName: flowStepScreenshotFileName({
|
|
375
|
+
flow,
|
|
376
|
+
stepIndex: index,
|
|
377
|
+
stepId: step.id,
|
|
378
|
+
}),
|
|
379
|
+
step,
|
|
380
|
+
});
|
|
381
|
+
if (screenshotPath)
|
|
382
|
+
screenshots.push(screenshotPath);
|
|
383
|
+
}
|
|
358
384
|
}
|
|
359
385
|
const resultPath = path_1.default.join(runDir, 'result.json');
|
|
360
386
|
await promises_1.default.writeFile(resultPath, JSON.stringify(input.result, null, 2));
|
package/build/runner.js
CHANGED
|
@@ -365,6 +365,24 @@ async function caseSensitiveGoto(page, targetUrl, timeoutMs) {
|
|
|
365
365
|
async function executeFlowStep(input) {
|
|
366
366
|
const stepTimeout = input.step.timeoutMs || Math.min(input.timeoutMs, 10000);
|
|
367
367
|
const exact = input.step.exact === true;
|
|
368
|
+
const withExpectations = async (detail) => {
|
|
369
|
+
const expectationDetails = [];
|
|
370
|
+
for (const text of input.step.expectText || []) {
|
|
371
|
+
await input.page
|
|
372
|
+
.getByText(text, { exact })
|
|
373
|
+
.first()
|
|
374
|
+
.waitFor({ state: 'visible', timeout: stepTimeout });
|
|
375
|
+
expectationDetails.push(`Visible text found: ${text}`);
|
|
376
|
+
}
|
|
377
|
+
if (input.step.expectNoConsoleErrors) {
|
|
378
|
+
const browserErrors = input.issues.filter(entry => ['console_error', 'page_error'].includes(entry.type));
|
|
379
|
+
if (browserErrors.length > 0) {
|
|
380
|
+
throw new Error(`Expected no console/page errors, found ${browserErrors.length}.`);
|
|
381
|
+
}
|
|
382
|
+
expectationDetails.push('No console/page errors observed.');
|
|
383
|
+
}
|
|
384
|
+
return [detail, ...expectationDetails].filter(Boolean).join(' ');
|
|
385
|
+
};
|
|
368
386
|
if (input.step.type === 'goto') {
|
|
369
387
|
const target = resolveTargetUrl(input.targetUrl, requireStepValue(input.step.url, 'url'));
|
|
370
388
|
await caseSensitiveGoto(input.page, target, stepTimeout);
|
|
@@ -373,7 +391,7 @@ async function executeFlowStep(input) {
|
|
|
373
391
|
.catch(() => {
|
|
374
392
|
// DOM assertions after the step are the source of truth.
|
|
375
393
|
});
|
|
376
|
-
return `Navigated to ${target}
|
|
394
|
+
return withExpectations(`Navigated to ${target}`);
|
|
377
395
|
}
|
|
378
396
|
if (input.step.type === 'assert_visible_text' ||
|
|
379
397
|
input.step.type === 'wait_for_text') {
|
|
@@ -382,25 +400,25 @@ async function executeFlowStep(input) {
|
|
|
382
400
|
.getByText(text, { exact })
|
|
383
401
|
.first()
|
|
384
402
|
.waitFor({ state: 'visible', timeout: stepTimeout });
|
|
385
|
-
return `Visible text found: ${text}
|
|
403
|
+
return withExpectations(`Visible text found: ${text}`);
|
|
386
404
|
}
|
|
387
405
|
if (input.step.type === 'click') {
|
|
388
406
|
if (input.step.selector) {
|
|
389
407
|
await input.page
|
|
390
408
|
.locator(input.step.selector)
|
|
391
409
|
.click({ timeout: stepTimeout });
|
|
392
|
-
return `Clicked selector: ${input.step.selector}
|
|
410
|
+
return withExpectations(`Clicked selector: ${input.step.selector}`);
|
|
393
411
|
}
|
|
394
412
|
const text = requireStepValue(input.step.text, 'text or selector');
|
|
395
413
|
await input.page.getByText(text, { exact }).click({ timeout: stepTimeout });
|
|
396
|
-
return `Clicked text: ${text}
|
|
414
|
+
return withExpectations(`Clicked text: ${text}`);
|
|
397
415
|
}
|
|
398
416
|
if (input.step.type === 'fill') {
|
|
399
417
|
const selector = requireStepValue(input.step.selector, 'selector');
|
|
400
418
|
await input.page
|
|
401
419
|
.locator(selector)
|
|
402
420
|
.fill(input.step.value || '', { timeout: stepTimeout });
|
|
403
|
-
return `Filled selector: ${selector}
|
|
421
|
+
return withExpectations(`Filled selector: ${selector}`);
|
|
404
422
|
}
|
|
405
423
|
if (input.step.type === 'assert_url') {
|
|
406
424
|
const currentUrl = input.page.url();
|
|
@@ -411,29 +429,29 @@ async function executeFlowStep(input) {
|
|
|
411
429
|
currentUrl !== resolveTargetUrl(input.targetUrl, input.step.url)) {
|
|
412
430
|
throw new Error(`Expected URL ${input.step.url}, got ${currentUrl}.`);
|
|
413
431
|
}
|
|
414
|
-
return `URL matched: ${currentUrl}
|
|
432
|
+
return withExpectations(`URL matched: ${currentUrl}`);
|
|
415
433
|
}
|
|
416
434
|
if (input.step.type === 'assert_no_horizontal_overflow') {
|
|
417
|
-
return assertNoHorizontalOverflow(input.page, input.step.tolerance ?? 1);
|
|
435
|
+
return withExpectations(await assertNoHorizontalOverflow(input.page, input.step.tolerance ?? 1));
|
|
418
436
|
}
|
|
419
437
|
if (input.step.type === 'assert_in_viewport') {
|
|
420
|
-
return assertElementInViewport({
|
|
438
|
+
return withExpectations(await assertElementInViewport({
|
|
421
439
|
page: input.page,
|
|
422
440
|
step: input.step,
|
|
423
441
|
exact,
|
|
424
442
|
timeoutMs: stepTimeout,
|
|
425
|
-
});
|
|
443
|
+
}));
|
|
426
444
|
}
|
|
427
445
|
if (input.step.type === 'assert_box') {
|
|
428
|
-
return assertElementBox({
|
|
446
|
+
return withExpectations(await assertElementBox({
|
|
429
447
|
page: input.page,
|
|
430
448
|
step: input.step,
|
|
431
449
|
exact,
|
|
432
450
|
timeoutMs: stepTimeout,
|
|
433
|
-
});
|
|
451
|
+
}));
|
|
434
452
|
}
|
|
435
453
|
if (input.step.type === 'screenshot') {
|
|
436
|
-
return 'Captured screenshot checkpoint.';
|
|
454
|
+
return withExpectations('Captured screenshot checkpoint.');
|
|
437
455
|
}
|
|
438
456
|
throw new Error(`Unsupported flow step type: ${input.step.type}`);
|
|
439
457
|
}
|
|
@@ -453,6 +471,7 @@ async function runFlow(input) {
|
|
|
453
471
|
step,
|
|
454
472
|
targetUrl: input.targetUrl,
|
|
455
473
|
timeoutMs: input.timeoutMs,
|
|
474
|
+
issues: input.issues,
|
|
456
475
|
});
|
|
457
476
|
result.url = input.page.url();
|
|
458
477
|
if (step.screenshot || step.type === 'screenshot') {
|
package/build/types.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export type QaTestRunResult = {
|
|
|
50
50
|
checks: QaTestRunCheck[];
|
|
51
51
|
issues: QaTestRunIssue[];
|
|
52
52
|
flow?: QaTestRunFlowResult;
|
|
53
|
+
flows?: QaTestRunFlowResult[];
|
|
53
54
|
nextAction?: QaTestRunNextAction;
|
|
54
55
|
};
|
|
55
56
|
export type QaFlowStep = {
|
|
@@ -57,6 +58,8 @@ export type QaFlowStep = {
|
|
|
57
58
|
type: string;
|
|
58
59
|
label?: string;
|
|
59
60
|
text?: string;
|
|
61
|
+
expectText?: string[];
|
|
62
|
+
expectNoConsoleErrors?: boolean;
|
|
60
63
|
selector?: string;
|
|
61
64
|
value?: string;
|
|
62
65
|
url?: string;
|