@m00nsolutions/playwright-reporter 1.0.4 → 1.0.6
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/README.md +7 -7
- package/index.mjs +86 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @m00nsolutions/playwright-reporter
|
|
2
2
|
|
|
3
|
-
Official Playwright test reporter for [M00n Report](https://
|
|
3
|
+
Official Playwright test reporter for [M00n Report](https://m00nreport.com) - a real-time test reporting dashboard.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ pnpm add @m00nsolutions/playwright-reporter --save-dev
|
|
|
24
24
|
|
|
25
25
|
### 1. Get your API key
|
|
26
26
|
|
|
27
|
-
1. Log in to [
|
|
27
|
+
1. Log in to [m00nreport.com](https://m00nreport.com)
|
|
28
28
|
2. Navigate to your project settings
|
|
29
29
|
3. Generate or copy your project API key
|
|
30
30
|
|
|
@@ -39,7 +39,7 @@ export default defineConfig({
|
|
|
39
39
|
reporter: [
|
|
40
40
|
['list'], // Keep default console output
|
|
41
41
|
['@m00nsolutions/playwright-reporter', {
|
|
42
|
-
serverUrl: 'https://
|
|
42
|
+
serverUrl: 'https://m00nreport.com', // Or your self-hosted URL
|
|
43
43
|
apiKey: process.env.M00N_API_KEY, // Your project API key
|
|
44
44
|
launch: 'Regression Suite', // Optional: run title
|
|
45
45
|
tags: ['smoke', 'regression'], // Optional: tags
|
|
@@ -75,7 +75,7 @@ That's it! Your test results will appear in the M00n Report dashboard in real-ti
|
|
|
75
75
|
You can also configure the reporter via environment variables:
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
M00N_SERVER_URL=https://
|
|
78
|
+
M00N_SERVER_URL=https://m00nreport.com
|
|
79
79
|
M00N_API_KEY=m00n_xxxxxxxxxxxxx
|
|
80
80
|
M00N_LAUNCH="Nightly Build"
|
|
81
81
|
M00N_TAGS=smoke,regression
|
|
@@ -88,7 +88,7 @@ M00N_TAGS=smoke,regression
|
|
|
88
88
|
```typescript
|
|
89
89
|
reporter: [
|
|
90
90
|
['@m00nsolutions/playwright-reporter', {
|
|
91
|
-
serverUrl: 'https://
|
|
91
|
+
serverUrl: 'https://m00nreport.com',
|
|
92
92
|
apiKey: process.env.M00N_API_KEY,
|
|
93
93
|
}],
|
|
94
94
|
],
|
|
@@ -132,6 +132,6 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
132
132
|
|
|
133
133
|
## Support
|
|
134
134
|
|
|
135
|
-
- 📖 [Documentation](https://
|
|
135
|
+
- 📖 [Documentation](https://m00nreport.com/documentation)
|
|
136
136
|
- 🐛 [Report Issues](https://github.com/m00nsolutions/m00nreport/issues)
|
|
137
|
-
- 💬 [Community Discord](https://discord.gg/
|
|
137
|
+
- 💬 [Community Discord](https://discord.gg/hzZvyVWS3Q)
|
package/index.mjs
CHANGED
|
@@ -38,22 +38,29 @@ import { fileURLToPath } from 'url';
|
|
|
38
38
|
// CONSTANTS
|
|
39
39
|
// ============================================================================
|
|
40
40
|
|
|
41
|
+
// Maximum attachment file size - files larger than this are skipped
|
|
42
|
+
// 200MB covers all realistic test artifacts (traces, videos, screenshots)
|
|
43
|
+
const MAX_ATTACHMENT_SIZE = 200 * 1024 * 1024; // 200MB
|
|
44
|
+
|
|
41
45
|
// Files larger than this threshold will be streamed directly to server
|
|
42
46
|
// to avoid memory issues in reporter process
|
|
43
47
|
const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10MB
|
|
44
48
|
|
|
45
49
|
// Maximum concurrent attachment uploads across ALL tests
|
|
46
50
|
// Prevents server/MinIO overload with high parallelism (30+ workers)
|
|
47
|
-
|
|
51
|
+
// Increased from 10 to 15 to reduce queue time when many tests have attachments
|
|
52
|
+
const MAX_GLOBAL_UPLOAD_CONCURRENCY = 15;
|
|
48
53
|
|
|
49
54
|
// Timeout for individual attachment uploads (ms)
|
|
50
|
-
|
|
55
|
+
// Increased from 60s to 90s to handle larger files under load
|
|
56
|
+
const UPLOAD_TIMEOUT = 90000; // 90 seconds
|
|
51
57
|
|
|
52
58
|
// Timeout for streaming large file uploads (ms)
|
|
53
59
|
const STREAM_UPLOAD_TIMEOUT = 300000; // 5 minutes
|
|
54
60
|
|
|
55
61
|
// Timeout for waiting on pending uploads at run end (ms)
|
|
56
|
-
|
|
62
|
+
// Increased from 3 to 5 minutes to handle many failing tests with videos
|
|
63
|
+
const END_UPLOAD_WAIT_TIMEOUT = 300000; // 5 minutes
|
|
57
64
|
|
|
58
65
|
// ============================================================================
|
|
59
66
|
// HELPERS
|
|
@@ -557,9 +564,9 @@ class HttpClient {
|
|
|
557
564
|
|
|
558
565
|
// Check for permanent errors (don't retry)
|
|
559
566
|
const errorData = await response.json().catch(() => ({}));
|
|
560
|
-
if (['PROJECT_NOT_FOUND', 'API_KEY_REQUIRED', 'INVALID_API_KEY'].includes(errorData.code)) {
|
|
567
|
+
if (['PROJECT_NOT_FOUND', 'API_KEY_REQUIRED', 'INVALID_API_KEY', 'RUN_ATTACHMENT_LIMIT_EXCEEDED'].includes(errorData.code)) {
|
|
561
568
|
endTiming(0);
|
|
562
|
-
return { error: errorData.code, permanent: true };
|
|
569
|
+
return { error: errorData.code, code: errorData.code, permanent: true, ...errorData };
|
|
563
570
|
}
|
|
564
571
|
|
|
565
572
|
// Transient error - retry if we have attempts left
|
|
@@ -731,6 +738,13 @@ class HttpClient {
|
|
|
731
738
|
}
|
|
732
739
|
|
|
733
740
|
const errorData = await response.json().catch(() => ({}));
|
|
741
|
+
|
|
742
|
+
// Check for permanent errors (don't retry)
|
|
743
|
+
if (['RUN_ATTACHMENT_LIMIT_EXCEEDED', 'PROJECT_NOT_FOUND', 'API_KEY_REQUIRED', 'INVALID_API_KEY'].includes(errorData.code)) {
|
|
744
|
+
endTiming(0);
|
|
745
|
+
return { error: errorData.code, code: errorData.code, permanent: true, ...errorData };
|
|
746
|
+
}
|
|
747
|
+
|
|
734
748
|
if (attempt < maxRetries) {
|
|
735
749
|
this.perfTracker?.recordRetry();
|
|
736
750
|
if (this.verbose) {
|
|
@@ -887,6 +901,13 @@ class HttpClient {
|
|
|
887
901
|
}
|
|
888
902
|
|
|
889
903
|
const errorData = await response.json().catch(() => ({}));
|
|
904
|
+
|
|
905
|
+
// Check for permanent errors (don't retry)
|
|
906
|
+
if (['RUN_ATTACHMENT_LIMIT_EXCEEDED', 'PROJECT_NOT_FOUND', 'API_KEY_REQUIRED', 'INVALID_API_KEY'].includes(errorData.code)) {
|
|
907
|
+
endTiming(0);
|
|
908
|
+
return { error: errorData.code, code: errorData.code, permanent: true, ...errorData };
|
|
909
|
+
}
|
|
910
|
+
|
|
890
911
|
if (attempt < maxRetries) {
|
|
891
912
|
this.perfTracker?.recordRetry();
|
|
892
913
|
if (this.verbose) {
|
|
@@ -1191,7 +1212,12 @@ class TestCollector {
|
|
|
1191
1212
|
// File path - check size to decide streaming vs buffering
|
|
1192
1213
|
try {
|
|
1193
1214
|
const stats = await fs.promises.stat(attachment.path);
|
|
1194
|
-
if (stats.size >
|
|
1215
|
+
if (stats.size > MAX_ATTACHMENT_SIZE) {
|
|
1216
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
|
|
1217
|
+
const limitMB = (MAX_ATTACHMENT_SIZE / 1024 / 1024).toFixed(0);
|
|
1218
|
+
console.warn(`[M00nReporter] Attachment skipped: "${name}" (${sizeMB}MB) exceeds ${limitMB}MB limit. File: ${attachment.path}`);
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1195
1221
|
|
|
1196
1222
|
if (name === 'trace') name = path.basename(attachment.path);
|
|
1197
1223
|
|
|
@@ -1237,8 +1263,15 @@ class TestCollector {
|
|
|
1237
1263
|
}
|
|
1238
1264
|
} catch (fileErr) {
|
|
1239
1265
|
// File doesn't exist or can't be read
|
|
1240
|
-
// This
|
|
1241
|
-
// temporary files from non-final retry attempts
|
|
1266
|
+
// This commonly happens with videos when:
|
|
1267
|
+
// 1. 'retain-on-failure' cleans up temporary files from non-final retry attempts
|
|
1268
|
+
// 2. Race condition where video file is deleted before reporter reads it
|
|
1269
|
+
// 3. Disk space issues during high-concurrency runs
|
|
1270
|
+
if (fileErr.code === 'ENOENT') {
|
|
1271
|
+
console.warn(`[M00nReporter] Video/attachment file not found: ${attachment.path} - file may have been cleaned up by Playwright`);
|
|
1272
|
+
} else {
|
|
1273
|
+
console.warn(`[M00nReporter] Failed to read attachment file ${attachment.path}: ${fileErr.message}`);
|
|
1274
|
+
}
|
|
1242
1275
|
return null;
|
|
1243
1276
|
}
|
|
1244
1277
|
}
|
|
@@ -1355,6 +1388,9 @@ export default class M00nReporter {
|
|
|
1355
1388
|
// Prevents server/MinIO overload with high parallelism
|
|
1356
1389
|
this.uploadSemaphore = new UploadSemaphore(MAX_GLOBAL_UPLOAD_CONCURRENCY);
|
|
1357
1390
|
|
|
1391
|
+
// Set when server returns RUN_ATTACHMENT_LIMIT_EXCEEDED -- skip remaining uploads for this run
|
|
1392
|
+
this._runLimitReached = false;
|
|
1393
|
+
|
|
1358
1394
|
// test/start is now fire-and-forget (no need to track promises)
|
|
1359
1395
|
// testId is generated client-side, so steps can be streamed immediately
|
|
1360
1396
|
|
|
@@ -1674,9 +1710,8 @@ export default class M00nReporter {
|
|
|
1674
1710
|
attachment => collector.addAttachment(attachment, collector.testId, this.runId, this.binaryAttachments)
|
|
1675
1711
|
.catch(err => {
|
|
1676
1712
|
// Don't let attachment read errors crash the test
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
}
|
|
1713
|
+
// Always log warning for attachment failures (helps diagnose missing videos)
|
|
1714
|
+
this.log('warn', `Failed to read attachment "${attachment.name || 'unknown'}": ${err.message}`);
|
|
1680
1715
|
return null;
|
|
1681
1716
|
})
|
|
1682
1717
|
);
|
|
@@ -1799,9 +1834,17 @@ export default class M00nReporter {
|
|
|
1799
1834
|
// Uses semaphore to limit concurrent uploads across ALL tests
|
|
1800
1835
|
// Returns { uploaded: N, failed: M } for tracking
|
|
1801
1836
|
async uploadAttachmentsWithBackpressure(runId, testId, attachments) {
|
|
1802
|
-
if (!attachments || attachments.length === 0) return { uploaded: 0, failed: 0 };
|
|
1837
|
+
if (!attachments || attachments.length === 0) return { uploaded: 0, failed: 0, skipped: 0 };
|
|
1838
|
+
|
|
1839
|
+
// If a previous upload hit the run attachment limit, skip all remaining uploads
|
|
1840
|
+
if (this._runLimitReached) {
|
|
1841
|
+
if (this.debug) {
|
|
1842
|
+
this.log('debug', `Skipping ${attachments.length} attachment(s) - run attachment limit already reached`);
|
|
1843
|
+
}
|
|
1844
|
+
return { uploaded: 0, failed: 0, skipped: attachments.length };
|
|
1845
|
+
}
|
|
1803
1846
|
|
|
1804
|
-
const results = { uploaded: 0, failed: 0 };
|
|
1847
|
+
const results = { uploaded: 0, failed: 0, skipped: 0 };
|
|
1805
1848
|
const uploadStart = performance.now();
|
|
1806
1849
|
|
|
1807
1850
|
// Separate small and large files
|
|
@@ -1814,6 +1857,9 @@ export default class M00nReporter {
|
|
|
1814
1857
|
// Small files - upload individually with semaphore for better parallelism
|
|
1815
1858
|
for (const file of smallFiles) {
|
|
1816
1859
|
uploadTasks.push(this.uploadSemaphore.run(async () => {
|
|
1860
|
+
// Check limit flag before each upload (may have been set by a concurrent upload)
|
|
1861
|
+
if (this._runLimitReached) return 'skipped';
|
|
1862
|
+
|
|
1817
1863
|
try {
|
|
1818
1864
|
const resp = await this.http.postMultipart(
|
|
1819
1865
|
'/api/ingest/v2/attachment/upload',
|
|
@@ -1822,6 +1868,13 @@ export default class M00nReporter {
|
|
|
1822
1868
|
{ timeout: UPLOAD_TIMEOUT }
|
|
1823
1869
|
);
|
|
1824
1870
|
|
|
1871
|
+
if (resp.code === 'RUN_ATTACHMENT_LIMIT_EXCEEDED') {
|
|
1872
|
+
this._runLimitReached = true;
|
|
1873
|
+
const usedMB = resp.currentBytes ? Math.round(resp.currentBytes / 1024 / 1024) : '?';
|
|
1874
|
+
const limitMB = resp.limitBytes ? Math.round(resp.limitBytes / 1024 / 1024) : '?';
|
|
1875
|
+
this.log('warn', `Attachment skipped: run attachment limit reached (${usedMB}/${limitMB} MB used). Remaining attachments for this run will be skipped.`);
|
|
1876
|
+
return 'skipped';
|
|
1877
|
+
}
|
|
1825
1878
|
if (resp.error) {
|
|
1826
1879
|
this.addError(`Upload failed (${file.name}): ${resp.error}`);
|
|
1827
1880
|
return false;
|
|
@@ -1837,6 +1890,9 @@ export default class M00nReporter {
|
|
|
1837
1890
|
// Large files - stream upload with semaphore
|
|
1838
1891
|
for (const largeFile of largeFiles) {
|
|
1839
1892
|
uploadTasks.push(this.uploadSemaphore.run(async () => {
|
|
1893
|
+
// Check limit flag before each upload (may have been set by a concurrent upload)
|
|
1894
|
+
if (this._runLimitReached) return 'skipped';
|
|
1895
|
+
|
|
1840
1896
|
try {
|
|
1841
1897
|
if (this.debug) {
|
|
1842
1898
|
this.log('debug', `Streaming large file: ${largeFile.name} (${(largeFile.size / 1024 / 1024).toFixed(1)}MB)`);
|
|
@@ -1851,6 +1907,13 @@ export default class M00nReporter {
|
|
|
1851
1907
|
{ timeout: STREAM_UPLOAD_TIMEOUT }
|
|
1852
1908
|
);
|
|
1853
1909
|
|
|
1910
|
+
if (resp.code === 'RUN_ATTACHMENT_LIMIT_EXCEEDED') {
|
|
1911
|
+
this._runLimitReached = true;
|
|
1912
|
+
const usedMB = resp.currentBytes ? Math.round(resp.currentBytes / 1024 / 1024) : '?';
|
|
1913
|
+
const limitMB = resp.limitBytes ? Math.round(resp.limitBytes / 1024 / 1024) : '?';
|
|
1914
|
+
this.log('warn', `Attachment skipped: run attachment limit reached (${usedMB}/${limitMB} MB used). Remaining attachments for this run will be skipped.`);
|
|
1915
|
+
return 'skipped';
|
|
1916
|
+
}
|
|
1854
1917
|
if (resp.error) {
|
|
1855
1918
|
this.addError(`Stream upload failed (${largeFile.name}): ${resp.error}`);
|
|
1856
1919
|
return false;
|
|
@@ -1870,6 +1933,8 @@ export default class M00nReporter {
|
|
|
1870
1933
|
for (const result of uploadResults) {
|
|
1871
1934
|
if (result.status === 'fulfilled' && result.value === true) {
|
|
1872
1935
|
results.uploaded++;
|
|
1936
|
+
} else if (result.status === 'fulfilled' && result.value === 'skipped') {
|
|
1937
|
+
results.skipped++;
|
|
1873
1938
|
} else {
|
|
1874
1939
|
results.failed++;
|
|
1875
1940
|
}
|
|
@@ -1878,7 +1943,9 @@ export default class M00nReporter {
|
|
|
1878
1943
|
const uploadDuration = performance.now() - uploadStart;
|
|
1879
1944
|
|
|
1880
1945
|
if (this.verbose) {
|
|
1881
|
-
|
|
1946
|
+
const parts = [`${results.uploaded}/${attachments.length} in ${Math.round(uploadDuration)}ms`];
|
|
1947
|
+
if (results.skipped > 0) parts.push(`${results.skipped} skipped (limit reached)`);
|
|
1948
|
+
this.log('perf', `Attachments uploaded: ${parts.join(', ')}`);
|
|
1882
1949
|
}
|
|
1883
1950
|
|
|
1884
1951
|
// Log to file
|
|
@@ -1887,6 +1954,8 @@ export default class M00nReporter {
|
|
|
1887
1954
|
total: attachments.length,
|
|
1888
1955
|
uploaded: results.uploaded,
|
|
1889
1956
|
failed: results.failed,
|
|
1957
|
+
skipped: results.skipped,
|
|
1958
|
+
limitReached: this._runLimitReached,
|
|
1890
1959
|
duration: Math.round(uploadDuration),
|
|
1891
1960
|
});
|
|
1892
1961
|
|
|
@@ -1988,6 +2057,9 @@ export default class M00nReporter {
|
|
|
1988
2057
|
const attachmentWaitDuration = Math.round(performance.now() - attachmentWaitStart);
|
|
1989
2058
|
|
|
1990
2059
|
if (uploadWaitResult === 'timeout') {
|
|
2060
|
+
const timeoutMinutes = Math.round(END_UPLOAD_WAIT_TIMEOUT / 60000);
|
|
2061
|
+
this.log('warn', `Timeout waiting for attachment uploads after ${timeoutMinutes} minutes - some attachments (videos/traces) may be missing!`);
|
|
2062
|
+
this.log('warn', `Tip: If this happens often, consider reducing video size or increasing END_UPLOAD_WAIT_TIMEOUT`);
|
|
1991
2063
|
this.addError(`Timeout waiting for attachment uploads after ${END_UPLOAD_WAIT_TIMEOUT/1000}s - some attachments may be missing`);
|
|
1992
2064
|
this.fileLogger?.event('ATTACHMENTS_WAIT_TIMEOUT', {
|
|
1993
2065
|
duration: attachmentWaitDuration,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m00nsolutions/playwright-reporter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Playwright test reporter for M00n Report dashboard - real-time test result streaming with step tracking, attachments, and retry support",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"url": "https://github.com/m00nsolutions/m00nreport.git",
|
|
43
43
|
"directory": "packages/m00n-playwright-reporter"
|
|
44
44
|
},
|
|
45
|
-
"homepage": "https://
|
|
45
|
+
"homepage": "https://m00nreport.com",
|
|
46
46
|
"bugs": {
|
|
47
47
|
"url": "https://github.com/m00nsolutions/m00nreport/issues"
|
|
48
48
|
},
|