@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, minor versions may contain breaking changes. The JSON report has its own version (`schemaVersion`), see [`docs/schema/v1.md`](docs/schema/v1.md#versioning-policy).
|
|
6
|
+
|
|
7
|
+
## [0.0.2] - 2026-10-15
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Error signatures change: failures are grouped differently from 0.0.1.** Normalization now also replaces e-mail addresses with `<email>` and cuid-like and nanoid-like identifiers (20+ characters mixing letters and digits) with `<id>`, so the same failure for different users or records gets one signature. A signature computed by 0.0.1 for a message containing such values no longer matches. The JSON shape is unchanged: `schemaVersion` stays `1`. See [Error signature](docs/schema/v1.md#error-signature).
|
|
12
|
+
- **New built-in error categories**, evaluated after "Locator not found" and before "Network", "Assertion" and "Timeout": **Rate limited** (`Too Many Requests`, `rate limit`, a 429 in an HTTP context such as `status 429`, `HTTP 429` or `Response status: 429`) and **Server error** (a 500–599 status in the same contexts, or with its reason phrase such as `502 Bad Gateway`). Errors from `failOnStatusCode` and failed `expect(response).toBeOK()` that were "Uncategorized" or "Assertion" now land in these categories. A bare number (`429ms`, `Received: 500`) does not match. See [Error categories](docs/schema/v1.md#error-categories).
|
|
13
|
+
- The package description now lists only what exists today.
|
|
14
|
+
|
|
15
|
+
## [0.0.1] - 2026-10-13
|
|
16
|
+
|
|
17
|
+
First public release. Work in progress: the reporter and its JSON model are usable, the HTML renderer is a minimal skeleton.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Playwright reporter** (`@qartisan/playwright-loupe`) that writes `report.json`, a self-contained `index.html` with the report injected, and the attachments under `data/`. Works with `playwright test` and `playwright merge-reports`, including sharded runs merged from blob reports.
|
|
22
|
+
- **JSON report model, schema v1** (`schemaVersion: 1`), documented in [`docs/schema/v1.md`](docs/schema/v1.md) with a JSON Schema and exported TypeScript types (`import type { LoupeReport } from '@qartisan/playwright-loupe'`):
|
|
23
|
+
- stable test ids (project + file + title path), identical across sharded, merged and unsharded runs;
|
|
24
|
+
- run metadata: status, duration, Playwright version, git commit and branch, CI detection (GitHub Actions, GitLab, Azure Pipelines, CircleCI, Jenkins, Bitbucket, Buildkite, Travis), environment name, shard, workers;
|
|
25
|
+
- run scope (`full` | `partial`) with the reasons it was detected (file filter, grep, project filter, last-failed, `--only-changed`, `test.only`, `LOUPE_RUN_SCOPE`, and others);
|
|
26
|
+
- every attempt of every test (retries included), each with its errors, steps, annotations, stdout/stderr and attachments; screenshot comparisons are linked as expected / actual / diff;
|
|
27
|
+
- normalized error signatures that group the same failure across tests and runs (ANSI codes, call log, UUIDs, URLs, dates, paths, durations, hex strings and numbers removed or replaced);
|
|
28
|
+
- fields reserved for phase 2: `run.excluded` (always `null` in v1) and `notes` (always empty in v1).
|
|
29
|
+
- **Run errors** (`run.errors`): errors reported outside any test (a spec file that fails to load, a failing global setup or teardown, a worker fixture failing in teardown), with location, signature and category. A run with zero tests still produces a valid report. Run errors survive `merge-reports`; the `(<tags>) ` prefix `merge-reports` adds for a shard config with a `tag` is ignored by signatures and category rules.
|
|
30
|
+
- **Configuration**: `loupe.config.ts` (or `.mts`, `.js`, `.mjs`) next to `playwright.config.ts`, typed with `defineConfig()`, loaded through Playwright's own TypeScript transform. Options can also be passed inline in the reporter entry and through `LOUPE_*` environment variables (precedence: defaults < config file < inline options < environment variables). The config is validated at startup: an invalid option or an unknown key stops the run with an explicit message. See [`docs/config.md`](docs/config.md).
|
|
31
|
+
- **Error categories**: ordered regex rules matched on the message, stack or failing step titles; built-in rules (locator not found, network, assertion, timeout); `extend` or `replace` mode; a visible uncategorized bucket.
|
|
32
|
+
- **Dimensions**: automatic dimensions from prefixed tags (`@layer:api`), and configured dimensions from tags or annotations (`{ type: 'bug', description: 'BUG-1' }`), with optional expected values.
|
|
33
|
+
- **Minimal HTML renderer**: a self-contained page with the run summary (status, counts, duration, scope, environment, commit) and the tests grouped by project and suite. Renderer settings: `title`, `theme`, `screenshots.defaultView`. The full dashboard and test details come in later releases.
|
|
34
|
+
|
|
35
|
+
### Compatibility
|
|
36
|
+
|
|
37
|
+
- Node.js **22.12 or later** (`engines.node`), needed to load ES module config files. CI tests the latest Node.js 22.x and 24.x.
|
|
38
|
+
- `@playwright/test` **1.52 or later** (peer dependency). CI tests 1.52 (on Node.js 22) and the latest version. Playwright 1.52 hangs on recent Node.js 24 releases, independently of Loupe: see [Compatibility](README.md#compatibility).
|
|
39
|
+
|
|
40
|
+
[Unreleased]: https://github.com/qartisan/playwright-loupe/compare/v0.0.1...HEAD
|
|
41
|
+
[0.0.1]: https://github.com/qartisan/playwright-loupe/releases/tag/v0.0.1
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gilles Capdivila
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# playwright-loupe
|
|
2
|
+
Loupe — an HTML reporter for Playwright: run summary, tests grouped by project and suite, dimensions from tags and annotations, and detailed failures with every attempt, error categories and signatures. Native Playwright APIs only.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
> **Status: work in progress — the JSON schema will change before 1.0.**
|
|
5
|
+
|
|
6
|
+
Loupe is a reporter for Playwright, not affiliated with Microsoft.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install --save-dev @qartisan/playwright-loupe
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Node.js 22.12 or later and `@playwright/test` 1.52 or later. See [Compatibility](#compatibility).
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// playwright.config.ts
|
|
20
|
+
import { defineConfig } from '@playwright/test';
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
reporter: [
|
|
24
|
+
['list'],
|
|
25
|
+
['@qartisan/playwright-loupe', { outputFolder: 'loupe-report', title: 'My suite' }],
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
After a run, open `loupe-report/index.html`. The raw data is in `loupe-report/report.json`, with attachments in `loupe-report/data/`. Its format is documented in [`docs/schema/v1.md`](docs/schema/v1.md), with a [JSON Schema](docs/schema/report-v1.schema.json) and TypeScript types (`import type { LoupeReport } from '@qartisan/playwright-loupe'`).
|
|
31
|
+
|
|
32
|
+
Loupe requires `@playwright/test` 1.52 or later.
|
|
33
|
+
|
|
34
|
+
A run that broke outside its tests (a spec file that fails to import, a failing global setup) is reported in `run.errors`, even when no test ran.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Put a `loupe.config.ts` next to `playwright.config.ts`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// loupe.config.ts
|
|
42
|
+
import { defineConfig } from '@qartisan/playwright-loupe';
|
|
43
|
+
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
title: 'My suite',
|
|
46
|
+
categories: {
|
|
47
|
+
rules: [{ name: 'Infra', match: [/ECONNRESET/, /ECONNREFUSED/] }],
|
|
48
|
+
},
|
|
49
|
+
dimensions: {
|
|
50
|
+
definitions: [
|
|
51
|
+
{ name: 'Layer', from: 'tag', prefix: 'layer', values: ['api', 'ui', 'e2e'] },
|
|
52
|
+
{ name: 'Bug', from: 'annotation', type: 'bug' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Options can also be passed inline (`['@qartisan/playwright-loupe', { title: 'Nightly' }]`) and through `LOUPE_*` environment variables. Precedence: defaults < config file < inline options < environment variables. The config is validated at startup: an invalid option, or a typo in a key, stops the run with an explicit message.
|
|
59
|
+
|
|
60
|
+
Every option, the lookup rules and the environment variables are documented in [`docs/config.md`](docs/config.md).
|
|
61
|
+
|
|
62
|
+
## Compatibility
|
|
63
|
+
|
|
64
|
+
| | Supported | Tested in CI |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| Node.js | 22.12 or later | latest 22.x and 24.x |
|
|
67
|
+
| `@playwright/test` | 1.52 or later | 1.52 (on Node.js 22) and the latest version |
|
|
68
|
+
|
|
69
|
+
Node.js 22.12 is the minimum because ES module config files are loaded with `require(esm)`, see [`docs/config.md`](docs/config.md#how-config-files-are-loaded).
|
|
70
|
+
|
|
71
|
+
> **Playwright 1.52 hangs on recent Node.js 24 releases.** `playwright test` never exits, even for a single passing test with the built-in `list` reporter and without Loupe: this is not caused by Loupe. Reproduced with Node.js 24.17.0; Node.js 24.11.1 is not affected, nor is Playwright 1.53 or later on Node.js 24.17.0. With Playwright 1.52, use Node.js 22, or upgrade Playwright.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
| Command | What it does |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `npm run build` | Builds the reporter (`dist/`) and the self-contained renderer template (`dist/renderer/index.html`). |
|
|
78
|
+
| `npm run typecheck` | Type-checks the core, tests, fixtures and renderer. |
|
|
79
|
+
| `npm test` | Builds, then runs the Vitest unit tests and the integration tests (fixture suite, and a sharded run merged with `merge-reports`). |
|
|
80
|
+
| `npm run fixtures` | Builds, then runs the fixture suite into `loupe-report/`. Its failures are intentional, see [`fixtures/README.md`](fixtures/README.md). |
|
|
81
|
+
| `npm run sample:update` | Runs the fixture suite and copies its report to `src/renderer/dev/sample.json`. |
|
|
82
|
+
| `npm run dev:renderer` | Vite dev server for the renderer, fed with `src/renderer/dev/sample.json`, with hot reload. |
|
|
83
|
+
|
|
84
|
+
Tests that drive a browser need Chromium: `npx playwright install chromium`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './types.js';
|
|
2
|
+
export type * from './types.js';
|
|
3
|
+
export { defineConfig } from './define.js';
|
|
4
|
+
export { LoupeConfigError } from './resolve.js';
|
|
5
|
+
/**
|
|
6
|
+
* Finds, loads, validates and merges the Loupe config for a reporter instance.
|
|
7
|
+
* `options` are the raw reporter options, including the keys Playwright adds:
|
|
8
|
+
* `configDir` is the directory of the Playwright config file (of the merge
|
|
9
|
+
* config under `merge-reports`, the working directory when there is none).
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadConfig(options: Record<string, unknown>, env?: Readonly<Record<string, string | undefined>>, cwd?: string): ResolvedConfig;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CONFIG_ENV, findConfigFile, loadConfigFile } from './load.js';
|
|
2
|
+
import { resolveConfig, stripPlaywrightKeys } from './resolve.js';
|
|
3
|
+
export { defineConfig } from './define.js';
|
|
4
|
+
export { LoupeConfigError } from './resolve.js';
|
|
5
|
+
/**
|
|
6
|
+
* Finds, loads, validates and merges the Loupe config for a reporter instance.
|
|
7
|
+
* `options` are the raw reporter options, including the keys Playwright adds:
|
|
8
|
+
* `configDir` is the directory of the Playwright config file (of the merge
|
|
9
|
+
* config under `merge-reports`, the working directory when there is none).
|
|
10
|
+
*/
|
|
11
|
+
export function loadConfig(options, env = process.env, cwd = process.cwd()) {
|
|
12
|
+
const baseDir = typeof options['configDir'] === 'string' ? options['configDir'] : cwd;
|
|
13
|
+
const inline = stripPlaywrightKeys(options);
|
|
14
|
+
const optionPath = typeof inline['configFile'] === 'string' ? inline['configFile'] : undefined;
|
|
15
|
+
const file = findConfigFile({ baseDir, cwd, envPath: env[CONFIG_ENV], optionPath });
|
|
16
|
+
return resolveConfig({
|
|
17
|
+
file: file ? { path: file, config: loadConfigFile(file) } : undefined,
|
|
18
|
+
inline,
|
|
19
|
+
env,
|
|
20
|
+
baseDir,
|
|
21
|
+
cwd,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Looked up in this order in the directory of the Playwright config file. */
|
|
2
|
+
export declare const CONFIG_FILE_NAMES: readonly ['loupe.config.ts', 'loupe.config.mts', 'loupe.config.js', 'loupe.config.mjs'];
|
|
3
|
+
export declare const CONFIG_ENV = "LOUPE_CONFIG";
|
|
4
|
+
/**
|
|
5
|
+
* Path of the config file to load, or null when there is none.
|
|
6
|
+
*
|
|
7
|
+
* - `LOUPE_CONFIG` (relative to the working directory) wins over the
|
|
8
|
+
* `configFile` reporter option (relative to the Playwright config's
|
|
9
|
+
* directory). An explicit file that does not exist is an error.
|
|
10
|
+
* - Otherwise the first of CONFIG_FILE_NAMES found in `baseDir`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function findConfigFile(options: {
|
|
13
|
+
baseDir: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
envPath: string | undefined;
|
|
16
|
+
optionPath: string | undefined;
|
|
17
|
+
}): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Loads a config file synchronously and returns its default export.
|
|
20
|
+
*
|
|
21
|
+
* Synchronous on purpose: the reporter constructor is the only place where an
|
|
22
|
+
* error stops the run (Playwright does not await `onBegin`). TypeScript works
|
|
23
|
+
* because Playwright has installed its own transform (Babel) before it builds
|
|
24
|
+
* any non-built-in reporter: `require()` of a `.ts` / `.mts` file outside
|
|
25
|
+
* `node_modules` goes through it, in normal runs and in `merge-reports`. ESM
|
|
26
|
+
* output is loaded through Node's `require(esm)` (Node 22.12+), so a config
|
|
27
|
+
* file cannot use top-level `await`. See "How config files are loaded" in
|
|
28
|
+
* docs/config.md.
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadConfigFile(file: string): unknown;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { LoupeConfigError } from './resolve.js';
|
|
5
|
+
/** Looked up in this order in the directory of the Playwright config file. */
|
|
6
|
+
export const CONFIG_FILE_NAMES = ['loupe.config.ts', 'loupe.config.mts', 'loupe.config.js', 'loupe.config.mjs'];
|
|
7
|
+
export const CONFIG_ENV = 'LOUPE_CONFIG';
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
/**
|
|
10
|
+
* Path of the config file to load, or null when there is none.
|
|
11
|
+
*
|
|
12
|
+
* - `LOUPE_CONFIG` (relative to the working directory) wins over the
|
|
13
|
+
* `configFile` reporter option (relative to the Playwright config's
|
|
14
|
+
* directory). An explicit file that does not exist is an error.
|
|
15
|
+
* - Otherwise the first of CONFIG_FILE_NAMES found in `baseDir`.
|
|
16
|
+
*/
|
|
17
|
+
export function findConfigFile(options) {
|
|
18
|
+
const envPath = options.envPath?.trim();
|
|
19
|
+
const explicit = envPath
|
|
20
|
+
? { file: path.resolve(options.cwd, envPath), from: `${CONFIG_ENV}=${envPath}` }
|
|
21
|
+
: options.optionPath
|
|
22
|
+
? { file: path.resolve(options.baseDir, options.optionPath), from: `reporter option configFile: "${options.optionPath}"` }
|
|
23
|
+
: null;
|
|
24
|
+
if (explicit) {
|
|
25
|
+
if (!existsSync(explicit.file) || !statSync(explicit.file).isFile()) {
|
|
26
|
+
throw new LoupeConfigError([`${explicit.from}: config file not found at ${explicit.file}`]);
|
|
27
|
+
}
|
|
28
|
+
return explicit.file;
|
|
29
|
+
}
|
|
30
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
31
|
+
const file = path.join(options.baseDir, name);
|
|
32
|
+
if (existsSync(file))
|
|
33
|
+
return file;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Loads a config file synchronously and returns its default export.
|
|
39
|
+
*
|
|
40
|
+
* Synchronous on purpose: the reporter constructor is the only place where an
|
|
41
|
+
* error stops the run (Playwright does not await `onBegin`). TypeScript works
|
|
42
|
+
* because Playwright has installed its own transform (Babel) before it builds
|
|
43
|
+
* any non-built-in reporter: `require()` of a `.ts` / `.mts` file outside
|
|
44
|
+
* `node_modules` goes through it, in normal runs and in `merge-reports`. ESM
|
|
45
|
+
* output is loaded through Node's `require(esm)` (Node 22.12+), so a config
|
|
46
|
+
* file cannot use top-level `await`. See "How config files are loaded" in
|
|
47
|
+
* docs/config.md.
|
|
48
|
+
*/
|
|
49
|
+
export function loadConfigFile(file) {
|
|
50
|
+
let loaded;
|
|
51
|
+
try {
|
|
52
|
+
// A long-lived process (watch mode, UI mode) reloads CommonJS output; ES modules stay cached by Node.
|
|
53
|
+
delete require.cache[file];
|
|
54
|
+
loaded = require(file);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const code = error.code;
|
|
58
|
+
const hint = code === 'ERR_REQUIRE_ASYNC_MODULE'
|
|
59
|
+
? ' A Loupe config file cannot use top-level await.'
|
|
60
|
+
: code === 'ERR_REQUIRE_ESM'
|
|
61
|
+
? ' Loading an ES module config needs Node.js 22.12 or later.'
|
|
62
|
+
: code === 'ERR_UNKNOWN_FILE_EXTENSION'
|
|
63
|
+
? ' TypeScript config files are loaded through Playwright: use the reporter through `playwright test` or `playwright merge-reports`.'
|
|
64
|
+
: '';
|
|
65
|
+
const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
66
|
+
throw new LoupeConfigError([`${path.basename(file)}: cannot be loaded: ${message}.${hint}`]);
|
|
67
|
+
}
|
|
68
|
+
return defaultExport(loaded);
|
|
69
|
+
}
|
|
70
|
+
/** Unwraps the default export of an ES module namespace or of Babel's CommonJS interop; CommonJS exports are used as is. */
|
|
71
|
+
function defaultExport(loaded) {
|
|
72
|
+
if (loaded !== null && typeof loaded === 'object') {
|
|
73
|
+
const module = loaded;
|
|
74
|
+
if (module.__esModule === true || module[Symbol.toStringTag] === 'Module')
|
|
75
|
+
return module.default;
|
|
76
|
+
}
|
|
77
|
+
return loaded;
|
|
78
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { LoupeConfig, ResolvedConfig } from './types.js';
|
|
2
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
3
|
+
export declare const DEFAULT_OUTPUT_FOLDER = "loupe-report";
|
|
4
|
+
export declare const DEFAULT_TITLE = "Loupe report";
|
|
5
|
+
export declare const DEFAULT_UNCATEGORIZED = "Uncategorized";
|
|
6
|
+
/** Environment variables mapped onto config options. `LOUPE_CONFIG` and `LOUPE_RUN_SCOPE` are handled apart. */
|
|
7
|
+
export declare const ENV_OPTIONS: {
|
|
8
|
+
readonly LOUPE_OUTPUT_FOLDER: 'outputFolder';
|
|
9
|
+
readonly LOUPE_TITLE: 'title';
|
|
10
|
+
readonly LOUPE_ENV: 'environment';
|
|
11
|
+
};
|
|
12
|
+
/** Thrown for any invalid configuration; the message lists every problem. */
|
|
13
|
+
export declare class LoupeConfigError extends Error {
|
|
14
|
+
readonly problems: readonly string[];
|
|
15
|
+
constructor(problems: readonly string[]);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Playwright adds its own keys to every reporter's options: `configDir`, and
|
|
19
|
+
* internal ones starting with `_` (`_mode`, `_commandHash`, `_isTestServer`).
|
|
20
|
+
* They are not Loupe options and are removed before validation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function stripPlaywrightKeys(options: Record<string, unknown>): Record<string, unknown>;
|
|
23
|
+
/** The environment layer: options set through `LOUPE_*` variables. Empty variables are ignored. */
|
|
24
|
+
export declare function envLayer(env: Env): LoupeConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Merges `layer` over `base`: nested objects (`categories`, `dimensions`,
|
|
27
|
+
* `screenshots`) merge key by key; arrays and other values replace. An
|
|
28
|
+
* `undefined` value leaves the base untouched.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mergeLayers<T extends object>(base: T, layer: object): T;
|
|
31
|
+
export interface ConfigLayers {
|
|
32
|
+
/** Contents of the config file, unvalidated; undefined when there is none. */
|
|
33
|
+
file?: {
|
|
34
|
+
path: string;
|
|
35
|
+
config: unknown;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/** Inline reporter options, Playwright's own keys already removed. */
|
|
38
|
+
inline: Record<string, unknown>;
|
|
39
|
+
env: Env;
|
|
40
|
+
/** Directory relative options are resolved from: the Playwright config's directory. */
|
|
41
|
+
baseDir: string;
|
|
42
|
+
cwd: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validates each layer, then merges them: defaults < config file < inline
|
|
46
|
+
* options < environment variables. Throws a LoupeConfigError listing every
|
|
47
|
+
* problem of every layer.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveConfig(layers: ConfigLayers): ResolvedConfig;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { validateConfig } from './validate.js';
|
|
3
|
+
export const DEFAULT_OUTPUT_FOLDER = 'loupe-report';
|
|
4
|
+
export const DEFAULT_TITLE = 'Loupe report';
|
|
5
|
+
export const DEFAULT_UNCATEGORIZED = 'Uncategorized';
|
|
6
|
+
/** Environment variables mapped onto config options. `LOUPE_CONFIG` and `LOUPE_RUN_SCOPE` are handled apart. */
|
|
7
|
+
export const ENV_OPTIONS = {
|
|
8
|
+
LOUPE_OUTPUT_FOLDER: 'outputFolder',
|
|
9
|
+
LOUPE_TITLE: 'title',
|
|
10
|
+
LOUPE_ENV: 'environment',
|
|
11
|
+
};
|
|
12
|
+
/** Thrown for any invalid configuration; the message lists every problem. */
|
|
13
|
+
export class LoupeConfigError extends Error {
|
|
14
|
+
problems;
|
|
15
|
+
constructor(problems) {
|
|
16
|
+
super(`Loupe: invalid configuration.\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
17
|
+
this.problems = problems;
|
|
18
|
+
this.name = 'LoupeConfigError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Playwright adds its own keys to every reporter's options: `configDir`, and
|
|
23
|
+
* internal ones starting with `_` (`_mode`, `_commandHash`, `_isTestServer`).
|
|
24
|
+
* They are not Loupe options and are removed before validation.
|
|
25
|
+
*/
|
|
26
|
+
export function stripPlaywrightKeys(options) {
|
|
27
|
+
return Object.fromEntries(Object.entries(options).filter(([key]) => key !== 'configDir' && !key.startsWith('_')));
|
|
28
|
+
}
|
|
29
|
+
/** Non-empty value of an environment variable, trimmed; undefined otherwise. */
|
|
30
|
+
function envValue(env, name) {
|
|
31
|
+
const value = env[name]?.trim();
|
|
32
|
+
return value ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The environment layer: options set through `LOUPE_*` variables. Empty variables are ignored. */
|
|
35
|
+
export function envLayer(env) {
|
|
36
|
+
const layer = {};
|
|
37
|
+
for (const [variable, option] of Object.entries(ENV_OPTIONS)) {
|
|
38
|
+
const value = envValue(env, variable);
|
|
39
|
+
if (value !== undefined)
|
|
40
|
+
layer[option] = value;
|
|
41
|
+
}
|
|
42
|
+
return layer;
|
|
43
|
+
}
|
|
44
|
+
function isObject(value) {
|
|
45
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof RegExp);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Merges `layer` over `base`: nested objects (`categories`, `dimensions`,
|
|
49
|
+
* `screenshots`) merge key by key; arrays and other values replace. An
|
|
50
|
+
* `undefined` value leaves the base untouched.
|
|
51
|
+
*/
|
|
52
|
+
export function mergeLayers(base, layer) {
|
|
53
|
+
const result = { ...base };
|
|
54
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
const current = result[key];
|
|
58
|
+
result[key] = isObject(current) && isObject(value) ? mergeLayers(current, value) : value;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/** Copies a pattern without the stateful `g` and `y` flags, so `test()` gives the same answer every time. */
|
|
63
|
+
function stateless(pattern) {
|
|
64
|
+
return pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')) : pattern;
|
|
65
|
+
}
|
|
66
|
+
function resolveRules(config) {
|
|
67
|
+
return (config.categories?.rules ?? []).map((rule) => ({
|
|
68
|
+
name: rule.name,
|
|
69
|
+
match: (Array.isArray(rule.match) ? rule.match : [rule.match]).map(stateless),
|
|
70
|
+
on: rule.on ?? 'all',
|
|
71
|
+
color: rule.color ?? null,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
function resolveDimensions(config) {
|
|
75
|
+
return (config.dimensions?.definitions ?? []).map((definition) => definition.from === 'tag'
|
|
76
|
+
? { name: definition.name, from: 'tag', prefix: definition.prefix, values: definition.values ? [...definition.values] : null }
|
|
77
|
+
: { name: definition.name, from: 'annotation', type: definition.type, values: definition.values ? [...definition.values] : null });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Validates each layer, then merges them: defaults < config file < inline
|
|
81
|
+
* options < environment variables. Throws a LoupeConfigError listing every
|
|
82
|
+
* problem of every layer.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveConfig(layers) {
|
|
85
|
+
const fileSource = layers.file ? path.basename(layers.file.path) : '';
|
|
86
|
+
const env = envLayer(layers.env);
|
|
87
|
+
const problems = [
|
|
88
|
+
...(layers.file ? validateConfig(layers.file.config, fileSource) : []),
|
|
89
|
+
...validateConfig(layers.inline, 'reporter options', { allowConfigFile: true }),
|
|
90
|
+
];
|
|
91
|
+
if (problems.length > 0)
|
|
92
|
+
throw new LoupeConfigError(problems);
|
|
93
|
+
const { configFile: _configFile, ...inline } = layers.inline;
|
|
94
|
+
const merged = mergeLayers(mergeLayers((layers.file?.config ?? {}), inline), env);
|
|
95
|
+
const uncategorized = merged.categories?.uncategorized ?? DEFAULT_UNCATEGORIZED;
|
|
96
|
+
const rules = resolveRules(merged);
|
|
97
|
+
if (rules.some((rule) => rule.name === uncategorized)) {
|
|
98
|
+
throw new LoupeConfigError([
|
|
99
|
+
`merged config: categories.uncategorized "${uncategorized}" is also the name of a rule; use a name no rule uses`,
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
// LOUPE_OUTPUT_FOLDER is relative to the working directory, like Playwright's own *_OUTPUT_DIR variables.
|
|
103
|
+
const outputFolder = env.outputFolder !== undefined
|
|
104
|
+
? path.resolve(layers.cwd, env.outputFolder)
|
|
105
|
+
: path.resolve(layers.baseDir, merged.outputFolder ?? DEFAULT_OUTPUT_FOLDER);
|
|
106
|
+
return {
|
|
107
|
+
configFile: layers.file?.path ?? null,
|
|
108
|
+
outputFolder,
|
|
109
|
+
title: merged.title ?? DEFAULT_TITLE,
|
|
110
|
+
environment: merged.environment ?? null,
|
|
111
|
+
categories: { mode: merged.categories?.mode ?? 'extend', rules, uncategorized },
|
|
112
|
+
dimensions: { auto: merged.dimensions?.auto ?? true, definitions: resolveDimensions(merged) },
|
|
113
|
+
screenshots: { defaultView: merged.screenshots?.defaultView ?? 'side-by-side' },
|
|
114
|
+
theme: merged.theme ?? 'system',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loupe configuration, as written in `loupe.config.ts` or passed inline as
|
|
3
|
+
* reporter options. Every option is optional; see docs/config.md.
|
|
4
|
+
*/
|
|
5
|
+
export declare const CATEGORY_MODES: readonly ['extend', 'replace'];
|
|
6
|
+
export type CategoryMode = (typeof CATEGORY_MODES)[number];
|
|
7
|
+
export declare const CATEGORY_TARGETS: readonly ['message', 'stack', 'step', 'all'];
|
|
8
|
+
export type CategoryTarget = (typeof CATEGORY_TARGETS)[number];
|
|
9
|
+
export declare const SCREENSHOT_VIEWS: readonly ['side-by-side', 'slider', 'overlay', 'diff'];
|
|
10
|
+
export type ScreenshotView = (typeof SCREENSHOT_VIEWS)[number];
|
|
11
|
+
export declare const THEMES: readonly ['system', 'light', 'dark'];
|
|
12
|
+
export type Theme = (typeof THEMES)[number];
|
|
13
|
+
export interface CategoryRuleConfig {
|
|
14
|
+
/** Category name. Several rules may share a name. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** One pattern or a list; the rule matches when any pattern matches. */
|
|
17
|
+
match: RegExp | RegExp[];
|
|
18
|
+
/** What the patterns are tested against. Default: `'all'`. */
|
|
19
|
+
on?: CategoryTarget;
|
|
20
|
+
/** `#rrggbb`. Default: a color from Loupe's palette. */
|
|
21
|
+
color?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CategoriesConfig {
|
|
24
|
+
/** `extend` (default): rules run before the built-in ones. `replace`: built-in rules are dropped. */
|
|
25
|
+
mode?: CategoryMode;
|
|
26
|
+
rules?: CategoryRuleConfig[];
|
|
27
|
+
/** Name of the bucket for errors no rule matches. Default: `Uncategorized`. */
|
|
28
|
+
uncategorized?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface TagDimensionConfig {
|
|
31
|
+
name: string;
|
|
32
|
+
from: 'tag';
|
|
33
|
+
/** Tag prefix, without `@` and `:`: `layer` for `@layer:api`. */
|
|
34
|
+
prefix: string;
|
|
35
|
+
/** Expected values. */
|
|
36
|
+
values?: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface AnnotationDimensionConfig {
|
|
39
|
+
name: string;
|
|
40
|
+
from: 'annotation';
|
|
41
|
+
/** Annotation type; the annotation description is the value. */
|
|
42
|
+
type: string;
|
|
43
|
+
/** Expected values. */
|
|
44
|
+
values?: string[];
|
|
45
|
+
}
|
|
46
|
+
export type DimensionConfig = TagDimensionConfig | AnnotationDimensionConfig;
|
|
47
|
+
export interface DimensionsConfig {
|
|
48
|
+
/** Keep the automatic dimensions built from prefixed tags. Default: `true`. */
|
|
49
|
+
auto?: boolean;
|
|
50
|
+
definitions?: DimensionConfig[];
|
|
51
|
+
}
|
|
52
|
+
export interface LoupeConfig {
|
|
53
|
+
/** Folder the report is written to, relative to the Playwright config file. Default: `loupe-report`. */
|
|
54
|
+
outputFolder?: string;
|
|
55
|
+
/** Report title. Default: `Loupe report`. */
|
|
56
|
+
title?: string;
|
|
57
|
+
/** Environment name written to `run.environment.name`. `LOUPE_ENV` overrides it. */
|
|
58
|
+
environment?: string;
|
|
59
|
+
categories?: CategoriesConfig;
|
|
60
|
+
dimensions?: DimensionsConfig;
|
|
61
|
+
screenshots?: {
|
|
62
|
+
/** Default comparison view in the test detail. Default: `side-by-side`. */
|
|
63
|
+
defaultView?: ScreenshotView;
|
|
64
|
+
};
|
|
65
|
+
/** Default theme of the report. Default: `system`. */
|
|
66
|
+
theme?: Theme;
|
|
67
|
+
}
|
|
68
|
+
/** Inline reporter options: a config, plus the path of the config file. */
|
|
69
|
+
export interface LoupeReporterOptions extends LoupeConfig {
|
|
70
|
+
/** Config file to load instead of the default lookup, relative to the Playwright config file. */
|
|
71
|
+
configFile?: string;
|
|
72
|
+
}
|
|
73
|
+
/** A category rule with defaults applied; patterns never carry the `g` or `y` flag. */
|
|
74
|
+
export interface ResolvedCategoryRule {
|
|
75
|
+
name: string;
|
|
76
|
+
match: RegExp[];
|
|
77
|
+
on: CategoryTarget;
|
|
78
|
+
color: string | null;
|
|
79
|
+
}
|
|
80
|
+
export type ResolvedDimension = {
|
|
81
|
+
name: string;
|
|
82
|
+
from: 'tag';
|
|
83
|
+
prefix: string;
|
|
84
|
+
values: string[] | null;
|
|
85
|
+
} | {
|
|
86
|
+
name: string;
|
|
87
|
+
from: 'annotation';
|
|
88
|
+
type: string;
|
|
89
|
+
values: string[] | null;
|
|
90
|
+
};
|
|
91
|
+
/** The config after defaults and every layer are applied. */
|
|
92
|
+
export interface ResolvedConfig {
|
|
93
|
+
/** Absolute path of the loaded config file, null when none. */
|
|
94
|
+
configFile: string | null;
|
|
95
|
+
/** Absolute. */
|
|
96
|
+
outputFolder: string;
|
|
97
|
+
title: string;
|
|
98
|
+
environment: string | null;
|
|
99
|
+
categories: {
|
|
100
|
+
mode: CategoryMode;
|
|
101
|
+
rules: ResolvedCategoryRule[];
|
|
102
|
+
uncategorized: string;
|
|
103
|
+
};
|
|
104
|
+
dimensions: {
|
|
105
|
+
auto: boolean;
|
|
106
|
+
definitions: ResolvedDimension[];
|
|
107
|
+
};
|
|
108
|
+
screenshots: {
|
|
109
|
+
defaultView: ScreenshotView;
|
|
110
|
+
};
|
|
111
|
+
theme: Theme;
|
|
112
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loupe configuration, as written in `loupe.config.ts` or passed inline as
|
|
3
|
+
* reporter options. Every option is optional; see docs/config.md.
|
|
4
|
+
*/
|
|
5
|
+
export const CATEGORY_MODES = ['extend', 'replace'];
|
|
6
|
+
export const CATEGORY_TARGETS = ['message', 'stack', 'step', 'all'];
|
|
7
|
+
export const SCREENSHOT_VIEWS = ['side-by-side', 'slider', 'overlay', 'diff'];
|
|
8
|
+
export const THEMES = ['system', 'light', 'dark'];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Optimal string alignment distance: Levenshtein plus adjacent transpositions (`titel` → `title` is 1). */
|
|
2
|
+
export declare function editDistance(a: string, b: string): number;
|
|
3
|
+
/** The closest known key, when it is close enough to be a typo. */
|
|
4
|
+
export declare function suggestKey(key: string, known: readonly string[]): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Validates one config layer. `source` names it in messages (`loupe.config.ts`,
|
|
7
|
+
* `reporter options`). `configFile` is only accepted in inline options.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateConfig(value: unknown, source: string, options?: {
|
|
10
|
+
allowConfigFile?: boolean;
|
|
11
|
+
}): string[];
|