@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 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.
@@ -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
@@ -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.2",
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",