@trylayout/qa 0.1.2 → 0.1.4
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 +69 -11
- package/build/cli/layoutQa.js +10 -2
- package/build/flows.js +5 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/report.js +124 -52
- package/build/runner.d.ts +4 -2
- package/build/runner.js +170 -21
- package/build/types.d.ts +11 -0
- package/build/viewports.d.ts +5 -0
- package/build/viewports.js +54 -0
- package/docs/assets/layout-qa-report.png +0 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Layout QA
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@trylayout/qa)
|
|
4
|
+
[](https://github.com/Layout-App/layout-qa/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
Layout QA is a local browser QA protocol and runner for AI-built frontends. It runs deterministic flows against a local or preview URL, captures screenshots at meaningful checkpoints, checks browser health, and writes a static HTML report.
|
|
4
8
|
|
|
5
9
|
The core loop is intentionally local:
|
|
@@ -7,10 +11,15 @@ The core loop is intentionally local:
|
|
|
7
11
|
```bash
|
|
8
12
|
npx @trylayout/qa init
|
|
9
13
|
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --open
|
|
14
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --viewport 390x844 --open
|
|
10
15
|
```
|
|
11
16
|
|
|
12
17
|
No account, upload, hosted service, or external docs are required.
|
|
13
18
|
|
|
19
|
+
## Example Report
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
14
23
|
Package names:
|
|
15
24
|
|
|
16
25
|
- Canonical npm package: `@trylayout/qa`
|
|
@@ -28,8 +37,8 @@ npx layout-qa run --target-url http://localhost:5173 --scenario happy_path --ope
|
|
|
28
37
|
|
|
29
38
|
Frontend agents can move faster when they have a visual feedback loop they can run themselves. Layout gives the agent a small protocol:
|
|
30
39
|
|
|
31
|
-
- Wire deterministic API/auth
|
|
32
|
-
- Switch
|
|
40
|
+
- Wire deterministic API/auth responses behind a local env flag such as `VITE_LAYOUT_QA_MOCKS=1`.
|
|
41
|
+
- Switch response states with `localStorage["layout.qa.scenario"]`.
|
|
33
42
|
- Declare high-value browser flows in `.layout/qa-flows.json`.
|
|
34
43
|
- Run the CLI locally and inspect the generated screenshots/report.
|
|
35
44
|
|
|
@@ -70,7 +79,7 @@ Create a starter flow manifest:
|
|
|
70
79
|
npx @trylayout/qa init
|
|
71
80
|
```
|
|
72
81
|
|
|
73
|
-
Start your app with whatever
|
|
82
|
+
Start your app with whatever local QA flag your project uses:
|
|
74
83
|
|
|
75
84
|
```bash
|
|
76
85
|
VITE_LAYOUT_QA_MOCKS=1 npm run dev
|
|
@@ -88,7 +97,7 @@ npx @trylayout/qa run \
|
|
|
88
97
|
Each run writes:
|
|
89
98
|
|
|
90
99
|
```text
|
|
91
|
-
.layout/runs/<timestamp-scenario>/
|
|
100
|
+
.layout/runs/<timestamp-scenario-viewport>/
|
|
92
101
|
index.html
|
|
93
102
|
result.json
|
|
94
103
|
screenshots/
|
|
@@ -110,9 +119,10 @@ Options:
|
|
|
110
119
|
|
|
111
120
|
```text
|
|
112
121
|
--target-url <url> URL of the running frontend to test.
|
|
113
|
-
--scenario <name>
|
|
122
|
+
--scenario <name> Scenario to activate. Defaults to happy_path.
|
|
114
123
|
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
115
124
|
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
125
|
+
--viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
|
|
116
126
|
--timeout <ms> Browser run timeout. Defaults to 60000.
|
|
117
127
|
--headed Show the browser instead of running headless.
|
|
118
128
|
--open Open the generated local HTML report after the run.
|
|
@@ -172,6 +182,8 @@ Step fields:
|
|
|
172
182
|
- `label`: optional human-readable report label.
|
|
173
183
|
- `screenshot`: set `true` to capture a screenshot after the step.
|
|
174
184
|
- `timeoutMs`: optional per-step timeout.
|
|
185
|
+
- `tolerance`: optional pixel tolerance for layout assertions.
|
|
186
|
+
- `minWidth`, `maxWidth`, `minHeight`, `maxHeight`: optional `assert_box` constraints.
|
|
175
187
|
|
|
176
188
|
Supported step types:
|
|
177
189
|
|
|
@@ -181,6 +193,9 @@ Supported step types:
|
|
|
181
193
|
- `assert_visible_text`: require visible `text`.
|
|
182
194
|
- `wait_for_text`: alias for a visible text wait.
|
|
183
195
|
- `assert_url`: require current URL to equal `url` or contain `contains`.
|
|
196
|
+
- `assert_no_horizontal_overflow`: require the page not to overflow the viewport horizontally.
|
|
197
|
+
- `assert_in_viewport`: require a `selector` or visible `text` to have a nonzero box intersecting the viewport.
|
|
198
|
+
- `assert_box`: require a `selector` or visible `text` to satisfy width/height constraints.
|
|
184
199
|
- `screenshot`: capture a screenshot checkpoint.
|
|
185
200
|
|
|
186
201
|
Examples:
|
|
@@ -197,7 +212,44 @@ Examples:
|
|
|
197
212
|
{ "id": "settings_url", "type": "assert_url", "contains": "/settings" }
|
|
198
213
|
```
|
|
199
214
|
|
|
200
|
-
|
|
215
|
+
```json
|
|
216
|
+
{ "id": "no_overflow", "type": "assert_no_horizontal_overflow" }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{ "id": "main_visible", "type": "assert_in_viewport", "selector": "main" }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"id": "primary_cta_size",
|
|
226
|
+
"type": "assert_box",
|
|
227
|
+
"selector": "[data-qa='primary-cta']",
|
|
228
|
+
"minWidth": 120,
|
|
229
|
+
"maxHeight": 56
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Viewports
|
|
234
|
+
|
|
235
|
+
The runner defaults to the desktop viewport, `1280x900`. Use `--viewport` to run the same flow at a preset or exact size:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport desktop
|
|
239
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport tablet
|
|
240
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport mobile
|
|
241
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport 390x844
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Presets:
|
|
245
|
+
|
|
246
|
+
- `desktop`: `1280x900`
|
|
247
|
+
- `tablet`: `768x1024`
|
|
248
|
+
- `mobile`: `390x844`
|
|
249
|
+
|
|
250
|
+
The selected viewport is written to `result.json`, shown in the HTML report, and included in the run directory name.
|
|
251
|
+
|
|
252
|
+
## Scenarios
|
|
201
253
|
|
|
202
254
|
Before the app loads, the runner sets:
|
|
203
255
|
|
|
@@ -206,7 +258,7 @@ localStorage.setItem("layout.qa.scenario", "<scenario>");
|
|
|
206
258
|
sessionStorage.setItem("layout.qa.runner", "1");
|
|
207
259
|
```
|
|
208
260
|
|
|
209
|
-
Your app can use `layout.qa.scenario` to switch deterministic
|
|
261
|
+
Your app can use `layout.qa.scenario` to switch deterministic API/auth response states:
|
|
210
262
|
|
|
211
263
|
- `happy_path`: normal populated data.
|
|
212
264
|
- `empty`: successful responses with empty states.
|
|
@@ -227,14 +279,14 @@ Create a local-only browser QA loop that an agent can run while changing fronten
|
|
|
227
279
|
Rules:
|
|
228
280
|
- Do not add a standalone mock server.
|
|
229
281
|
- Do not require a hosted Layout service.
|
|
230
|
-
- Keep all
|
|
231
|
-
- Gate
|
|
232
|
-
- Use localStorage["layout.qa.scenario"] to select at least happy_path, empty, and error
|
|
282
|
+
- Keep all deterministic response fixtures local to this app.
|
|
283
|
+
- Gate deterministic API/auth responses behind a local-only env flag such as VITE_LAYOUT_QA_MOCKS=1, NEXT_PUBLIC_LAYOUT_QA_MOCKS=1, or the framework-appropriate equivalent.
|
|
284
|
+
- Use localStorage["layout.qa.scenario"] to select at least happy_path, empty, and error response states.
|
|
233
285
|
- Hide any local QA switcher or debug controls when sessionStorage["layout.qa.runner"] === "1".
|
|
234
286
|
|
|
235
287
|
Implementation:
|
|
236
288
|
- Add deterministic API fixtures for the highest-value frontend route.
|
|
237
|
-
- If the app has a central auth/session abstraction, add a deterministic
|
|
289
|
+
- If the app has a central auth/session abstraction, add a deterministic QA user only when the Layout QA env flag is enabled.
|
|
238
290
|
- 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.
|
|
239
291
|
- Add .layout/qa-flows.json with one smoke flow for the most important page.
|
|
240
292
|
- Prefer visible text and stable selectors.
|
|
@@ -281,8 +333,14 @@ This package is intentionally small:
|
|
|
281
333
|
- It does run Playwright against an already-running frontend.
|
|
282
334
|
- It does write local screenshots and an HTML report.
|
|
283
335
|
- It does support deterministic scenario switching.
|
|
336
|
+
- It does support explicit viewport sizing.
|
|
337
|
+
- It does support lightweight layout assertions.
|
|
284
338
|
- It does not build or host your app.
|
|
285
339
|
- It does not upload results.
|
|
286
340
|
- It does not perform AI review by itself.
|
|
287
341
|
|
|
288
342
|
Those hosted/reporting layers can be added later without changing the local protocol.
|
|
343
|
+
|
|
344
|
+
## Feedback
|
|
345
|
+
|
|
346
|
+
Issues and examples are welcome in [GitHub Issues](https://github.com/Layout-App/layout-qa/issues). You can also reach me on X at [@tscepo](https://x.com/tscepo).
|
package/build/cli/layoutQa.js
CHANGED
|
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
9
9
|
const flows_1 = require("../flows");
|
|
10
10
|
const runner_1 = require("../runner");
|
|
11
11
|
const report_1 = require("../report");
|
|
12
|
+
const viewports_1 = require("../viewports");
|
|
12
13
|
function printHelp() {
|
|
13
14
|
process.stdout.write(`Layout QA CLI
|
|
14
15
|
|
|
@@ -25,9 +26,10 @@ Commands:
|
|
|
25
26
|
|
|
26
27
|
Options:
|
|
27
28
|
--target-url <url> URL of the running frontend to test.
|
|
28
|
-
--scenario <name>
|
|
29
|
+
--scenario <name> Scenario to activate. Defaults to happy_path.
|
|
29
30
|
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
30
31
|
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
32
|
+
--viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
|
|
31
33
|
--timeout <ms> Browser run timeout. Defaults to LAYOUT_QA_TEST_TIMEOUT_MS or 60000.
|
|
32
34
|
--headed Show the browser instead of running headless.
|
|
33
35
|
--open Open the generated local HTML report after the run.
|
|
@@ -59,6 +61,7 @@ function parseArgs(args) {
|
|
|
59
61
|
scenario: readFlag(args, '--scenario') || 'happy_path',
|
|
60
62
|
flowsPath: readFlag(args, '--flows'),
|
|
61
63
|
outDir: readFlag(args, '--out'),
|
|
64
|
+
viewport: (0, viewports_1.parseViewport)(readFlag(args, '--viewport')),
|
|
62
65
|
timeoutMs: parsedTimeoutMs,
|
|
63
66
|
headed: hasFlag(args, '--headed'),
|
|
64
67
|
json: hasFlag(args, '--json'),
|
|
@@ -92,6 +95,9 @@ function printHumanSummary(input) {
|
|
|
92
95
|
process.stdout.write(`\nLayout QA ${passed ? 'passed' : 'failed'}\n` +
|
|
93
96
|
`Scenario: ${input.scenario}\n` +
|
|
94
97
|
`Target: ${input.targetUrl}\n` +
|
|
98
|
+
`Viewport: ${input.result.viewport
|
|
99
|
+
? (0, viewports_1.formatViewport)(input.result.viewport)
|
|
100
|
+
: 'unavailable'}\n` +
|
|
95
101
|
`Final URL: ${input.result.finalUrl || 'unavailable'}\n` +
|
|
96
102
|
`Flow: ${input.result.flow?.name || 'None'} (${input.result.flow?.source || 'none'})\n` +
|
|
97
103
|
`Manifest: ${input.manifestFound ? input.manifestPath : 'not found; default smoke'}\n\n`);
|
|
@@ -142,11 +148,12 @@ async function runCommand(options) {
|
|
|
142
148
|
flow,
|
|
143
149
|
timeoutMs: options.timeoutMs || (0, flows_1.getTestTimeoutMs)(),
|
|
144
150
|
headless: !options.headed,
|
|
151
|
+
viewport: options.viewport,
|
|
145
152
|
});
|
|
146
153
|
}
|
|
147
154
|
catch (error) {
|
|
148
155
|
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
-
result = (0, runner_1.buildRunnerErrorResult)(message);
|
|
156
|
+
result = (0, runner_1.buildRunnerErrorResult)(message, options.viewport);
|
|
150
157
|
}
|
|
151
158
|
const artifacts = await (0, report_1.writeArtifacts)({
|
|
152
159
|
outDir: options.outDir,
|
|
@@ -162,6 +169,7 @@ async function runCommand(options) {
|
|
|
162
169
|
status: passed ? 'passed' : 'failed',
|
|
163
170
|
scenario: options.scenario,
|
|
164
171
|
targetUrl: options.targetUrl,
|
|
172
|
+
viewport: options.viewport,
|
|
165
173
|
manifestPath,
|
|
166
174
|
manifestFound,
|
|
167
175
|
artifacts,
|
package/build/flows.js
CHANGED
|
@@ -59,6 +59,11 @@ function normalizeFlowStep(value, index) {
|
|
|
59
59
|
exact: booleanValue(value.exact),
|
|
60
60
|
screenshot: booleanValue(value.screenshot, type === 'screenshot'),
|
|
61
61
|
timeoutMs: numberValue(value.timeoutMs),
|
|
62
|
+
tolerance: numberValue(value.tolerance),
|
|
63
|
+
minWidth: numberValue(value.minWidth),
|
|
64
|
+
maxWidth: numberValue(value.maxWidth),
|
|
65
|
+
minHeight: numberValue(value.minHeight),
|
|
66
|
+
maxHeight: numberValue(value.maxHeight),
|
|
62
67
|
};
|
|
63
68
|
}
|
|
64
69
|
function normalizeFlow(value, index) {
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
package/build/report.js
CHANGED
|
@@ -12,6 +12,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
12
12
|
const child_process_1 = require("child_process");
|
|
13
13
|
const flows_1 = require("./flows");
|
|
14
14
|
const runner_1 = require("./runner");
|
|
15
|
+
const viewports_1 = require("./viewports");
|
|
15
16
|
function safeName(value) {
|
|
16
17
|
return value.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
17
18
|
}
|
|
@@ -56,7 +57,7 @@ function renderStatusBadge(status) {
|
|
|
56
57
|
function renderCheckList(result) {
|
|
57
58
|
return result.checks
|
|
58
59
|
.map(check => `<li class="row">
|
|
59
|
-
<span class="status ${check.passed ? 'passed' : 'failed'}">${check.passed ? '
|
|
60
|
+
<span class="status ${check.passed ? 'passed' : 'failed'}">${check.passed ? 'Pass' : 'Fail'}</span>
|
|
60
61
|
<div>
|
|
61
62
|
<p class="row-title">${escapeHtml(check.label)}</p>
|
|
62
63
|
${check.detail
|
|
@@ -111,6 +112,7 @@ function renderReport(input) {
|
|
|
111
112
|
: '';
|
|
112
113
|
const resultHref = relativeHref(input.runDir, input.resultPath);
|
|
113
114
|
const flow = input.result.flow;
|
|
115
|
+
const viewport = input.result.viewport;
|
|
114
116
|
const failedCheckCount = input.result.checks.filter(check => !check.passed).length;
|
|
115
117
|
return `<!doctype html>
|
|
116
118
|
<html lang="en">
|
|
@@ -121,81 +123,149 @@ function renderReport(input) {
|
|
|
121
123
|
<style>
|
|
122
124
|
:root {
|
|
123
125
|
color-scheme: light;
|
|
124
|
-
--bg: #
|
|
125
|
-
--
|
|
126
|
-
--
|
|
127
|
-
--
|
|
128
|
-
--
|
|
129
|
-
--
|
|
130
|
-
--
|
|
131
|
-
--
|
|
132
|
-
--
|
|
133
|
-
--
|
|
134
|
-
--amber
|
|
126
|
+
--bg: #ffffff;
|
|
127
|
+
--surface: #f5f5f7;
|
|
128
|
+
--surface-strong: #fbfbfd;
|
|
129
|
+
--line: #d2d2d7;
|
|
130
|
+
--line-soft: #e8e8ed;
|
|
131
|
+
--text: #1d1d1f;
|
|
132
|
+
--muted: #6e6e73;
|
|
133
|
+
--blue: #06c;
|
|
134
|
+
--green: #248a3d;
|
|
135
|
+
--red: #d70015;
|
|
136
|
+
--amber: #b25000;
|
|
135
137
|
}
|
|
136
138
|
* { box-sizing: border-box; }
|
|
137
139
|
body {
|
|
138
140
|
margin: 0;
|
|
139
141
|
background: var(--bg);
|
|
140
142
|
color: var(--text);
|
|
141
|
-
font-family:
|
|
142
|
-
line-height: 1.
|
|
143
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
|
144
|
+
line-height: 1.47059;
|
|
145
|
+
-webkit-font-smoothing: antialiased;
|
|
146
|
+
text-rendering: optimizeLegibility;
|
|
147
|
+
}
|
|
148
|
+
main { max-width: 1180px; margin: 0 auto; padding: 56px 28px 72px; }
|
|
149
|
+
header.page {
|
|
150
|
+
display: flex;
|
|
151
|
+
justify-content: space-between;
|
|
152
|
+
gap: 32px;
|
|
153
|
+
align-items: flex-start;
|
|
154
|
+
border-bottom: 1px solid var(--line-soft);
|
|
155
|
+
padding-bottom: 36px;
|
|
143
156
|
}
|
|
144
|
-
main { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }
|
|
145
|
-
header.page { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; border-bottom: 1px solid var(--line); padding-bottom: 24px; }
|
|
146
157
|
h1, h2, h3, p { margin: 0; }
|
|
147
|
-
h1 {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
h1 {
|
|
159
|
+
font-size: clamp(2.75rem, 7vw, 5.5rem);
|
|
160
|
+
line-height: .95;
|
|
161
|
+
font-weight: 500;
|
|
162
|
+
letter-spacing: 0;
|
|
163
|
+
}
|
|
164
|
+
h2 { font-size: 1.375rem; line-height: 1.2; font-weight: 500; margin-bottom: 18px; }
|
|
165
|
+
h3 { font-size: 1.0625rem; line-height: 1.25; font-weight: 500; }
|
|
166
|
+
section { margin-top: 42px; }
|
|
167
|
+
a { color: var(--blue); text-decoration: none; }
|
|
168
|
+
a:hover { text-decoration: underline; }
|
|
169
|
+
.eyebrow {
|
|
170
|
+
color: var(--muted);
|
|
171
|
+
font-size: .8125rem;
|
|
172
|
+
font-weight: 500;
|
|
173
|
+
text-transform: uppercase;
|
|
174
|
+
letter-spacing: .04em;
|
|
175
|
+
margin-bottom: 10px;
|
|
176
|
+
}
|
|
177
|
+
.summary {
|
|
178
|
+
display: grid;
|
|
179
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
180
|
+
gap: 1px;
|
|
181
|
+
margin-top: 32px;
|
|
182
|
+
border: 1px solid var(--line-soft);
|
|
183
|
+
border-radius: 14px;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
background: var(--line-soft);
|
|
186
|
+
}
|
|
154
187
|
.metric, .panel, .step, .issue {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
188
|
+
background: var(--surface-strong);
|
|
189
|
+
}
|
|
190
|
+
.metric { padding: 18px 20px; min-width: 0; }
|
|
191
|
+
.metric dt { color: var(--muted); font-size: .8125rem; }
|
|
192
|
+
.metric dd { margin: 5px 0 0; font-weight: 500; overflow-wrap: anywhere; }
|
|
193
|
+
.panel {
|
|
194
|
+
padding: 22px;
|
|
195
|
+
border: 1px solid var(--line-soft);
|
|
196
|
+
border-radius: 14px;
|
|
197
|
+
}
|
|
198
|
+
.stack { display: grid; gap: 0; list-style: none; margin: 0; padding: 0; }
|
|
199
|
+
.row {
|
|
200
|
+
display: grid;
|
|
201
|
+
grid-template-columns: 64px minmax(0, 1fr);
|
|
202
|
+
gap: 16px;
|
|
203
|
+
padding: 14px 0;
|
|
204
|
+
border-top: 1px solid var(--line-soft);
|
|
158
205
|
}
|
|
159
|
-
.metric { padding: 14px; min-width: 0; }
|
|
160
|
-
.metric dt { color: var(--muted); font-size: .82rem; }
|
|
161
|
-
.metric dd { margin: 4px 0 0; font-weight: 650; overflow-wrap: anywhere; }
|
|
162
|
-
.panel { padding: 16px; }
|
|
163
|
-
.stack { display: grid; gap: 10px; list-style: none; margin: 0; padding: 0; }
|
|
164
|
-
.row { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); }
|
|
165
206
|
.row:first-child { border-top: 0; padding-top: 0; }
|
|
166
207
|
.row:last-child { padding-bottom: 0; }
|
|
167
|
-
.row-title { font-weight:
|
|
168
|
-
.muted { color: var(--muted); font-size: .
|
|
169
|
-
.detail { margin-top:
|
|
208
|
+
.row-title { font-weight: 500; overflow-wrap: anywhere; }
|
|
209
|
+
.muted { color: var(--muted); font-size: .9375rem; overflow-wrap: anywhere; }
|
|
210
|
+
.detail { margin-top: 10px; color: #424245; overflow-wrap: anywhere; }
|
|
170
211
|
.break { word-break: break-all; }
|
|
171
|
-
.status { font-size: .
|
|
212
|
+
.status { font-size: .8125rem; font-weight: 500; padding-top: 2px; }
|
|
172
213
|
.status.passed { color: var(--green); }
|
|
173
214
|
.status.failed { color: var(--red); }
|
|
174
215
|
.badge {
|
|
175
216
|
display: inline-flex;
|
|
176
217
|
align-items: center;
|
|
177
|
-
border-radius:
|
|
178
|
-
border: 1px solid var(--line);
|
|
179
|
-
padding:
|
|
180
|
-
font-size: .
|
|
181
|
-
font-weight:
|
|
218
|
+
border-radius: 999px;
|
|
219
|
+
border: 1px solid var(--line-soft);
|
|
220
|
+
padding: 5px 10px;
|
|
221
|
+
font-size: .875rem;
|
|
222
|
+
font-weight: 500;
|
|
182
223
|
text-transform: capitalize;
|
|
183
224
|
white-space: nowrap;
|
|
225
|
+
background: var(--surface);
|
|
226
|
+
}
|
|
227
|
+
.badge.passed { color: var(--green); }
|
|
228
|
+
.badge.failed { color: var(--red); }
|
|
229
|
+
.badge.skipped { color: var(--amber); }
|
|
230
|
+
.badge.hero { font-size: 1rem; padding: 8px 14px; }
|
|
231
|
+
.step {
|
|
232
|
+
padding: 22px;
|
|
233
|
+
margin-top: 16px;
|
|
234
|
+
border: 1px solid var(--line-soft);
|
|
235
|
+
border-radius: 14px;
|
|
184
236
|
}
|
|
185
|
-
.badge.passed { color: var(--green); background: var(--green-bg); border-color: #c8e6d0; }
|
|
186
|
-
.badge.failed { color: var(--red); background: var(--red-bg); border-color: #f0c9c9; }
|
|
187
|
-
.badge.skipped { color: var(--amber); background: var(--amber-bg); border-color: #f0dc9f; }
|
|
188
|
-
.badge.hero { font-size: .95rem; padding: 8px 12px; }
|
|
189
|
-
.step { padding: 16px; margin-top: 12px; }
|
|
190
237
|
.step-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
|
191
|
-
.screenshot-link {
|
|
238
|
+
.screenshot-link {
|
|
239
|
+
display: block;
|
|
240
|
+
margin-top: 18px;
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
border-radius: 12px;
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
background: white;
|
|
245
|
+
box-shadow: 0 12px 36px rgba(0, 0, 0, .06);
|
|
246
|
+
}
|
|
247
|
+
.screenshot-link:hover { text-decoration: none; }
|
|
192
248
|
img { display: block; width: 100%; height: auto; }
|
|
193
|
-
.issue {
|
|
194
|
-
|
|
249
|
+
.issue {
|
|
250
|
+
padding: 16px;
|
|
251
|
+
border: 1px solid #ffd7d9;
|
|
252
|
+
border-radius: 12px;
|
|
253
|
+
background: #fff7f7;
|
|
254
|
+
}
|
|
255
|
+
.next { background: var(--surface); }
|
|
195
256
|
.empty { color: var(--muted); }
|
|
196
|
-
.footer {
|
|
257
|
+
.footer {
|
|
258
|
+
margin-top: 42px;
|
|
259
|
+
padding-top: 20px;
|
|
260
|
+
border-top: 1px solid var(--line-soft);
|
|
261
|
+
color: var(--muted);
|
|
262
|
+
font-size: .9375rem;
|
|
263
|
+
overflow-wrap: anywhere;
|
|
264
|
+
}
|
|
265
|
+
ul:not(.stack) { margin: 14px 0 0; padding-left: 1.2rem; color: #424245; }
|
|
266
|
+
li + li { margin-top: 6px; }
|
|
197
267
|
@media (max-width: 760px) {
|
|
198
|
-
main { padding:
|
|
268
|
+
main { padding: 34px 18px 48px; }
|
|
199
269
|
header.page { display: grid; }
|
|
200
270
|
.summary { grid-template-columns: 1fr; }
|
|
201
271
|
}
|
|
@@ -215,6 +285,7 @@ function renderReport(input) {
|
|
|
215
285
|
<div class="metric"><dt>Scenario</dt><dd>${escapeHtml(input.scenario)}</dd></div>
|
|
216
286
|
<div class="metric"><dt>Flow</dt><dd>${escapeHtml(flow?.name || 'None')}</dd></div>
|
|
217
287
|
<div class="metric"><dt>Target URL</dt><dd>${escapeHtml(input.targetUrl)}</dd></div>
|
|
288
|
+
<div class="metric"><dt>Viewport</dt><dd>${escapeHtml(viewport ? (0, viewports_1.formatViewport)(viewport) : 'unavailable')}</dd></div>
|
|
218
289
|
<div class="metric"><dt>Final URL</dt><dd>${escapeHtml(input.result.finalUrl || 'unavailable')}</dd></div>
|
|
219
290
|
</dl>
|
|
220
291
|
|
|
@@ -265,7 +336,8 @@ async function writeArtifacts(input) {
|
|
|
265
336
|
? path_1.default.resolve(process.cwd(), input.outDir)
|
|
266
337
|
: await (0, flows_1.resolveDefaultPath)('.layout/runs');
|
|
267
338
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
268
|
-
const
|
|
339
|
+
const viewportId = input.result.viewport?.id || 'viewport_unknown';
|
|
340
|
+
const runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}-${safeName(viewportId)}`);
|
|
269
341
|
const screenshotsDir = path_1.default.join(runDir, 'screenshots');
|
|
270
342
|
await promises_1.default.mkdir(screenshotsDir, { recursive: true });
|
|
271
343
|
const screenshots = [];
|
package/build/runner.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { LoadedQaFlow, QaTestRunCheck, QaTestRunFlowResult, QaTestRunIssue, QaTestRunNextAction, QaTestRunResult } from './types';
|
|
1
|
+
import { LoadedQaFlow, QaTestRunCheck, QaTestRunFlowResult, QaTestRunIssue, QaTestRunNextAction, QaTestRunResult, QaViewport } from './types';
|
|
2
2
|
export declare function runLayoutQaBrowser(input: {
|
|
3
3
|
targetUrl: string;
|
|
4
4
|
scenario: string;
|
|
5
5
|
flow: LoadedQaFlow;
|
|
6
6
|
timeoutMs?: number;
|
|
7
7
|
headless?: boolean;
|
|
8
|
+
viewport?: QaViewport;
|
|
8
9
|
}): Promise<{
|
|
9
10
|
finalUrl: string;
|
|
10
11
|
title: string;
|
|
@@ -13,10 +14,11 @@ export declare function runLayoutQaBrowser(input: {
|
|
|
13
14
|
screenshotDataUrl: string | undefined;
|
|
14
15
|
screenshotBytes: number;
|
|
15
16
|
bodyTextSample: string;
|
|
17
|
+
viewport: QaViewport;
|
|
16
18
|
checks: QaTestRunCheck[];
|
|
17
19
|
issues: QaTestRunIssue[];
|
|
18
20
|
flow: QaTestRunFlowResult;
|
|
19
21
|
nextAction: QaTestRunNextAction;
|
|
20
22
|
}>;
|
|
21
|
-
export declare function buildRunnerErrorResult(message: string): QaTestRunResult;
|
|
23
|
+
export declare function buildRunnerErrorResult(message: string, viewport?: QaViewport): QaTestRunResult;
|
|
22
24
|
export declare function isQaRunPassed(result: QaTestRunResult): boolean;
|
package/build/runner.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.buildRunnerErrorResult = buildRunnerErrorResult;
|
|
|
38
38
|
exports.isQaRunPassed = isQaRunPassed;
|
|
39
39
|
const url_1 = require("url");
|
|
40
40
|
const flows_1 = require("./flows");
|
|
41
|
+
const viewports_1 = require("./viewports");
|
|
41
42
|
function truncate(value, maxLength = 1000) {
|
|
42
43
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
|
|
43
44
|
}
|
|
@@ -80,7 +81,7 @@ function buildChecks(input) {
|
|
|
80
81
|
},
|
|
81
82
|
{
|
|
82
83
|
id: 'scenario_ready',
|
|
83
|
-
label: '
|
|
84
|
+
label: 'Scenario flag is available',
|
|
84
85
|
passed: scenarioReady,
|
|
85
86
|
detail: input.controlsPresent
|
|
86
87
|
? `Layout QA controls detected; requested ${input.scenario}.`
|
|
@@ -125,11 +126,11 @@ function buildNextAction(input) {
|
|
|
125
126
|
return {
|
|
126
127
|
category: 'target_unreachable',
|
|
127
128
|
title: 'Target URL did not load cleanly',
|
|
128
|
-
detail: 'Layout could not reach the target page well enough to evaluate
|
|
129
|
+
detail: 'Layout could not reach the target page well enough to evaluate deterministic response states.',
|
|
129
130
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
130
131
|
nextSteps: [
|
|
131
132
|
'Start the app or deploy preview URL before running Layout QA.',
|
|
132
|
-
'Use the URL where the frontend is served with the Layout
|
|
133
|
+
'Use the URL where the frontend is served with the Layout QA env flag enabled.',
|
|
133
134
|
'Retry the same scenario after the target URL is reachable from the runner.',
|
|
134
135
|
],
|
|
135
136
|
};
|
|
@@ -137,13 +138,13 @@ function buildNextAction(input) {
|
|
|
137
138
|
if (failedCheckIds.has('scenario_ready')) {
|
|
138
139
|
return {
|
|
139
140
|
category: 'fixtures',
|
|
140
|
-
title: '
|
|
141
|
-
detail: 'The page loaded, but Layout could not confirm that the requested
|
|
141
|
+
title: 'Scenario flag was not active',
|
|
142
|
+
detail: 'The page loaded, but Layout could not confirm that the requested scenario was available.',
|
|
142
143
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
143
144
|
nextSteps: [
|
|
144
|
-
'Confirm the target is running with the Layout
|
|
145
|
+
'Confirm the target is running with the Layout QA env flag set to 1.',
|
|
145
146
|
'Check that the app reads localStorage["layout.qa.scenario"] before API calls run.',
|
|
146
|
-
`Review ${flows_1.FLOW_MANIFEST_PATH}, the Layout QA docs, and the
|
|
147
|
+
`Review ${flows_1.FLOW_MANIFEST_PATH}, the Layout QA docs, and the API/auth response fixtures for missing handlers.`,
|
|
147
148
|
],
|
|
148
149
|
};
|
|
149
150
|
}
|
|
@@ -153,11 +154,11 @@ function buildNextAction(input) {
|
|
|
153
154
|
category: 'flow',
|
|
154
155
|
title: 'Flow step needs review',
|
|
155
156
|
detail: failedStep?.detail ||
|
|
156
|
-
'The target loaded with
|
|
157
|
+
'The target loaded with deterministic responses, but a declared QA flow step failed.',
|
|
157
158
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
158
159
|
nextSteps: [
|
|
159
160
|
`Inspect ${flows_1.FLOW_MANIFEST_PATH} and confirm the failing step still matches the app UI.`,
|
|
160
|
-
'Update selectors, visible text assertions, or scenario
|
|
161
|
+
'Update selectors, visible text assertions, or scenario responses so the flow follows real user behavior.',
|
|
161
162
|
`Use ${flows_1.QA_DOCS_URL} for the supported flow step schema.`,
|
|
162
163
|
],
|
|
163
164
|
};
|
|
@@ -168,24 +169,24 @@ function buildNextAction(input) {
|
|
|
168
169
|
return {
|
|
169
170
|
category: 'browser_errors',
|
|
170
171
|
title: 'Browser errors need review',
|
|
171
|
-
detail: 'The target loaded with
|
|
172
|
+
detail: 'The target loaded with deterministic responses, but browser errors or failed requests were observed.',
|
|
172
173
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
173
174
|
nextSteps: [
|
|
174
175
|
'Inspect the issues captured on this QA run.',
|
|
175
|
-
'Add or correct fixtures for unhandled frontend API requests.',
|
|
176
|
-
'Fix app code that throws under the selected
|
|
176
|
+
'Add or correct fixtures for unhandled frontend API/auth requests.',
|
|
177
|
+
'Fix app code that throws under the selected scenario, then rerun.',
|
|
177
178
|
],
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
if (appearsToBePublicOrAuthSurface(input.bodyTextSample)) {
|
|
181
182
|
return {
|
|
182
183
|
category: 'auth_boundary',
|
|
183
|
-
title: 'Public surface reached; wire
|
|
184
|
-
detail: 'The run passed the basic
|
|
184
|
+
title: 'Public surface reached; wire deterministic auth next',
|
|
185
|
+
detail: 'The run passed the basic browser checks, but the page appears to be a logged-out or public surface. Authenticated flows need a deterministic auth boundary before Layout can test them end to end.',
|
|
185
186
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
186
187
|
nextSteps: [
|
|
187
|
-
`Use ${flows_1.FLOW_MANIFEST_PATH} and the Layout QA docs to add or confirm a central
|
|
188
|
-
'Expose a deterministic
|
|
188
|
+
`Use ${flows_1.FLOW_MANIFEST_PATH} and the Layout QA docs to add or confirm a central auth boundary for QA runs.`,
|
|
189
|
+
'Expose a deterministic user/session only when the Layout QA env flag is enabled.',
|
|
189
190
|
'Point the next QA run at an authenticated route and rerun happy_path, empty, and error scenarios.',
|
|
190
191
|
],
|
|
191
192
|
};
|
|
@@ -193,11 +194,11 @@ function buildNextAction(input) {
|
|
|
193
194
|
return {
|
|
194
195
|
category: 'ready',
|
|
195
196
|
title: 'Ready for deeper flow coverage',
|
|
196
|
-
detail: 'The target loaded with the requested
|
|
197
|
+
detail: 'The target loaded with the requested scenario and no basic browser issues were detected.',
|
|
197
198
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
198
199
|
nextSteps: [
|
|
199
200
|
`Add route-specific Playwright-style flow steps to ${flows_1.FLOW_MANIFEST_PATH} for the highest-value user path.`,
|
|
200
|
-
'Expand
|
|
201
|
+
'Expand deterministic API/auth responses for any requests encountered by that flow.',
|
|
201
202
|
'Run the same flow across happy_path, empty, and error scenarios.',
|
|
202
203
|
],
|
|
203
204
|
};
|
|
@@ -230,6 +231,131 @@ function requireStepValue(value, field) {
|
|
|
230
231
|
}
|
|
231
232
|
return value;
|
|
232
233
|
}
|
|
234
|
+
function stepLocator(page, step, exact, requiredField) {
|
|
235
|
+
if (step.selector) {
|
|
236
|
+
return page.locator(step.selector).first();
|
|
237
|
+
}
|
|
238
|
+
const text = requireStepValue(step.text, requiredField);
|
|
239
|
+
return page.getByText(text, { exact }).first();
|
|
240
|
+
}
|
|
241
|
+
function formatBox(box) {
|
|
242
|
+
return `${Math.round(box.width)}x${Math.round(box.height)} at ${Math.round(box.x)},${Math.round(box.y)}`;
|
|
243
|
+
}
|
|
244
|
+
async function assertNoHorizontalOverflow(page, tolerance = 1) {
|
|
245
|
+
const result = (await page.evaluate(checkTolerance => {
|
|
246
|
+
function elementLabel(element) {
|
|
247
|
+
const tagName = element.tagName.toLowerCase();
|
|
248
|
+
const testId = element.getAttribute('data-testid') ||
|
|
249
|
+
element.getAttribute('data-qa') ||
|
|
250
|
+
'';
|
|
251
|
+
if (testId)
|
|
252
|
+
return `${tagName}[data-qa/testid="${testId}"]`;
|
|
253
|
+
if (element.id)
|
|
254
|
+
return `${tagName}#${element.id}`;
|
|
255
|
+
const className = Array.from(element.classList).slice(0, 3).join('.');
|
|
256
|
+
return className ? `${tagName}.${className}` : tagName;
|
|
257
|
+
}
|
|
258
|
+
const root = document.documentElement;
|
|
259
|
+
const body = document.body;
|
|
260
|
+
const viewportWidth = window.innerWidth;
|
|
261
|
+
const scrollWidth = Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0);
|
|
262
|
+
const overflowPx = Math.max(0, scrollWidth - viewportWidth);
|
|
263
|
+
const offenders = Array.from(document.body?.querySelectorAll('*') || [])
|
|
264
|
+
.map(element => {
|
|
265
|
+
const rect = element.getBoundingClientRect();
|
|
266
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
267
|
+
return null;
|
|
268
|
+
const rightOverflow = Math.max(0, rect.right - viewportWidth);
|
|
269
|
+
const leftOverflow = Math.max(0, -rect.left);
|
|
270
|
+
const elementOverflowPx = Math.max(rightOverflow, leftOverflow);
|
|
271
|
+
if (elementOverflowPx <= checkTolerance)
|
|
272
|
+
return null;
|
|
273
|
+
return {
|
|
274
|
+
label: elementLabel(element),
|
|
275
|
+
x: Math.round(rect.x),
|
|
276
|
+
width: Math.round(rect.width),
|
|
277
|
+
right: Math.round(rect.right),
|
|
278
|
+
overflowPx: Math.round(elementOverflowPx),
|
|
279
|
+
};
|
|
280
|
+
})
|
|
281
|
+
.filter((entry) => Boolean(entry))
|
|
282
|
+
.sort((a, b) => b.overflowPx - a.overflowPx)
|
|
283
|
+
.slice(0, 5);
|
|
284
|
+
return {
|
|
285
|
+
viewportWidth,
|
|
286
|
+
scrollWidth,
|
|
287
|
+
overflowPx,
|
|
288
|
+
offenders,
|
|
289
|
+
};
|
|
290
|
+
}, tolerance));
|
|
291
|
+
if (result.overflowPx > tolerance) {
|
|
292
|
+
const offenderDetail = result.offenders.length
|
|
293
|
+
? ` Offenders: ${result.offenders
|
|
294
|
+
.map(offender => `${offender.label} overflowed ${offender.overflowPx}px (x=${offender.x}, width=${offender.width}, right=${offender.right})`)
|
|
295
|
+
.join('; ')}.`
|
|
296
|
+
: '';
|
|
297
|
+
throw new Error(`Horizontal overflow detected: document width ${result.scrollWidth}px exceeds viewport ${result.viewportWidth}px by ${result.overflowPx}px.${offenderDetail}`);
|
|
298
|
+
}
|
|
299
|
+
return `No horizontal overflow: document width ${result.scrollWidth}px, viewport ${result.viewportWidth}px.`;
|
|
300
|
+
}
|
|
301
|
+
async function assertElementInViewport(input) {
|
|
302
|
+
const locator = stepLocator(input.page, input.step, input.exact, 'selector or text');
|
|
303
|
+
await locator.waitFor({ state: 'visible', timeout: input.timeoutMs });
|
|
304
|
+
const box = await locator.boundingBox();
|
|
305
|
+
if (!box || box.width <= 0 || box.height <= 0) {
|
|
306
|
+
throw new Error('Expected element to have a visible nonzero layout box.');
|
|
307
|
+
}
|
|
308
|
+
const viewport = input.page.viewportSize();
|
|
309
|
+
if (!viewport) {
|
|
310
|
+
throw new Error('Could not read the current browser viewport size.');
|
|
311
|
+
}
|
|
312
|
+
const tolerance = input.step.tolerance ?? 1;
|
|
313
|
+
const right = box.x + box.width;
|
|
314
|
+
const bottom = box.y + box.height;
|
|
315
|
+
const visibleWidth = Math.min(right, viewport.width) - Math.max(box.x, 0);
|
|
316
|
+
const visibleHeight = Math.min(bottom, viewport.height) - Math.max(box.y, 0);
|
|
317
|
+
if (visibleWidth <= tolerance || visibleHeight <= tolerance) {
|
|
318
|
+
throw new Error(`Expected element to intersect viewport ${viewport.width}x${viewport.height}, got box ${formatBox(box)}.`);
|
|
319
|
+
}
|
|
320
|
+
return `Element intersects viewport ${viewport.width}x${viewport.height}: ${formatBox(box)}.`;
|
|
321
|
+
}
|
|
322
|
+
function definedBoxConstraints(step) {
|
|
323
|
+
return [
|
|
324
|
+
['minWidth', step.minWidth],
|
|
325
|
+
['maxWidth', step.maxWidth],
|
|
326
|
+
['minHeight', step.minHeight],
|
|
327
|
+
['maxHeight', step.maxHeight],
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
async function assertElementBox(input) {
|
|
331
|
+
const constraints = definedBoxConstraints(input.step).filter(([, value]) => value !== undefined);
|
|
332
|
+
if (constraints.length === 0) {
|
|
333
|
+
throw new Error('assert_box requires at least one of minWidth, maxWidth, minHeight, or maxHeight.');
|
|
334
|
+
}
|
|
335
|
+
const locator = stepLocator(input.page, input.step, input.exact, 'selector or text');
|
|
336
|
+
await locator.waitFor({ state: 'visible', timeout: input.timeoutMs });
|
|
337
|
+
const box = await locator.boundingBox();
|
|
338
|
+
if (!box || box.width <= 0 || box.height <= 0) {
|
|
339
|
+
throw new Error('Expected element to have a visible nonzero layout box.');
|
|
340
|
+
}
|
|
341
|
+
const failures = [];
|
|
342
|
+
if (input.step.minWidth !== undefined && box.width < input.step.minWidth) {
|
|
343
|
+
failures.push(`width ${Math.round(box.width)}px is below minWidth ${input.step.minWidth}px`);
|
|
344
|
+
}
|
|
345
|
+
if (input.step.maxWidth !== undefined && box.width > input.step.maxWidth) {
|
|
346
|
+
failures.push(`width ${Math.round(box.width)}px is above maxWidth ${input.step.maxWidth}px`);
|
|
347
|
+
}
|
|
348
|
+
if (input.step.minHeight !== undefined && box.height < input.step.minHeight) {
|
|
349
|
+
failures.push(`height ${Math.round(box.height)}px is below minHeight ${input.step.minHeight}px`);
|
|
350
|
+
}
|
|
351
|
+
if (input.step.maxHeight !== undefined && box.height > input.step.maxHeight) {
|
|
352
|
+
failures.push(`height ${Math.round(box.height)}px is above maxHeight ${input.step.maxHeight}px`);
|
|
353
|
+
}
|
|
354
|
+
if (failures.length > 0) {
|
|
355
|
+
throw new Error(`Element box ${formatBox(box)} failed constraints: ${failures.join('; ')}.`);
|
|
356
|
+
}
|
|
357
|
+
return `Element box matched constraints: ${formatBox(box)}.`;
|
|
358
|
+
}
|
|
233
359
|
async function caseSensitiveGoto(page, targetUrl, timeoutMs) {
|
|
234
360
|
return page.goto(targetUrl, {
|
|
235
361
|
waitUntil: 'domcontentloaded',
|
|
@@ -287,6 +413,25 @@ async function executeFlowStep(input) {
|
|
|
287
413
|
}
|
|
288
414
|
return `URL matched: ${currentUrl}`;
|
|
289
415
|
}
|
|
416
|
+
if (input.step.type === 'assert_no_horizontal_overflow') {
|
|
417
|
+
return assertNoHorizontalOverflow(input.page, input.step.tolerance ?? 1);
|
|
418
|
+
}
|
|
419
|
+
if (input.step.type === 'assert_in_viewport') {
|
|
420
|
+
return assertElementInViewport({
|
|
421
|
+
page: input.page,
|
|
422
|
+
step: input.step,
|
|
423
|
+
exact,
|
|
424
|
+
timeoutMs: stepTimeout,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (input.step.type === 'assert_box') {
|
|
428
|
+
return assertElementBox({
|
|
429
|
+
page: input.page,
|
|
430
|
+
step: input.step,
|
|
431
|
+
exact,
|
|
432
|
+
timeoutMs: stepTimeout,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
290
435
|
if (input.step.type === 'screenshot') {
|
|
291
436
|
return 'Captured screenshot checkpoint.';
|
|
292
437
|
}
|
|
@@ -345,13 +490,14 @@ async function runFlow(input) {
|
|
|
345
490
|
}
|
|
346
491
|
async function runLayoutQaBrowser(input) {
|
|
347
492
|
const timeoutMs = input.timeoutMs || flows_1.DEFAULT_TEST_TIMEOUT_MS;
|
|
493
|
+
const viewport = input.viewport || viewports_1.DEFAULT_VIEWPORT;
|
|
348
494
|
const issues = [];
|
|
349
495
|
const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
|
|
350
496
|
const browser = await chromium.launch({ headless: input.headless !== false });
|
|
351
497
|
try {
|
|
352
498
|
const context = await browser.newContext({
|
|
353
499
|
ignoreHTTPSErrors: true,
|
|
354
|
-
viewport: { width:
|
|
500
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
355
501
|
});
|
|
356
502
|
await context.addInitScript({
|
|
357
503
|
content: `
|
|
@@ -444,6 +590,7 @@ async function runLayoutQaBrowser(input) {
|
|
|
444
590
|
screenshotDataUrl,
|
|
445
591
|
screenshotBytes: screenshot.byteLength,
|
|
446
592
|
bodyTextSample: pageState.bodyTextSample,
|
|
593
|
+
viewport,
|
|
447
594
|
checks,
|
|
448
595
|
issues: issues.slice(0, 20),
|
|
449
596
|
flow: flowResult,
|
|
@@ -464,8 +611,9 @@ async function runLayoutQaBrowser(input) {
|
|
|
464
611
|
});
|
|
465
612
|
}
|
|
466
613
|
}
|
|
467
|
-
function buildRunnerErrorResult(message) {
|
|
614
|
+
function buildRunnerErrorResult(message, viewport = viewports_1.DEFAULT_VIEWPORT) {
|
|
468
615
|
return {
|
|
616
|
+
viewport,
|
|
469
617
|
checks: [
|
|
470
618
|
{
|
|
471
619
|
id: 'runner_error',
|
|
@@ -487,8 +635,9 @@ function buildRunnerErrorResult(message) {
|
|
|
487
635
|
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
488
636
|
nextSteps: [
|
|
489
637
|
'Confirm the target URL is reachable by the runner.',
|
|
490
|
-
'Confirm the app is served with the Layout
|
|
638
|
+
'Confirm the app is served with the Layout QA env flag enabled.',
|
|
491
639
|
'Retry after the target loads consistently in a browser.',
|
|
640
|
+
`Viewport used for this run: ${(0, viewports_1.formatViewport)(viewport)}.`,
|
|
492
641
|
],
|
|
493
642
|
},
|
|
494
643
|
};
|
package/build/types.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export type QaTestRunFlowStepResult = {
|
|
|
27
27
|
screenshotDataUrl?: string;
|
|
28
28
|
screenshotBytes?: number;
|
|
29
29
|
};
|
|
30
|
+
export type QaViewport = {
|
|
31
|
+
id: string;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
30
35
|
export type QaTestRunFlowResult = {
|
|
31
36
|
id: string;
|
|
32
37
|
name: string;
|
|
@@ -41,6 +46,7 @@ export type QaTestRunResult = {
|
|
|
41
46
|
screenshotDataUrl?: string;
|
|
42
47
|
screenshotBytes?: number;
|
|
43
48
|
bodyTextSample?: string;
|
|
49
|
+
viewport?: QaViewport;
|
|
44
50
|
checks: QaTestRunCheck[];
|
|
45
51
|
issues: QaTestRunIssue[];
|
|
46
52
|
flow?: QaTestRunFlowResult;
|
|
@@ -58,6 +64,11 @@ export type QaFlowStep = {
|
|
|
58
64
|
exact?: boolean;
|
|
59
65
|
screenshot?: boolean;
|
|
60
66
|
timeoutMs?: number;
|
|
67
|
+
tolerance?: number;
|
|
68
|
+
minWidth?: number;
|
|
69
|
+
maxWidth?: number;
|
|
70
|
+
minHeight?: number;
|
|
71
|
+
maxHeight?: number;
|
|
61
72
|
};
|
|
62
73
|
export type QaFlowDefinition = {
|
|
63
74
|
id: string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { QaViewport } from './types';
|
|
2
|
+
export declare const DEFAULT_VIEWPORT: QaViewport;
|
|
3
|
+
export declare const VIEWPORT_PRESETS: Record<string, QaViewport>;
|
|
4
|
+
export declare function parseViewport(value?: string): QaViewport;
|
|
5
|
+
export declare function formatViewport(viewport: QaViewport): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VIEWPORT_PRESETS = exports.DEFAULT_VIEWPORT = void 0;
|
|
4
|
+
exports.parseViewport = parseViewport;
|
|
5
|
+
exports.formatViewport = formatViewport;
|
|
6
|
+
exports.DEFAULT_VIEWPORT = {
|
|
7
|
+
id: 'desktop',
|
|
8
|
+
width: 1280,
|
|
9
|
+
height: 900,
|
|
10
|
+
};
|
|
11
|
+
exports.VIEWPORT_PRESETS = {
|
|
12
|
+
desktop: exports.DEFAULT_VIEWPORT,
|
|
13
|
+
tablet: {
|
|
14
|
+
id: 'tablet',
|
|
15
|
+
width: 768,
|
|
16
|
+
height: 1024,
|
|
17
|
+
},
|
|
18
|
+
mobile: {
|
|
19
|
+
id: 'mobile',
|
|
20
|
+
width: 390,
|
|
21
|
+
height: 844,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function cloneViewport(viewport) {
|
|
25
|
+
return { ...viewport };
|
|
26
|
+
}
|
|
27
|
+
function parseViewport(value) {
|
|
28
|
+
const raw = (value || '').trim().toLowerCase();
|
|
29
|
+
if (!raw)
|
|
30
|
+
return cloneViewport(exports.DEFAULT_VIEWPORT);
|
|
31
|
+
const preset = exports.VIEWPORT_PRESETS[raw];
|
|
32
|
+
if (preset)
|
|
33
|
+
return cloneViewport(preset);
|
|
34
|
+
const match = raw.match(/^(\d{2,5})x(\d{2,5})$/);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(`Unsupported viewport "${value}". Use desktop, tablet, mobile, or WIDTHxHEIGHT.`);
|
|
37
|
+
}
|
|
38
|
+
const width = Number(match[1]);
|
|
39
|
+
const height = Number(match[2]);
|
|
40
|
+
if (!Number.isInteger(width) ||
|
|
41
|
+
!Number.isInteger(height) ||
|
|
42
|
+
width <= 0 ||
|
|
43
|
+
height <= 0) {
|
|
44
|
+
throw new Error(`Viewport "${value}" must use positive integer dimensions.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
id: `${width}x${height}`,
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function formatViewport(viewport) {
|
|
53
|
+
return `${viewport.id} (${viewport.width}x${viewport.height})`;
|
|
54
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylayout/qa",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Local browser QA runner and HTML reports for AI-built frontends.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Layout",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"build",
|
|
29
|
+
"docs/assets/layout-qa-report.png",
|
|
29
30
|
"README.md",
|
|
30
31
|
"LICENSE"
|
|
31
32
|
],
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"scripts": {
|
|
39
40
|
"build": "tsc",
|
|
40
41
|
"check": "tsc --noEmit",
|
|
42
|
+
"publish:alias": "cd packages/layout-qa && npm publish --access public",
|
|
41
43
|
"prepack": "npm run build"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|