@qartisan/playwright-loupe 0.0.0-stage → 0.0.2
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 +41 -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 +138 -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 +71 -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,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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { RunScope } from '../schema/v1.js';
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort detection of partial runs. What a reporter can see (checked
|
|
4
|
+
* against Playwright 1.52–1.64):
|
|
5
|
+
*
|
|
6
|
+
* - CLI `--grep`, file filters, `--last-failed`, `--only-changed` and
|
|
7
|
+
* `--test-list` never reach the resolved config: the only trace is the
|
|
8
|
+
* command line (`config.argv` from 1.61, `process.argv` before).
|
|
9
|
+
* - `--project` is visible as `config.filteredProjects` from 1.64; earlier, the
|
|
10
|
+
* command line plus the projects present in the suite are used.
|
|
11
|
+
* - A `grep` / `grepInvert` in the config file is visible in the config.
|
|
12
|
+
* - `test.only` is only visible through Playwright's internal `_only` flag.
|
|
13
|
+
*
|
|
14
|
+
* Limits are documented in docs/schema/v1.md ("Run scope").
|
|
15
|
+
*/
|
|
16
|
+
/** Reasons, in the order they are reported. */
|
|
17
|
+
export declare const SCOPE_REASONS: readonly ['env', 'file-filter', 'grep', 'project-filter', 'last-failed', 'only-changed', 'test-list', 'only', 'shard', 'list'];
|
|
18
|
+
export type ScopeReason = (typeof SCOPE_REASONS)[number];
|
|
19
|
+
export interface TestCommand {
|
|
20
|
+
/** Positional arguments: file / line filters. */
|
|
21
|
+
filters: string[];
|
|
22
|
+
/** Option names used (long form when given that way), e.g. '--grep', '-g'. */
|
|
23
|
+
options: Set<string>;
|
|
24
|
+
}
|
|
25
|
+
/** Parses `playwright test` arguments from a full argv; null when the command is not `test`. */
|
|
26
|
+
export declare function parseTestCommand(argv: readonly string[]): TestCommand | null;
|
|
27
|
+
export interface ProjectRef {
|
|
28
|
+
name: string;
|
|
29
|
+
dependencies: readonly string[];
|
|
30
|
+
teardown?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
export interface ScopeInput {
|
|
33
|
+
argv: readonly string[];
|
|
34
|
+
/** `grep` / `grepInvert` set in the config file (not their defaults). */
|
|
35
|
+
configGrep: boolean;
|
|
36
|
+
shard: boolean;
|
|
37
|
+
/** All projects of the config. */
|
|
38
|
+
projects: readonly ProjectRef[];
|
|
39
|
+
/** `config.filteredProjects` names when Playwright exposes them (1.64+); empty under merge-reports. */
|
|
40
|
+
filteredProjects: readonly string[] | undefined;
|
|
41
|
+
/** Names of the projects that have a suite in this run. */
|
|
42
|
+
runProjects: readonly string[];
|
|
43
|
+
/** A `test.only` / `describe.only` is in effect. */
|
|
44
|
+
only: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function detectScopeReasons(input: ScopeInput): ScopeReason[];
|
|
47
|
+
export declare const SCOPE_ENV = "LOUPE_RUN_SCOPE";
|
|
48
|
+
/** Validates the LOUPE_RUN_SCOPE value; null when unset or empty. Throws on anything else than full / partial. */
|
|
49
|
+
export declare function parseScopeOverride(value: string | undefined): RunScope | null;
|
|
50
|
+
/**
|
|
51
|
+
* Final scope: the override wins. A forced `full` has no reasons; a forced
|
|
52
|
+
* `partial` reports 'env' followed by whatever was detected.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveScope(detected: readonly ScopeReason[], override: RunScope | null): {
|
|
55
|
+
scope: RunScope;
|
|
56
|
+
scopeReasons: ScopeReason[];
|
|
57
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort detection of partial runs. What a reporter can see (checked
|
|
3
|
+
* against Playwright 1.52–1.64):
|
|
4
|
+
*
|
|
5
|
+
* - CLI `--grep`, file filters, `--last-failed`, `--only-changed` and
|
|
6
|
+
* `--test-list` never reach the resolved config: the only trace is the
|
|
7
|
+
* command line (`config.argv` from 1.61, `process.argv` before).
|
|
8
|
+
* - `--project` is visible as `config.filteredProjects` from 1.64; earlier, the
|
|
9
|
+
* command line plus the projects present in the suite are used.
|
|
10
|
+
* - A `grep` / `grepInvert` in the config file is visible in the config.
|
|
11
|
+
* - `test.only` is only visible through Playwright's internal `_only` flag.
|
|
12
|
+
*
|
|
13
|
+
* Limits are documented in docs/schema/v1.md ("Run scope").
|
|
14
|
+
*/
|
|
15
|
+
/** Reasons, in the order they are reported. */
|
|
16
|
+
export const SCOPE_REASONS = [
|
|
17
|
+
'env',
|
|
18
|
+
'file-filter',
|
|
19
|
+
'grep',
|
|
20
|
+
'project-filter',
|
|
21
|
+
'last-failed',
|
|
22
|
+
'only-changed',
|
|
23
|
+
'test-list',
|
|
24
|
+
'only',
|
|
25
|
+
'shard',
|
|
26
|
+
'list',
|
|
27
|
+
];
|
|
28
|
+
/** `playwright test` options that take a value (the next token, or `--opt=value`). */
|
|
29
|
+
const VALUE_OPTIONS = new Set([
|
|
30
|
+
'-c', '--config',
|
|
31
|
+
'-g', '--grep',
|
|
32
|
+
'--grep-invert',
|
|
33
|
+
'--project',
|
|
34
|
+
'-j', '--workers',
|
|
35
|
+
'--reporter',
|
|
36
|
+
'--retries',
|
|
37
|
+
'--timeout',
|
|
38
|
+
'--global-timeout',
|
|
39
|
+
'--max-failures',
|
|
40
|
+
'--output',
|
|
41
|
+
'--repeat-each',
|
|
42
|
+
'--shard',
|
|
43
|
+
'--trace',
|
|
44
|
+
'--tsconfig',
|
|
45
|
+
'--update-source-method',
|
|
46
|
+
'--browser',
|
|
47
|
+
'--test-list',
|
|
48
|
+
'--test-list-invert',
|
|
49
|
+
'--ui-host',
|
|
50
|
+
'--ui-port',
|
|
51
|
+
]);
|
|
52
|
+
/** Options with an optional value: like commander, they take the next token unless it starts with '-'. */
|
|
53
|
+
const OPTIONAL_VALUE_OPTIONS = new Set(['--only-changed', '-u', '--update-snapshots']);
|
|
54
|
+
/** Parses `playwright test` arguments from a full argv; null when the command is not `test`. */
|
|
55
|
+
export function parseTestCommand(argv) {
|
|
56
|
+
const args = argv.slice(2);
|
|
57
|
+
if (args[0] !== 'test')
|
|
58
|
+
return null;
|
|
59
|
+
const command = { filters: [], options: new Set() };
|
|
60
|
+
for (let i = 1; i < args.length; i++) {
|
|
61
|
+
const arg = args[i] ?? '';
|
|
62
|
+
if (arg === '--') {
|
|
63
|
+
command.filters.push(...args.slice(i + 1));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
67
|
+
command.filters.push(arg);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const equals = arg.indexOf('=');
|
|
71
|
+
const name = arg.startsWith('--') && equals !== -1 ? arg.slice(0, equals) : arg;
|
|
72
|
+
// Short options with an attached value, e.g. `-gsmoke`.
|
|
73
|
+
const short = !arg.startsWith('--') && arg.length > 2 && VALUE_OPTIONS.has(arg.slice(0, 2)) ? arg.slice(0, 2) : null;
|
|
74
|
+
if (short) {
|
|
75
|
+
command.options.add(short);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
command.options.add(name);
|
|
79
|
+
if (name !== arg)
|
|
80
|
+
continue;
|
|
81
|
+
if (VALUE_OPTIONS.has(name))
|
|
82
|
+
i++;
|
|
83
|
+
else if (OPTIONAL_VALUE_OPTIONS.has(name) && args[i + 1] !== undefined && !args[i + 1]?.startsWith('-'))
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
return command;
|
|
87
|
+
}
|
|
88
|
+
/** Projects that run when `selected` are requested: their dependencies and teardowns included. */
|
|
89
|
+
function withDependencies(selected, projects) {
|
|
90
|
+
const byName = new Map(projects.map((project) => [project.name, project]));
|
|
91
|
+
const result = new Set();
|
|
92
|
+
const pending = [...selected];
|
|
93
|
+
while (pending.length > 0) {
|
|
94
|
+
const name = pending.pop();
|
|
95
|
+
if (result.has(name))
|
|
96
|
+
continue;
|
|
97
|
+
result.add(name);
|
|
98
|
+
const project = byName.get(name);
|
|
99
|
+
if (!project)
|
|
100
|
+
continue;
|
|
101
|
+
pending.push(...project.dependencies);
|
|
102
|
+
if (project.teardown)
|
|
103
|
+
pending.push(project.teardown);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function hasProjectFilter(input, command) {
|
|
108
|
+
const all = input.projects.map((project) => project.name);
|
|
109
|
+
// merge-reports exposes an empty list: a real run always selects at least one project.
|
|
110
|
+
if (input.filteredProjects && input.filteredProjects.length > 0) {
|
|
111
|
+
const running = withDependencies(input.filteredProjects, input.projects);
|
|
112
|
+
return all.some((name) => !running.has(name));
|
|
113
|
+
}
|
|
114
|
+
if (!command?.options.has('--project'))
|
|
115
|
+
return false;
|
|
116
|
+
const running = new Set(input.runProjects);
|
|
117
|
+
return all.some((name) => !running.has(name));
|
|
118
|
+
}
|
|
119
|
+
export function detectScopeReasons(input) {
|
|
120
|
+
const command = parseTestCommand(input.argv);
|
|
121
|
+
const reasons = new Set();
|
|
122
|
+
const has = (...names) => names.some((name) => command?.options.has(name));
|
|
123
|
+
if (command && command.filters.length > 0)
|
|
124
|
+
reasons.add('file-filter');
|
|
125
|
+
if (input.configGrep || has('-g', '--grep', '--grep-invert'))
|
|
126
|
+
reasons.add('grep');
|
|
127
|
+
if (hasProjectFilter(input, command))
|
|
128
|
+
reasons.add('project-filter');
|
|
129
|
+
if (has('--last-failed'))
|
|
130
|
+
reasons.add('last-failed');
|
|
131
|
+
if (has('--only-changed'))
|
|
132
|
+
reasons.add('only-changed');
|
|
133
|
+
if (has('--test-list', '--test-list-invert'))
|
|
134
|
+
reasons.add('test-list');
|
|
135
|
+
if (input.only)
|
|
136
|
+
reasons.add('only');
|
|
137
|
+
if (input.shard)
|
|
138
|
+
reasons.add('shard');
|
|
139
|
+
// `--list` runs nothing: the report lists tests without results.
|
|
140
|
+
if (has('--list'))
|
|
141
|
+
reasons.add('list');
|
|
142
|
+
return SCOPE_REASONS.filter((reason) => reasons.has(reason));
|
|
143
|
+
}
|
|
144
|
+
export const SCOPE_ENV = 'LOUPE_RUN_SCOPE';
|
|
145
|
+
/** Validates the LOUPE_RUN_SCOPE value; null when unset or empty. Throws on anything else than full / partial. */
|
|
146
|
+
export function parseScopeOverride(value) {
|
|
147
|
+
const trimmed = value?.trim() ?? '';
|
|
148
|
+
if (trimmed === '')
|
|
149
|
+
return null;
|
|
150
|
+
if (trimmed === 'full' || trimmed === 'partial')
|
|
151
|
+
return trimmed;
|
|
152
|
+
throw new Error(`Loupe: invalid ${SCOPE_ENV}=${JSON.stringify(value)}. Expected "full" or "partial".`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Final scope: the override wins. A forced `full` has no reasons; a forced
|
|
156
|
+
* `partial` reports 'env' followed by whatever was detected.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveScope(detected, override) {
|
|
159
|
+
if (override === 'full')
|
|
160
|
+
return { scope: 'full', scopeReasons: [] };
|
|
161
|
+
if (override === 'partial')
|
|
162
|
+
return { scope: 'partial', scopeReasons: ['env', ...detected] };
|
|
163
|
+
return { scope: detected.length > 0 ? 'partial' : 'full', scopeReasons: [...detected] };
|
|
164
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function stripAnsi(text: string): string;
|
|
2
|
+
/** Removes the `(<tags>) ` prefix `merge-reports` adds to run errors. ANSI codes must be stripped first. */
|
|
3
|
+
export declare function stripMergeTagPrefix(text: string): string;
|
|
4
|
+
export interface NormalizeOptions {
|
|
5
|
+
/** Run errors (`onError`) also lose the `(<tags>) ` prefix added by `merge-reports`. */
|
|
6
|
+
runError?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Normalizes an error message so the same failure produces the same text
|
|
10
|
+
* across tests and runs: ANSI codes and the call log are removed, volatile
|
|
11
|
+
* values are replaced with placeholders and whitespace is collapsed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeErrorMessage(message: string, options?: NormalizeOptions): string;
|
|
14
|
+
/** First 12 hex chars of sha256 of the normalized message. Independent of the test. */
|
|
15
|
+
export declare function errorSignature(message: string, options?: NormalizeOptions): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
// Same pattern as the `ansi-regex` package (CSI and OSC sequences).
|
|
3
|
+
const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
4
|
+
export function stripAnsi(text) {
|
|
5
|
+
return text.replace(ANSI_PATTERN, '');
|
|
6
|
+
}
|
|
7
|
+
/** Playwright appends the action log after a line starting with "Call log:". */
|
|
8
|
+
const CALL_LOG = /^[ \t]*Call log:/m;
|
|
9
|
+
/**
|
|
10
|
+
* `merge-reports` prefixes the run errors of a blob whose config has a `tag`
|
|
11
|
+
* with `(<tags>) `, tags joined by spaces. Config tags always start with `@`.
|
|
12
|
+
*/
|
|
13
|
+
const MERGE_TAG_PREFIX = /^\(@[^()\n]*\) /;
|
|
14
|
+
/** Removes the `(<tags>) ` prefix `merge-reports` adds to run errors. ANSI codes must be stripped first. */
|
|
15
|
+
export function stripMergeTagPrefix(text) {
|
|
16
|
+
return text.replace(MERGE_TAG_PREFIX, '');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ordered replacements. The order matters: URLs before e-mails (a URL may
|
|
20
|
+
* contain `user@host`) and paths (a URL contains slashes), e-mails and paths
|
|
21
|
+
* before identifiers, identifiers before durations, hex and numbers (so each
|
|
22
|
+
* is replaced as a whole), hex before numbers. See "Error signature" in
|
|
23
|
+
* docs/schema/v1.md.
|
|
24
|
+
*/
|
|
25
|
+
const REPLACEMENTS = [
|
|
26
|
+
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>'],
|
|
27
|
+
[/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>()[\]{}]*[^\s'"`<>()[\]{}.,;:!?]/gi, '<url>'],
|
|
28
|
+
// Local part, then a domain with at least one dot and an alphabetic TLD.
|
|
29
|
+
[/(?<![\w.%+-])[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}(?![\w-])/gi, '<email>'],
|
|
30
|
+
// ISO 8601 date with optional time and zone.
|
|
31
|
+
[/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?![\w:])/g, '<date>'],
|
|
32
|
+
// Time of day.
|
|
33
|
+
[/\b\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\b/g, '<date>'],
|
|
34
|
+
// Unix timestamps in seconds or milliseconds (2001–2033).
|
|
35
|
+
[/\b1\d{9}(?:\d{3})?\b/g, '<date>'],
|
|
36
|
+
// Absolute POSIX paths with at least two segments, and Windows drive paths.
|
|
37
|
+
[/(?<![\w.~:/\\-])\/(?:[\w.@+~-]+\/)+[\w.@+~-]+\/?/g, '<path>'],
|
|
38
|
+
[/\b[A-Za-z]:[\\/](?:[^\\/\s'"`:*?<>|]+[\\/])*[^\\/\s'"`:*?<>|]+/g, '<path>'],
|
|
39
|
+
// nanoid-like: 20+ chars of [A-Za-z0-9_-] with a lowercase letter, an
|
|
40
|
+
// uppercase letter and a digit. Mixed case keeps kebab-case and snake_case
|
|
41
|
+
// names out; a digit keeps camelCase words out.
|
|
42
|
+
[/(?<![\w-])(?=[\w-]*[a-z])(?=[\w-]*[A-Z])(?=[\w-]*\d)[\w-]{20,}(?![\w-])/g, '<id>'],
|
|
43
|
+
// cuid-like: 20+ lowercase letters and digits, with a digit and a letter
|
|
44
|
+
// beyond a-f (pure hex strings stay <hex>).
|
|
45
|
+
[/(?<![0-9A-Za-z])(?=[a-z0-9]*\d)(?=[a-z0-9]*[g-z])[a-z0-9]{20,}(?![0-9A-Za-z])/g, '<id>'],
|
|
46
|
+
[/\b\d+(?:\.\d+)?\s?(?:ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b/gi, '<duration>'],
|
|
47
|
+
// 8+ hex chars with at least one letter (pure digit runs fall through to <n>).
|
|
48
|
+
[/(?<![0-9A-Za-z])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}(?![0-9A-Za-z])/gi, '<hex>'],
|
|
49
|
+
[/\d+(?:\.\d+)?/g, '<n>'],
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Normalizes an error message so the same failure produces the same text
|
|
53
|
+
* across tests and runs: ANSI codes and the call log are removed, volatile
|
|
54
|
+
* values are replaced with placeholders and whitespace is collapsed.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeErrorMessage(message, options = {}) {
|
|
57
|
+
let text = stripAnsi(message);
|
|
58
|
+
if (options.runError)
|
|
59
|
+
text = stripMergeTagPrefix(text);
|
|
60
|
+
const callLog = CALL_LOG.exec(text);
|
|
61
|
+
if (callLog)
|
|
62
|
+
text = text.slice(0, callLog.index);
|
|
63
|
+
for (const [pattern, placeholder] of REPLACEMENTS) {
|
|
64
|
+
text = text.replace(pattern, placeholder);
|
|
65
|
+
}
|
|
66
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
67
|
+
}
|
|
68
|
+
/** First 12 hex chars of sha256 of the normalized message. Independent of the test. */
|
|
69
|
+
export function errorSignature(message, options = {}) {
|
|
70
|
+
return createHash('sha256').update(normalizeErrorMessage(message, options)).digest('hex').slice(0, 12);
|
|
71
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
// Two levels up from both `src/core` and `dist/core`: the package root.
|
|
3
|
+
const PACKAGE_JSON = new URL('../../package.json', import.meta.url);
|
|
4
|
+
/** Version of the installed Loupe package, written to `generator.version`. */
|
|
5
|
+
export async function generatorVersion() {
|
|
6
|
+
const { version } = JSON.parse(await readFile(PACKAGE_JSON, 'utf8'));
|
|
7
|
+
return version;
|
|
8
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { LoupeReporter } from './core/reporter.js';
|
|
2
|
+
export default LoupeReporter;
|
|
3
|
+
export { LoupeReporter };
|
|
4
|
+
export { defineConfig } from './config/index.js';
|
|
5
|
+
export type { AnnotationDimensionConfig, CategoriesConfig, CategoryMode, CategoryRuleConfig, CategoryTarget, DimensionConfig, DimensionsConfig, LoupeConfig, LoupeReporterOptions, ScreenshotView, TagDimensionConfig, Theme, } from './config/types.js';
|
|
6
|
+
export type { RendererSettings } from './core/inject.js';
|
|
7
|
+
export type * from './schema/v1.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Loupe report</title>
|
|
7
|
+
<script type="module">var e={title:`Loupe report`,theme:`system`,screenshots:{defaultView:`side-by-side`}},t=[`passed`,`failed`,`flaky`,`skipped`];function n(e){return JSON.parse(document.getElementById(e)?.textContent??``)}function r(){try{return{...e,...n(`loupe-settings`)}}catch{return e}}function i(e,t={},n=[]){let r=document.createElement(e);return t.className&&(r.className=t.className),t.text!==void 0&&(r.textContent=t.text),r.append(...n),r}function a(e){return e<1e3?`${e} ms`:`${(e/1e3).toFixed(1)} s`}function o(e,n){let{run:r}=e,o=t.map(e=>i(`li`,{className:`count status-${e}`},[i(`span`,{className:`count-value`,text:String(r.stats[e])}),i(`span`,{className:`count-label`,text:e})])),s=[`${r.stats.total} tests`,a(r.durationMs),`${r.scope} run`,`Playwright ${r.playwrightVersion}`,...r.environment.name?[r.environment.name]:[],...r.git.commit?[r.git.commit.slice(0,8)]:[]];return i(`header`,{className:`summary`},[i(`h1`,{text:n}),i(`p`,{className:`meta`},[i(`span`,{className:`badge status-${r.status}`,text:r.status}),document.createTextNode(` ${s.join(` · `)}`)]),i(`ul`,{className:`counts`},o)])}function s(e){let t={title:``,suites:new Map,tests:[]};for(let n of e){let e=t;for(let t of[n.file,...n.suitePath]){let n=e.suites.get(t);n||(n={title:t,suites:new Map,tests:[]},e.suites.set(t,n)),e=n}e.tests.push(n)}return t}function c(e){return i(`li`,{className:`test`},[i(`span`,{className:`badge status-${e.outcome}`,text:e.outcome}),i(`span`,{className:`title`,text:e.title}),i(`span`,{className:`attempts`,text:e.attempts.length>1?`${e.attempts.length} attempts`:``})])}function l(e,t){return i(`li`,{className:`suite depth-${Math.min(t,3)}`},[i(`span`,{className:t===0?`file`:`suite-title`,text:e.title}),i(`ul`,{className:`tests`},[...[...e.suites.values()].map(e=>l(e,t+1)),...e.tests.map(c)])])}function u(e,t){let n=s(t);return i(`section`,{className:`project`},[i(`h2`,{text:`${e||`(no project)`} (${t.length})`}),i(`ul`,{className:`suites`},[...n.suites.values()].map(e=>l(e,0)))])}function d(e){let t=r(),a;try{a=n(`loupe-data`)}catch{e.append(i(`p`,{className:`error`,text:`Loupe: could not read report data.`}));return}if(a.schemaVersion!==1){e.append(i(`p`,{className:`error`,text:`Loupe: unsupported schema version ${String(a.schemaVersion)}.`}));return}document.title=t.title;let s=new Map(a.projects.map(e=>[e.name,[]]));for(let e of a.tests)s.set(e.project,[...s.get(e.project)??[],e]);e.append(o(a,t.title),...[...s].map(([e,t])=>u(e,t)))}d(document.getElementById(`app`)??document.body);</script>
|
|
8
|
+
<style>:root{--bg:#fff;--fg:#1f2328;--muted:#656d76;--border:#d0d7de;--surface:#f6f8fa;--passed:#1a7f37;--failed:#cf222e;--flaky:#9a6700;--skipped:#6e7781;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial;--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--border:#30363d;--surface:#161b22;--passed:#3fb950;--failed:#f85149;--flaky:#d29922;--skipped:#8d96a0}}*{box-sizing:border-box}body{background:var(--bg);color:var(--fg);margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}main{max-width:960px;margin:0 auto;padding:24px 16px}h1{margin:0 0 4px;font-size:22px}h2{margin:0 0 8px;font-size:16px}.meta{color:var(--muted);margin:0 0 16px}.counts{flex-wrap:wrap;gap:8px;margin:0 0 24px;padding:0;list-style:none;display:flex}.count{border:1px solid var(--border);background:var(--surface);border-radius:6px;flex-direction:column;min-width:96px;padding:8px 12px;display:flex}.count-value{font-size:20px;font-weight:600}.count-label{color:var(--muted);text-transform:capitalize}.project{margin-bottom:24px}.suites{border:1px solid var(--border);border-radius:6px;margin:0;padding:0;list-style:none}.suites>.suite{border-top:1px solid var(--border);padding:6px 12px}.suites>.suite:first-child{border-top:0}.tests{margin:0;padding:0 0 0 16px;list-style:none}.suite .file{color:var(--muted);overflow-wrap:anywhere;font-size:12px}.suite .suite-title{font-weight:600}.test{grid-template-columns:72px 1fr auto;align-items:baseline;gap:4px 12px;padding:2px 0;display:grid}.test .attempts{color:var(--muted);font-size:12px}.badge{text-transform:uppercase;font-size:12px;font-weight:600}.status-passed{color:var(--passed)}.status-failed,.status-timedout,.status-interrupted{color:var(--failed)}.status-flaky{color:var(--flaky)}.status-skipped{color:var(--skipped)}.error{color:var(--failed)}
|
|
9
|
+
/*$vite$:1*/</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main id="app"></main>
|
|
13
|
+
<script id="loupe-settings" type="application/json">__LOUPE_SETTINGS__</script>
|
|
14
|
+
<script id="loupe-data" type="application/json">__LOUPE_DATA__</script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loupe JSON report — schema v1. This file is the source of truth for the
|
|
3
|
+
* public contract; `docs/schema/report-v1.schema.json` mirrors it and
|
|
4
|
+
* `docs/schema/v1.md` documents every field and rule.
|
|
5
|
+
*
|
|
6
|
+
* Any change to the shape below requires a new `schemaVersion` (see the
|
|
7
|
+
* versioning policy in `docs/schema/v1.md`). Before the first npm
|
|
8
|
+
* publication, v1 was amended in place (`Run.errors`,
|
|
9
|
+
* `DimensionDefinition.values`); after it, shape changes need a new version.
|
|
10
|
+
*
|
|
11
|
+
* Enumerations are declared as runtime constants so the JSON Schema test can
|
|
12
|
+
* check that both sides list the same values. Kept free of Node imports so the
|
|
13
|
+
* renderer can import it too.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SCHEMA_VERSION = 1;
|
|
16
|
+
export declare const RUN_STATUSES: readonly ['passed', 'failed', 'timedout', 'interrupted'];
|
|
17
|
+
export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
18
|
+
export declare const RUN_SCOPES: readonly ['full', 'partial'];
|
|
19
|
+
export type RunScope = (typeof RUN_SCOPES)[number];
|
|
20
|
+
export declare const OUTCOMES: readonly ['passed', 'failed', 'flaky', 'skipped'];
|
|
21
|
+
export type Outcome = (typeof OUTCOMES)[number];
|
|
22
|
+
export declare const ATTEMPT_STATUSES: readonly ['passed', 'failed', 'timedOut', 'skipped', 'interrupted'];
|
|
23
|
+
export type AttemptStatus = (typeof ATTEMPT_STATUSES)[number];
|
|
24
|
+
export declare const ATTACHMENT_KINDS: readonly ['screenshot', 'video', 'trace', 'text', 'other'];
|
|
25
|
+
export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];
|
|
26
|
+
export declare const COMPARISON_ROLES: readonly ['expected', 'actual', 'diff'];
|
|
27
|
+
export type ComparisonRole = (typeof COMPARISON_ROLES)[number];
|
|
28
|
+
export declare const DIMENSION_SOURCES: readonly ['tag', 'annotation'];
|
|
29
|
+
export type DimensionSource = (typeof DIMENSION_SOURCES)[number];
|
|
30
|
+
export interface LoupeReport {
|
|
31
|
+
schemaVersion: 1;
|
|
32
|
+
generator: {
|
|
33
|
+
name: 'playwright-loupe';
|
|
34
|
+
version: string;
|
|
35
|
+
};
|
|
36
|
+
run: Run;
|
|
37
|
+
projects: Project[];
|
|
38
|
+
tests: TestCase[];
|
|
39
|
+
/** Category definitions actually used in this report. */
|
|
40
|
+
categories: CategoryDefinition[];
|
|
41
|
+
/** Dimension definitions actually used in this report. */
|
|
42
|
+
dimensions: DimensionDefinition[];
|
|
43
|
+
}
|
|
44
|
+
export interface Run {
|
|
45
|
+
/** Random UUID generated at onBegin. */
|
|
46
|
+
id: string;
|
|
47
|
+
/** ISO 8601. */
|
|
48
|
+
startedAt: string;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
status: RunStatus;
|
|
51
|
+
scope: RunScope;
|
|
52
|
+
/** Why the run was considered partial, e.g. ['grep', 'file-filter']. Empty when full. */
|
|
53
|
+
scopeReasons: string[];
|
|
54
|
+
playwrightVersion: string;
|
|
55
|
+
workers: number;
|
|
56
|
+
shard: {
|
|
57
|
+
current: number;
|
|
58
|
+
total: number;
|
|
59
|
+
} | null;
|
|
60
|
+
environment: {
|
|
61
|
+
/** From LOUPE_ENV, null otherwise. */
|
|
62
|
+
name: string | null;
|
|
63
|
+
ci: boolean;
|
|
64
|
+
/** 'github-actions', 'gitlab', ... when detectable. */
|
|
65
|
+
ciProvider: string | null;
|
|
66
|
+
/** Platform and architecture, e.g. 'linux-x64'. */
|
|
67
|
+
os: string;
|
|
68
|
+
nodeVersion: string;
|
|
69
|
+
};
|
|
70
|
+
git: {
|
|
71
|
+
commit: string | null;
|
|
72
|
+
branch: string | null;
|
|
73
|
+
remote: string | null;
|
|
74
|
+
};
|
|
75
|
+
/** Phase 2 slot: always null in v1. */
|
|
76
|
+
excluded: {
|
|
77
|
+
reason: string;
|
|
78
|
+
} | null;
|
|
79
|
+
/** Phase 2 slot: always [] in v1. */
|
|
80
|
+
notes: Note[];
|
|
81
|
+
stats: Stats;
|
|
82
|
+
/** Errors not attached to a test: load errors, global setup/teardown failures, worker teardown errors. */
|
|
83
|
+
errors: RunError[];
|
|
84
|
+
}
|
|
85
|
+
export interface RunError {
|
|
86
|
+
/** ANSI codes stripped. */
|
|
87
|
+
message: string;
|
|
88
|
+
stack: string | null;
|
|
89
|
+
location: SourceLocation | null;
|
|
90
|
+
snippet: string | null;
|
|
91
|
+
/** Same normalization as test errors. */
|
|
92
|
+
signature: string;
|
|
93
|
+
/** Same rules as test errors. */
|
|
94
|
+
category: string | null;
|
|
95
|
+
/** Project concerned when Playwright makes it knowable, null otherwise. */
|
|
96
|
+
project: string | null;
|
|
97
|
+
}
|
|
98
|
+
export interface Note {
|
|
99
|
+
createdAt: string;
|
|
100
|
+
author: string | null;
|
|
101
|
+
text: string;
|
|
102
|
+
/** Test id when the note targets a test result; null for a run-level note. */
|
|
103
|
+
testId: string | null;
|
|
104
|
+
}
|
|
105
|
+
export interface Stats {
|
|
106
|
+
total: number;
|
|
107
|
+
passed: number;
|
|
108
|
+
failed: number;
|
|
109
|
+
flaky: number;
|
|
110
|
+
skipped: number;
|
|
111
|
+
}
|
|
112
|
+
export interface Project {
|
|
113
|
+
name: string;
|
|
114
|
+
/** Relative to the Playwright root dir, POSIX separators ('.' when equal). */
|
|
115
|
+
testDir: string;
|
|
116
|
+
retries: number;
|
|
117
|
+
timeoutMs: number;
|
|
118
|
+
stats: Stats;
|
|
119
|
+
}
|
|
120
|
+
export interface SourceLocation {
|
|
121
|
+
/** Relative to the Playwright root dir, POSIX separators. */
|
|
122
|
+
file: string;
|
|
123
|
+
line: number;
|
|
124
|
+
column: number;
|
|
125
|
+
}
|
|
126
|
+
export interface TestCase {
|
|
127
|
+
/** Stable id: first 16 hex chars of sha256(project + '\0' + file + '\0' + [...suitePath, title].join('\0')). */
|
|
128
|
+
id: string;
|
|
129
|
+
/**
|
|
130
|
+
* 0 unless the run used `--repeat-each`, which yields several tests with the
|
|
131
|
+
* same `id`. The pair (id, repeatEachIndex) is unique within a report.
|
|
132
|
+
*/
|
|
133
|
+
repeatEachIndex: number;
|
|
134
|
+
project: string;
|
|
135
|
+
/** Relative to the Playwright root dir, POSIX separators. */
|
|
136
|
+
file: string;
|
|
137
|
+
line: number;
|
|
138
|
+
column: number;
|
|
139
|
+
/** describe() titles only — no project, no file, no test title. */
|
|
140
|
+
suitePath: string[];
|
|
141
|
+
title: string;
|
|
142
|
+
/** As declared, with the leading '@'. */
|
|
143
|
+
tags: string[];
|
|
144
|
+
annotations: Annotation[];
|
|
145
|
+
/** Final outcome; a test.fail() that fails as expected is 'passed'. */
|
|
146
|
+
outcome: Outcome;
|
|
147
|
+
expectedStatus: AttemptStatus;
|
|
148
|
+
/** Sum of all attempts. */
|
|
149
|
+
durationMs: number;
|
|
150
|
+
/** Computed: dimension name → values. */
|
|
151
|
+
dimensions: Record<string, string[]>;
|
|
152
|
+
/** From the last failing attempt; null when the test never failed. */
|
|
153
|
+
category: string | null;
|
|
154
|
+
errorSignature: string | null;
|
|
155
|
+
attempts: Attempt[];
|
|
156
|
+
}
|
|
157
|
+
export interface Annotation {
|
|
158
|
+
type: string;
|
|
159
|
+
description: string | null;
|
|
160
|
+
}
|
|
161
|
+
export interface Attempt {
|
|
162
|
+
/** 0 for the first run, 1 for the first retry, ... */
|
|
163
|
+
index: number;
|
|
164
|
+
status: AttemptStatus;
|
|
165
|
+
startedAt: string;
|
|
166
|
+
durationMs: number;
|
|
167
|
+
workerIndex: number;
|
|
168
|
+
errors: LoupeError[];
|
|
169
|
+
steps: Step[];
|
|
170
|
+
attachments: Attachment[];
|
|
171
|
+
annotations: Annotation[];
|
|
172
|
+
/** Truncated to a fixed size, with a flag when truncated. */
|
|
173
|
+
stdout: Output;
|
|
174
|
+
stderr: Output;
|
|
175
|
+
}
|
|
176
|
+
export interface Output {
|
|
177
|
+
text: string;
|
|
178
|
+
truncated: boolean;
|
|
179
|
+
}
|
|
180
|
+
export interface LoupeError {
|
|
181
|
+
/** ANSI codes stripped. */
|
|
182
|
+
message: string;
|
|
183
|
+
stack: string | null;
|
|
184
|
+
location: SourceLocation | null;
|
|
185
|
+
snippet: string | null;
|
|
186
|
+
signature: string;
|
|
187
|
+
category: string | null;
|
|
188
|
+
}
|
|
189
|
+
export interface Step {
|
|
190
|
+
title: string;
|
|
191
|
+
/** Playwright step subtitle (e.g. the target locator of a `pw:api` step); null when absent. */
|
|
192
|
+
subtitle: string | null;
|
|
193
|
+
/** Playwright step category: 'test.step', 'expect', 'pw:api', 'hook', 'fixture', ... */
|
|
194
|
+
category: string;
|
|
195
|
+
durationMs: number;
|
|
196
|
+
error: LoupeError | null;
|
|
197
|
+
location: SourceLocation | null;
|
|
198
|
+
steps: Step[];
|
|
199
|
+
}
|
|
200
|
+
export interface Attachment {
|
|
201
|
+
name: string;
|
|
202
|
+
contentType: string;
|
|
203
|
+
kind: AttachmentKind;
|
|
204
|
+
/** Relative to the report folder; null when the body is inlined or the file is missing. */
|
|
205
|
+
path: string | null;
|
|
206
|
+
/** Inline only for small text bodies. */
|
|
207
|
+
body: string | null;
|
|
208
|
+
/** Set for toHaveScreenshot / toMatchSnapshot images. */
|
|
209
|
+
comparison: {
|
|
210
|
+
group: string;
|
|
211
|
+
role: ComparisonRole;
|
|
212
|
+
} | null;
|
|
213
|
+
}
|
|
214
|
+
export interface CategoryDefinition {
|
|
215
|
+
name: string;
|
|
216
|
+
color: string;
|
|
217
|
+
builtIn: boolean;
|
|
218
|
+
}
|
|
219
|
+
export interface DimensionDefinition {
|
|
220
|
+
name: string;
|
|
221
|
+
source: DimensionSource;
|
|
222
|
+
/** Tag prefix (without '@' and ':') or annotation type. */
|
|
223
|
+
key: string;
|
|
224
|
+
/** Expected values declared in the config; null when not declared. */
|
|
225
|
+
values: string[] | null;
|
|
226
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loupe JSON report — schema v1. This file is the source of truth for the
|
|
3
|
+
* public contract; `docs/schema/report-v1.schema.json` mirrors it and
|
|
4
|
+
* `docs/schema/v1.md` documents every field and rule.
|
|
5
|
+
*
|
|
6
|
+
* Any change to the shape below requires a new `schemaVersion` (see the
|
|
7
|
+
* versioning policy in `docs/schema/v1.md`). Before the first npm
|
|
8
|
+
* publication, v1 was amended in place (`Run.errors`,
|
|
9
|
+
* `DimensionDefinition.values`); after it, shape changes need a new version.
|
|
10
|
+
*
|
|
11
|
+
* Enumerations are declared as runtime constants so the JSON Schema test can
|
|
12
|
+
* check that both sides list the same values. Kept free of Node imports so the
|
|
13
|
+
* renderer can import it too.
|
|
14
|
+
*/
|
|
15
|
+
export const SCHEMA_VERSION = 1;
|
|
16
|
+
export const RUN_STATUSES = ['passed', 'failed', 'timedout', 'interrupted'];
|
|
17
|
+
export const RUN_SCOPES = ['full', 'partial'];
|
|
18
|
+
export const OUTCOMES = ['passed', 'failed', 'flaky', 'skipped'];
|
|
19
|
+
export const ATTEMPT_STATUSES = ['passed', 'failed', 'timedOut', 'skipped', 'interrupted'];
|
|
20
|
+
export const ATTACHMENT_KINDS = ['screenshot', 'video', 'trace', 'text', 'other'];
|
|
21
|
+
export const COMPARISON_ROLES = ['expected', 'actual', 'diff'];
|
|
22
|
+
export const DIMENSION_SOURCES = ['tag', 'annotation'];
|