@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/report.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.safeName = safeName;
|
|
7
|
+
exports.stepScreenshotFileName = stepScreenshotFileName;
|
|
8
|
+
exports.writeArtifacts = writeArtifacts;
|
|
9
|
+
exports.openReport = openReport;
|
|
10
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const child_process_1 = require("child_process");
|
|
13
|
+
const flows_1 = require("./flows");
|
|
14
|
+
const runner_1 = require("./runner");
|
|
15
|
+
function safeName(value) {
|
|
16
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
17
|
+
}
|
|
18
|
+
function stepScreenshotFileName(index, stepId) {
|
|
19
|
+
return `${String(index + 1).padStart(2, '0')}-${safeName(stepId)}.jpg`;
|
|
20
|
+
}
|
|
21
|
+
async function writeDataUrl(filePath, dataUrl) {
|
|
22
|
+
const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return false;
|
|
25
|
+
await promises_1.default.writeFile(filePath, Buffer.from(match[2], 'base64'));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
async function writeStepScreenshot(input) {
|
|
29
|
+
if (!input.step.screenshotDataUrl)
|
|
30
|
+
return '';
|
|
31
|
+
const fileName = stepScreenshotFileName(input.index, input.step.id);
|
|
32
|
+
const filePath = path_1.default.join(input.screenshotsDir, fileName);
|
|
33
|
+
const written = await writeDataUrl(filePath, input.step.screenshotDataUrl);
|
|
34
|
+
return written ? filePath : '';
|
|
35
|
+
}
|
|
36
|
+
function escapeHtml(value) {
|
|
37
|
+
return String(value ?? '')
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
43
|
+
}
|
|
44
|
+
function hrefPath(value) {
|
|
45
|
+
return value
|
|
46
|
+
.split(path_1.default.sep)
|
|
47
|
+
.map(part => encodeURIComponent(part))
|
|
48
|
+
.join('/');
|
|
49
|
+
}
|
|
50
|
+
function relativeHref(fromDir, toPath) {
|
|
51
|
+
return hrefPath(path_1.default.relative(fromDir, toPath));
|
|
52
|
+
}
|
|
53
|
+
function renderStatusBadge(status) {
|
|
54
|
+
return `<span class="badge ${status}">${escapeHtml(status)}</span>`;
|
|
55
|
+
}
|
|
56
|
+
function renderCheckList(result) {
|
|
57
|
+
return result.checks
|
|
58
|
+
.map(check => `<li class="row">
|
|
59
|
+
<span class="status ${check.passed ? 'passed' : 'failed'}">${check.passed ? 'PASS' : 'FAIL'}</span>
|
|
60
|
+
<div>
|
|
61
|
+
<p class="row-title">${escapeHtml(check.label)}</p>
|
|
62
|
+
${check.detail
|
|
63
|
+
? `<p class="muted">${escapeHtml(check.detail)}</p>`
|
|
64
|
+
: ''}
|
|
65
|
+
</div>
|
|
66
|
+
</li>`)
|
|
67
|
+
.join('');
|
|
68
|
+
}
|
|
69
|
+
function renderIssueList(result) {
|
|
70
|
+
if (result.issues.length === 0) {
|
|
71
|
+
return '<p class="empty">No browser issues captured.</p>';
|
|
72
|
+
}
|
|
73
|
+
return `<ul class="stack">${result.issues
|
|
74
|
+
.map(issue => `<li class="issue">
|
|
75
|
+
<p class="row-title">${escapeHtml(issue.type)}</p>
|
|
76
|
+
<p>${escapeHtml(issue.message)}</p>
|
|
77
|
+
${issue.source ? `<p class="muted">${escapeHtml(issue.source)}</p>` : ''}
|
|
78
|
+
</li>`)
|
|
79
|
+
.join('')}</ul>`;
|
|
80
|
+
}
|
|
81
|
+
function renderStepList(input) {
|
|
82
|
+
const steps = input.result.flow?.steps || [];
|
|
83
|
+
if (steps.length === 0)
|
|
84
|
+
return '<p class="empty">No flow steps declared.</p>';
|
|
85
|
+
return steps
|
|
86
|
+
.map((step, index) => {
|
|
87
|
+
const screenshotHref = step.screenshotDataUrl
|
|
88
|
+
? relativeHref(input.runDir, path_1.default.join(input.runDir, 'screenshots', stepScreenshotFileName(index, step.id)))
|
|
89
|
+
: '';
|
|
90
|
+
return `<article class="step">
|
|
91
|
+
<header class="step-header">
|
|
92
|
+
<div>
|
|
93
|
+
<p class="eyebrow">${escapeHtml(step.type)}</p>
|
|
94
|
+
<h3>${escapeHtml(step.label || step.id)}</h3>
|
|
95
|
+
</div>
|
|
96
|
+
${renderStatusBadge(step.status)}
|
|
97
|
+
</header>
|
|
98
|
+
${step.detail ? `<p class="detail">${escapeHtml(step.detail)}</p>` : ''}
|
|
99
|
+
${step.url ? `<p class="muted break">${escapeHtml(step.url)}</p>` : ''}
|
|
100
|
+
${screenshotHref
|
|
101
|
+
? `<a class="screenshot-link" href="${screenshotHref}"><img src="${screenshotHref}" alt="${escapeHtml(step.label || step.id)} screenshot" /></a>`
|
|
102
|
+
: ''}
|
|
103
|
+
</article>`;
|
|
104
|
+
})
|
|
105
|
+
.join('');
|
|
106
|
+
}
|
|
107
|
+
function renderReport(input) {
|
|
108
|
+
const passed = (0, runner_1.isQaRunPassed)(input.result);
|
|
109
|
+
const finalScreenshotHref = input.result.screenshotDataUrl
|
|
110
|
+
? relativeHref(input.runDir, path_1.default.join(input.runDir, 'screenshots', 'final.jpg'))
|
|
111
|
+
: '';
|
|
112
|
+
const resultHref = relativeHref(input.runDir, input.resultPath);
|
|
113
|
+
const flow = input.result.flow;
|
|
114
|
+
const failedCheckCount = input.result.checks.filter(check => !check.passed).length;
|
|
115
|
+
return `<!doctype html>
|
|
116
|
+
<html lang="en">
|
|
117
|
+
<head>
|
|
118
|
+
<meta charset="utf-8" />
|
|
119
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
120
|
+
<title>Layout QA ${escapeHtml(input.scenario)} ${passed ? 'passed' : 'failed'}</title>
|
|
121
|
+
<style>
|
|
122
|
+
:root {
|
|
123
|
+
color-scheme: light;
|
|
124
|
+
--bg: #f7f7f2;
|
|
125
|
+
--panel: #fffdf8;
|
|
126
|
+
--line: #e4e1d8;
|
|
127
|
+
--text: #1b1a17;
|
|
128
|
+
--muted: #65635d;
|
|
129
|
+
--green: #2f7a45;
|
|
130
|
+
--green-bg: #e6f4ea;
|
|
131
|
+
--red: #9b2c2c;
|
|
132
|
+
--red-bg: #fff0f0;
|
|
133
|
+
--amber: #7a5c14;
|
|
134
|
+
--amber-bg: #fff5d6;
|
|
135
|
+
}
|
|
136
|
+
* { box-sizing: border-box; }
|
|
137
|
+
body {
|
|
138
|
+
margin: 0;
|
|
139
|
+
background: var(--bg);
|
|
140
|
+
color: var(--text);
|
|
141
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
142
|
+
line-height: 1.5;
|
|
143
|
+
}
|
|
144
|
+
main { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }
|
|
145
|
+
header.page { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; border-bottom: 1px solid var(--line); padding-bottom: 24px; }
|
|
146
|
+
h1, h2, h3, p { margin: 0; }
|
|
147
|
+
h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1; font-weight: 600; letter-spacing: 0; }
|
|
148
|
+
h2 { font-size: 1rem; font-weight: 650; margin-bottom: 12px; }
|
|
149
|
+
h3 { font-size: 1rem; font-weight: 650; }
|
|
150
|
+
section { margin-top: 28px; }
|
|
151
|
+
a { color: inherit; }
|
|
152
|
+
.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; }
|
|
154
|
+
.metric, .panel, .step, .issue {
|
|
155
|
+
border: 1px solid var(--line);
|
|
156
|
+
background: var(--panel);
|
|
157
|
+
border-radius: 8px;
|
|
158
|
+
}
|
|
159
|
+
.metric { padding: 14px; min-width: 0; }
|
|
160
|
+
.metric dt { color: var(--muted); font-size: .82rem; }
|
|
161
|
+
.metric dd { margin: 4px 0 0; font-weight: 650; overflow-wrap: anywhere; }
|
|
162
|
+
.panel { padding: 16px; }
|
|
163
|
+
.stack { display: grid; gap: 10px; list-style: none; margin: 0; padding: 0; }
|
|
164
|
+
.row { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); }
|
|
165
|
+
.row:first-child { border-top: 0; padding-top: 0; }
|
|
166
|
+
.row:last-child { padding-bottom: 0; }
|
|
167
|
+
.row-title { font-weight: 650; overflow-wrap: anywhere; }
|
|
168
|
+
.muted { color: var(--muted); font-size: .92rem; overflow-wrap: anywhere; }
|
|
169
|
+
.detail { margin-top: 8px; color: #3f3d38; overflow-wrap: anywhere; }
|
|
170
|
+
.break { word-break: break-all; }
|
|
171
|
+
.status { font-size: .78rem; font-weight: 750; padding-top: 2px; }
|
|
172
|
+
.status.passed { color: var(--green); }
|
|
173
|
+
.status.failed { color: var(--red); }
|
|
174
|
+
.badge {
|
|
175
|
+
display: inline-flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
border-radius: 6px;
|
|
178
|
+
border: 1px solid var(--line);
|
|
179
|
+
padding: 4px 8px;
|
|
180
|
+
font-size: .82rem;
|
|
181
|
+
font-weight: 650;
|
|
182
|
+
text-transform: capitalize;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
.badge.passed { color: var(--green); background: var(--green-bg); border-color: #c8e6d0; }
|
|
186
|
+
.badge.failed { color: var(--red); background: var(--red-bg); border-color: #f0c9c9; }
|
|
187
|
+
.badge.skipped { color: var(--amber); background: var(--amber-bg); border-color: #f0dc9f; }
|
|
188
|
+
.badge.hero { font-size: .95rem; padding: 8px 12px; }
|
|
189
|
+
.step { padding: 16px; margin-top: 12px; }
|
|
190
|
+
.step-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
|
|
191
|
+
.screenshot-link { display: block; margin-top: 14px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: white; }
|
|
192
|
+
img { display: block; width: 100%; height: auto; }
|
|
193
|
+
.issue { padding: 12px; border-color: #f0c9c9; background: #fff8f8; }
|
|
194
|
+
.next { border-color: #d9d6cb; }
|
|
195
|
+
.empty { color: var(--muted); }
|
|
196
|
+
.footer { margin-top: 28px; color: var(--muted); font-size: .9rem; }
|
|
197
|
+
@media (max-width: 760px) {
|
|
198
|
+
main { padding: 24px 16px 40px; }
|
|
199
|
+
header.page { display: grid; }
|
|
200
|
+
.summary { grid-template-columns: 1fr; }
|
|
201
|
+
}
|
|
202
|
+
</style>
|
|
203
|
+
</head>
|
|
204
|
+
<body>
|
|
205
|
+
<main>
|
|
206
|
+
<header class="page">
|
|
207
|
+
<div>
|
|
208
|
+
<p class="eyebrow">Layout QA report</p>
|
|
209
|
+
<h1>${passed ? 'Passed' : 'Failed'}</h1>
|
|
210
|
+
</div>
|
|
211
|
+
<span class="badge hero ${passed ? 'passed' : 'failed'}">${passed ? 'passed' : 'failed'}</span>
|
|
212
|
+
</header>
|
|
213
|
+
|
|
214
|
+
<dl class="summary">
|
|
215
|
+
<div class="metric"><dt>Scenario</dt><dd>${escapeHtml(input.scenario)}</dd></div>
|
|
216
|
+
<div class="metric"><dt>Flow</dt><dd>${escapeHtml(flow?.name || 'None')}</dd></div>
|
|
217
|
+
<div class="metric"><dt>Target URL</dt><dd>${escapeHtml(input.targetUrl)}</dd></div>
|
|
218
|
+
<div class="metric"><dt>Final URL</dt><dd>${escapeHtml(input.result.finalUrl || 'unavailable')}</dd></div>
|
|
219
|
+
</dl>
|
|
220
|
+
|
|
221
|
+
<section class="panel">
|
|
222
|
+
<h2>Checks</h2>
|
|
223
|
+
<ul class="stack">${renderCheckList(input.result)}</ul>
|
|
224
|
+
</section>
|
|
225
|
+
|
|
226
|
+
<section>
|
|
227
|
+
<h2>${escapeHtml(flow?.name || 'Flow Steps')}</h2>
|
|
228
|
+
${renderStepList({ runDir: input.runDir, result: input.result })}
|
|
229
|
+
</section>
|
|
230
|
+
|
|
231
|
+
${finalScreenshotHref
|
|
232
|
+
? `<section>
|
|
233
|
+
<h2>Final Screenshot</h2>
|
|
234
|
+
<a class="screenshot-link" href="${finalScreenshotHref}"><img src="${finalScreenshotHref}" alt="Final screenshot" /></a>
|
|
235
|
+
</section>`
|
|
236
|
+
: ''}
|
|
237
|
+
|
|
238
|
+
<section class="panel">
|
|
239
|
+
<h2>Issues</h2>
|
|
240
|
+
${renderIssueList(input.result)}
|
|
241
|
+
</section>
|
|
242
|
+
|
|
243
|
+
${input.result.nextAction
|
|
244
|
+
? `<section class="panel next">
|
|
245
|
+
<h2>Next Action</h2>
|
|
246
|
+
<p class="row-title">${escapeHtml(input.result.nextAction.title)}</p>
|
|
247
|
+
<p class="detail">${escapeHtml(input.result.nextAction.detail)}</p>
|
|
248
|
+
<ul>
|
|
249
|
+
${input.result.nextAction.nextSteps
|
|
250
|
+
.map(step => `<li>${escapeHtml(step)}</li>`)
|
|
251
|
+
.join('')}
|
|
252
|
+
</ul>
|
|
253
|
+
</section>`
|
|
254
|
+
: ''}
|
|
255
|
+
|
|
256
|
+
<p class="footer">
|
|
257
|
+
${failedCheckCount} failed checks. Manifest ${input.manifestFound ? escapeHtml(input.manifestPath) : 'not found'}. Raw result: <a href="${resultHref}">result.json</a>.
|
|
258
|
+
</p>
|
|
259
|
+
</main>
|
|
260
|
+
</body>
|
|
261
|
+
</html>`;
|
|
262
|
+
}
|
|
263
|
+
async function writeArtifacts(input) {
|
|
264
|
+
const outRoot = input.outDir
|
|
265
|
+
? path_1.default.resolve(process.cwd(), input.outDir)
|
|
266
|
+
: await (0, flows_1.resolveDefaultPath)('.layout/runs');
|
|
267
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
268
|
+
const runDir = path_1.default.join(outRoot, `${stamp}-${safeName(input.scenario)}`);
|
|
269
|
+
const screenshotsDir = path_1.default.join(runDir, 'screenshots');
|
|
270
|
+
await promises_1.default.mkdir(screenshotsDir, { recursive: true });
|
|
271
|
+
const screenshots = [];
|
|
272
|
+
if (input.result.screenshotDataUrl) {
|
|
273
|
+
const finalPath = path_1.default.join(screenshotsDir, 'final.jpg');
|
|
274
|
+
if (await writeDataUrl(finalPath, input.result.screenshotDataUrl)) {
|
|
275
|
+
screenshots.push(finalPath);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const [index, step] of (input.result.flow?.steps || []).entries()) {
|
|
279
|
+
const screenshotPath = await writeStepScreenshot({
|
|
280
|
+
screenshotsDir,
|
|
281
|
+
index,
|
|
282
|
+
step,
|
|
283
|
+
});
|
|
284
|
+
if (screenshotPath)
|
|
285
|
+
screenshots.push(screenshotPath);
|
|
286
|
+
}
|
|
287
|
+
const resultPath = path_1.default.join(runDir, 'result.json');
|
|
288
|
+
await promises_1.default.writeFile(resultPath, JSON.stringify(input.result, null, 2));
|
|
289
|
+
const reportPath = path_1.default.join(runDir, 'index.html');
|
|
290
|
+
await promises_1.default.writeFile(reportPath, renderReport({
|
|
291
|
+
result: input.result,
|
|
292
|
+
scenario: input.scenario,
|
|
293
|
+
targetUrl: input.targetUrl,
|
|
294
|
+
manifestPath: input.manifestPath,
|
|
295
|
+
manifestFound: input.manifestFound,
|
|
296
|
+
runDir,
|
|
297
|
+
resultPath,
|
|
298
|
+
}));
|
|
299
|
+
return { runDir, resultPath, reportPath, screenshots };
|
|
300
|
+
}
|
|
301
|
+
function fileUrl(filePath) {
|
|
302
|
+
const resolved = path_1.default.resolve(filePath);
|
|
303
|
+
const withForwardSlashes = resolved.split(path_1.default.sep).join('/');
|
|
304
|
+
return `file://${encodeURI(process.platform === 'win32' && !withForwardSlashes.startsWith('/')
|
|
305
|
+
? `/${withForwardSlashes}`
|
|
306
|
+
: withForwardSlashes)}`;
|
|
307
|
+
}
|
|
308
|
+
async function openReport(reportPath) {
|
|
309
|
+
const url = fileUrl(reportPath);
|
|
310
|
+
const command = process.platform === 'darwin'
|
|
311
|
+
? 'open'
|
|
312
|
+
: process.platform === 'win32'
|
|
313
|
+
? 'cmd'
|
|
314
|
+
: 'xdg-open';
|
|
315
|
+
const args = process.platform === 'darwin'
|
|
316
|
+
? [url]
|
|
317
|
+
: process.platform === 'win32'
|
|
318
|
+
? ['/c', 'start', '', url]
|
|
319
|
+
: [url];
|
|
320
|
+
await new Promise((resolve, reject) => {
|
|
321
|
+
const child = (0, child_process_1.spawn)(command, args, {
|
|
322
|
+
detached: true,
|
|
323
|
+
stdio: 'ignore',
|
|
324
|
+
});
|
|
325
|
+
child.once('error', reject);
|
|
326
|
+
child.unref();
|
|
327
|
+
resolve();
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LoadedQaFlow, QaTestRunCheck, QaTestRunFlowResult, QaTestRunIssue, QaTestRunNextAction, QaTestRunResult } from './types';
|
|
2
|
+
export declare function runLayoutQaBrowser(input: {
|
|
3
|
+
targetUrl: string;
|
|
4
|
+
scenario: string;
|
|
5
|
+
flow: LoadedQaFlow;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
headless?: boolean;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
finalUrl: string;
|
|
10
|
+
title: string;
|
|
11
|
+
scenarioActive: string;
|
|
12
|
+
controlsPresent: boolean;
|
|
13
|
+
screenshotDataUrl: string | undefined;
|
|
14
|
+
screenshotBytes: number;
|
|
15
|
+
bodyTextSample: string;
|
|
16
|
+
checks: QaTestRunCheck[];
|
|
17
|
+
issues: QaTestRunIssue[];
|
|
18
|
+
flow: QaTestRunFlowResult;
|
|
19
|
+
nextAction: QaTestRunNextAction;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function buildRunnerErrorResult(message: string): QaTestRunResult;
|
|
22
|
+
export declare function isQaRunPassed(result: QaTestRunResult): boolean;
|