@trylayout/qa 0.1.2 → 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 +47 -1
- package/build/cli/layoutQa.js +9 -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 +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ 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.
|
|
@@ -88,7 +89,7 @@ npx @trylayout/qa run \
|
|
|
88
89
|
Each run writes:
|
|
89
90
|
|
|
90
91
|
```text
|
|
91
|
-
.layout/runs/<timestamp-scenario>/
|
|
92
|
+
.layout/runs/<timestamp-scenario-viewport>/
|
|
92
93
|
index.html
|
|
93
94
|
result.json
|
|
94
95
|
screenshots/
|
|
@@ -113,6 +114,7 @@ Options:
|
|
|
113
114
|
--scenario <name> Mock scenario to activate. Defaults to happy_path.
|
|
114
115
|
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
115
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.
|
|
116
118
|
--timeout <ms> Browser run timeout. Defaults to 60000.
|
|
117
119
|
--headed Show the browser instead of running headless.
|
|
118
120
|
--open Open the generated local HTML report after the run.
|
|
@@ -172,6 +174,8 @@ Step fields:
|
|
|
172
174
|
- `label`: optional human-readable report label.
|
|
173
175
|
- `screenshot`: set `true` to capture a screenshot after the step.
|
|
174
176
|
- `timeoutMs`: optional per-step timeout.
|
|
177
|
+
- `tolerance`: optional pixel tolerance for layout assertions.
|
|
178
|
+
- `minWidth`, `maxWidth`, `minHeight`, `maxHeight`: optional `assert_box` constraints.
|
|
175
179
|
|
|
176
180
|
Supported step types:
|
|
177
181
|
|
|
@@ -181,6 +185,9 @@ Supported step types:
|
|
|
181
185
|
- `assert_visible_text`: require visible `text`.
|
|
182
186
|
- `wait_for_text`: alias for a visible text wait.
|
|
183
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.
|
|
184
191
|
- `screenshot`: capture a screenshot checkpoint.
|
|
185
192
|
|
|
186
193
|
Examples:
|
|
@@ -197,6 +204,43 @@ Examples:
|
|
|
197
204
|
{ "id": "settings_url", "type": "assert_url", "contains": "/settings" }
|
|
198
205
|
```
|
|
199
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
|
+
|
|
200
244
|
## Mock Scenarios
|
|
201
245
|
|
|
202
246
|
Before the app loads, the runner sets:
|
|
@@ -281,6 +325,8 @@ This package is intentionally small:
|
|
|
281
325
|
- It does run Playwright against an already-running frontend.
|
|
282
326
|
- It does write local screenshots and an HTML report.
|
|
283
327
|
- It does support deterministic scenario switching.
|
|
328
|
+
- It does support explicit viewport sizing.
|
|
329
|
+
- It does support lightweight layout assertions.
|
|
284
330
|
- It does not build or host your app.
|
|
285
331
|
- It does not upload results.
|
|
286
332
|
- It does not perform AI review by itself.
|
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
|
|
|
@@ -28,6 +29,7 @@ Options:
|
|
|
28
29
|
--scenario <name> Mock 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
|
}
|
|
@@ -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
|
+
}
|