@trylayout/qa 0.1.0
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/LICENSE +21 -0
- package/README.md +161 -0
- package/build/cli/layoutQa.d.ts +2 -0
- package/build/cli/layoutQa.js +203 -0
- package/build/flows.d.ts +35 -0
- package/build/flows.js +182 -0
- package/build/index.d.ts +4 -0
- package/build/index.js +20 -0
- package/build/report.d.ts +12 -0
- package/build/report.js +329 -0
- package/build/runner.d.ts +22 -0
- package/build/runner.js +498 -0
- package/build/types.d.ts +77 -0
- package/build/types.js +2 -0
- package/package.json +49 -0
package/build/runner.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runLayoutQaBrowser = runLayoutQaBrowser;
|
|
37
|
+
exports.buildRunnerErrorResult = buildRunnerErrorResult;
|
|
38
|
+
exports.isQaRunPassed = isQaRunPassed;
|
|
39
|
+
const url_1 = require("url");
|
|
40
|
+
const flows_1 = require("./flows");
|
|
41
|
+
function truncate(value, maxLength = 1000) {
|
|
42
|
+
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`;
|
|
43
|
+
}
|
|
44
|
+
function issue(input) {
|
|
45
|
+
return {
|
|
46
|
+
...input,
|
|
47
|
+
message: truncate(input.message, 1200),
|
|
48
|
+
source: input.source ? truncate(input.source, 600) : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function isExpectedMockErrorScenarioConsole(input) {
|
|
52
|
+
return (input.scenario === 'error' &&
|
|
53
|
+
/^Failed to load resource: the server responded with a status of [45]\d\d\b/.test(input.message) &&
|
|
54
|
+
/\/api\//.test(input.source || ''));
|
|
55
|
+
}
|
|
56
|
+
function buildChecks(input) {
|
|
57
|
+
const pageLoaded = input.responseStatus === null ||
|
|
58
|
+
(input.responseStatus >= 200 && input.responseStatus < 500);
|
|
59
|
+
const appRendered = input.bodyTextSample.trim().length > 20;
|
|
60
|
+
const scenarioReady = input.scenarioActive === input.scenario || input.controlsPresent;
|
|
61
|
+
const consoleErrorCount = input.issues.filter(entry => entry.type === 'console_error').length;
|
|
62
|
+
const pageErrorCount = input.issues.filter(entry => entry.type === 'page_error').length;
|
|
63
|
+
const failedRequestCount = input.issues.filter(entry => entry.type === 'request_failed').length;
|
|
64
|
+
const flowSteps = input.flow?.steps || [];
|
|
65
|
+
const failedFlowStepCount = flowSteps.filter(step => step.status === 'failed').length;
|
|
66
|
+
const checks = [
|
|
67
|
+
{
|
|
68
|
+
id: 'page_loaded',
|
|
69
|
+
label: 'Target page loaded',
|
|
70
|
+
passed: pageLoaded,
|
|
71
|
+
detail: input.responseStatus === null
|
|
72
|
+
? 'No HTTP response status was available after navigation.'
|
|
73
|
+
: `HTTP ${input.responseStatus}`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'app_rendered',
|
|
77
|
+
label: 'Page rendered visible content',
|
|
78
|
+
passed: appRendered,
|
|
79
|
+
detail: `${input.bodyTextSample.trim().length} visible text characters`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'scenario_ready',
|
|
83
|
+
label: 'Mock scenario is available',
|
|
84
|
+
passed: scenarioReady,
|
|
85
|
+
detail: input.controlsPresent
|
|
86
|
+
? `Layout QA controls detected; requested ${input.scenario}.`
|
|
87
|
+
: `Active scenario: ${input.scenarioActive || 'unknown'}`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'no_page_errors',
|
|
91
|
+
label: 'No uncaught browser errors',
|
|
92
|
+
passed: pageErrorCount === 0,
|
|
93
|
+
detail: `${pageErrorCount} page errors`,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'no_console_errors',
|
|
97
|
+
label: 'No console errors',
|
|
98
|
+
passed: consoleErrorCount === 0,
|
|
99
|
+
detail: `${consoleErrorCount} console errors`,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'no_failed_requests',
|
|
103
|
+
label: 'No failed network requests',
|
|
104
|
+
passed: failedRequestCount === 0,
|
|
105
|
+
detail: `${failedRequestCount} failed requests`,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
if (flowSteps.length > 0) {
|
|
109
|
+
checks.push({
|
|
110
|
+
id: 'flow_steps',
|
|
111
|
+
label: 'Flow steps passed',
|
|
112
|
+
passed: failedFlowStepCount === 0,
|
|
113
|
+
detail: `${flowSteps.length - failedFlowStepCount}/${flowSteps.length} flow steps passed`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return checks;
|
|
117
|
+
}
|
|
118
|
+
function appearsToBePublicOrAuthSurface(bodyText) {
|
|
119
|
+
return /(^|\b)(sign in|sign up|log in|get started|continue with google|create account)(\b|$)/i.test(bodyText);
|
|
120
|
+
}
|
|
121
|
+
function buildNextAction(input) {
|
|
122
|
+
const failedChecks = input.checks.filter(check => !check.passed);
|
|
123
|
+
const failedCheckIds = new Set(failedChecks.map(check => check.id));
|
|
124
|
+
if (failedCheckIds.has('page_loaded')) {
|
|
125
|
+
return {
|
|
126
|
+
category: 'target_unreachable',
|
|
127
|
+
title: 'Target URL did not load cleanly',
|
|
128
|
+
detail: 'Layout could not reach the target page well enough to evaluate mock states.',
|
|
129
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
130
|
+
nextSteps: [
|
|
131
|
+
'Start the app or deploy preview URL before running Layout QA.',
|
|
132
|
+
'Use the URL where the frontend is served with the Layout mock env flag enabled.',
|
|
133
|
+
'Retry the same scenario after the target URL is reachable from the runner.',
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (failedCheckIds.has('scenario_ready')) {
|
|
138
|
+
return {
|
|
139
|
+
category: 'fixtures',
|
|
140
|
+
title: 'Mock scenario was not active',
|
|
141
|
+
detail: 'The page loaded, but Layout could not confirm that the requested mock scenario was available.',
|
|
142
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
143
|
+
nextSteps: [
|
|
144
|
+
'Confirm the target is running with the Layout mock env flag set to 1.',
|
|
145
|
+
'Check that the app reads localStorage["layout.qa.scenario"] before API calls run.',
|
|
146
|
+
`Review ${flows_1.FLOW_MANIFEST_PATH}, the Layout QA docs, and the fixture file for missing handlers.`,
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (failedCheckIds.has('flow_steps')) {
|
|
151
|
+
const failedStep = input.flow?.steps.find(step => step.status === 'failed');
|
|
152
|
+
return {
|
|
153
|
+
category: 'flow',
|
|
154
|
+
title: 'Flow step needs review',
|
|
155
|
+
detail: failedStep?.detail ||
|
|
156
|
+
'The target loaded with mocks, but a declared QA flow step failed.',
|
|
157
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
158
|
+
nextSteps: [
|
|
159
|
+
`Inspect ${flows_1.FLOW_MANIFEST_PATH} and confirm the failing step still matches the app UI.`,
|
|
160
|
+
'Update selectors, visible text assertions, or scenario fixtures so the flow follows real user behavior.',
|
|
161
|
+
`Use ${flows_1.QA_DOCS_URL} for the supported flow step schema.`,
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (failedCheckIds.has('no_page_errors') ||
|
|
166
|
+
failedCheckIds.has('no_console_errors') ||
|
|
167
|
+
failedCheckIds.has('no_failed_requests')) {
|
|
168
|
+
return {
|
|
169
|
+
category: 'browser_errors',
|
|
170
|
+
title: 'Browser errors need review',
|
|
171
|
+
detail: 'The target loaded with mocks, but browser errors or failed requests were observed.',
|
|
172
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
173
|
+
nextSteps: [
|
|
174
|
+
'Inspect the issues captured on this QA run.',
|
|
175
|
+
'Add or correct fixtures for unhandled frontend API requests.',
|
|
176
|
+
'Fix app code that throws under the selected mock scenario, then rerun.',
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (appearsToBePublicOrAuthSurface(input.bodyTextSample)) {
|
|
181
|
+
return {
|
|
182
|
+
category: 'auth_boundary',
|
|
183
|
+
title: 'Public surface reached; wire mock auth next',
|
|
184
|
+
detail: 'The run passed the basic mock-browser checks, but the page appears to be a logged-out or public surface. Authenticated flows need a mockable auth boundary before Layout can test them end to end.',
|
|
185
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
186
|
+
nextSteps: [
|
|
187
|
+
`Use ${flows_1.FLOW_MANIFEST_PATH} and the Layout QA docs to add or confirm a central mock auth boundary.`,
|
|
188
|
+
'Expose a deterministic mock user/session only when the Layout mock env flag is enabled.',
|
|
189
|
+
'Point the next QA run at an authenticated route and rerun happy_path, empty, and error scenarios.',
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
category: 'ready',
|
|
195
|
+
title: 'Ready for deeper flow coverage',
|
|
196
|
+
detail: 'The target loaded with the requested mock scenario and no basic browser issues were detected.',
|
|
197
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
198
|
+
nextSteps: [
|
|
199
|
+
`Add route-specific Playwright-style flow steps to ${flows_1.FLOW_MANIFEST_PATH} for the highest-value user path.`,
|
|
200
|
+
'Expand fixtures for any API calls encountered by that flow.',
|
|
201
|
+
'Run the same flow across happy_path, empty, and error scenarios.',
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function resolveTargetUrl(targetUrl, stepUrl) {
|
|
206
|
+
if (/^https?:\/\//i.test(stepUrl))
|
|
207
|
+
return stepUrl;
|
|
208
|
+
const base = new url_1.URL(targetUrl);
|
|
209
|
+
if (stepUrl.startsWith('/')) {
|
|
210
|
+
return `${base.origin}${stepUrl}`;
|
|
211
|
+
}
|
|
212
|
+
return new url_1.URL(stepUrl || '.', targetUrl).toString();
|
|
213
|
+
}
|
|
214
|
+
async function captureStepScreenshot(page) {
|
|
215
|
+
const screenshot = await page.screenshot({
|
|
216
|
+
type: 'jpeg',
|
|
217
|
+
quality: 65,
|
|
218
|
+
fullPage: false,
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
screenshotBytes: screenshot.byteLength,
|
|
222
|
+
screenshotDataUrl: screenshot.byteLength <= flows_1.SCREENSHOT_LIMIT_BYTES
|
|
223
|
+
? `data:image/jpeg;base64,${screenshot.toString('base64')}`
|
|
224
|
+
: undefined,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function requireStepValue(value, field) {
|
|
228
|
+
if (!value) {
|
|
229
|
+
throw new Error(`Flow step is missing required ${field}.`);
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
async function caseSensitiveGoto(page, targetUrl, timeoutMs) {
|
|
234
|
+
return page.goto(targetUrl, {
|
|
235
|
+
waitUntil: 'domcontentloaded',
|
|
236
|
+
timeout: timeoutMs,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function executeFlowStep(input) {
|
|
240
|
+
const stepTimeout = input.step.timeoutMs || Math.min(input.timeoutMs, 10000);
|
|
241
|
+
const exact = input.step.exact === true;
|
|
242
|
+
if (input.step.type === 'goto') {
|
|
243
|
+
const target = resolveTargetUrl(input.targetUrl, requireStepValue(input.step.url, 'url'));
|
|
244
|
+
await caseSensitiveGoto(input.page, target, stepTimeout);
|
|
245
|
+
await input.page
|
|
246
|
+
.waitForLoadState('networkidle', { timeout: 5000 })
|
|
247
|
+
.catch(() => {
|
|
248
|
+
// DOM assertions after the step are the source of truth.
|
|
249
|
+
});
|
|
250
|
+
return `Navigated to ${target}`;
|
|
251
|
+
}
|
|
252
|
+
if (input.step.type === 'assert_visible_text' ||
|
|
253
|
+
input.step.type === 'wait_for_text') {
|
|
254
|
+
const text = requireStepValue(input.step.text, 'text');
|
|
255
|
+
await input.page
|
|
256
|
+
.getByText(text, { exact })
|
|
257
|
+
.first()
|
|
258
|
+
.waitFor({ state: 'visible', timeout: stepTimeout });
|
|
259
|
+
return `Visible text found: ${text}`;
|
|
260
|
+
}
|
|
261
|
+
if (input.step.type === 'click') {
|
|
262
|
+
if (input.step.selector) {
|
|
263
|
+
await input.page
|
|
264
|
+
.locator(input.step.selector)
|
|
265
|
+
.click({ timeout: stepTimeout });
|
|
266
|
+
return `Clicked selector: ${input.step.selector}`;
|
|
267
|
+
}
|
|
268
|
+
const text = requireStepValue(input.step.text, 'text or selector');
|
|
269
|
+
await input.page.getByText(text, { exact }).click({ timeout: stepTimeout });
|
|
270
|
+
return `Clicked text: ${text}`;
|
|
271
|
+
}
|
|
272
|
+
if (input.step.type === 'fill') {
|
|
273
|
+
const selector = requireStepValue(input.step.selector, 'selector');
|
|
274
|
+
await input.page
|
|
275
|
+
.locator(selector)
|
|
276
|
+
.fill(input.step.value || '', { timeout: stepTimeout });
|
|
277
|
+
return `Filled selector: ${selector}`;
|
|
278
|
+
}
|
|
279
|
+
if (input.step.type === 'assert_url') {
|
|
280
|
+
const currentUrl = input.page.url();
|
|
281
|
+
if (input.step.contains && !currentUrl.includes(input.step.contains)) {
|
|
282
|
+
throw new Error(`Expected URL to contain ${input.step.contains}, got ${currentUrl}.`);
|
|
283
|
+
}
|
|
284
|
+
if (input.step.url &&
|
|
285
|
+
currentUrl !== resolveTargetUrl(input.targetUrl, input.step.url)) {
|
|
286
|
+
throw new Error(`Expected URL ${input.step.url}, got ${currentUrl}.`);
|
|
287
|
+
}
|
|
288
|
+
return `URL matched: ${currentUrl}`;
|
|
289
|
+
}
|
|
290
|
+
if (input.step.type === 'screenshot') {
|
|
291
|
+
return 'Captured screenshot checkpoint.';
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`Unsupported flow step type: ${input.step.type}`);
|
|
294
|
+
}
|
|
295
|
+
async function runFlow(input) {
|
|
296
|
+
const steps = [];
|
|
297
|
+
for (const step of input.flow.steps) {
|
|
298
|
+
const startedAt = Date.now();
|
|
299
|
+
const result = {
|
|
300
|
+
id: step.id,
|
|
301
|
+
type: step.type,
|
|
302
|
+
label: step.label,
|
|
303
|
+
status: 'passed',
|
|
304
|
+
};
|
|
305
|
+
try {
|
|
306
|
+
result.detail = await executeFlowStep({
|
|
307
|
+
page: input.page,
|
|
308
|
+
step,
|
|
309
|
+
targetUrl: input.targetUrl,
|
|
310
|
+
timeoutMs: input.timeoutMs,
|
|
311
|
+
});
|
|
312
|
+
result.url = input.page.url();
|
|
313
|
+
if (step.screenshot || step.type === 'screenshot') {
|
|
314
|
+
Object.assign(result, await captureStepScreenshot(input.page));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
319
|
+
result.status = 'failed';
|
|
320
|
+
result.detail = message;
|
|
321
|
+
result.url = input.page.url();
|
|
322
|
+
Object.assign(result, await captureStepScreenshot(input.page));
|
|
323
|
+
input.issues.push(issue({
|
|
324
|
+
type: 'assertion',
|
|
325
|
+
message,
|
|
326
|
+
source: `${flows_1.FLOW_MANIFEST_PATH}#${input.flow.id}.${step.id}`,
|
|
327
|
+
}));
|
|
328
|
+
steps.push({
|
|
329
|
+
...result,
|
|
330
|
+
durationMs: Date.now() - startedAt,
|
|
331
|
+
});
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
steps.push({
|
|
335
|
+
...result,
|
|
336
|
+
durationMs: Date.now() - startedAt,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
id: input.flow.id,
|
|
341
|
+
name: input.flow.name,
|
|
342
|
+
source: input.flow.source,
|
|
343
|
+
steps,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async function runLayoutQaBrowser(input) {
|
|
347
|
+
const timeoutMs = input.timeoutMs || flows_1.DEFAULT_TEST_TIMEOUT_MS;
|
|
348
|
+
const issues = [];
|
|
349
|
+
const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
|
|
350
|
+
const browser = await chromium.launch({ headless: input.headless !== false });
|
|
351
|
+
try {
|
|
352
|
+
const context = await browser.newContext({
|
|
353
|
+
ignoreHTTPSErrors: true,
|
|
354
|
+
viewport: { width: 1280, height: 900 },
|
|
355
|
+
});
|
|
356
|
+
await context.addInitScript({
|
|
357
|
+
content: `
|
|
358
|
+
window.localStorage.setItem('layout.qa.scenario', ${JSON.stringify(input.scenario)});
|
|
359
|
+
window.sessionStorage.setItem('layout.qa.runner', '1');
|
|
360
|
+
`,
|
|
361
|
+
});
|
|
362
|
+
const page = await context.newPage();
|
|
363
|
+
page.on('console', message => {
|
|
364
|
+
if (message.type() !== 'error')
|
|
365
|
+
return;
|
|
366
|
+
if (isExpectedMockErrorScenarioConsole({
|
|
367
|
+
scenario: input.scenario,
|
|
368
|
+
message: message.text(),
|
|
369
|
+
source: message.location().url,
|
|
370
|
+
})) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
issues.push(issue({
|
|
374
|
+
type: 'console_error',
|
|
375
|
+
message: message.text(),
|
|
376
|
+
source: message.location().url,
|
|
377
|
+
}));
|
|
378
|
+
});
|
|
379
|
+
page.on('pageerror', error => {
|
|
380
|
+
issues.push(issue({
|
|
381
|
+
type: 'page_error',
|
|
382
|
+
message: error.message,
|
|
383
|
+
source: error.stack,
|
|
384
|
+
}));
|
|
385
|
+
});
|
|
386
|
+
page.on('requestfailed', request => {
|
|
387
|
+
const url = request.url();
|
|
388
|
+
if (/\/favicon\.(ico|png|svg)$/i.test(url))
|
|
389
|
+
return;
|
|
390
|
+
issues.push(issue({
|
|
391
|
+
type: 'request_failed',
|
|
392
|
+
message: request.failure()?.errorText || 'Request failed',
|
|
393
|
+
source: url,
|
|
394
|
+
}));
|
|
395
|
+
});
|
|
396
|
+
const response = await caseSensitiveGoto(page, resolveTargetUrl(input.targetUrl, input.flow.startUrl), timeoutMs);
|
|
397
|
+
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {
|
|
398
|
+
// Many apps keep long-lived connections open; DOM checks below are enough.
|
|
399
|
+
});
|
|
400
|
+
const flowResult = await runFlow({
|
|
401
|
+
page,
|
|
402
|
+
flow: input.flow,
|
|
403
|
+
targetUrl: input.targetUrl,
|
|
404
|
+
timeoutMs,
|
|
405
|
+
issues,
|
|
406
|
+
});
|
|
407
|
+
const pageState = (await page.evaluate(`(() => {
|
|
408
|
+
const scenarioStorageKey = 'layout.qa.scenario';
|
|
409
|
+
const bodyText = document.body?.innerText || '';
|
|
410
|
+
const controls = document.getElementById(
|
|
411
|
+
'layout-qa-scenario-controls'
|
|
412
|
+
);
|
|
413
|
+
return {
|
|
414
|
+
title: document.title,
|
|
415
|
+
finalUrl: window.location.href,
|
|
416
|
+
bodyTextSample: bodyText.trim().slice(0, 1200),
|
|
417
|
+
controlsPresent: Boolean(controls),
|
|
418
|
+
scenarioActive:
|
|
419
|
+
window.localStorage.getItem(scenarioStorageKey) || '',
|
|
420
|
+
};
|
|
421
|
+
})()`));
|
|
422
|
+
const screenshot = await page.screenshot({
|
|
423
|
+
type: 'jpeg',
|
|
424
|
+
quality: 65,
|
|
425
|
+
fullPage: false,
|
|
426
|
+
});
|
|
427
|
+
const screenshotDataUrl = screenshot.byteLength <= flows_1.SCREENSHOT_LIMIT_BYTES
|
|
428
|
+
? `data:image/jpeg;base64,${screenshot.toString('base64')}`
|
|
429
|
+
: undefined;
|
|
430
|
+
const checks = buildChecks({
|
|
431
|
+
responseStatus: response?.status() || null,
|
|
432
|
+
bodyTextSample: pageState.bodyTextSample,
|
|
433
|
+
controlsPresent: pageState.controlsPresent,
|
|
434
|
+
scenarioActive: pageState.scenarioActive,
|
|
435
|
+
scenario: input.scenario,
|
|
436
|
+
issues,
|
|
437
|
+
flow: flowResult,
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
finalUrl: pageState.finalUrl,
|
|
441
|
+
title: pageState.title,
|
|
442
|
+
scenarioActive: pageState.scenarioActive,
|
|
443
|
+
controlsPresent: pageState.controlsPresent,
|
|
444
|
+
screenshotDataUrl,
|
|
445
|
+
screenshotBytes: screenshot.byteLength,
|
|
446
|
+
bodyTextSample: pageState.bodyTextSample,
|
|
447
|
+
checks,
|
|
448
|
+
issues: issues.slice(0, 20),
|
|
449
|
+
flow: flowResult,
|
|
450
|
+
nextAction: buildNextAction({
|
|
451
|
+
checks,
|
|
452
|
+
issues,
|
|
453
|
+
bodyTextSample: pageState.bodyTextSample,
|
|
454
|
+
scenario: input.scenario,
|
|
455
|
+
scenarioActive: pageState.scenarioActive,
|
|
456
|
+
controlsPresent: pageState.controlsPresent,
|
|
457
|
+
flow: flowResult,
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
await browser.close().catch(() => {
|
|
463
|
+
// Best-effort cleanup.
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function buildRunnerErrorResult(message) {
|
|
468
|
+
return {
|
|
469
|
+
checks: [
|
|
470
|
+
{
|
|
471
|
+
id: 'runner_error',
|
|
472
|
+
label: 'Runner completed',
|
|
473
|
+
passed: false,
|
|
474
|
+
detail: message,
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
issues: [
|
|
478
|
+
issue({
|
|
479
|
+
type: 'assertion',
|
|
480
|
+
message,
|
|
481
|
+
}),
|
|
482
|
+
],
|
|
483
|
+
nextAction: {
|
|
484
|
+
category: 'target_unreachable',
|
|
485
|
+
title: 'Runner could not complete',
|
|
486
|
+
detail: message,
|
|
487
|
+
docsPath: flows_1.FLOW_MANIFEST_PATH,
|
|
488
|
+
nextSteps: [
|
|
489
|
+
'Confirm the target URL is reachable by the runner.',
|
|
490
|
+
'Confirm the app is served with the Layout mock env flag enabled.',
|
|
491
|
+
'Retry after the target loads consistently in a browser.',
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function isQaRunPassed(result) {
|
|
497
|
+
return result.checks.every(check => check.passed);
|
|
498
|
+
}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type QaTestRunCheck = {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
passed: boolean;
|
|
5
|
+
detail?: string;
|
|
6
|
+
};
|
|
7
|
+
export type QaTestRunIssue = {
|
|
8
|
+
type: 'console_error' | 'page_error' | 'request_failed' | 'assertion';
|
|
9
|
+
message: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
};
|
|
12
|
+
export type QaTestRunNextAction = {
|
|
13
|
+
category: 'ready' | 'auth_boundary' | 'fixtures' | 'flow' | 'target_unreachable' | 'browser_errors';
|
|
14
|
+
title: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
nextSteps: string[];
|
|
17
|
+
docsPath?: string;
|
|
18
|
+
};
|
|
19
|
+
export type QaTestRunFlowStepResult = {
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
status: 'passed' | 'failed' | 'skipped';
|
|
24
|
+
detail?: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
screenshotDataUrl?: string;
|
|
28
|
+
screenshotBytes?: number;
|
|
29
|
+
};
|
|
30
|
+
export type QaTestRunFlowResult = {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
source: 'manifest' | 'default';
|
|
34
|
+
steps: QaTestRunFlowStepResult[];
|
|
35
|
+
};
|
|
36
|
+
export type QaTestRunResult = {
|
|
37
|
+
finalUrl?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
scenarioActive?: string;
|
|
40
|
+
controlsPresent?: boolean;
|
|
41
|
+
screenshotDataUrl?: string;
|
|
42
|
+
screenshotBytes?: number;
|
|
43
|
+
bodyTextSample?: string;
|
|
44
|
+
checks: QaTestRunCheck[];
|
|
45
|
+
issues: QaTestRunIssue[];
|
|
46
|
+
flow?: QaTestRunFlowResult;
|
|
47
|
+
nextAction?: QaTestRunNextAction;
|
|
48
|
+
};
|
|
49
|
+
export type QaFlowStep = {
|
|
50
|
+
id: string;
|
|
51
|
+
type: string;
|
|
52
|
+
label?: string;
|
|
53
|
+
text?: string;
|
|
54
|
+
selector?: string;
|
|
55
|
+
value?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
contains?: string;
|
|
58
|
+
exact?: boolean;
|
|
59
|
+
screenshot?: boolean;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
};
|
|
62
|
+
export type QaFlowDefinition = {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
startUrl: string;
|
|
66
|
+
scenarios: string[];
|
|
67
|
+
steps: QaFlowStep[];
|
|
68
|
+
};
|
|
69
|
+
export type LoadedQaFlow = QaFlowDefinition & {
|
|
70
|
+
source: 'manifest' | 'default';
|
|
71
|
+
};
|
|
72
|
+
export type ArtifactSummary = {
|
|
73
|
+
runDir: string;
|
|
74
|
+
resultPath: string;
|
|
75
|
+
reportPath: string;
|
|
76
|
+
screenshots: string[];
|
|
77
|
+
};
|
package/build/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trylayout/qa",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local browser QA runner and HTML reports for AI-built frontends.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Layout",
|
|
7
|
+
"homepage": "https://trylayout.com/docs/qa",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Layout-App/layout-qa.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Layout-App/layout-qa/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"layout",
|
|
17
|
+
"qa",
|
|
18
|
+
"playwright",
|
|
19
|
+
"frontend",
|
|
20
|
+
"visual-testing",
|
|
21
|
+
"ai-agents"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"trylayout": "build/cli/layoutQa.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"build",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"check": "tsc --noEmit",
|
|
40
|
+
"prepack": "npm run build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"playwright": "^1.60.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.14.12",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
}
|
|
49
|
+
}
|