@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Layout
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Layout QA
|
|
2
|
+
|
|
3
|
+
Layout QA is a local browser QA protocol and runner for AI-built frontends. It runs deterministic Playwright-style flows against a local or preview URL, captures the screenshot sequence, checks browser health, and writes a static HTML report.
|
|
4
|
+
|
|
5
|
+
The core loop is intentionally local:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @trylayout/qa init
|
|
9
|
+
npx @trylayout/qa run --target-url http://localhost:5173 --scenario happy_path --open
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why this exists
|
|
13
|
+
|
|
14
|
+
Frontend agents can move faster when they have a visual feedback loop they can run themselves. Layout gives the agent a small protocol:
|
|
15
|
+
|
|
16
|
+
- Wire app mocks behind a local env flag such as `VITE_LAYOUT_QA_MOCKS=1`.
|
|
17
|
+
- Keep deterministic API/auth scenarios in the app repo.
|
|
18
|
+
- Declare high-value browser flows in `.layout/qa-flows.json`.
|
|
19
|
+
- Run the CLI locally and inspect the generated screenshots/report.
|
|
20
|
+
|
|
21
|
+
Layout does not require uploading screenshots or source code. Hosted reports, PR comments, and AI review notes can be layered on later.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Use it directly with `npx`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @trylayout/qa run --target-url http://localhost:5173
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install it in a project:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install --save-dev @trylayout/qa
|
|
35
|
+
npx trylayout run --target-url http://localhost:5173
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package uses Playwright. If your environment does not already have the browser binaries, run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx playwright install chromium
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
Initialize a starter flow file:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx @trylayout/qa init
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run a flow:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx @trylayout/qa run \
|
|
56
|
+
--target-url http://localhost:5173 \
|
|
57
|
+
--scenario happy_path \
|
|
58
|
+
--open
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Useful options:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
--target-url <url> URL of the running frontend to test.
|
|
65
|
+
--scenario <name> Mock scenario to activate. Defaults to happy_path.
|
|
66
|
+
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
67
|
+
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
68
|
+
--timeout <ms> Browser run timeout. Defaults to 60000.
|
|
69
|
+
--headed Show the browser instead of running headless.
|
|
70
|
+
--open Open the generated local HTML report after the run.
|
|
71
|
+
--json Print machine-readable JSON.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each run writes:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
.layout/runs/<timestamp-scenario>/
|
|
78
|
+
index.html
|
|
79
|
+
result.json
|
|
80
|
+
screenshots/
|
|
81
|
+
01-<step>.jpg
|
|
82
|
+
final.jpg
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The process exits `0` on pass and `1` on failure, so the same command can run in CI.
|
|
86
|
+
|
|
87
|
+
## Flow Manifest
|
|
88
|
+
|
|
89
|
+
Default path: `.layout/qa-flows.json`.
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"$schema": "https://trylayout.com/schemas/qa-flows.v1.json",
|
|
94
|
+
"docsUrl": "https://trylayout.com/docs/qa",
|
|
95
|
+
"schemaVersion": 1,
|
|
96
|
+
"flows": [
|
|
97
|
+
{
|
|
98
|
+
"id": "workspace_smoke",
|
|
99
|
+
"name": "Workspace smoke",
|
|
100
|
+
"startUrl": "/",
|
|
101
|
+
"scenarios": ["happy_path"],
|
|
102
|
+
"steps": [
|
|
103
|
+
{
|
|
104
|
+
"id": "workspace_loaded",
|
|
105
|
+
"type": "assert_visible_text",
|
|
106
|
+
"text": "Dashboard",
|
|
107
|
+
"screenshot": true
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Supported step types:
|
|
116
|
+
|
|
117
|
+
- `goto`: navigate to `url`.
|
|
118
|
+
- `click`: click by `selector` or visible `text`.
|
|
119
|
+
- `fill`: fill a `selector` with `value`.
|
|
120
|
+
- `assert_visible_text`: require visible `text`.
|
|
121
|
+
- `wait_for_text`: alias for a visible text wait.
|
|
122
|
+
- `assert_url`: require current URL to equal `url` or contain `contains`.
|
|
123
|
+
- `screenshot`: capture a screenshot checkpoint.
|
|
124
|
+
|
|
125
|
+
The runner sets these before the app loads:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
localStorage.setItem("layout.qa.scenario", "<scenario>");
|
|
129
|
+
sessionStorage.setItem("layout.qa.runner", "1");
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Your app can use `layout.qa.scenario` to switch deterministic mock states. The `layout.qa.runner` flag is useful for hiding local-only QA switchers from screenshots.
|
|
133
|
+
|
|
134
|
+
## Agent Setup Prompt
|
|
135
|
+
|
|
136
|
+
Paste this into your coding agent inside the frontend repo:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
Set up Layout QA for this web app.
|
|
140
|
+
|
|
141
|
+
Docs: https://trylayout.com/docs/qa
|
|
142
|
+
Flow schema: https://trylayout.com/schemas/qa-flows.v1.json
|
|
143
|
+
|
|
144
|
+
Add deterministic mock API/auth states behind a local-only env flag such as VITE_LAYOUT_QA_MOCKS=1. Use the scenario key localStorage["layout.qa.scenario"] with at least happy_path, empty, and error states. Keep mocks inside the app test/dev setup; do not add a standalone mock server.
|
|
145
|
+
|
|
146
|
+
Add .layout/qa-flows.json with one smoke flow for the highest-value page and screenshot checkpoints after meaningful user-visible states. Prefer visible text and stable selectors. Hide any local QA scenario switcher when sessionStorage["layout.qa.runner"] === "1".
|
|
147
|
+
|
|
148
|
+
Then run:
|
|
149
|
+
npx @trylayout/qa run --target-url <local app url> --scenario happy_path --open
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## npm Publishing
|
|
153
|
+
|
|
154
|
+
This repo is configured for a public scoped npm package:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm login
|
|
158
|
+
npm publish --access public
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
If the `@trylayout` npm scope does not exist yet, create it in npm first, then rerun publish from this repo.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const flows_1 = require("../flows");
|
|
10
|
+
const runner_1 = require("../runner");
|
|
11
|
+
const report_1 = require("../report");
|
|
12
|
+
function printHelp() {
|
|
13
|
+
process.stdout.write(`Layout QA CLI
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
trylayout init [options]
|
|
17
|
+
trylayout run --target-url <url> [options]
|
|
18
|
+
npx @trylayout/qa run --target-url <url> [options]
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
init Write a starter .layout/qa-flows.json.
|
|
22
|
+
run Run browser QA and write a local HTML report.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--target-url <url> URL of the running frontend to test.
|
|
26
|
+
--scenario <name> Mock scenario to activate. Defaults to happy_path.
|
|
27
|
+
--flows <path> Flow manifest path. Defaults to .layout/qa-flows.json.
|
|
28
|
+
--out <path> Artifact directory. Defaults to .layout/runs.
|
|
29
|
+
--timeout <ms> Browser run timeout. Defaults to LAYOUT_QA_TEST_TIMEOUT_MS or 60000.
|
|
30
|
+
--headed Show the browser instead of running headless.
|
|
31
|
+
--open Open the generated local HTML report after the run.
|
|
32
|
+
--json Print machine-readable JSON.
|
|
33
|
+
--force Overwrite an existing flow file during init.
|
|
34
|
+
--help Show this help.
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
function readFlag(args, name) {
|
|
38
|
+
const index = args.indexOf(name);
|
|
39
|
+
if (index === -1)
|
|
40
|
+
return '';
|
|
41
|
+
return args[index + 1] || '';
|
|
42
|
+
}
|
|
43
|
+
function hasFlag(args, name) {
|
|
44
|
+
return args.includes(name);
|
|
45
|
+
}
|
|
46
|
+
function parseArgs(args) {
|
|
47
|
+
const command = args[0] && !args[0].startsWith('--') ? args[0] : 'help';
|
|
48
|
+
const timeoutValue = readFlag(args, '--timeout');
|
|
49
|
+
const parsedTimeoutMs = timeoutValue ? Number(timeoutValue) : undefined;
|
|
50
|
+
if (timeoutValue &&
|
|
51
|
+
(!Number.isFinite(parsedTimeoutMs) || Number(parsedTimeoutMs) <= 0)) {
|
|
52
|
+
throw new Error('--timeout must be a positive number of milliseconds.');
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
command,
|
|
56
|
+
targetUrl: readFlag(args, '--target-url'),
|
|
57
|
+
scenario: readFlag(args, '--scenario') || 'happy_path',
|
|
58
|
+
flowsPath: readFlag(args, '--flows'),
|
|
59
|
+
outDir: readFlag(args, '--out'),
|
|
60
|
+
timeoutMs: parsedTimeoutMs,
|
|
61
|
+
headed: hasFlag(args, '--headed'),
|
|
62
|
+
json: hasFlag(args, '--json'),
|
|
63
|
+
open: hasFlag(args, '--open'),
|
|
64
|
+
force: hasFlag(args, '--force'),
|
|
65
|
+
help: hasFlag(args, '--help') || command === 'help',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function exists(filePath) {
|
|
69
|
+
return promises_1.default
|
|
70
|
+
.access(filePath)
|
|
71
|
+
.then(() => true)
|
|
72
|
+
.catch(() => false);
|
|
73
|
+
}
|
|
74
|
+
async function initCommand(options) {
|
|
75
|
+
const manifestPath = options.flowsPath
|
|
76
|
+
? path_1.default.resolve(process.cwd(), options.flowsPath)
|
|
77
|
+
: await (0, flows_1.resolveDefaultPath)(flows_1.FLOW_MANIFEST_PATH);
|
|
78
|
+
if ((await exists(manifestPath)) && !options.force) {
|
|
79
|
+
throw new Error(`${manifestPath} already exists. Use --force to overwrite it.`);
|
|
80
|
+
}
|
|
81
|
+
await promises_1.default.mkdir(path_1.default.dirname(manifestPath), { recursive: true });
|
|
82
|
+
await promises_1.default.writeFile(manifestPath, `${JSON.stringify((0, flows_1.starterFlowManifest)(), null, 2)}\n`);
|
|
83
|
+
process.stdout.write(`Created ${manifestPath}\n`);
|
|
84
|
+
}
|
|
85
|
+
function statusIcon(passed) {
|
|
86
|
+
return passed ? 'PASS' : 'FAIL';
|
|
87
|
+
}
|
|
88
|
+
function printHumanSummary(input) {
|
|
89
|
+
const passed = (0, runner_1.isQaRunPassed)(input.result);
|
|
90
|
+
process.stdout.write(`\nLayout QA ${passed ? 'passed' : 'failed'}\n` +
|
|
91
|
+
`Scenario: ${input.scenario}\n` +
|
|
92
|
+
`Target: ${input.targetUrl}\n` +
|
|
93
|
+
`Final URL: ${input.result.finalUrl || 'unavailable'}\n` +
|
|
94
|
+
`Flow: ${input.result.flow?.name || 'None'} (${input.result.flow?.source || 'none'})\n` +
|
|
95
|
+
`Manifest: ${input.manifestFound ? input.manifestPath : 'not found; default smoke'}\n\n`);
|
|
96
|
+
for (const check of input.result.checks) {
|
|
97
|
+
process.stdout.write(`${statusIcon(check.passed)} ${check.label}${check.detail ? ` - ${check.detail}` : ''}\n`);
|
|
98
|
+
}
|
|
99
|
+
if (input.result.flow?.steps.length) {
|
|
100
|
+
process.stdout.write('\nFlow steps:\n');
|
|
101
|
+
for (const step of input.result.flow.steps) {
|
|
102
|
+
process.stdout.write(`${statusIcon(step.status === 'passed')} ${step.label || step.id}${step.detail ? ` - ${step.detail}` : ''}\n`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (input.result.issues.length) {
|
|
106
|
+
process.stdout.write('\nIssues:\n');
|
|
107
|
+
for (const issue of input.result.issues) {
|
|
108
|
+
process.stdout.write(`- ${issue.type}: ${issue.message}${issue.source ? ` (${issue.source})` : ''}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (input.result.nextAction) {
|
|
112
|
+
process.stdout.write(`\nNext action: ${input.result.nextAction.title}\n` +
|
|
113
|
+
`${input.result.nextAction.detail}\n`);
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(`\nArtifacts: ${input.artifacts.runDir}\n`);
|
|
116
|
+
process.stdout.write(`Report: ${input.artifacts.reportPath}\n`);
|
|
117
|
+
}
|
|
118
|
+
function resultForConsole(result) {
|
|
119
|
+
const clean = JSON.parse(JSON.stringify(result));
|
|
120
|
+
delete clean.screenshotDataUrl;
|
|
121
|
+
const flow = clean.flow;
|
|
122
|
+
for (const step of flow?.steps || []) {
|
|
123
|
+
delete step.screenshotDataUrl;
|
|
124
|
+
}
|
|
125
|
+
return clean;
|
|
126
|
+
}
|
|
127
|
+
async function runCommand(options) {
|
|
128
|
+
if (!options.targetUrl) {
|
|
129
|
+
throw new Error('--target-url is required.');
|
|
130
|
+
}
|
|
131
|
+
const { flow, manifestPath, manifestFound } = await (0, flows_1.loadFlow)({
|
|
132
|
+
flowsPath: options.flowsPath,
|
|
133
|
+
scenario: options.scenario,
|
|
134
|
+
});
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = await (0, runner_1.runLayoutQaBrowser)({
|
|
138
|
+
targetUrl: options.targetUrl,
|
|
139
|
+
scenario: options.scenario,
|
|
140
|
+
flow,
|
|
141
|
+
timeoutMs: options.timeoutMs || (0, flows_1.getTestTimeoutMs)(),
|
|
142
|
+
headless: !options.headed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
result = (0, runner_1.buildRunnerErrorResult)(message);
|
|
148
|
+
}
|
|
149
|
+
const artifacts = await (0, report_1.writeArtifacts)({
|
|
150
|
+
outDir: options.outDir,
|
|
151
|
+
scenario: options.scenario,
|
|
152
|
+
targetUrl: options.targetUrl,
|
|
153
|
+
manifestPath,
|
|
154
|
+
manifestFound,
|
|
155
|
+
result,
|
|
156
|
+
});
|
|
157
|
+
const passed = (0, runner_1.isQaRunPassed)(result);
|
|
158
|
+
if (options.json) {
|
|
159
|
+
process.stdout.write(`${JSON.stringify({
|
|
160
|
+
status: passed ? 'passed' : 'failed',
|
|
161
|
+
scenario: options.scenario,
|
|
162
|
+
targetUrl: options.targetUrl,
|
|
163
|
+
manifestPath,
|
|
164
|
+
manifestFound,
|
|
165
|
+
artifacts,
|
|
166
|
+
result: resultForConsole(result),
|
|
167
|
+
}, null, 2)}\n`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
printHumanSummary({
|
|
171
|
+
result,
|
|
172
|
+
scenario: options.scenario,
|
|
173
|
+
targetUrl: options.targetUrl,
|
|
174
|
+
manifestPath,
|
|
175
|
+
manifestFound,
|
|
176
|
+
artifacts,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (options.open) {
|
|
180
|
+
await (0, report_1.openReport)(artifacts.reportPath);
|
|
181
|
+
}
|
|
182
|
+
process.exitCode = passed ? 0 : 1;
|
|
183
|
+
}
|
|
184
|
+
async function main() {
|
|
185
|
+
const options = parseArgs(process.argv.slice(2));
|
|
186
|
+
if (options.help) {
|
|
187
|
+
printHelp();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (options.command === 'init') {
|
|
191
|
+
await initCommand(options);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (options.command === 'run') {
|
|
195
|
+
await runCommand(options);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`Unsupported command: ${options.command}`);
|
|
199
|
+
}
|
|
200
|
+
main().catch(error => {
|
|
201
|
+
process.stderr.write(`Layout QA failed to start: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
});
|
package/build/flows.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LoadedQaFlow, QaFlowDefinition } from './types';
|
|
2
|
+
export declare const DEFAULT_TEST_TIMEOUT_MS: number;
|
|
3
|
+
export declare const SCREENSHOT_LIMIT_BYTES: number;
|
|
4
|
+
export declare const FLOW_MANIFEST_PATH = ".layout/qa-flows.json";
|
|
5
|
+
export declare const QA_DOCS_URL = "https://trylayout.com/docs/qa";
|
|
6
|
+
export declare function getTestTimeoutMs(): number;
|
|
7
|
+
export declare function selectFlowFromManifest(raw: unknown, scenario: string): QaFlowDefinition | null;
|
|
8
|
+
export declare function parseFlowManifestContent(content: string, scenario: string): LoadedQaFlow | null;
|
|
9
|
+
export declare function defaultFlow(): LoadedQaFlow;
|
|
10
|
+
export declare function resolveDefaultPath(defaultPath: string): Promise<string>;
|
|
11
|
+
export declare function loadFlow(input: {
|
|
12
|
+
flowsPath: string;
|
|
13
|
+
scenario: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
flow: LoadedQaFlow;
|
|
16
|
+
manifestPath: string;
|
|
17
|
+
manifestFound: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function starterFlowManifest(): {
|
|
20
|
+
$schema: string;
|
|
21
|
+
docsUrl: string;
|
|
22
|
+
schemaVersion: number;
|
|
23
|
+
flows: {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
startUrl: string;
|
|
27
|
+
scenarios: string[];
|
|
28
|
+
steps: {
|
|
29
|
+
id: string;
|
|
30
|
+
type: string;
|
|
31
|
+
label: string;
|
|
32
|
+
screenshot: boolean;
|
|
33
|
+
}[];
|
|
34
|
+
}[];
|
|
35
|
+
};
|
package/build/flows.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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.QA_DOCS_URL = exports.FLOW_MANIFEST_PATH = exports.SCREENSHOT_LIMIT_BYTES = exports.DEFAULT_TEST_TIMEOUT_MS = void 0;
|
|
7
|
+
exports.getTestTimeoutMs = getTestTimeoutMs;
|
|
8
|
+
exports.selectFlowFromManifest = selectFlowFromManifest;
|
|
9
|
+
exports.parseFlowManifestContent = parseFlowManifestContent;
|
|
10
|
+
exports.defaultFlow = defaultFlow;
|
|
11
|
+
exports.resolveDefaultPath = resolveDefaultPath;
|
|
12
|
+
exports.loadFlow = loadFlow;
|
|
13
|
+
exports.starterFlowManifest = starterFlowManifest;
|
|
14
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
exports.DEFAULT_TEST_TIMEOUT_MS = 60 * 1000;
|
|
17
|
+
exports.SCREENSHOT_LIMIT_BYTES = 300 * 1024;
|
|
18
|
+
exports.FLOW_MANIFEST_PATH = '.layout/qa-flows.json';
|
|
19
|
+
exports.QA_DOCS_URL = 'https://trylayout.com/docs/qa';
|
|
20
|
+
function getTestTimeoutMs() {
|
|
21
|
+
const value = Number(process.env.LAYOUT_QA_TEST_TIMEOUT_MS);
|
|
22
|
+
return Number.isFinite(value) && value > 0 ? value : exports.DEFAULT_TEST_TIMEOUT_MS;
|
|
23
|
+
}
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
26
|
+
}
|
|
27
|
+
function stringValue(value, fallback = '') {
|
|
28
|
+
return typeof value === 'string' ? value : fallback;
|
|
29
|
+
}
|
|
30
|
+
function booleanValue(value, fallback = false) {
|
|
31
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
32
|
+
}
|
|
33
|
+
function numberValue(value) {
|
|
34
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
35
|
+
? value
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
function stringArray(value) {
|
|
39
|
+
return Array.isArray(value)
|
|
40
|
+
? value.filter(item => typeof item === 'string')
|
|
41
|
+
: [];
|
|
42
|
+
}
|
|
43
|
+
function normalizeFlowStep(value, index) {
|
|
44
|
+
if (!isRecord(value))
|
|
45
|
+
return null;
|
|
46
|
+
const type = stringValue(value.type).trim();
|
|
47
|
+
if (!type)
|
|
48
|
+
return null;
|
|
49
|
+
return {
|
|
50
|
+
id: stringValue(value.id).trim() ||
|
|
51
|
+
`${type.replace(/[^a-zA-Z0-9_-]+/g, '_')}_${index + 1}`,
|
|
52
|
+
type,
|
|
53
|
+
label: stringValue(value.label || value.name).trim(),
|
|
54
|
+
text: stringValue(value.text).trim(),
|
|
55
|
+
selector: stringValue(value.selector).trim(),
|
|
56
|
+
value: stringValue(value.value),
|
|
57
|
+
url: stringValue(value.url || value.path).trim(),
|
|
58
|
+
contains: stringValue(value.contains).trim(),
|
|
59
|
+
exact: booleanValue(value.exact),
|
|
60
|
+
screenshot: booleanValue(value.screenshot, type === 'screenshot'),
|
|
61
|
+
timeoutMs: numberValue(value.timeoutMs),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function normalizeFlow(value, index) {
|
|
65
|
+
if (!isRecord(value))
|
|
66
|
+
return null;
|
|
67
|
+
const rawSteps = Array.isArray(value.steps) ? value.steps : [];
|
|
68
|
+
const steps = rawSteps
|
|
69
|
+
.map((step, stepIndex) => normalizeFlowStep(step, stepIndex))
|
|
70
|
+
.filter((step) => Boolean(step));
|
|
71
|
+
if (steps.length === 0)
|
|
72
|
+
return null;
|
|
73
|
+
const id = stringValue(value.id).trim() || `flow_${index + 1}`;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
name: stringValue(value.name).trim() || id,
|
|
77
|
+
startUrl: stringValue(value.startUrl).trim() || '/',
|
|
78
|
+
scenarios: stringArray(value.scenarios),
|
|
79
|
+
steps,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function selectFlowFromManifest(raw, scenario) {
|
|
83
|
+
if (!isRecord(raw) || !Array.isArray(raw.flows))
|
|
84
|
+
return null;
|
|
85
|
+
const flows = raw.flows
|
|
86
|
+
.map((flow, index) => normalizeFlow(flow, index))
|
|
87
|
+
.filter((flow) => Boolean(flow));
|
|
88
|
+
if (flows.length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return (flows.find(flow => flow.scenarios.length === 0 || flow.scenarios.includes(scenario)) || flows[0]);
|
|
91
|
+
}
|
|
92
|
+
function parseFlowManifestContent(content, scenario) {
|
|
93
|
+
const flow = selectFlowFromManifest(JSON.parse(content), scenario);
|
|
94
|
+
return flow ? { ...flow, source: 'manifest' } : null;
|
|
95
|
+
}
|
|
96
|
+
function defaultFlow() {
|
|
97
|
+
return {
|
|
98
|
+
id: 'target_smoke',
|
|
99
|
+
name: 'Target smoke',
|
|
100
|
+
startUrl: '/',
|
|
101
|
+
scenarios: [],
|
|
102
|
+
source: 'default',
|
|
103
|
+
steps: [
|
|
104
|
+
{
|
|
105
|
+
id: 'initial_screen',
|
|
106
|
+
type: 'screenshot',
|
|
107
|
+
label: 'Initial screen',
|
|
108
|
+
screenshot: true,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function exists(filePath) {
|
|
114
|
+
return promises_1.default
|
|
115
|
+
.access(filePath)
|
|
116
|
+
.then(() => true)
|
|
117
|
+
.catch(() => false);
|
|
118
|
+
}
|
|
119
|
+
async function resolveDefaultPath(defaultPath) {
|
|
120
|
+
const cwdPath = path_1.default.resolve(process.cwd(), defaultPath);
|
|
121
|
+
if (await exists(cwdPath))
|
|
122
|
+
return cwdPath;
|
|
123
|
+
const parentPath = path_1.default.resolve(process.cwd(), '..', defaultPath);
|
|
124
|
+
if (await exists(parentPath))
|
|
125
|
+
return parentPath;
|
|
126
|
+
if (defaultPath === '.layout' || defaultPath.startsWith('.layout/')) {
|
|
127
|
+
const cwdLayoutDir = path_1.default.resolve(process.cwd(), '.layout');
|
|
128
|
+
const parentLayoutDir = path_1.default.resolve(process.cwd(), '..', '.layout');
|
|
129
|
+
if (await exists(cwdLayoutDir))
|
|
130
|
+
return cwdPath;
|
|
131
|
+
if (await exists(parentLayoutDir))
|
|
132
|
+
return parentPath;
|
|
133
|
+
}
|
|
134
|
+
return cwdPath;
|
|
135
|
+
}
|
|
136
|
+
async function loadFlow(input) {
|
|
137
|
+
const manifestPath = input.flowsPath
|
|
138
|
+
? path_1.default.resolve(process.cwd(), input.flowsPath)
|
|
139
|
+
: await resolveDefaultPath(exports.FLOW_MANIFEST_PATH);
|
|
140
|
+
const content = await promises_1.default.readFile(manifestPath, 'utf8').catch(error => {
|
|
141
|
+
if (error.code === 'ENOENT') {
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
});
|
|
146
|
+
if (!content) {
|
|
147
|
+
return {
|
|
148
|
+
flow: defaultFlow(),
|
|
149
|
+
manifestPath,
|
|
150
|
+
manifestFound: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const flow = parseFlowManifestContent(content, input.scenario);
|
|
154
|
+
return {
|
|
155
|
+
flow: flow || defaultFlow(),
|
|
156
|
+
manifestPath,
|
|
157
|
+
manifestFound: Boolean(flow),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function starterFlowManifest() {
|
|
161
|
+
return {
|
|
162
|
+
$schema: 'https://trylayout.com/schemas/qa-flows.v1.json',
|
|
163
|
+
docsUrl: exports.QA_DOCS_URL,
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
flows: [
|
|
166
|
+
{
|
|
167
|
+
id: 'smoke',
|
|
168
|
+
name: 'Smoke',
|
|
169
|
+
startUrl: '/',
|
|
170
|
+
scenarios: ['happy_path'],
|
|
171
|
+
steps: [
|
|
172
|
+
{
|
|
173
|
+
id: 'initial_screen',
|
|
174
|
+
type: 'screenshot',
|
|
175
|
+
label: 'Initial screen',
|
|
176
|
+
screenshot: true,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./flows"), exports);
|
|
18
|
+
__exportStar(require("./report"), exports);
|
|
19
|
+
__exportStar(require("./runner"), exports);
|
|
20
|
+
__exportStar(require("./types"), exports);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ArtifactSummary, QaTestRunResult } from './types';
|
|
2
|
+
export declare function safeName(value: string): string;
|
|
3
|
+
export declare function stepScreenshotFileName(index: number, stepId: string): string;
|
|
4
|
+
export declare function writeArtifacts(input: {
|
|
5
|
+
outDir: string;
|
|
6
|
+
scenario: string;
|
|
7
|
+
targetUrl: string;
|
|
8
|
+
manifestPath: string;
|
|
9
|
+
manifestFound: boolean;
|
|
10
|
+
result: QaTestRunResult;
|
|
11
|
+
}): Promise<ArtifactSummary>;
|
|
12
|
+
export declare function openReport(reportPath: string): Promise<void>;
|