@trylayout/qa 0.1.1 → 0.1.3
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 +67 -1
- package/build/cli/layoutQa.js +11 -1
- package/build/flows.js +5 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/report.js +6 -2
- package/build/runner.d.ts +4 -2
- package/build/runner.js +151 -2
- package/build/types.d.ts +11 -0
- package/build/viewports.d.ts +5 -0
- package/build/viewports.js +54 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -7,10 +7,24 @@ The core loop is intentionally local:
|
|
|
7
7
|
```bash
|
|
8
8
|
npx @trylayout/qa init
|
|
9
9
|
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --open
|
|
10
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --viewport 390x844 --open
|
|
10
11
|
```
|
|
11
12
|
|
|
12
13
|
No account, upload, hosted service, or external docs are required.
|
|
13
14
|
|
|
15
|
+
Package names:
|
|
16
|
+
|
|
17
|
+
- Canonical npm package: `@trylayout/qa`
|
|
18
|
+
- Convenience npm alias: `layout-qa`
|
|
19
|
+
- CLI binaries: `trylayout` and `layout-qa`
|
|
20
|
+
|
|
21
|
+
These commands are equivalent:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --open
|
|
25
|
+
npx layout-qa run --target-url http://localhost:5173 --scenario happy_path --open
|
|
26
|
+
```
|
|
27
|
+
|
|
14
28
|
## Why This Exists
|
|
15
29
|
|
|
16
30
|
Frontend agents can move faster when they have a visual feedback loop they can run themselves. Layout gives the agent a small protocol:
|
|
@@ -30,6 +44,12 @@ Use it directly with `npx`:
|
|
|
30
44
|
npx @trylayout/qa run --target-url http://localhost:5173
|
|
31
45
|
```
|
|
32
46
|
|
|
47
|
+
The package is also available under the unscoped alias `layout-qa` for agents and tools that infer the package name from this repository:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx layout-qa run --target-url http://localhost:5173
|
|
51
|
+
```
|
|
52
|
+
|
|
33
53
|
Or install it in a project:
|
|
34
54
|
|
|
35
55
|
```bash
|
|
@@ -69,7 +89,7 @@ npx @trylayout/qa run \
|
|
|
69
89
|
Each run writes:
|
|
70
90
|
|
|
71
91
|
```text
|
|
72
|
-
.layout/runs/<timestamp-scenario>/
|
|
92
|
+
.layout/runs/<timestamp-scenario-viewport>/
|
|
73
93
|
index.html
|
|
74
94
|
result.json
|
|
75
95
|
screenshots/
|
|
@@ -84,6 +104,7 @@ The process exits `0` on pass and `1` on failure, so the same command can run in
|
|
|
84
104
|
```text
|
|
85
105
|
trylayout init [options]
|
|
86
106
|
trylayout run --target-url <url> [options]
|
|
107
|
+
layout-qa run --target-url <url> [options]
|
|
87
108
|
```
|
|
88
109
|
|
|
89
110
|
Options:
|
|
@@ -93,6 +114,7 @@ Options:
|
|
|
93
114
|
--scenario <name> Mock scenario to activate. Defaults to happy_path.
|
|
94
115
|
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
95
116
|
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
117
|
+
--viewport <value> Viewport preset or size. Use desktop, tablet, mobile, or WIDTHxHEIGHT. Defaults to desktop.
|
|
96
118
|
--timeout <ms> Browser run timeout. Defaults to 60000.
|
|
97
119
|
--headed Show the browser instead of running headless.
|
|
98
120
|
--open Open the generated local HTML report after the run.
|
|
@@ -152,6 +174,8 @@ Step fields:
|
|
|
152
174
|
- `label`: optional human-readable report label.
|
|
153
175
|
- `screenshot`: set `true` to capture a screenshot after the step.
|
|
154
176
|
- `timeoutMs`: optional per-step timeout.
|
|
177
|
+
- `tolerance`: optional pixel tolerance for layout assertions.
|
|
178
|
+
- `minWidth`, `maxWidth`, `minHeight`, `maxHeight`: optional `assert_box` constraints.
|
|
155
179
|
|
|
156
180
|
Supported step types:
|
|
157
181
|
|
|
@@ -161,6 +185,9 @@ Supported step types:
|
|
|
161
185
|
- `assert_visible_text`: require visible `text`.
|
|
162
186
|
- `wait_for_text`: alias for a visible text wait.
|
|
163
187
|
- `assert_url`: require current URL to equal `url` or contain `contains`.
|
|
188
|
+
- `assert_no_horizontal_overflow`: require the page not to overflow the viewport horizontally.
|
|
189
|
+
- `assert_in_viewport`: require a `selector` or visible `text` to have a nonzero box intersecting the viewport.
|
|
190
|
+
- `assert_box`: require a `selector` or visible `text` to satisfy width/height constraints.
|
|
164
191
|
- `screenshot`: capture a screenshot checkpoint.
|
|
165
192
|
|
|
166
193
|
Examples:
|
|
@@ -177,6 +204,43 @@ Examples:
|
|
|
177
204
|
{ "id": "settings_url", "type": "assert_url", "contains": "/settings" }
|
|
178
205
|
```
|
|
179
206
|
|
|
207
|
+
```json
|
|
208
|
+
{ "id": "no_overflow", "type": "assert_no_horizontal_overflow" }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{ "id": "main_visible", "type": "assert_in_viewport", "selector": "main" }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"id": "primary_cta_size",
|
|
218
|
+
"type": "assert_box",
|
|
219
|
+
"selector": "[data-qa='primary-cta']",
|
|
220
|
+
"minWidth": 120,
|
|
221
|
+
"maxHeight": 56
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Viewports
|
|
226
|
+
|
|
227
|
+
The runner defaults to the desktop viewport, `1280x900`. Use `--viewport` to run the same flow at a preset or exact size:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport desktop
|
|
231
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport tablet
|
|
232
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport mobile
|
|
233
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --viewport 390x844
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Presets:
|
|
237
|
+
|
|
238
|
+
- `desktop`: `1280x900`
|
|
239
|
+
- `tablet`: `768x1024`
|
|
240
|
+
- `mobile`: `390x844`
|
|
241
|
+
|
|
242
|
+
The selected viewport is written to `result.json`, shown in the HTML report, and included in the run directory name.
|
|
243
|
+
|
|
180
244
|
## Mock Scenarios
|
|
181
245
|
|
|
182
246
|
Before the app loads, the runner sets:
|
|
@@ -261,6 +325,8 @@ This package is intentionally small:
|
|
|
261
325
|
- It does run Playwright against an already-running frontend.
|
|
262
326
|
- It does write local screenshots and an HTML report.
|
|
263
327
|
- It does support deterministic scenario switching.
|
|
328
|
+
- It does support explicit viewport sizing.
|
|
329
|
+
- It does support lightweight layout assertions.
|
|
264
330
|
- It does not build or host your app.
|
|
265
331
|
- It does not upload results.
|
|
266
332
|
- It does not perform AI review by itself.
|
package/build/cli/layoutQa.js
CHANGED
|
@@ -9,13 +9,16 @@ 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
|
|
|
15
16
|
Usage:
|
|
16
17
|
trylayout init [options]
|
|
17
18
|
trylayout run --target-url <url> [options]
|
|
19
|
+
layout-qa run --target-url <url> [options]
|
|
18
20
|
npx @trylayout/qa run --target-url <url> [options]
|
|
21
|
+
npx layout-qa run --target-url <url> [options]
|
|
19
22
|
|
|
20
23
|
Commands:
|
|
21
24
|
init Write a starter .layout/qa-flows.json.
|
|
@@ -26,6 +29,7 @@ Options:
|
|
|
26
29
|
--scenario <name> Mock scenario to activate. Defaults to happy_path.
|
|
27
30
|
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
28
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.
|
|
29
33
|
--timeout <ms> Browser run timeout. Defaults to LAYOUT_QA_TEST_TIMEOUT_MS or 60000.
|
|
30
34
|
--headed Show the browser instead of running headless.
|
|
31
35
|
--open Open the generated local HTML report after the run.
|
|
@@ -57,6 +61,7 @@ function parseArgs(args) {
|
|
|
57
61
|
scenario: readFlag(args, '--scenario') || 'happy_path',
|
|
58
62
|
flowsPath: readFlag(args, '--flows'),
|
|
59
63
|
outDir: readFlag(args, '--out'),
|
|
64
|
+
viewport: (0, viewports_1.parseViewport)(readFlag(args, '--viewport')),
|
|
60
65
|
timeoutMs: parsedTimeoutMs,
|
|
61
66
|
headed: hasFlag(args, '--headed'),
|
|
62
67
|
json: hasFlag(args, '--json'),
|
|
@@ -90,6 +95,9 @@ function printHumanSummary(input) {
|
|
|
90
95
|
process.stdout.write(`\nLayout QA ${passed ? 'passed' : 'failed'}\n` +
|
|
91
96
|
`Scenario: ${input.scenario}\n` +
|
|
92
97
|
`Target: ${input.targetUrl}\n` +
|
|
98
|
+
`Viewport: ${input.result.viewport
|
|
99
|
+
? (0, viewports_1.formatViewport)(input.result.viewport)
|
|
100
|
+
: 'unavailable'}\n` +
|
|
93
101
|
`Final URL: ${input.result.finalUrl || 'unavailable'}\n` +
|
|
94
102
|
`Flow: ${input.result.flow?.name || 'None'} (${input.result.flow?.source || 'none'})\n` +
|
|
95
103
|
`Manifest: ${input.manifestFound ? input.manifestPath : 'not found; default smoke'}\n\n`);
|
|
@@ -140,11 +148,12 @@ async function runCommand(options) {
|
|
|
140
148
|
flow,
|
|
141
149
|
timeoutMs: options.timeoutMs || (0, flows_1.getTestTimeoutMs)(),
|
|
142
150
|
headless: !options.headed,
|
|
151
|
+
viewport: options.viewport,
|
|
143
152
|
});
|
|
144
153
|
}
|
|
145
154
|
catch (error) {
|
|
146
155
|
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
-
result = (0, runner_1.buildRunnerErrorResult)(message);
|
|
156
|
+
result = (0, runner_1.buildRunnerErrorResult)(message, options.viewport);
|
|
148
157
|
}
|
|
149
158
|
const artifacts = await (0, report_1.writeArtifacts)({
|
|
150
159
|
outDir: options.outDir,
|
|
@@ -160,6 +169,7 @@ async function runCommand(options) {
|
|
|
160
169
|
status: passed ? 'passed' : 'failed',
|
|
161
170
|
scenario: options.scenario,
|
|
162
171
|
targetUrl: options.targetUrl,
|
|
172
|
+
viewport: options.viewport,
|
|
163
173
|
manifestPath,
|
|
164
174
|
manifestFound,
|
|
165
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
|
}
|
|
@@ -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">
|
|
@@ -150,7 +152,7 @@ function renderReport(input) {
|
|
|
150
152
|
section { margin-top: 28px; }
|
|
151
153
|
a { color: inherit; }
|
|
152
154
|
.eyebrow { color: var(--muted); font-size: .78rem; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
|
|
153
|
-
.summary { display: grid; grid-template-columns: repeat(
|
|
155
|
+
.summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
|
|
154
156
|
.metric, .panel, .step, .issue {
|
|
155
157
|
border: 1px solid var(--line);
|
|
156
158
|
background: var(--panel);
|
|
@@ -215,6 +217,7 @@ function renderReport(input) {
|
|
|
215
217
|
<div class="metric"><dt>Scenario</dt><dd>${escapeHtml(input.scenario)}</dd></div>
|
|
216
218
|
<div class="metric"><dt>Flow</dt><dd>${escapeHtml(flow?.name || 'None')}</dd></div>
|
|
217
219
|
<div class="metric"><dt>Target URL</dt><dd>${escapeHtml(input.targetUrl)}</dd></div>
|
|
220
|
+
<div class="metric"><dt>Viewport</dt><dd>${escapeHtml(viewport ? (0, viewports_1.formatViewport)(viewport) : 'unavailable')}</dd></div>
|
|
218
221
|
<div class="metric"><dt>Final URL</dt><dd>${escapeHtml(input.result.finalUrl || 'unavailable')}</dd></div>
|
|
219
222
|
</dl>
|
|
220
223
|
|
|
@@ -265,7 +268,8 @@ async function writeArtifacts(input) {
|
|
|
265
268
|
? path_1.default.resolve(process.cwd(), input.outDir)
|
|
266
269
|
: await (0, flows_1.resolveDefaultPath)('.layout/runs');
|
|
267
270
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
268
|
-
const
|
|
271
|
+
const viewportId = input.result.viewport?.id || 'viewport_unknown';
|
|
272
|
+
const runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}-${safeName(viewportId)}`);
|
|
269
273
|
const screenshotsDir = path_1.default.join(runDir, 'screenshots');
|
|
270
274
|
await promises_1.default.mkdir(screenshotsDir, { recursive: true });
|
|
271
275
|
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
|
}
|
|
@@ -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',
|
|
@@ -489,6 +637,7 @@ function buildRunnerErrorResult(message) {
|
|
|
489
637
|
'Confirm the target URL is reachable by the runner.',
|
|
490
638
|
'Confirm the app is served with the Layout mock 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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylayout/qa",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Local browser QA runner and HTML reports for AI-built frontends.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Layout",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"ai-agents"
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
|
+
"layout-qa": "build/cli/layoutQa.js",
|
|
24
25
|
"trylayout": "build/cli/layoutQa.js"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|