@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 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.
@@ -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
@@ -2,3 +2,4 @@ export * from './flows';
2
2
  export * from './report';
3
3
  export * from './runner';
4
4
  export * from './types';
5
+ export * from './viewports';
package/build/index.js CHANGED
@@ -18,3 +18,4 @@ __exportStar(require("./flows"), exports);
18
18
  __exportStar(require("./report"), exports);
19
19
  __exportStar(require("./runner"), exports);
20
20
  __exportStar(require("./types"), exports);
21
+ __exportStar(require("./viewports"), exports);
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(4, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
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 runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}`);
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: 1280, height: 900 },
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.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": [