@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,45 @@
|
|
|
1
|
+
import type { CategoryMode, CategoryTarget, ResolvedCategoryRule } from '../config/types.js';
|
|
2
|
+
import type { CategoryDefinition } from '../schema/v1.js';
|
|
3
|
+
/**
|
|
4
|
+
* Error categorization. Rules are ordered: the first rule with a pattern
|
|
5
|
+
* matching one of its targets wins; errors matching no rule go to the
|
|
6
|
+
* uncategorized bucket. Config rules run before the built-in ones (`extend`)
|
|
7
|
+
* or replace them (`replace`).
|
|
8
|
+
*/
|
|
9
|
+
/** Text an error is matched against. `step`: titles of the failing steps, outermost first, one per line. */
|
|
10
|
+
export interface ErrorText {
|
|
11
|
+
message: string;
|
|
12
|
+
stack: string | null;
|
|
13
|
+
step: string | null;
|
|
14
|
+
}
|
|
15
|
+
type Target = Exclude<CategoryTarget, 'all'>;
|
|
16
|
+
export interface CategoryRule {
|
|
17
|
+
name: string;
|
|
18
|
+
/** Null: a color is picked from the palette. */
|
|
19
|
+
color: string | null;
|
|
20
|
+
match: RegExp[];
|
|
21
|
+
targets: readonly Target[];
|
|
22
|
+
}
|
|
23
|
+
export declare const UNCATEGORIZED_COLOR = "#6b7280";
|
|
24
|
+
export declare const BUILT_IN_RULES: readonly CategoryRule[];
|
|
25
|
+
/**
|
|
26
|
+
* Colors for categories without one, assigned in rule order. Distinct from the
|
|
27
|
+
* built-in and uncategorized colors; colors already taken by another category
|
|
28
|
+
* are skipped, and the palette wraps around when exhausted.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PALETTE: readonly string[];
|
|
31
|
+
export interface CategoriesOptions {
|
|
32
|
+
mode: CategoryMode;
|
|
33
|
+
rules: readonly ResolvedCategoryRule[];
|
|
34
|
+
uncategorized: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Categorizer {
|
|
37
|
+
/** Category name of an error. Never null: unmatched errors go to the uncategorized bucket. */
|
|
38
|
+
categorize(error: ErrorText): string;
|
|
39
|
+
/** Definitions of the categories in `used`, in rule order, the uncategorized bucket last. */
|
|
40
|
+
definitions(used: ReadonlySet<string>): CategoryDefinition[];
|
|
41
|
+
}
|
|
42
|
+
export declare function createCategorizer(options: CategoriesOptions): Categorizer;
|
|
43
|
+
/** Categorizer with the built-in rules only. */
|
|
44
|
+
export declare const DEFAULT_CATEGORIES: CategoriesOptions;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const ALL_TARGETS = ['message', 'stack', 'step'];
|
|
2
|
+
export const UNCATEGORIZED_COLOR = '#6b7280';
|
|
3
|
+
export const BUILT_IN_RULES = [
|
|
4
|
+
{
|
|
5
|
+
// Before "Assertion" and "Timeout": a missing element surfaces as a failed
|
|
6
|
+
// or timed-out expect/action, and "not found" is the useful diagnosis.
|
|
7
|
+
name: 'Locator not found',
|
|
8
|
+
color: '#7c3aed',
|
|
9
|
+
match: [/element\(s\) not found/i, /resolved to 0 elements/i],
|
|
10
|
+
targets: ['message', 'stack'],
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
// Before "Timeout" so connection timeouts (ETIMEDOUT) count as network.
|
|
14
|
+
name: 'Network',
|
|
15
|
+
color: '#0891b2',
|
|
16
|
+
match: [
|
|
17
|
+
/\bnet::ERR_[A-Z_]+/,
|
|
18
|
+
/\bNS_ERROR_[A-Z_]+/,
|
|
19
|
+
/\b(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH)\b/,
|
|
20
|
+
/socket hang up/i,
|
|
21
|
+
/\bfetch failed\b/i,
|
|
22
|
+
],
|
|
23
|
+
targets: ['message', 'stack'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
// Before "Timeout": a retrying expect that never matched reports its own
|
|
27
|
+
// timeout but is an assertion failure.
|
|
28
|
+
name: 'Assertion',
|
|
29
|
+
color: '#dc2626',
|
|
30
|
+
match: [/\bexpect\(/, /^\s*Expected\b.*:/m, /\bAssertionError\b/],
|
|
31
|
+
targets: ['message', 'stack'],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'Timeout',
|
|
35
|
+
color: '#d97706',
|
|
36
|
+
match: [/\bTest timeout of \d+ms exceeded/i, /\bTimeout \d+ms exceeded/i, /\btimed out\b/i],
|
|
37
|
+
targets: ['message', 'stack'],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Colors for categories without one, assigned in rule order. Distinct from the
|
|
42
|
+
* built-in and uncategorized colors; colors already taken by another category
|
|
43
|
+
* are skipped, and the palette wraps around when exhausted.
|
|
44
|
+
*/
|
|
45
|
+
export const PALETTE = [
|
|
46
|
+
'#2563eb',
|
|
47
|
+
'#16a34a',
|
|
48
|
+
'#db2777',
|
|
49
|
+
'#ca8a04',
|
|
50
|
+
'#0d9488',
|
|
51
|
+
'#9333ea',
|
|
52
|
+
'#ea580c',
|
|
53
|
+
'#4f46e5',
|
|
54
|
+
'#65a30d',
|
|
55
|
+
'#be123c',
|
|
56
|
+
];
|
|
57
|
+
function matches(rule, error) {
|
|
58
|
+
return rule.targets.some((target) => {
|
|
59
|
+
const text = error[target];
|
|
60
|
+
return text !== null && rule.match.some((pattern) => pattern.test(text));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function createCategorizer(options) {
|
|
64
|
+
const builtIns = options.mode === 'extend' ? BUILT_IN_RULES : [];
|
|
65
|
+
const rules = [
|
|
66
|
+
...options.rules.map((rule) => ({
|
|
67
|
+
name: rule.name,
|
|
68
|
+
color: rule.color,
|
|
69
|
+
match: rule.match,
|
|
70
|
+
targets: rule.on === 'all' ? ALL_TARGETS : [rule.on],
|
|
71
|
+
})),
|
|
72
|
+
...builtIns,
|
|
73
|
+
];
|
|
74
|
+
// One definition per name, in order of first appearance. The first explicit
|
|
75
|
+
// color wins (a config rule named like a built-in one may recolor it).
|
|
76
|
+
const names = [...new Set(rules.map((rule) => rule.name))];
|
|
77
|
+
const colors = new Map();
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const color = rules.find((rule) => rule.name === name && rule.color !== null)?.color;
|
|
80
|
+
if (color)
|
|
81
|
+
colors.set(name, color);
|
|
82
|
+
}
|
|
83
|
+
const taken = new Set([...colors.values(), UNCATEGORIZED_COLOR].map((color) => color.toLowerCase()));
|
|
84
|
+
let next = 0;
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (colors.has(name))
|
|
87
|
+
continue;
|
|
88
|
+
const free = PALETTE.slice(next).concat(PALETTE.slice(0, next)).find((color) => !taken.has(color));
|
|
89
|
+
const color = free ?? PALETTE[next % PALETTE.length];
|
|
90
|
+
next = (PALETTE.indexOf(color) + 1) % PALETTE.length;
|
|
91
|
+
taken.add(color);
|
|
92
|
+
colors.set(name, color);
|
|
93
|
+
}
|
|
94
|
+
const builtInNames = new Set(builtIns.map((rule) => rule.name));
|
|
95
|
+
return {
|
|
96
|
+
categorize(error) {
|
|
97
|
+
return rules.find((rule) => matches(rule, error))?.name ?? options.uncategorized;
|
|
98
|
+
},
|
|
99
|
+
definitions(used) {
|
|
100
|
+
const definitions = names
|
|
101
|
+
.filter((name) => used.has(name))
|
|
102
|
+
.map((name) => ({ name, color: colors.get(name), builtIn: builtInNames.has(name) }));
|
|
103
|
+
if (used.has(options.uncategorized)) {
|
|
104
|
+
definitions.push({ name: options.uncategorized, color: UNCATEGORIZED_COLOR, builtIn: true });
|
|
105
|
+
}
|
|
106
|
+
return definitions;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Categorizer with the built-in rules only. */
|
|
111
|
+
export const DEFAULT_CATEGORIES = { mode: 'extend', rules: [], uncategorized: 'Uncategorized' };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ResolvedDimension } from '../config/types.js';
|
|
2
|
+
import type { Annotation, DimensionDefinition } from '../schema/v1.js';
|
|
3
|
+
/**
|
|
4
|
+
* Dimensions from prefixed tags: `@layer:api` → `{ layer: ['api'] }`.
|
|
5
|
+
* Unprefixed tags produce nothing. Values keep their declaration order and are
|
|
6
|
+
* deduplicated; everything after the first ':' is the value.
|
|
7
|
+
*/
|
|
8
|
+
export declare function tagDimensions(tags: readonly string[]): Record<string, string[]>;
|
|
9
|
+
/** Values of the annotations of one type: their descriptions, deduplicated. Annotations without description give no value. */
|
|
10
|
+
export declare function annotationValues(annotations: readonly Annotation[], type: string): string[];
|
|
11
|
+
export interface DimensionsOptions {
|
|
12
|
+
/** Keep automatic dimensions from prefixed tags. */
|
|
13
|
+
auto: boolean;
|
|
14
|
+
definitions: readonly ResolvedDimension[];
|
|
15
|
+
}
|
|
16
|
+
export interface Dimensions {
|
|
17
|
+
/** Dimension name → values for one test. Dimensions without value are absent. */
|
|
18
|
+
of(tags: readonly string[], annotations: readonly Annotation[]): Record<string, string[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Definitions for the report: every configured dimension, in config order,
|
|
21
|
+
* then the automatic dimensions among `used` (dimension names of the
|
|
22
|
+
* tests), sorted by name.
|
|
23
|
+
*/
|
|
24
|
+
definitions(used: ReadonlySet<string>): DimensionDefinition[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Configured dimensions plus, when `auto` is on, one automatic dimension per
|
|
28
|
+
* tag prefix. A configured tag dimension takes over the automatic dimension of
|
|
29
|
+
* its prefix; an automatic dimension whose name is used by a configured one is
|
|
30
|
+
* dropped, so names stay unique.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createDimensions(options: DimensionsOptions): Dimensions;
|
|
33
|
+
export declare const DEFAULT_DIMENSIONS: DimensionsOptions;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const PREFIXED_TAG = /^@([^:\s]+):(.+)$/;
|
|
2
|
+
/**
|
|
3
|
+
* Dimensions from prefixed tags: `@layer:api` → `{ layer: ['api'] }`.
|
|
4
|
+
* Unprefixed tags produce nothing. Values keep their declaration order and are
|
|
5
|
+
* deduplicated; everything after the first ':' is the value.
|
|
6
|
+
*/
|
|
7
|
+
export function tagDimensions(tags) {
|
|
8
|
+
const dimensions = {};
|
|
9
|
+
for (const tag of tags) {
|
|
10
|
+
const match = PREFIXED_TAG.exec(tag);
|
|
11
|
+
if (!match)
|
|
12
|
+
continue;
|
|
13
|
+
const [, prefix = '', value = ''] = match;
|
|
14
|
+
const values = (dimensions[prefix] ??= []);
|
|
15
|
+
if (!values.includes(value))
|
|
16
|
+
values.push(value);
|
|
17
|
+
}
|
|
18
|
+
return dimensions;
|
|
19
|
+
}
|
|
20
|
+
/** Values of the annotations of one type: their descriptions, deduplicated. Annotations without description give no value. */
|
|
21
|
+
export function annotationValues(annotations, type) {
|
|
22
|
+
const values = [];
|
|
23
|
+
for (const annotation of annotations) {
|
|
24
|
+
const value = annotation.description?.trim();
|
|
25
|
+
if (annotation.type === type && value && !values.includes(value))
|
|
26
|
+
values.push(value);
|
|
27
|
+
}
|
|
28
|
+
return values;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Configured dimensions plus, when `auto` is on, one automatic dimension per
|
|
32
|
+
* tag prefix. A configured tag dimension takes over the automatic dimension of
|
|
33
|
+
* its prefix; an automatic dimension whose name is used by a configured one is
|
|
34
|
+
* dropped, so names stay unique.
|
|
35
|
+
*/
|
|
36
|
+
export function createDimensions(options) {
|
|
37
|
+
const configured = options.definitions;
|
|
38
|
+
const claimedPrefixes = new Set(configured.flatMap((definition) => (definition.from === 'tag' ? [definition.prefix] : [])));
|
|
39
|
+
const configuredNames = new Set(configured.map((definition) => definition.name));
|
|
40
|
+
const isAuto = (prefix) => options.auto && !claimedPrefixes.has(prefix) && !configuredNames.has(prefix);
|
|
41
|
+
return {
|
|
42
|
+
of(tags, annotations) {
|
|
43
|
+
const byPrefix = tagDimensions(tags);
|
|
44
|
+
const dimensions = {};
|
|
45
|
+
for (const definition of configured) {
|
|
46
|
+
const values = definition.from === 'tag' ? (byPrefix[definition.prefix] ?? []) : annotationValues(annotations, definition.type);
|
|
47
|
+
if (values.length > 0)
|
|
48
|
+
dimensions[definition.name] = values;
|
|
49
|
+
}
|
|
50
|
+
for (const [prefix, values] of Object.entries(byPrefix)) {
|
|
51
|
+
if (isAuto(prefix))
|
|
52
|
+
dimensions[prefix] = values;
|
|
53
|
+
}
|
|
54
|
+
return dimensions;
|
|
55
|
+
},
|
|
56
|
+
definitions(used) {
|
|
57
|
+
return [
|
|
58
|
+
...configured.map((definition) => ({
|
|
59
|
+
name: definition.name,
|
|
60
|
+
source: definition.from,
|
|
61
|
+
key: definition.from === 'tag' ? definition.prefix : definition.type,
|
|
62
|
+
values: definition.values ? [...definition.values] : null,
|
|
63
|
+
})),
|
|
64
|
+
...[...used]
|
|
65
|
+
.filter(isAuto)
|
|
66
|
+
.sort()
|
|
67
|
+
.map((name) => ({ name, source: 'tag', key: name, values: null })),
|
|
68
|
+
];
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export const DEFAULT_DIMENSIONS = { auto: true, definitions: [] };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Stable test id: first 16 hex chars of
|
|
4
|
+
* sha256(project + '\0' + file + '\0' + titlePath.join('\0')), where `file` is
|
|
5
|
+
* root-relative with POSIX separators and `titlePath` is the describe titles
|
|
6
|
+
* followed by the test title.
|
|
7
|
+
*/
|
|
8
|
+
export declare function computeTestId(project: string, file: string, titlePath: readonly string[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* Path of `target` relative to `base`, with POSIX separators; '.' when both are
|
|
11
|
+
* the same directory. `pathImpl` is injectable so Windows paths can be tested
|
|
12
|
+
* on any platform.
|
|
13
|
+
*/
|
|
14
|
+
export declare function relativePosix(base: string, target: string, pathImpl?: path.PlatformPath): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Stable test id: first 16 hex chars of
|
|
5
|
+
* sha256(project + '\0' + file + '\0' + titlePath.join('\0')), where `file` is
|
|
6
|
+
* root-relative with POSIX separators and `titlePath` is the describe titles
|
|
7
|
+
* followed by the test title.
|
|
8
|
+
*/
|
|
9
|
+
export function computeTestId(project, file, titlePath) {
|
|
10
|
+
return createHash('sha256')
|
|
11
|
+
.update([project, file, ...titlePath].join('\0'))
|
|
12
|
+
.digest('hex')
|
|
13
|
+
.slice(0, 16);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Path of `target` relative to `base`, with POSIX separators; '.' when both are
|
|
17
|
+
* the same directory. `pathImpl` is injectable so Windows paths can be tested
|
|
18
|
+
* on any platform.
|
|
19
|
+
*/
|
|
20
|
+
export function relativePosix(base, target, pathImpl = path) {
|
|
21
|
+
const relative = pathImpl.relative(base, target);
|
|
22
|
+
if (relative === '')
|
|
23
|
+
return '.';
|
|
24
|
+
return pathImpl.sep === '/' ? relative : relative.split(pathImpl.sep).join('/');
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Placeholder the renderer template carries where the report data goes. */
|
|
2
|
+
export declare const DATA_PLACEHOLDER = "<script id=\"loupe-data\" type=\"application/json\">__LOUPE_DATA__</script>";
|
|
3
|
+
/** Placeholder for the renderer settings, which are not part of the report data contract. */
|
|
4
|
+
export declare const SETTINGS_PLACEHOLDER = "<script id=\"loupe-settings\" type=\"application/json\">__LOUPE_SETTINGS__</script>";
|
|
5
|
+
/**
|
|
6
|
+
* Renderer settings: presentation choices injected next to the report data.
|
|
7
|
+
* Not part of the data contract (see "Renderer settings" in docs/schema/v1.md).
|
|
8
|
+
* Kept free of Node imports so the renderer can import it.
|
|
9
|
+
*/
|
|
10
|
+
export interface RendererSettings {
|
|
11
|
+
title: string;
|
|
12
|
+
theme: 'system' | 'light' | 'dark';
|
|
13
|
+
screenshots: {
|
|
14
|
+
defaultView: 'side-by-side' | 'slider' | 'overlay' | 'diff';
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_RENDERER_SETTINGS: RendererSettings;
|
|
18
|
+
/**
|
|
19
|
+
* Serializes a value to JSON that can be embedded verbatim inside a
|
|
20
|
+
* `<script type="application/json">` element.
|
|
21
|
+
*
|
|
22
|
+
* `<`, `>` and `&` only ever appear inside JSON string literals, so replacing
|
|
23
|
+
* them with their `\uXXXX` escapes keeps the JSON equivalent while making
|
|
24
|
+
* `</script>` and `<!--` impossible. U+2028/U+2029 are escaped too so the
|
|
25
|
+
* payload stays safe if it is ever evaluated as JavaScript.
|
|
26
|
+
*/
|
|
27
|
+
export declare function serializeForScript(value: unknown): string;
|
|
28
|
+
/** Replaces the data placeholder in the renderer template with the serialized report. */
|
|
29
|
+
export declare function injectData(template: string, data: unknown): string;
|
|
30
|
+
/** Replaces the settings placeholder in the renderer template with the serialized settings. */
|
|
31
|
+
export declare function injectSettings(template: string, settings: RendererSettings): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Placeholder the renderer template carries where the report data goes. */
|
|
2
|
+
export const DATA_PLACEHOLDER = '<script id="loupe-data" type="application/json">__LOUPE_DATA__</script>';
|
|
3
|
+
/** Placeholder for the renderer settings, which are not part of the report data contract. */
|
|
4
|
+
export const SETTINGS_PLACEHOLDER = '<script id="loupe-settings" type="application/json">__LOUPE_SETTINGS__</script>';
|
|
5
|
+
export const DEFAULT_RENDERER_SETTINGS = {
|
|
6
|
+
title: 'Loupe report',
|
|
7
|
+
theme: 'system',
|
|
8
|
+
screenshots: { defaultView: 'side-by-side' },
|
|
9
|
+
};
|
|
10
|
+
const UNSAFE_CHARS = {
|
|
11
|
+
'<': '\\u003c',
|
|
12
|
+
'>': '\\u003e',
|
|
13
|
+
'&': '\\u0026',
|
|
14
|
+
'\u2028': '\\u2028',
|
|
15
|
+
'\u2029': '\\u2029',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Serializes a value to JSON that can be embedded verbatim inside a
|
|
19
|
+
* `<script type="application/json">` element.
|
|
20
|
+
*
|
|
21
|
+
* `<`, `>` and `&` only ever appear inside JSON string literals, so replacing
|
|
22
|
+
* them with their `\uXXXX` escapes keeps the JSON equivalent while making
|
|
23
|
+
* `</script>` and `<!--` impossible. U+2028/U+2029 are escaped too so the
|
|
24
|
+
* payload stays safe if it is ever evaluated as JavaScript.
|
|
25
|
+
*/
|
|
26
|
+
export function serializeForScript(value) {
|
|
27
|
+
const json = JSON.stringify(value);
|
|
28
|
+
if (json === undefined) {
|
|
29
|
+
throw new TypeError('Loupe: report data is not JSON-serializable');
|
|
30
|
+
}
|
|
31
|
+
return json.replace(/[<>&\u2028\u2029]/g, (char) => UNSAFE_CHARS[char] ?? char);
|
|
32
|
+
}
|
|
33
|
+
function injectScript(template, placeholder, id, value) {
|
|
34
|
+
const index = template.indexOf(placeholder);
|
|
35
|
+
if (index === -1) {
|
|
36
|
+
throw new Error(`Loupe: renderer template does not contain the #${id} placeholder`);
|
|
37
|
+
}
|
|
38
|
+
const script = `<script id="${id}" type="application/json">${serializeForScript(value)}</script>`;
|
|
39
|
+
// Slicing instead of String#replace avoids `$&`-style substitution patterns in the data.
|
|
40
|
+
return template.slice(0, index) + script + template.slice(index + placeholder.length);
|
|
41
|
+
}
|
|
42
|
+
/** Replaces the data placeholder in the renderer template with the serialized report. */
|
|
43
|
+
export function injectData(template, data) {
|
|
44
|
+
return injectScript(template, DATA_PLACEHOLDER, 'loupe-data', data);
|
|
45
|
+
}
|
|
46
|
+
/** Replaces the settings placeholder in the renderer template with the serialized settings. */
|
|
47
|
+
export function injectSettings(template, settings) {
|
|
48
|
+
return injectScript(template, SETTINGS_PLACEHOLDER, 'loupe-settings', settings);
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Run } from '../schema/v1.js';
|
|
2
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
3
|
+
export declare function detectCi(env: Env): {
|
|
4
|
+
ci: boolean;
|
|
5
|
+
ciProvider: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** Removes credentials from a URL-like remote (`https://user:token@host/...`). */
|
|
8
|
+
export declare function stripCredentials(remote: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Git info from Playwright's own metadata first (`metadata.gitCommit`, captured
|
|
11
|
+
* when `captureGitInfo.commit` is on, by default in CI; `metadata.ci`), then
|
|
12
|
+
* from common CI environment variables. Never shells out to git.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectGit(metadata: Readonly<Record<string, unknown>>, env: Env): Run['git'];
|
|
15
|
+
/** `name` comes from the config (`environment` option, overridden by `LOUPE_ENV`). */
|
|
16
|
+
export declare function detectEnvironment(env: Env, name: string | null, platform: string, arch: string, nodeVersion: string): Run['environment'];
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Returns the value when it is a non-empty string, null otherwise. */
|
|
2
|
+
function text(value) {
|
|
3
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
4
|
+
}
|
|
5
|
+
function record(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' ? value : {};
|
|
7
|
+
}
|
|
8
|
+
/** CI providers, checked in order. */
|
|
9
|
+
const CI_PROVIDERS = [
|
|
10
|
+
['github-actions', 'GITHUB_ACTIONS'],
|
|
11
|
+
['gitlab', 'GITLAB_CI'],
|
|
12
|
+
['azure-pipelines', 'TF_BUILD'],
|
|
13
|
+
['circleci', 'CIRCLECI'],
|
|
14
|
+
['jenkins', 'JENKINS_URL'],
|
|
15
|
+
['bitbucket', 'BITBUCKET_BUILD_NUMBER'],
|
|
16
|
+
['buildkite', 'BUILDKITE'],
|
|
17
|
+
['travis', 'TRAVIS'],
|
|
18
|
+
];
|
|
19
|
+
export function detectCi(env) {
|
|
20
|
+
const ciProvider = CI_PROVIDERS.find(([, variable]) => text(env[variable]) !== null)?.[0] ?? null;
|
|
21
|
+
const flag = text(env['CI'])?.toLowerCase();
|
|
22
|
+
return { ci: ciProvider !== null || (flag !== undefined && flag !== 'false' && flag !== '0'), ciProvider };
|
|
23
|
+
}
|
|
24
|
+
function first(...values) {
|
|
25
|
+
for (const value of values) {
|
|
26
|
+
const found = text(value);
|
|
27
|
+
if (found !== null)
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Removes credentials from a URL-like remote (`https://user:token@host/...`). */
|
|
33
|
+
export function stripCredentials(remote) {
|
|
34
|
+
return remote.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1');
|
|
35
|
+
}
|
|
36
|
+
function githubBranch(env) {
|
|
37
|
+
// GITHUB_HEAD_REF is set on pull requests; GITHUB_REF_NAME is a tag name on tag pushes.
|
|
38
|
+
return first(env['GITHUB_HEAD_REF'], env['GITHUB_REF_TYPE'] === 'branch' ? env['GITHUB_REF_NAME'] : undefined);
|
|
39
|
+
}
|
|
40
|
+
function githubRemote(env) {
|
|
41
|
+
const server = text(env['GITHUB_SERVER_URL']);
|
|
42
|
+
const repository = text(env['GITHUB_REPOSITORY']);
|
|
43
|
+
return server && repository ? `${server}/${repository}` : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Git info from Playwright's own metadata first (`metadata.gitCommit`, captured
|
|
47
|
+
* when `captureGitInfo.commit` is on, by default in CI; `metadata.ci`), then
|
|
48
|
+
* from common CI environment variables. Never shells out to git.
|
|
49
|
+
*/
|
|
50
|
+
export function detectGit(metadata, env) {
|
|
51
|
+
const gitCommit = record(metadata['gitCommit']);
|
|
52
|
+
const ciInfo = record(metadata['ci']);
|
|
53
|
+
// A detached checkout reports the branch as "HEAD", which is not a branch.
|
|
54
|
+
const playwrightBranch = [gitCommit['branch'], ciInfo['branch']].find((branch) => text(branch) !== null && text(branch) !== 'HEAD');
|
|
55
|
+
const commit = first(gitCommit['hash'], ciInfo['commitHash'], env['GITHUB_SHA'], env['CI_COMMIT_SHA'], env['BUILD_SOURCEVERSION'], env['CIRCLE_SHA1'], env['GIT_COMMIT'], env['BITBUCKET_COMMIT'], env['BUILDKITE_COMMIT']);
|
|
56
|
+
const branch = first(playwrightBranch, env['GITHUB_ACTIONS'] ? githubBranch(env) : undefined, env['CI_COMMIT_BRANCH'], env['CI_MERGE_REQUEST_SOURCE_BRANCH_NAME'], env['BUILD_SOURCEBRANCHNAME'], env['CIRCLE_BRANCH'], env['GIT_BRANCH'], env['BITBUCKET_BRANCH'], env['BUILDKITE_BRANCH']);
|
|
57
|
+
const remote = first(env['GITHUB_ACTIONS'] ? githubRemote(env) : undefined, env['CI_PROJECT_URL'], env['BUILD_REPOSITORY_URI'], env['CIRCLE_REPOSITORY_URL'], env['GIT_URL'], env['BITBUCKET_GIT_HTTP_ORIGIN'], env['BUILDKITE_REPO']);
|
|
58
|
+
return { commit, branch, remote: remote === null ? null : stripCredentials(remote) };
|
|
59
|
+
}
|
|
60
|
+
/** `name` comes from the config (`environment` option, overridden by `LOUPE_ENV`). */
|
|
61
|
+
export function detectEnvironment(env, name, platform, arch, nodeVersion) {
|
|
62
|
+
return {
|
|
63
|
+
name: text(name),
|
|
64
|
+
...detectCi(env),
|
|
65
|
+
os: `${platform}-${arch}`,
|
|
66
|
+
nodeVersion,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Output } from '../schema/v1.js';
|
|
2
|
+
/** Maximum stdout or stderr kept per attempt, in UTF-16 code units, after ANSI stripping. */
|
|
3
|
+
export declare const OUTPUT_LIMIT: number;
|
|
4
|
+
/** Joins the output chunks of an attempt, strips ANSI codes and keeps the first OUTPUT_LIMIT characters. */
|
|
5
|
+
export declare function collectOutput(chunks: ReadonlyArray<string | Buffer>, limit?: number): Output;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { stripAnsi } from './signature.js';
|
|
2
|
+
/** Maximum stdout or stderr kept per attempt, in UTF-16 code units, after ANSI stripping. */
|
|
3
|
+
export const OUTPUT_LIMIT = 32 * 1024;
|
|
4
|
+
/** Joins the output chunks of an attempt, strips ANSI codes and keeps the first OUTPUT_LIMIT characters. */
|
|
5
|
+
export function collectOutput(chunks, limit = OUTPUT_LIMIT) {
|
|
6
|
+
const text = stripAnsi(chunks.map((chunk) => (typeof chunk === 'string' ? chunk : chunk.toString('utf8'))).join(''));
|
|
7
|
+
if (text.length <= limit)
|
|
8
|
+
return { text, truncated: false };
|
|
9
|
+
let end = limit;
|
|
10
|
+
// Do not cut a surrogate pair in half.
|
|
11
|
+
const last = text.charCodeAt(end - 1);
|
|
12
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
13
|
+
end -= 1;
|
|
14
|
+
return { text: text.slice(0, end), truncated: true };
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FullConfig, FullResult, Reporter, Suite, TestError } from '@playwright/test/reporter';
|
|
2
|
+
import type { LoupeReporterOptions } from '../config/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The part of `onError`'s second argument Loupe reads. Declared here because
|
|
5
|
+
* `@playwright/test/reporter` only exports `WorkerInfo` in recent versions.
|
|
6
|
+
*/
|
|
7
|
+
interface ErrorWorkerInfo {
|
|
8
|
+
project: {
|
|
9
|
+
name: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare class LoupeReporter implements Reporter {
|
|
13
|
+
private readonly loupeConfig;
|
|
14
|
+
private readonly scopeOverride;
|
|
15
|
+
private readonly runErrors;
|
|
16
|
+
private config;
|
|
17
|
+
private suite;
|
|
18
|
+
private runId;
|
|
19
|
+
constructor(options?: LoupeReporterOptions);
|
|
20
|
+
printsToStdio(): boolean;
|
|
21
|
+
onBegin(config: FullConfig, suite: Suite): void;
|
|
22
|
+
/** Errors outside tests: load errors, global setup / teardown, worker teardown. Playwright 1.52 does not pass `workerInfo`; recent versions do. */
|
|
23
|
+
onError(error: TestError, workerInfo?: ErrorWorkerInfo): void;
|
|
24
|
+
onEnd(result: FullResult): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig } from '../config/index.js';
|
|
5
|
+
import { buildReport } from './build.js';
|
|
6
|
+
import { injectData, injectSettings } from './inject.js';
|
|
7
|
+
import { SCOPE_ENV, parseScopeOverride } from './scope.js';
|
|
8
|
+
import { generatorVersion } from './version.js';
|
|
9
|
+
/** Built renderer template, shipped next to the compiled core (`dist/renderer/index.html`). */
|
|
10
|
+
const TEMPLATE_URL = new URL('../renderer/index.html', import.meta.url);
|
|
11
|
+
export class LoupeReporter {
|
|
12
|
+
loupeConfig;
|
|
13
|
+
scopeOverride;
|
|
14
|
+
runErrors = [];
|
|
15
|
+
config;
|
|
16
|
+
suite;
|
|
17
|
+
runId = '';
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
// Everything is validated here: an error thrown by the constructor stops the
|
|
20
|
+
// run with its message, which no later reporter hook can do.
|
|
21
|
+
this.scopeOverride = parseScopeOverride(process.env[SCOPE_ENV]);
|
|
22
|
+
this.loupeConfig = loadConfig(options);
|
|
23
|
+
}
|
|
24
|
+
printsToStdio() {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
onBegin(config, suite) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.suite = suite;
|
|
30
|
+
this.runId = randomUUID();
|
|
31
|
+
}
|
|
32
|
+
/** Errors outside tests: load errors, global setup / teardown, worker teardown. Playwright 1.52 does not pass `workerInfo`; recent versions do. */
|
|
33
|
+
onError(error, workerInfo) {
|
|
34
|
+
this.runErrors.push({ error, project: workerInfo?.project.name ?? null });
|
|
35
|
+
}
|
|
36
|
+
async onEnd(result) {
|
|
37
|
+
if (!this.config || !this.suite)
|
|
38
|
+
return;
|
|
39
|
+
const { outputFolder } = this.loupeConfig;
|
|
40
|
+
const report = await buildReport(this.config, this.suite, result, {
|
|
41
|
+
runId: this.runId,
|
|
42
|
+
generatorVersion: await generatorVersion(),
|
|
43
|
+
outputFolder,
|
|
44
|
+
env: process.env,
|
|
45
|
+
scopeOverride: this.scopeOverride,
|
|
46
|
+
environmentName: this.loupeConfig.environment,
|
|
47
|
+
categories: this.loupeConfig.categories,
|
|
48
|
+
dimensions: this.loupeConfig.dimensions,
|
|
49
|
+
runErrors: this.runErrors,
|
|
50
|
+
});
|
|
51
|
+
const settings = {
|
|
52
|
+
title: this.loupeConfig.title,
|
|
53
|
+
theme: this.loupeConfig.theme,
|
|
54
|
+
screenshots: { defaultView: this.loupeConfig.screenshots.defaultView },
|
|
55
|
+
};
|
|
56
|
+
const template = await readFile(TEMPLATE_URL, 'utf8');
|
|
57
|
+
await mkdir(outputFolder, { recursive: true });
|
|
58
|
+
await writeFile(path.join(outputFolder, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
59
|
+
await writeFile(path.join(outputFolder, 'index.html'), injectSettings(injectData(template, report), settings));
|
|
60
|
+
const errors = report.run.errors.length;
|
|
61
|
+
const suffix = errors > 0 ? ` (${errors} run error${errors > 1 ? 's' : ''} outside tests)` : '';
|
|
62
|
+
console.log(`\nLoupe report written to ${path.relative(process.cwd(), outputFolder) || '.'}${suffix}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Location } from '@playwright/test/reporter';
|
|
2
|
+
/**
|
|
3
|
+
* Location of an ESM import failure. For ES module spec files, Playwright
|
|
4
|
+
* reports such load errors at `<anonymous>:1:1`; the importing file is only in
|
|
5
|
+
* the message. Returns that file with the line of the failing import (found by
|
|
6
|
+
* its specifier), or null when the message is not such an error or the import
|
|
7
|
+
* cannot be found in the file. CommonJS spec files already get a precise
|
|
8
|
+
* location and do not need this.
|
|
9
|
+
*/
|
|
10
|
+
export declare function esmImportLocation(message: string): Promise<Location | null>;
|
|
11
|
+
/** True when Playwright gave a usable source location (a real file path, not `<anonymous>`). */
|
|
12
|
+
export declare function isUsableLocation(location: Location | undefined): location is Location;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* Node's ESM resolution error, as Playwright reports it for a spec file that
|
|
6
|
+
* imports a missing module or package:
|
|
7
|
+
* Cannot find module '/abs/tests/missing' imported from /abs/tests/a.spec.ts
|
|
8
|
+
* Cannot find package 'nope' imported from /abs/tests/a.spec.ts
|
|
9
|
+
*/
|
|
10
|
+
const ESM_NOT_FOUND = /Cannot find (?:module|package) '([^']+)' imported from (\S+)/;
|
|
11
|
+
function escapeRegExp(value) {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Location of an ESM import failure. For ES module spec files, Playwright
|
|
16
|
+
* reports such load errors at `<anonymous>:1:1`; the importing file is only in
|
|
17
|
+
* the message. Returns that file with the line of the failing import (found by
|
|
18
|
+
* its specifier), or null when the message is not such an error or the import
|
|
19
|
+
* cannot be found in the file. CommonJS spec files already get a precise
|
|
20
|
+
* location and do not need this.
|
|
21
|
+
*/
|
|
22
|
+
export async function esmImportLocation(message) {
|
|
23
|
+
const match = ESM_NOT_FOUND.exec(message);
|
|
24
|
+
if (!match)
|
|
25
|
+
return null;
|
|
26
|
+
const [, missing = '', importer = ''] = match;
|
|
27
|
+
const file = importer.startsWith('file://') ? fileURLToPath(importer) : importer;
|
|
28
|
+
let source;
|
|
29
|
+
try {
|
|
30
|
+
source = await readFile(file, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// The message gives a package name, or the resolved path of a relative import without its extension.
|
|
36
|
+
const name = path.isAbsolute(missing) ? path.basename(missing).replace(/\.[cm]?[jt]sx?$/, '') : missing;
|
|
37
|
+
// './missing', '../lib/missing.js', 'pkg', 'pkg/sub', '@scope/pkg': the quoted specifier ending with the name.
|
|
38
|
+
const specifier = new RegExp(`(['"\`])(?:[^'"\`]*/)?${escapeRegExp(name)}(?:\\.[cm]?[jt]sx?|/[^'"\`]*)?\\1`);
|
|
39
|
+
const lines = source.split(/\r?\n/);
|
|
40
|
+
const index = lines.findIndex((line) => specifier.test(line));
|
|
41
|
+
if (index === -1)
|
|
42
|
+
return null;
|
|
43
|
+
const line = lines[index];
|
|
44
|
+
return { file, line: index + 1, column: line.length - line.trimStart().length + 1 };
|
|
45
|
+
}
|
|
46
|
+
/** True when Playwright gave a usable source location (a real file path, not `<anonymous>`). */
|
|
47
|
+
export function isUsableLocation(location) {
|
|
48
|
+
return location !== undefined && path.isAbsolute(location.file);
|
|
49
|
+
}
|