@qartisan/playwright-loupe 0.0.0-stage → 0.0.1
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.js +4 -0
- package/dist/config/index.d.ts +11 -0
- package/dist/config/index.js +23 -0
- package/dist/config/load.d.ts +30 -0
- package/dist/config/load.js +78 -0
- package/dist/config/resolve.d.ts +50 -0
- package/dist/config/resolve.js +116 -0
- package/dist/config/types.d.ts +112 -0
- package/dist/config/types.js +8 -0
- package/dist/config/validate.d.ts +11 -0
- package/dist/config/validate.js +231 -0
- package/dist/core/attachments.d.ts +22 -0
- package/dist/core/attachments.js +79 -0
- package/dist/core/build.d.ts +34 -0
- package/dist/core/build.js +300 -0
- package/dist/core/categories.d.ts +45 -0
- package/dist/core/categories.js +111 -0
- package/dist/core/dimensions.d.ts +33 -0
- package/dist/core/dimensions.js +72 -0
- package/dist/core/identity.d.ts +14 -0
- package/dist/core/identity.js +25 -0
- package/dist/core/inject.d.ts +31 -0
- package/dist/core/inject.js +49 -0
- package/dist/core/metadata.d.ts +17 -0
- package/dist/core/metadata.js +68 -0
- package/dist/core/output.d.ts +5 -0
- package/dist/core/output.js +15 -0
- package/dist/core/reporter.d.ts +26 -0
- package/dist/core/reporter.js +64 -0
- package/dist/core/runErrors.d.ts +12 -0
- package/dist/core/runErrors.js +49 -0
- package/dist/core/scope.d.ts +57 -0
- package/dist/core/scope.js +164 -0
- package/dist/core/signature.d.ts +15 -0
- package/dist/core/signature.js +60 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +8 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/renderer/index.html +16 -0
- package/dist/schema/v1.d.ts +226 -0
- package/dist/schema/v1.js +22 -0
- package/package.json +63 -4
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { CATEGORY_MODES, CATEGORY_TARGETS, SCREENSHOT_VIEWS, THEMES } from './types.js';
|
|
2
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
3
|
+
/** What a tag prefix cannot contain: `@layer:api` has the prefix `layer`. */
|
|
4
|
+
const INVALID_PREFIX = /[@:\s]/;
|
|
5
|
+
function describe(value) {
|
|
6
|
+
if (value === null)
|
|
7
|
+
return 'null';
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return 'undefined';
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return 'an array';
|
|
12
|
+
if (value instanceof RegExp)
|
|
13
|
+
return `a RegExp (${String(value)})`;
|
|
14
|
+
if (typeof value === 'string')
|
|
15
|
+
return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
|
|
16
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
17
|
+
return String(value);
|
|
18
|
+
if (typeof value === 'function')
|
|
19
|
+
return 'a function';
|
|
20
|
+
return `an ${typeof value}`;
|
|
21
|
+
}
|
|
22
|
+
function isPlainObject(value) {
|
|
23
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
24
|
+
return false;
|
|
25
|
+
const prototype = Object.getPrototypeOf(value);
|
|
26
|
+
return prototype === Object.prototype || prototype === null;
|
|
27
|
+
}
|
|
28
|
+
function join(path, key) {
|
|
29
|
+
return path === '' ? key : `${path}.${key}`;
|
|
30
|
+
}
|
|
31
|
+
/** Optimal string alignment distance: Levenshtein plus adjacent transpositions (`titel` → `title` is 1). */
|
|
32
|
+
export function editDistance(a, b) {
|
|
33
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
34
|
+
for (let j = 1; j <= b.length; j++)
|
|
35
|
+
d[0][j] = j;
|
|
36
|
+
for (let i = 1; i <= a.length; i++) {
|
|
37
|
+
for (let j = 1; j <= b.length; j++) {
|
|
38
|
+
const row = d[i];
|
|
39
|
+
const previous = d[i - 1];
|
|
40
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
41
|
+
row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + cost);
|
|
42
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
43
|
+
row[j] = Math.min(row[j], d[i - 2][j - 2] + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return d[a.length][b.length];
|
|
48
|
+
}
|
|
49
|
+
/** The closest known key, when it is close enough to be a typo. */
|
|
50
|
+
export function suggestKey(key, known) {
|
|
51
|
+
let best = null;
|
|
52
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
53
|
+
for (const candidate of known) {
|
|
54
|
+
const distance = candidate.toLowerCase() === key.toLowerCase() ? 0 : editDistance(key, candidate);
|
|
55
|
+
if (distance < bestDistance) {
|
|
56
|
+
best = candidate;
|
|
57
|
+
bestDistance = distance;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const tolerance = key.length <= 3 ? 1 : key.length <= 6 ? 2 : 3;
|
|
61
|
+
return bestDistance <= tolerance ? best : null;
|
|
62
|
+
}
|
|
63
|
+
function object(fields, ctx) {
|
|
64
|
+
return (value, path, errors) => {
|
|
65
|
+
if (!isPlainObject(value)) {
|
|
66
|
+
errors.push(`${ctx.source}: ${path || 'the config'} must be an object, got ${describe(value)}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const known = Object.keys(fields);
|
|
70
|
+
for (const [key, child] of Object.entries(value)) {
|
|
71
|
+
const check = fields[key];
|
|
72
|
+
if (check) {
|
|
73
|
+
if (child !== undefined)
|
|
74
|
+
check(child, join(path, key), errors);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const suggestion = suggestKey(key, known);
|
|
78
|
+
const hint = suggestion ? `Did you mean "${suggestion}"?` : `Known options: ${known.join(', ')}.`;
|
|
79
|
+
errors.push(`${ctx.source}: ${join(path, key)} is not a known option. ${hint}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function fail(ctx, path, expected, value, errors) {
|
|
84
|
+
errors.push(`${ctx.source}: ${path} must be ${expected}, got ${describe(value)}`);
|
|
85
|
+
}
|
|
86
|
+
function nonEmptyString(ctx) {
|
|
87
|
+
return (value, path, errors) => {
|
|
88
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
89
|
+
fail(ctx, path, 'a non-empty string', value, errors);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function oneOf(values, ctx) {
|
|
93
|
+
return (value, path, errors) => {
|
|
94
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
95
|
+
fail(ctx, path, `one of ${values.map((item) => `"${item}"`).join(', ')}`, value, errors);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function boolean(ctx) {
|
|
100
|
+
return (value, path, errors) => {
|
|
101
|
+
if (typeof value !== 'boolean')
|
|
102
|
+
fail(ctx, path, 'a boolean', value, errors);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function color(ctx) {
|
|
106
|
+
return (value, path, errors) => {
|
|
107
|
+
if (typeof value !== 'string' || !COLOR.test(value))
|
|
108
|
+
fail(ctx, path, 'a #rrggbb color', value, errors);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function regexps(ctx) {
|
|
112
|
+
return (value, path, errors) => {
|
|
113
|
+
const expected = 'a RegExp or an array of RegExp';
|
|
114
|
+
if (value instanceof RegExp)
|
|
115
|
+
return;
|
|
116
|
+
if (!Array.isArray(value))
|
|
117
|
+
return fail(ctx, path, expected, value, errors);
|
|
118
|
+
if (value.length === 0)
|
|
119
|
+
return fail(ctx, path, `${expected} (not empty)`, value, errors);
|
|
120
|
+
value.forEach((item, index) => {
|
|
121
|
+
if (!(item instanceof RegExp))
|
|
122
|
+
fail(ctx, `${path}[${index}]`, 'a RegExp', item, errors);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function arrayOf(item, ctx) {
|
|
127
|
+
return (value, path, errors) => {
|
|
128
|
+
if (!Array.isArray(value))
|
|
129
|
+
return fail(ctx, path, 'an array', value, errors);
|
|
130
|
+
value.forEach((child, index) => item(child, `${path}[${index}]`, errors));
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function values(ctx) {
|
|
134
|
+
return (value, path, errors) => {
|
|
135
|
+
if (!Array.isArray(value))
|
|
136
|
+
return fail(ctx, path, 'an array of non-empty strings', value, errors);
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
value.forEach((item, index) => {
|
|
139
|
+
if (typeof item !== 'string' || item.trim() === '')
|
|
140
|
+
return fail(ctx, `${path}[${index}]`, 'a non-empty string', item, errors);
|
|
141
|
+
if (seen.has(item))
|
|
142
|
+
errors.push(`${ctx.source}: ${path}[${index}] repeats the value "${item}"`);
|
|
143
|
+
seen.add(item);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function tagPrefix(ctx) {
|
|
148
|
+
return (value, path, errors) => {
|
|
149
|
+
if (typeof value !== 'string' || value === '' || INVALID_PREFIX.test(value)) {
|
|
150
|
+
fail(ctx, path, "a tag prefix without '@', ':' or spaces (\"layer\" for @layer:api)", value, errors);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function dimension(ctx) {
|
|
155
|
+
const tag = object({ name: nonEmptyString(ctx), from: () => { }, prefix: tagPrefix(ctx), values: values(ctx) }, ctx);
|
|
156
|
+
const annotation = object({ name: nonEmptyString(ctx), from: () => { }, type: nonEmptyString(ctx), values: values(ctx) }, ctx);
|
|
157
|
+
return (value, path, errors) => {
|
|
158
|
+
if (!isPlainObject(value))
|
|
159
|
+
return fail(ctx, path, 'an object', value, errors);
|
|
160
|
+
const required = value['from'] === 'tag' ? ['name', 'prefix'] : value['from'] === 'annotation' ? ['name', 'type'] : null;
|
|
161
|
+
if (!required)
|
|
162
|
+
return fail(ctx, `${path}.from`, '"tag" or "annotation"', value['from'], errors);
|
|
163
|
+
for (const key of required) {
|
|
164
|
+
if (value[key] === undefined)
|
|
165
|
+
errors.push(`${ctx.source}: ${join(path, key)} is required when from is "${String(value['from'])}"`);
|
|
166
|
+
}
|
|
167
|
+
(value['from'] === 'tag' ? tag : annotation)(value, path, errors);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function categoryRule(ctx) {
|
|
171
|
+
const fields = object({ name: nonEmptyString(ctx), match: regexps(ctx), on: oneOf(CATEGORY_TARGETS, ctx), color: color(ctx) }, ctx);
|
|
172
|
+
return (value, path, errors) => {
|
|
173
|
+
fields(value, path, errors);
|
|
174
|
+
if (!isPlainObject(value))
|
|
175
|
+
return;
|
|
176
|
+
for (const key of ['name', 'match']) {
|
|
177
|
+
if (value[key] === undefined)
|
|
178
|
+
errors.push(`${ctx.source}: ${join(path, key)} is required`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Checks across the items of one list: unique dimension names, prefixes and annotation types. */
|
|
183
|
+
function checkDimensionList(definitions, ctx, errors) {
|
|
184
|
+
if (!Array.isArray(definitions))
|
|
185
|
+
return;
|
|
186
|
+
const seen = new Map();
|
|
187
|
+
definitions.forEach((definition, index) => {
|
|
188
|
+
if (!isPlainObject(definition))
|
|
189
|
+
return;
|
|
190
|
+
const keys = [
|
|
191
|
+
['name', definition['name']],
|
|
192
|
+
[definition['from'] === 'tag' ? 'prefix' : 'type', definition['from'] === 'tag' ? definition['prefix'] : definition['type']],
|
|
193
|
+
];
|
|
194
|
+
for (const [field, value] of keys) {
|
|
195
|
+
if (typeof value !== 'string')
|
|
196
|
+
continue;
|
|
197
|
+
const key = `${field === 'name' ? 'name' : `${String(definition['from'])}:${field}`}\0${value}`;
|
|
198
|
+
const first = seen.get(key);
|
|
199
|
+
if (first !== undefined) {
|
|
200
|
+
errors.push(`${ctx.source}: dimensions.definitions[${index}].${field} "${value}" is already used by dimensions.definitions[${first}]`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
seen.set(key, index);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Validates one config layer. `source` names it in messages (`loupe.config.ts`,
|
|
210
|
+
* `reporter options`). `configFile` is only accepted in inline options.
|
|
211
|
+
*/
|
|
212
|
+
export function validateConfig(value, source, options = {}) {
|
|
213
|
+
const ctx = { source };
|
|
214
|
+
const errors = [];
|
|
215
|
+
const fields = {
|
|
216
|
+
outputFolder: nonEmptyString(ctx),
|
|
217
|
+
title: nonEmptyString(ctx),
|
|
218
|
+
environment: nonEmptyString(ctx),
|
|
219
|
+
categories: object({ mode: oneOf(CATEGORY_MODES, ctx), rules: arrayOf(categoryRule(ctx), ctx), uncategorized: nonEmptyString(ctx) }, ctx),
|
|
220
|
+
dimensions: object({ auto: boolean(ctx), definitions: arrayOf(dimension(ctx), ctx) }, ctx),
|
|
221
|
+
screenshots: object({ defaultView: oneOf(SCREENSHOT_VIEWS, ctx) }, ctx),
|
|
222
|
+
theme: oneOf(THEMES, ctx),
|
|
223
|
+
};
|
|
224
|
+
if (options.allowConfigFile)
|
|
225
|
+
fields['configFile'] = nonEmptyString(ctx);
|
|
226
|
+
object(fields, ctx)(value, '', errors);
|
|
227
|
+
if (isPlainObject(value) && isPlainObject(value['dimensions'])) {
|
|
228
|
+
checkDimensionList(value['dimensions']['definitions'], ctx, errors);
|
|
229
|
+
}
|
|
230
|
+
return errors;
|
|
231
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AttachmentKind, ComparisonRole } from '../schema/v1.js';
|
|
2
|
+
/** Text bodies up to this size (UTF-8 bytes) are inlined in the JSON; larger ones are written to files. */
|
|
3
|
+
export declare const INLINE_TEXT_LIMIT: number;
|
|
4
|
+
export declare function isTextContentType(contentType: string): boolean;
|
|
5
|
+
export declare function attachmentKind(name: string, contentType: string): AttachmentKind;
|
|
6
|
+
/**
|
|
7
|
+
* toHaveScreenshot / toMatchSnapshot attach images named `<name>-expected.png`,
|
|
8
|
+
* `<name>-actual.png` and `<name>-diff.png`. The group is the name without the
|
|
9
|
+
* role suffix (`<name>.png`), shared by the three images.
|
|
10
|
+
*/
|
|
11
|
+
export declare function comparisonOf(name: string, contentType: string): {
|
|
12
|
+
group: string;
|
|
13
|
+
role: ComparisonRole;
|
|
14
|
+
} | null;
|
|
15
|
+
/** File extension for a content type, '.bin' when unknown. */
|
|
16
|
+
export declare function extensionFor(contentType: string): string;
|
|
17
|
+
/** Turns an attachment name into a safe single-segment file name. */
|
|
18
|
+
export declare function safeFileName(name: string): string;
|
|
19
|
+
/** File name for a body attachment: the safe name, plus an extension from the content type when it has none. */
|
|
20
|
+
export declare function bodyFileName(name: string, contentType: string): string;
|
|
21
|
+
/** Returns `fileName`, or `name-1.ext`, `name-2.ext`, ... when taken; records the result in `taken`. */
|
|
22
|
+
export declare function uniqueFileName(fileName: string, taken: Set<string>): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Text bodies up to this size (UTF-8 bytes) are inlined in the JSON; larger ones are written to files. */
|
|
2
|
+
export const INLINE_TEXT_LIMIT = 4 * 1024;
|
|
3
|
+
const TEXT_TYPE = /^(?:text\/|application\/(?:json|xml|javascript)\b|application\/[\w.+-]+\+(?:json|xml)\b)/i;
|
|
4
|
+
export function isTextContentType(contentType) {
|
|
5
|
+
return TEXT_TYPE.test(contentType);
|
|
6
|
+
}
|
|
7
|
+
export function attachmentKind(name, contentType) {
|
|
8
|
+
const type = contentType.toLowerCase();
|
|
9
|
+
if (type.startsWith('image/'))
|
|
10
|
+
return 'screenshot';
|
|
11
|
+
if (type.startsWith('video/'))
|
|
12
|
+
return 'video';
|
|
13
|
+
if (name === 'trace' || (type === 'application/zip' && /trace/i.test(name)))
|
|
14
|
+
return 'trace';
|
|
15
|
+
if (isTextContentType(type))
|
|
16
|
+
return 'text';
|
|
17
|
+
return 'other';
|
|
18
|
+
}
|
|
19
|
+
const COMPARISON_NAME = /^(.+)-(expected|actual|diff)(\.[a-z0-9]+)?$/i;
|
|
20
|
+
/**
|
|
21
|
+
* toHaveScreenshot / toMatchSnapshot attach images named `<name>-expected.png`,
|
|
22
|
+
* `<name>-actual.png` and `<name>-diff.png`. The group is the name without the
|
|
23
|
+
* role suffix (`<name>.png`), shared by the three images.
|
|
24
|
+
*/
|
|
25
|
+
export function comparisonOf(name, contentType) {
|
|
26
|
+
if (!contentType.toLowerCase().startsWith('image/'))
|
|
27
|
+
return null;
|
|
28
|
+
const match = COMPARISON_NAME.exec(name);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
const [, base = '', role = '', extension = ''] = match;
|
|
32
|
+
return { group: base + extension, role: role.toLowerCase() };
|
|
33
|
+
}
|
|
34
|
+
const EXTENSIONS = {
|
|
35
|
+
'image/png': '.png',
|
|
36
|
+
'image/jpeg': '.jpg',
|
|
37
|
+
'image/gif': '.gif',
|
|
38
|
+
'image/webp': '.webp',
|
|
39
|
+
'image/svg+xml': '.svg',
|
|
40
|
+
'video/webm': '.webm',
|
|
41
|
+
'video/mp4': '.mp4',
|
|
42
|
+
'application/zip': '.zip',
|
|
43
|
+
'application/json': '.json',
|
|
44
|
+
'application/xml': '.xml',
|
|
45
|
+
'text/plain': '.txt',
|
|
46
|
+
'text/html': '.html',
|
|
47
|
+
'text/markdown': '.md',
|
|
48
|
+
'text/csv': '.csv',
|
|
49
|
+
};
|
|
50
|
+
/** File extension for a content type, '.bin' when unknown. */
|
|
51
|
+
export function extensionFor(contentType) {
|
|
52
|
+
const type = contentType.toLowerCase().split(';')[0]?.trim() ?? '';
|
|
53
|
+
return EXTENSIONS[type] ?? (isTextContentType(type) ? '.txt' : '.bin');
|
|
54
|
+
}
|
|
55
|
+
/** Turns an attachment name into a safe single-segment file name. */
|
|
56
|
+
export function safeFileName(name) {
|
|
57
|
+
const safe = name
|
|
58
|
+
.replace(/[^\w.@+-]+/g, '-')
|
|
59
|
+
.replace(/^[.-]+/, '')
|
|
60
|
+
.slice(0, 100);
|
|
61
|
+
return safe || 'attachment';
|
|
62
|
+
}
|
|
63
|
+
/** File name for a body attachment: the safe name, plus an extension from the content type when it has none. */
|
|
64
|
+
export function bodyFileName(name, contentType) {
|
|
65
|
+
const safe = safeFileName(name);
|
|
66
|
+
return /\.[a-z0-9]{1,8}$/i.test(safe) ? safe : safe + extensionFor(contentType);
|
|
67
|
+
}
|
|
68
|
+
/** Returns `fileName`, or `name-1.ext`, `name-2.ext`, ... when taken; records the result in `taken`. */
|
|
69
|
+
export function uniqueFileName(fileName, taken) {
|
|
70
|
+
let candidate = fileName;
|
|
71
|
+
if (taken.has(candidate)) {
|
|
72
|
+
const dot = fileName.lastIndexOf('.');
|
|
73
|
+
const [stem, extension] = dot > 0 ? [fileName.slice(0, dot), fileName.slice(dot)] : [fileName, ''];
|
|
74
|
+
for (let i = 1; taken.has(candidate); i++)
|
|
75
|
+
candidate = `${stem}-${i}${extension}`;
|
|
76
|
+
}
|
|
77
|
+
taken.add(candidate);
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FullConfig, FullResult, Suite, TestError, TestStep } from '@playwright/test/reporter';
|
|
2
|
+
import type { LoupeReport, RunScope } from '../schema/v1.js';
|
|
3
|
+
import { type CategoriesOptions, type ErrorText } from './categories.js';
|
|
4
|
+
import { type DimensionsOptions } from './dimensions.js';
|
|
5
|
+
/** Folder, inside the report folder, that holds the copied attachments. */
|
|
6
|
+
export declare const DATA_FOLDER = "data";
|
|
7
|
+
export interface BuildOptions {
|
|
8
|
+
runId: string;
|
|
9
|
+
generatorVersion: string;
|
|
10
|
+
/** Absolute report folder; attachments are copied under `<outputFolder>/data/`. */
|
|
11
|
+
outputFolder: string;
|
|
12
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
13
|
+
scopeOverride: RunScope | null;
|
|
14
|
+
/** `run.environment.name`, from the config (`environment`, `LOUPE_ENV`). */
|
|
15
|
+
environmentName: string | null;
|
|
16
|
+
categories?: CategoriesOptions;
|
|
17
|
+
dimensions?: DimensionsOptions;
|
|
18
|
+
/** Errors received through `onError`, with the project of the worker when Playwright gives it. */
|
|
19
|
+
runErrors?: ReadonlyArray<{
|
|
20
|
+
error: TestError;
|
|
21
|
+
project: string | null;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Titles of the failing steps leading to `error`, outermost first: the path to
|
|
26
|
+
* the deepest step that ended with the same message. Empty when no step did.
|
|
27
|
+
*/
|
|
28
|
+
export declare function failingSteps(steps: readonly TestStep[], error: TestError): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Text an error is categorized on. `steps`: titles of the failing steps, outermost first.
|
|
31
|
+
* Run errors lose the `merge-reports` tag prefix, so rules match the same text merged or not.
|
|
32
|
+
*/
|
|
33
|
+
export declare function errorText(message: string, stack: string | null, steps: readonly string[], runError: boolean): ErrorText;
|
|
34
|
+
export declare function buildReport(config: FullConfig, suite: Suite, result: FullResult, options: BuildOptions): Promise<LoupeReport>;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { INLINE_TEXT_LIMIT, attachmentKind, bodyFileName, comparisonOf, isTextContentType, safeFileName, uniqueFileName, } from './attachments.js';
|
|
4
|
+
import { DEFAULT_CATEGORIES, createCategorizer } from './categories.js';
|
|
5
|
+
import { DEFAULT_DIMENSIONS, createDimensions } from './dimensions.js';
|
|
6
|
+
import { computeTestId, relativePosix } from './identity.js';
|
|
7
|
+
import { detectEnvironment, detectGit } from './metadata.js';
|
|
8
|
+
import { collectOutput } from './output.js';
|
|
9
|
+
import { esmImportLocation, isUsableLocation } from './runErrors.js';
|
|
10
|
+
import { detectScopeReasons, resolveScope } from './scope.js';
|
|
11
|
+
import { errorSignature, stripAnsi, stripMergeTagPrefix } from './signature.js';
|
|
12
|
+
/** Folder, inside the report folder, that holds the copied attachments. */
|
|
13
|
+
export const DATA_FOLDER = 'data';
|
|
14
|
+
const OUTCOMES = {
|
|
15
|
+
expected: 'passed',
|
|
16
|
+
unexpected: 'failed',
|
|
17
|
+
flaky: 'flaky',
|
|
18
|
+
skipped: 'skipped',
|
|
19
|
+
};
|
|
20
|
+
/** Copies or writes attachments into the report folder, with collision-safe names per folder. */
|
|
21
|
+
class AttachmentStore {
|
|
22
|
+
outputFolder;
|
|
23
|
+
taken = new Map();
|
|
24
|
+
constructor(outputFolder) {
|
|
25
|
+
this.outputFolder = outputFolder;
|
|
26
|
+
}
|
|
27
|
+
/** Removes attachments left by a previous report in the same folder. */
|
|
28
|
+
async reset() {
|
|
29
|
+
await rm(path.join(this.outputFolder, DATA_FOLDER), { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
async add(folder, attachment) {
|
|
32
|
+
const { name, contentType } = attachment;
|
|
33
|
+
const result = {
|
|
34
|
+
name,
|
|
35
|
+
contentType,
|
|
36
|
+
kind: attachmentKind(name, contentType),
|
|
37
|
+
path: null,
|
|
38
|
+
body: null,
|
|
39
|
+
comparison: comparisonOf(name, contentType),
|
|
40
|
+
};
|
|
41
|
+
if (attachment.path) {
|
|
42
|
+
result.path = await this.store(folder, safeFileName(path.basename(attachment.path)), (target) => copyFile(attachment.path, target));
|
|
43
|
+
}
|
|
44
|
+
else if (attachment.body) {
|
|
45
|
+
const body = attachment.body;
|
|
46
|
+
if (isTextContentType(contentType) && body.byteLength <= INLINE_TEXT_LIMIT) {
|
|
47
|
+
result.body = body.toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
result.path = await this.store(folder, bodyFileName(name, contentType), (target) => writeFile(target, body));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/** Writes one file and returns its report-relative path, or null when the source file is missing. */
|
|
56
|
+
async store(folder, fileName, write) {
|
|
57
|
+
const taken = this.taken.get(folder) ?? new Set();
|
|
58
|
+
this.taken.set(folder, taken);
|
|
59
|
+
const unique = uniqueFileName(fileName, taken);
|
|
60
|
+
const target = path.join(this.outputFolder, ...folder.split('/'), unique);
|
|
61
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
62
|
+
try {
|
|
63
|
+
await write(target);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT')
|
|
67
|
+
return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
return `${folder}/${unique}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Playwright reports -1 for steps and attempts that never finished. */
|
|
74
|
+
function toMs(duration) {
|
|
75
|
+
return Math.max(0, Math.round(duration));
|
|
76
|
+
}
|
|
77
|
+
function emptyStats() {
|
|
78
|
+
return { total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0 };
|
|
79
|
+
}
|
|
80
|
+
function countOutcome(stats, outcome) {
|
|
81
|
+
stats.total++;
|
|
82
|
+
stats[outcome]++;
|
|
83
|
+
}
|
|
84
|
+
function toAnnotation(annotation) {
|
|
85
|
+
return { type: annotation.type, description: annotation.description ?? null };
|
|
86
|
+
}
|
|
87
|
+
function isDefaultGrep(grep) {
|
|
88
|
+
return grep instanceof RegExp && grep.source === '.*' && grep.flags === '';
|
|
89
|
+
}
|
|
90
|
+
/** `test.only` / `describe.only` leave Playwright's internal `_only` flag on the node or an ancestor. */
|
|
91
|
+
function isFocused(test) {
|
|
92
|
+
for (let node = test; node; node = node.parent) {
|
|
93
|
+
if (node._only === true)
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Titles of the failing steps leading to `error`, outermost first: the path to
|
|
100
|
+
* the deepest step that ended with the same message. Empty when no step did.
|
|
101
|
+
*/
|
|
102
|
+
export function failingSteps(steps, error) {
|
|
103
|
+
const message = error.message ?? error.value;
|
|
104
|
+
if (message === undefined)
|
|
105
|
+
return [];
|
|
106
|
+
for (const step of [...steps].reverse()) {
|
|
107
|
+
if (!step.error || (step.error.message ?? step.error.value) !== message)
|
|
108
|
+
continue;
|
|
109
|
+
return [step.title, ...failingSteps(step.steps, error)];
|
|
110
|
+
}
|
|
111
|
+
// A step may fail with a wrapped message while one of its children has the exact one.
|
|
112
|
+
for (const step of [...steps].reverse()) {
|
|
113
|
+
const inner = failingSteps(step.steps, error);
|
|
114
|
+
if (inner.length > 0)
|
|
115
|
+
return [step.title, ...inner];
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Text an error is categorized on. `steps`: titles of the failing steps, outermost first.
|
|
121
|
+
* Run errors lose the `merge-reports` tag prefix, so rules match the same text merged or not.
|
|
122
|
+
*/
|
|
123
|
+
export function errorText(message, stack, steps, runError) {
|
|
124
|
+
return {
|
|
125
|
+
message: runError ? stripMergeTagPrefix(message) : message,
|
|
126
|
+
stack: runError && stack !== null ? stripMergeTagPrefix(stack) : stack,
|
|
127
|
+
step: steps.length > 0 ? steps.join('\n') : null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function buildReport(config, suite, result, options) {
|
|
131
|
+
const rootDir = config.rootDir;
|
|
132
|
+
const store = new AttachmentStore(options.outputFolder);
|
|
133
|
+
const categorizer = createCategorizer(options.categories ?? DEFAULT_CATEGORIES);
|
|
134
|
+
const dimensionSet = createDimensions(options.dimensions ?? DEFAULT_DIMENSIONS);
|
|
135
|
+
const categories = new Set();
|
|
136
|
+
const usedDimensions = new Set();
|
|
137
|
+
// `<anonymous>` and other non-paths are not locations.
|
|
138
|
+
const toLocation = (location) => isUsableLocation(location)
|
|
139
|
+
? { file: relativePosix(rootDir, location.file), line: location.line, column: location.column }
|
|
140
|
+
: null;
|
|
141
|
+
/**
|
|
142
|
+
* `steps`: titles of the failing steps leading to the error, outermost first.
|
|
143
|
+
* `runError`: the `merge-reports` tag prefix is ignored by categories and the signature, kept in `message` and `stack`.
|
|
144
|
+
*/
|
|
145
|
+
const toError = (error, steps = [], location = error.location, runError = false) => {
|
|
146
|
+
const message = stripAnsi(error.message ?? error.value ?? '');
|
|
147
|
+
const stack = error.stack ? stripAnsi(error.stack) : null;
|
|
148
|
+
const category = categorizer.categorize(errorText(message, stack, steps, runError));
|
|
149
|
+
categories.add(category);
|
|
150
|
+
return {
|
|
151
|
+
message,
|
|
152
|
+
stack,
|
|
153
|
+
location: toLocation(location),
|
|
154
|
+
snippet: error.snippet ? stripAnsi(error.snippet) : null,
|
|
155
|
+
signature: errorSignature(message, { runError }),
|
|
156
|
+
category,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
const toStep = (step, parents) => {
|
|
160
|
+
const chain = [...parents, step.title];
|
|
161
|
+
return {
|
|
162
|
+
title: step.title,
|
|
163
|
+
// `subtitle` exists from Playwright 1.61; older versions keep the target in the title.
|
|
164
|
+
subtitle: step.subtitle ?? null,
|
|
165
|
+
category: step.category,
|
|
166
|
+
durationMs: toMs(step.duration),
|
|
167
|
+
error: step.error ? toError(step.error, chain) : null,
|
|
168
|
+
location: toLocation(step.location),
|
|
169
|
+
steps: step.steps.map((child) => toStep(child, chain)),
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
const toAttempt = async (attempt, folder) => {
|
|
173
|
+
const attachments = [];
|
|
174
|
+
for (const attachment of attempt.attachments) {
|
|
175
|
+
attachments.push(await store.add(`${folder}/${attempt.retry}`, attachment));
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
index: attempt.retry,
|
|
179
|
+
status: attempt.status,
|
|
180
|
+
startedAt: attempt.startTime.toISOString(),
|
|
181
|
+
durationMs: toMs(attempt.duration),
|
|
182
|
+
workerIndex: attempt.workerIndex,
|
|
183
|
+
errors: attempt.errors.map((error) => toError(error, failingSteps(attempt.steps, error))),
|
|
184
|
+
steps: attempt.steps.map((step) => toStep(step, [])),
|
|
185
|
+
attachments,
|
|
186
|
+
annotations: attempt.annotations.map(toAnnotation),
|
|
187
|
+
stdout: collectOutput(attempt.stdout),
|
|
188
|
+
stderr: collectOutput(attempt.stderr),
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
await store.reset();
|
|
192
|
+
const tests = [];
|
|
193
|
+
for (const test of suite.allTests()) {
|
|
194
|
+
// titlePath() is ['', project, file, ...describes, title]; anonymous describes are omitted.
|
|
195
|
+
const suitePath = test.titlePath().slice(3, -1);
|
|
196
|
+
const project = test.parent.project()?.name ?? '';
|
|
197
|
+
const file = relativePosix(rootDir, test.location.file);
|
|
198
|
+
const id = computeTestId(project, file, [...suitePath, test.title]);
|
|
199
|
+
const folder = `${DATA_FOLDER}/${id}${test.repeatEachIndex > 0 ? `-r${test.repeatEachIndex}` : ''}`;
|
|
200
|
+
const attempts = [];
|
|
201
|
+
for (const attempt of test.results)
|
|
202
|
+
attempts.push(await toAttempt(attempt, folder));
|
|
203
|
+
const annotations = [
|
|
204
|
+
...test.annotations.map(toAnnotation),
|
|
205
|
+
...attempts.flatMap((attempt) => attempt.annotations),
|
|
206
|
+
];
|
|
207
|
+
const dimensions = dimensionSet.of(test.tags, annotations);
|
|
208
|
+
for (const name of Object.keys(dimensions))
|
|
209
|
+
usedDimensions.add(name);
|
|
210
|
+
// A failing attempt did not end as expected (a test.fail() failing as expected is not one).
|
|
211
|
+
const lastFailing = attempts.findLast((attempt) => attempt.status !== test.expectedStatus && attempt.status !== 'skipped');
|
|
212
|
+
const firstError = lastFailing?.errors[0];
|
|
213
|
+
tests.push({
|
|
214
|
+
id,
|
|
215
|
+
repeatEachIndex: test.repeatEachIndex,
|
|
216
|
+
project,
|
|
217
|
+
file,
|
|
218
|
+
line: test.location.line,
|
|
219
|
+
column: test.location.column,
|
|
220
|
+
suitePath,
|
|
221
|
+
title: test.title,
|
|
222
|
+
tags: [...test.tags],
|
|
223
|
+
annotations: test.annotations.map(toAnnotation),
|
|
224
|
+
outcome: OUTCOMES[test.outcome()],
|
|
225
|
+
expectedStatus: test.expectedStatus,
|
|
226
|
+
durationMs: attempts.reduce((total, attempt) => total + attempt.durationMs, 0),
|
|
227
|
+
dimensions,
|
|
228
|
+
category: firstError?.category ?? null,
|
|
229
|
+
errorSignature: firstError?.signature ?? null,
|
|
230
|
+
attempts,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const projectStats = new Map();
|
|
234
|
+
const runStats = emptyStats();
|
|
235
|
+
for (const test of tests) {
|
|
236
|
+
countOutcome(runStats, test.outcome);
|
|
237
|
+
const stats = projectStats.get(test.project) ?? emptyStats();
|
|
238
|
+
projectStats.set(test.project, stats);
|
|
239
|
+
countOutcome(stats, test.outcome);
|
|
240
|
+
}
|
|
241
|
+
const projects = [];
|
|
242
|
+
for (const projectSuite of suite.suites) {
|
|
243
|
+
const project = projectSuite.project();
|
|
244
|
+
if (!project || projects.some((known) => known.name === project.name))
|
|
245
|
+
continue;
|
|
246
|
+
projects.push({
|
|
247
|
+
name: project.name,
|
|
248
|
+
testDir: relativePosix(rootDir, project.testDir),
|
|
249
|
+
retries: project.retries,
|
|
250
|
+
timeoutMs: project.timeout,
|
|
251
|
+
stats: projectStats.get(project.name) ?? emptyStats(),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Newer Playwright versions expose more about the CLI; read them when present.
|
|
255
|
+
const extra = config;
|
|
256
|
+
const detected = detectScopeReasons({
|
|
257
|
+
argv: extra.argv ?? process.argv,
|
|
258
|
+
configGrep: !isDefaultGrep(config.grep) || config.grepInvert !== null,
|
|
259
|
+
shard: config.shard !== null,
|
|
260
|
+
projects: config.projects.map((project) => ({
|
|
261
|
+
name: project.name,
|
|
262
|
+
dependencies: project.dependencies,
|
|
263
|
+
teardown: project.teardown,
|
|
264
|
+
})),
|
|
265
|
+
filteredProjects: extra.filteredProjects?.map((project) => project.name),
|
|
266
|
+
runProjects: projects.map((project) => project.name),
|
|
267
|
+
only: suite.allTests().some(isFocused),
|
|
268
|
+
});
|
|
269
|
+
const actualWorkers = config.metadata['actualWorkers'];
|
|
270
|
+
const errors = [];
|
|
271
|
+
for (const { error, project } of options.runErrors ?? []) {
|
|
272
|
+
const message = stripAnsi(error.message ?? error.value ?? '');
|
|
273
|
+
const location = isUsableLocation(error.location) ? error.location : ((await esmImportLocation(message)) ?? undefined);
|
|
274
|
+
errors.push({ ...toError(error, [], location, true), project });
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
schemaVersion: 1,
|
|
278
|
+
generator: { name: 'playwright-loupe', version: options.generatorVersion },
|
|
279
|
+
run: {
|
|
280
|
+
id: options.runId,
|
|
281
|
+
startedAt: result.startTime.toISOString(),
|
|
282
|
+
durationMs: toMs(result.duration),
|
|
283
|
+
status: result.status,
|
|
284
|
+
...resolveScope(detected, options.scopeOverride),
|
|
285
|
+
playwrightVersion: config.version,
|
|
286
|
+
workers: typeof actualWorkers === 'number' ? actualWorkers : config.workers,
|
|
287
|
+
shard: config.shard ? { current: config.shard.current, total: config.shard.total } : null,
|
|
288
|
+
environment: detectEnvironment(options.env, options.environmentName, process.platform, process.arch, process.version),
|
|
289
|
+
git: detectGit(config.metadata, options.env),
|
|
290
|
+
excluded: null,
|
|
291
|
+
notes: [],
|
|
292
|
+
stats: runStats,
|
|
293
|
+
errors,
|
|
294
|
+
},
|
|
295
|
+
projects,
|
|
296
|
+
tests,
|
|
297
|
+
categories: categorizer.definitions(categories),
|
|
298
|
+
dimensions: dimensionSet.definitions(usedDimensions),
|
|
299
|
+
};
|
|
300
|
+
}
|