playwright-toolbox 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/README.md +9 -0
- package/.changeset/config.json +11 -0
- package/README.md +90 -0
- package/package.json +26 -0
- package/packages/playwright-config/CHANGELOG.md +21 -0
- package/packages/playwright-config/README.md +22 -0
- package/packages/playwright-config/package.json +47 -0
- package/packages/playwright-config/src/index.ts +21 -0
- package/packages/playwright-config/tsconfig.json +19 -0
- package/packages/playwright-history-dashboard/CHANGELOG.md +21 -0
- package/packages/playwright-history-dashboard/README.md +216 -0
- package/packages/playwright-history-dashboard/RELEASING.md +249 -0
- package/packages/playwright-history-dashboard/dashboard/index.html +2825 -0
- package/packages/playwright-history-dashboard/package-lock.json +105 -0
- package/packages/playwright-history-dashboard/package.json +56 -0
- package/packages/playwright-history-dashboard/pw-dashboard.config.js +22 -0
- package/packages/playwright-history-dashboard/scripts/init.ts +95 -0
- package/packages/playwright-history-dashboard/src/reporter.ts +376 -0
- package/packages/playwright-history-dashboard/tsconfig.json +19 -0
- package/packages/pw-standard/.eslintrc.js +23 -0
- package/packages/pw-standard/CHANGELOG.md +31 -0
- package/packages/pw-standard/README.md +50 -0
- package/packages/pw-standard/jest.config.js +28 -0
- package/packages/pw-standard/package.json +86 -0
- package/packages/pw-standard/src/base/index.ts +19 -0
- package/packages/pw-standard/src/eslint/index.ts +91 -0
- package/packages/pw-standard/src/eslint/rules/no-brittle-selectors.ts +53 -0
- package/packages/pw-standard/src/eslint/rules/no-focused-tests.ts +61 -0
- package/packages/pw-standard/src/eslint/rules/no-page-pause.ts +37 -0
- package/packages/pw-standard/src/eslint/rules/no-wait-for-timeout.ts +34 -0
- package/packages/pw-standard/src/eslint/rules/prefer-web-first-assertions.ts +90 -0
- package/packages/pw-standard/src/eslint/rules/require-test-description.ts +159 -0
- package/packages/pw-standard/src/eslint/types.ts +20 -0
- package/packages/pw-standard/src/eslint/utils/ast.ts +59 -0
- package/packages/pw-standard/src/index.ts +13 -0
- package/packages/pw-standard/src/playwright/index.ts +6 -0
- package/packages/pw-standard/src/tsconfig/base.json +21 -0
- package/packages/pw-standard/src/tsconfig/strict.json +11 -0
- package/packages/pw-standard/tests/eslint/no-brittle-selectors.test.ts +34 -0
- package/packages/pw-standard/tests/eslint/no-page-pause-and-focused.test.ts +41 -0
- package/packages/pw-standard/tests/eslint/no-wait-for-timeout.test.ts +30 -0
- package/packages/pw-standard/tests/eslint/prefer-web-first-assertions.test.ts +25 -0
- package/packages/pw-standard/tests/eslint/require-test-description.test.ts +49 -0
- package/packages/pw-standard/tsconfig.json +24 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acahet/playwright-history-dashboard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "@acahet/playwright-history-dashboard",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"bin": {
|
|
12
|
+
"pw-history-init": "dist/scripts/init.js"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@playwright/test": "^1.50.0",
|
|
16
|
+
"typescript": "^5.4.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@playwright/test": ">=1.40.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"@playwright/test": {
|
|
23
|
+
"optional": false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"node_modules/@playwright/test": {
|
|
28
|
+
"version": "1.58.2",
|
|
29
|
+
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
|
30
|
+
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
|
31
|
+
"dev": true,
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"playwright": "1.58.2"
|
|
35
|
+
},
|
|
36
|
+
"bin": {
|
|
37
|
+
"playwright": "cli.js"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"node_modules/fsevents": {
|
|
44
|
+
"version": "2.3.2",
|
|
45
|
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
46
|
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
47
|
+
"dev": true,
|
|
48
|
+
"hasInstallScript": true,
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"optional": true,
|
|
51
|
+
"os": [
|
|
52
|
+
"darwin"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"node_modules/playwright": {
|
|
59
|
+
"version": "1.58.2",
|
|
60
|
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
|
61
|
+
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
|
62
|
+
"dev": true,
|
|
63
|
+
"license": "Apache-2.0",
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"playwright-core": "1.58.2"
|
|
66
|
+
},
|
|
67
|
+
"bin": {
|
|
68
|
+
"playwright": "cli.js"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=18"
|
|
72
|
+
},
|
|
73
|
+
"optionalDependencies": {
|
|
74
|
+
"fsevents": "2.3.2"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"node_modules/playwright-core": {
|
|
78
|
+
"version": "1.58.2",
|
|
79
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
|
80
|
+
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
|
81
|
+
"dev": true,
|
|
82
|
+
"license": "Apache-2.0",
|
|
83
|
+
"bin": {
|
|
84
|
+
"playwright-core": "cli.js"
|
|
85
|
+
},
|
|
86
|
+
"engines": {
|
|
87
|
+
"node": ">=18"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"node_modules/typescript": {
|
|
91
|
+
"version": "5.9.3",
|
|
92
|
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
93
|
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
94
|
+
"dev": true,
|
|
95
|
+
"license": "Apache-2.0",
|
|
96
|
+
"bin": {
|
|
97
|
+
"tsc": "bin/tsc",
|
|
98
|
+
"tsserver": "bin/tsserver"
|
|
99
|
+
},
|
|
100
|
+
"engines": {
|
|
101
|
+
"node": ">=14.17"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acahet/playwright-history-dashboard",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Self-hosted Playwright test history dashboard — custom reporter + single-file HTML dashboard. No SaaS, no subscriptions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"playwright",
|
|
7
|
+
"playwright-reporter",
|
|
8
|
+
"test-history",
|
|
9
|
+
"test-automation",
|
|
10
|
+
"dashboard",
|
|
11
|
+
"testing",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/acahet-automation-org/playwright-toolbox#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/acahet-automation-org/playwright-toolbox/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/acahet-automation-org/playwright-toolbox.git",
|
|
21
|
+
"directory": "packages/playwright-history-dashboard"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "acahet",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
"./reporter": {
|
|
28
|
+
"import": "./dist/src/reporter.js",
|
|
29
|
+
"types": "./dist/src/reporter.d.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"pw-history-init": "./dist/scripts/init.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/",
|
|
37
|
+
"dashboard/"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --project tsconfig.json",
|
|
41
|
+
"build:watch": "tsc --project tsconfig.json --watch",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@playwright/test": ">=1.40.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@playwright/test": {
|
|
49
|
+
"optional": false
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@playwright/test": "^1.50.0",
|
|
54
|
+
"typescript": "^5.4.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// pw-dashboard.config.js
|
|
2
|
+
// Copy this file to your project root and rename it to pw-dashboard.config.js
|
|
3
|
+
// Then run: npx pw-history-init
|
|
4
|
+
//
|
|
5
|
+
// All fields are optional — omit any you don't want to override.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
// Your project name — shown in the topbar and footer
|
|
9
|
+
projectName: 'My Project',
|
|
10
|
+
|
|
11
|
+
// Top-left brand label
|
|
12
|
+
brandName: 'pw_dashboard',
|
|
13
|
+
|
|
14
|
+
// Browser tab title
|
|
15
|
+
pageTitle: 'Test History Dashboard',
|
|
16
|
+
|
|
17
|
+
// Where the reporter writes history (must match historyDir in playwright.config.ts)
|
|
18
|
+
historyDir: 'tests/report/test-history',
|
|
19
|
+
|
|
20
|
+
// Path to history-index.json relative to index.html (rarely needs changing)
|
|
21
|
+
historyIndexPath: 'history-index.json',
|
|
22
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
interface DashboardConfig {
|
|
5
|
+
projectName?: string;
|
|
6
|
+
brandName?: string;
|
|
7
|
+
pageTitle?: string;
|
|
8
|
+
historyIndexPath?: string;
|
|
9
|
+
emptyTitle?: string;
|
|
10
|
+
emptyMessage?: string;
|
|
11
|
+
errorTitle?: string;
|
|
12
|
+
errorMessage?: string;
|
|
13
|
+
historyDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Defaults — used when the consumer hasn't overridden a value
|
|
17
|
+
const DEFAULTS: Required<DashboardConfig> = {
|
|
18
|
+
projectName: '',
|
|
19
|
+
brandName: 'pw_dashboard',
|
|
20
|
+
pageTitle: 'Test History Dashboard',
|
|
21
|
+
historyIndexPath: 'history-index.json',
|
|
22
|
+
emptyTitle: 'No test history yet',
|
|
23
|
+
emptyMessage: 'Run your tests to start tracking history.',
|
|
24
|
+
errorTitle: 'Could not load history',
|
|
25
|
+
errorMessage: 'Make sure history-index.json is in the same directory.',
|
|
26
|
+
historyDir: 'tests/report/test-history',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function loadConsumerConfig(): DashboardConfig {
|
|
30
|
+
const configPath = path.resolve(process.cwd(), 'pw-dashboard.config.js');
|
|
31
|
+
if (fs.existsSync(configPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const cfg = require(configPath) as DashboardConfig;
|
|
34
|
+
console.log(
|
|
35
|
+
`\nFound pw-dashboard.config.js — applying your settings.`,
|
|
36
|
+
);
|
|
37
|
+
return cfg;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`\nWarning: could not read pw-dashboard.config.js — using defaults.\n${String(err)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function injectConfig(html: string, cfg: Required<DashboardConfig>): string {
|
|
48
|
+
const configBlock = `const CONFIG = {
|
|
49
|
+
pageTitle: ${JSON.stringify(cfg.pageTitle)},
|
|
50
|
+
projectName: ${JSON.stringify(cfg.projectName)},
|
|
51
|
+
brandName: ${JSON.stringify(cfg.brandName)},
|
|
52
|
+
historyIndexPath: ${JSON.stringify(cfg.historyIndexPath)},
|
|
53
|
+
emptyTitle: ${JSON.stringify(cfg.emptyTitle)},
|
|
54
|
+
emptyMessage: ${JSON.stringify(cfg.emptyMessage)},
|
|
55
|
+
errorTitle: ${JSON.stringify(cfg.errorTitle)},
|
|
56
|
+
errorMessage: ${JSON.stringify(cfg.errorMessage)},
|
|
57
|
+
};`;
|
|
58
|
+
|
|
59
|
+
// Replace the entire CONFIG block between the banner comments
|
|
60
|
+
return html.replace(
|
|
61
|
+
/\/\/ ─+\n\/\/ CONFIG[\s\S]*?\/\/ ─+/,
|
|
62
|
+
`// ─────────────────────────────────────────────────────────────────────────────\n// CONFIG — generated by pw-history-init · edit pw-dashboard.config.js to change\n// ─────────────────────────────────────────────────────────────────────────────\n${configBlock}\n// ─────────────────────────────────────────────────────────────────────────────`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function main() {
|
|
67
|
+
const consumerCfg = loadConsumerConfig();
|
|
68
|
+
const cfg: Required<DashboardConfig> = { ...DEFAULTS, ...consumerCfg };
|
|
69
|
+
|
|
70
|
+
const src = path.resolve(__dirname, '../dashboard/index.html');
|
|
71
|
+
const destDir = path.resolve(process.cwd(), cfg.historyDir);
|
|
72
|
+
const dest = path.join(destDir, 'index.html');
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(destDir)) {
|
|
75
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const template = fs.readFileSync(src, 'utf-8');
|
|
79
|
+
const output = injectConfig(template, cfg);
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(dest, output, 'utf-8');
|
|
82
|
+
|
|
83
|
+
console.log(`\n✓ Dashboard written to:\n ${dest}\n`);
|
|
84
|
+
if (!consumerCfg.projectName) {
|
|
85
|
+
console.log(
|
|
86
|
+
`Tip: create pw-dashboard.config.js in your project root to set your project name:\n`,
|
|
87
|
+
);
|
|
88
|
+
console.log(` module.exports = {`);
|
|
89
|
+
console.log(` projectName: 'Your Project Name',`);
|
|
90
|
+
console.log(` };\n`);
|
|
91
|
+
}
|
|
92
|
+
console.log(`Serve with: npx serve ${cfg.historyDir}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main();
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
FullConfig,
|
|
5
|
+
FullResult,
|
|
6
|
+
Reporter,
|
|
7
|
+
Suite,
|
|
8
|
+
TestCase,
|
|
9
|
+
TestResult,
|
|
10
|
+
} from '@playwright/test/reporter';
|
|
11
|
+
|
|
12
|
+
interface Annotation {
|
|
13
|
+
type: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TestInfo {
|
|
18
|
+
title: string;
|
|
19
|
+
file: string;
|
|
20
|
+
project: string;
|
|
21
|
+
duration: number;
|
|
22
|
+
status: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
annotations?: Annotation[];
|
|
26
|
+
artifacts: {
|
|
27
|
+
trace?: string;
|
|
28
|
+
screenshot?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TestHistoryEntry {
|
|
33
|
+
runId: string;
|
|
34
|
+
timestamp: string;
|
|
35
|
+
duration: number;
|
|
36
|
+
totalTests: number;
|
|
37
|
+
passed: number;
|
|
38
|
+
failed: number;
|
|
39
|
+
skipped: number;
|
|
40
|
+
flaky: number;
|
|
41
|
+
allTests: TestInfo[];
|
|
42
|
+
failedTests: TestInfo[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface HistoryIndex {
|
|
46
|
+
runs: TestHistoryEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// How many runs to keep in the index AND on disk — overridable via options
|
|
50
|
+
const DEFAULT_MAX_RUNS = 30;
|
|
51
|
+
|
|
52
|
+
interface ReporterOptions {
|
|
53
|
+
historyDir?: string;
|
|
54
|
+
maxRuns?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class LocalHistoryReporter implements Reporter {
|
|
58
|
+
private config!: FullConfig;
|
|
59
|
+
private suite!: Suite;
|
|
60
|
+
private startTime!: number;
|
|
61
|
+
private historyDir: string;
|
|
62
|
+
private historyIndexFile: string;
|
|
63
|
+
private maxRuns: number;
|
|
64
|
+
private currentRunId!: string;
|
|
65
|
+
private currentRunDir!: string;
|
|
66
|
+
|
|
67
|
+
constructor(options: ReporterOptions = {}) {
|
|
68
|
+
this.historyDir = options.historyDir ?? './tests/report/test-history';
|
|
69
|
+
this.maxRuns = options.maxRuns ?? DEFAULT_MAX_RUNS;
|
|
70
|
+
this.historyIndexFile = `${this.historyDir}/history-index.json`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async onBegin(config: FullConfig, suite: Suite) {
|
|
74
|
+
this.config = config;
|
|
75
|
+
this.suite = suite;
|
|
76
|
+
this.startTime = Date.now();
|
|
77
|
+
this.currentRunId = new Date().toISOString().replace(/[:.]/g, '-');
|
|
78
|
+
this.currentRunDir = `${this.historyDir}/runs/${this.currentRunId}`;
|
|
79
|
+
|
|
80
|
+
await fs.mkdir(this.currentRunDir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async onEnd(_result: FullResult) {
|
|
84
|
+
try {
|
|
85
|
+
const duration = Date.now() - this.startTime;
|
|
86
|
+
const stats = this.collectTestStats(this.suite);
|
|
87
|
+
|
|
88
|
+
await this.copyFailureArtifacts(stats.failedTests);
|
|
89
|
+
|
|
90
|
+
const historyEntry: TestHistoryEntry = {
|
|
91
|
+
runId: this.currentRunId,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
duration,
|
|
94
|
+
totalTests: stats.total,
|
|
95
|
+
passed: stats.passed,
|
|
96
|
+
failed: stats.failed,
|
|
97
|
+
skipped: stats.skipped,
|
|
98
|
+
flaky: stats.flaky,
|
|
99
|
+
allTests: stats.allTests,
|
|
100
|
+
failedTests: stats.failedTests,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await this.updateHistoryIndex(historyEntry);
|
|
104
|
+
|
|
105
|
+
// Cleanup is driven by the index: runs evicted from the index get their
|
|
106
|
+
// directories deleted. No separate time-based policy — one source of truth.
|
|
107
|
+
await this.cleanupOrphanedRunDirs();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
process.stderr.write(
|
|
110
|
+
`[LocalHistoryReporter] Failed to save history: ${String(err)}\n`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Stats collection ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
private collectTestStats(suite: Suite) {
|
|
118
|
+
let total = 0;
|
|
119
|
+
let passed = 0;
|
|
120
|
+
let failed = 0;
|
|
121
|
+
let skipped = 0;
|
|
122
|
+
let flaky = 0;
|
|
123
|
+
const allTests: TestInfo[] = [];
|
|
124
|
+
const failedTests: TestInfo[] = [];
|
|
125
|
+
|
|
126
|
+
const processSuite = (s: Suite) => {
|
|
127
|
+
for (const test of s.tests) {
|
|
128
|
+
// Skip setup project tests — they're infrastructure, not results
|
|
129
|
+
if (test.parent?.project()?.name === 'setup') {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
total++;
|
|
134
|
+
|
|
135
|
+
const results = test.results;
|
|
136
|
+
|
|
137
|
+
if (results.length === 0) {
|
|
138
|
+
skipped++;
|
|
139
|
+
allTests.push(this.createTestEntry(test, null, 'skipped'));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lastResult = results[results.length - 1];
|
|
144
|
+
|
|
145
|
+
if (lastResult.status === 'skipped') {
|
|
146
|
+
skipped++;
|
|
147
|
+
allTests.push(
|
|
148
|
+
this.createTestEntry(test, lastResult, 'skipped'),
|
|
149
|
+
);
|
|
150
|
+
} else if (lastResult.status === 'passed') {
|
|
151
|
+
// Flaky = passed on a retry (at least one earlier attempt failed)
|
|
152
|
+
const isFlaky =
|
|
153
|
+
results.length > 1 &&
|
|
154
|
+
results.some((r) => r.status !== 'passed');
|
|
155
|
+
if (isFlaky) {
|
|
156
|
+
flaky++;
|
|
157
|
+
}
|
|
158
|
+
passed++;
|
|
159
|
+
allTests.push(
|
|
160
|
+
this.createTestEntry(
|
|
161
|
+
test,
|
|
162
|
+
lastResult,
|
|
163
|
+
isFlaky ? 'flaky' : 'passed',
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
failed++;
|
|
168
|
+
const entry = this.createTestEntry(
|
|
169
|
+
test,
|
|
170
|
+
lastResult,
|
|
171
|
+
'failed',
|
|
172
|
+
);
|
|
173
|
+
failedTests.push(entry);
|
|
174
|
+
allTests.push(entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const child of s.suites) {
|
|
179
|
+
processSuite(child);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
processSuite(suite);
|
|
184
|
+
|
|
185
|
+
return { total, passed, failed, skipped, flaky, allTests, failedTests };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private createTestEntry(
|
|
189
|
+
test: TestCase,
|
|
190
|
+
result: TestResult | null,
|
|
191
|
+
status: string,
|
|
192
|
+
): TestInfo {
|
|
193
|
+
return {
|
|
194
|
+
title: test.title,
|
|
195
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
196
|
+
project: test.parent?.project()?.name ?? 'unknown',
|
|
197
|
+
duration: result?.duration ?? 0,
|
|
198
|
+
status,
|
|
199
|
+
error: result?.error?.message,
|
|
200
|
+
tags: test.tags.length > 0 ? test.tags : undefined,
|
|
201
|
+
// Playwright annotations: test.info().annotations or test.annotations
|
|
202
|
+
annotations:
|
|
203
|
+
test.annotations.length > 0 ? test.annotations : undefined,
|
|
204
|
+
artifacts: {},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Artifact copying ────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Copies trace and screenshot for each failed test by looking inside the
|
|
212
|
+
* test-specific output folder Playwright creates, rather than scanning the
|
|
213
|
+
* entire project outputDir. This prevents one test from overwriting another's
|
|
214
|
+
* artifacts when multiple tests fail in the same project.
|
|
215
|
+
*
|
|
216
|
+
* Playwright names the folder:
|
|
217
|
+
* test-results/<sanitized-test-title>-<project>/
|
|
218
|
+
*/
|
|
219
|
+
private async copyFailureArtifacts(failedTests: TestInfo[]) {
|
|
220
|
+
for (const failedTest of failedTests) {
|
|
221
|
+
try {
|
|
222
|
+
const testOutputDir = this.resolveTestOutputDir(failedTest);
|
|
223
|
+
|
|
224
|
+
let files: string[];
|
|
225
|
+
try {
|
|
226
|
+
const entries = await fs.readdir(testOutputDir, {
|
|
227
|
+
recursive: true,
|
|
228
|
+
});
|
|
229
|
+
files = entries.map((e) => e.toString());
|
|
230
|
+
} catch {
|
|
231
|
+
// Test output directory doesn't exist — no artifacts to copy
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
const srcPath = path.join(testOutputDir, file);
|
|
237
|
+
|
|
238
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
|
239
|
+
try {
|
|
240
|
+
stat = await fs.stat(srcPath);
|
|
241
|
+
} catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!stat.isFile()) continue;
|
|
245
|
+
|
|
246
|
+
const baseName = this.sanitizeFilename(failedTest.title);
|
|
247
|
+
|
|
248
|
+
if (file.endsWith('trace.zip')) {
|
|
249
|
+
const destPath = path.join(
|
|
250
|
+
this.currentRunDir,
|
|
251
|
+
`${baseName}-trace.zip`,
|
|
252
|
+
);
|
|
253
|
+
await fs.copyFile(srcPath, destPath);
|
|
254
|
+
failedTest.artifacts.trace = path.relative(
|
|
255
|
+
this.historyDir,
|
|
256
|
+
destPath,
|
|
257
|
+
);
|
|
258
|
+
} else if (
|
|
259
|
+
/\.(png|jpg|jpeg)$/i.test(file) &&
|
|
260
|
+
file.includes('screenshot')
|
|
261
|
+
) {
|
|
262
|
+
const destPath = path.join(
|
|
263
|
+
this.currentRunDir,
|
|
264
|
+
`${baseName}-screenshot.png`,
|
|
265
|
+
);
|
|
266
|
+
await fs.copyFile(srcPath, destPath);
|
|
267
|
+
failedTest.artifacts.screenshot = path.relative(
|
|
268
|
+
this.historyDir,
|
|
269
|
+
destPath,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
process.stderr.write(
|
|
275
|
+
`[LocalHistoryReporter] Failed to copy artifacts for "${failedTest.title}": ${String(err)}\n`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolves the Playwright-generated output folder for a specific test.
|
|
283
|
+
* Playwright convention: test-results/{title-slug}-{project}/
|
|
284
|
+
* Falls back to the project's configured outputDir if no match is found.
|
|
285
|
+
*/
|
|
286
|
+
private resolveTestOutputDir(test: TestInfo): string {
|
|
287
|
+
const slug = test.title
|
|
288
|
+
.replace(/\W+/g, '-')
|
|
289
|
+
.replace(/^-+|-+$/g, '')
|
|
290
|
+
.toLowerCase();
|
|
291
|
+
|
|
292
|
+
const projectSlug = test.project.toLowerCase();
|
|
293
|
+
|
|
294
|
+
// Standard Playwright output dir layout
|
|
295
|
+
return path.join('test-results', `${slug}-${projectSlug}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private sanitizeFilename(filename: string): string {
|
|
299
|
+
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── History index management ────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
private async updateHistoryIndex(entry: TestHistoryEntry) {
|
|
305
|
+
let historyIndex: HistoryIndex;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const content = await fs.readFile(this.historyIndexFile, 'utf-8');
|
|
309
|
+
historyIndex = JSON.parse(content) as HistoryIndex;
|
|
310
|
+
} catch {
|
|
311
|
+
historyIndex = { runs: [] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
historyIndex.runs.unshift(entry);
|
|
315
|
+
|
|
316
|
+
// Evict runs beyond the cap — collect their IDs before trimming
|
|
317
|
+
const evictedRunIds = historyIndex.runs
|
|
318
|
+
.slice(this.maxRuns)
|
|
319
|
+
.map((r) => r.runId);
|
|
320
|
+
|
|
321
|
+
historyIndex.runs = historyIndex.runs.slice(0, this.maxRuns);
|
|
322
|
+
|
|
323
|
+
await fs.writeFile(
|
|
324
|
+
this.historyIndexFile,
|
|
325
|
+
JSON.stringify(historyIndex, null, 2),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Delete directories for evicted runs immediately — index and disk stay in sync
|
|
329
|
+
for (const runId of evictedRunIds) {
|
|
330
|
+
await this.deleteRunDir(runId);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Removes run directories that exist on disk but are no longer referenced
|
|
336
|
+
* by the index. Guards against orphans left by earlier bugs or manual edits.
|
|
337
|
+
*/
|
|
338
|
+
private async cleanupOrphanedRunDirs() {
|
|
339
|
+
const runsBaseDir = path.join(this.historyDir, 'runs');
|
|
340
|
+
|
|
341
|
+
let existingDirs: string[];
|
|
342
|
+
try {
|
|
343
|
+
existingDirs = await fs.readdir(runsBaseDir);
|
|
344
|
+
} catch {
|
|
345
|
+
return; // runs/ dir doesn't exist yet — nothing to clean
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let indexedRunIds: Set<string>;
|
|
349
|
+
try {
|
|
350
|
+
const content = await fs.readFile(this.historyIndexFile, 'utf-8');
|
|
351
|
+
const index = JSON.parse(content) as HistoryIndex;
|
|
352
|
+
indexedRunIds = new Set(index.runs.map((r) => r.runId));
|
|
353
|
+
} catch {
|
|
354
|
+
return; // can't read index — don't delete anything
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const dir of existingDirs) {
|
|
358
|
+
if (!indexedRunIds.has(dir)) {
|
|
359
|
+
await this.deleteRunDir(dir);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async deleteRunDir(runId: string) {
|
|
365
|
+
const runDir = path.join(this.historyDir, 'runs', runId);
|
|
366
|
+
try {
|
|
367
|
+
await fs.rm(runDir, { recursive: true, force: true });
|
|
368
|
+
} catch (err) {
|
|
369
|
+
process.stderr.write(
|
|
370
|
+
`[LocalHistoryReporter] Failed to delete run dir "${runDir}": ${String(err)}\n`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export default LocalHistoryReporter;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2020"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*", "scripts/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @type {import('eslint').Linter.Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
root: true,
|
|
4
|
+
parser: '@typescript-eslint/parser',
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 2020,
|
|
7
|
+
sourceType: 'module',
|
|
8
|
+
project: './tsconfig.json',
|
|
9
|
+
},
|
|
10
|
+
plugins: ['@typescript-eslint'],
|
|
11
|
+
extends: [
|
|
12
|
+
'eslint:recommended',
|
|
13
|
+
'plugin:@typescript-eslint/recommended',
|
|
14
|
+
],
|
|
15
|
+
env: {
|
|
16
|
+
node: true,
|
|
17
|
+
es2020: true,
|
|
18
|
+
},
|
|
19
|
+
rules: {
|
|
20
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
21
|
+
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
22
|
+
},
|
|
23
|
+
};
|