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.
Files changed (44) hide show
  1. package/.changeset/README.md +9 -0
  2. package/.changeset/config.json +11 -0
  3. package/README.md +90 -0
  4. package/package.json +26 -0
  5. package/packages/playwright-config/CHANGELOG.md +21 -0
  6. package/packages/playwright-config/README.md +22 -0
  7. package/packages/playwright-config/package.json +47 -0
  8. package/packages/playwright-config/src/index.ts +21 -0
  9. package/packages/playwright-config/tsconfig.json +19 -0
  10. package/packages/playwright-history-dashboard/CHANGELOG.md +21 -0
  11. package/packages/playwright-history-dashboard/README.md +216 -0
  12. package/packages/playwright-history-dashboard/RELEASING.md +249 -0
  13. package/packages/playwright-history-dashboard/dashboard/index.html +2825 -0
  14. package/packages/playwright-history-dashboard/package-lock.json +105 -0
  15. package/packages/playwright-history-dashboard/package.json +56 -0
  16. package/packages/playwright-history-dashboard/pw-dashboard.config.js +22 -0
  17. package/packages/playwright-history-dashboard/scripts/init.ts +95 -0
  18. package/packages/playwright-history-dashboard/src/reporter.ts +376 -0
  19. package/packages/playwright-history-dashboard/tsconfig.json +19 -0
  20. package/packages/pw-standard/.eslintrc.js +23 -0
  21. package/packages/pw-standard/CHANGELOG.md +31 -0
  22. package/packages/pw-standard/README.md +50 -0
  23. package/packages/pw-standard/jest.config.js +28 -0
  24. package/packages/pw-standard/package.json +86 -0
  25. package/packages/pw-standard/src/base/index.ts +19 -0
  26. package/packages/pw-standard/src/eslint/index.ts +91 -0
  27. package/packages/pw-standard/src/eslint/rules/no-brittle-selectors.ts +53 -0
  28. package/packages/pw-standard/src/eslint/rules/no-focused-tests.ts +61 -0
  29. package/packages/pw-standard/src/eslint/rules/no-page-pause.ts +37 -0
  30. package/packages/pw-standard/src/eslint/rules/no-wait-for-timeout.ts +34 -0
  31. package/packages/pw-standard/src/eslint/rules/prefer-web-first-assertions.ts +90 -0
  32. package/packages/pw-standard/src/eslint/rules/require-test-description.ts +159 -0
  33. package/packages/pw-standard/src/eslint/types.ts +20 -0
  34. package/packages/pw-standard/src/eslint/utils/ast.ts +59 -0
  35. package/packages/pw-standard/src/index.ts +13 -0
  36. package/packages/pw-standard/src/playwright/index.ts +6 -0
  37. package/packages/pw-standard/src/tsconfig/base.json +21 -0
  38. package/packages/pw-standard/src/tsconfig/strict.json +11 -0
  39. package/packages/pw-standard/tests/eslint/no-brittle-selectors.test.ts +34 -0
  40. package/packages/pw-standard/tests/eslint/no-page-pause-and-focused.test.ts +41 -0
  41. package/packages/pw-standard/tests/eslint/no-wait-for-timeout.test.ts +30 -0
  42. package/packages/pw-standard/tests/eslint/prefer-web-first-assertions.test.ts +25 -0
  43. package/packages/pw-standard/tests/eslint/require-test-description.test.ts +49 -0
  44. 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
+ };