playwright-slack-report-burak 3.0.112 → 3.1.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/dist/src/LayoutGenerator.js +7 -28
- package/dist/src/ResultsParser.js +322 -248
- package/dist/src/SlackReporter.js +6 -297
- package/package.json +3 -2
|
@@ -8,24 +8,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.generateAllRunSuites =
|
|
9
9
|
void 0;
|
|
10
10
|
|
|
11
|
-
// Helper function to get build URL and CI platform name
|
|
12
|
-
const getBuildInfo = () => {
|
|
13
|
-
if (process.env.GITHUB_ACTIONS || process.env.SAFETYWINGTEST_GITHUB_TOKEN) {
|
|
14
|
-
const repo = process.env.GITHUB_REPOSITORY;
|
|
15
|
-
const runId = process.env.GITHUB_RUN_ID;
|
|
16
|
-
const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com';
|
|
17
|
-
const buildUrl = `${serverUrl}/${repo}/actions/runs/${runId}`;
|
|
18
|
-
return {
|
|
19
|
-
url: buildUrl,
|
|
20
|
-
platform: 'GitHub Actions'
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
return {
|
|
24
|
-
url: '#',
|
|
25
|
-
platform: 'CI'
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
|
|
29
11
|
const generateAllRunSuites = async (summaryResults) => {
|
|
30
12
|
const suitesResults = [];
|
|
31
13
|
const allSuites = [];
|
|
@@ -273,13 +255,12 @@ const generateProblemCaseList = async (summaryResults) => {
|
|
|
273
255
|
let text = `${title}${list.map((value, index) => `*${index + 1}.* ${value}`).join('\n')}`;
|
|
274
256
|
if (text.length > 2700) {
|
|
275
257
|
text = text.substring(0, 2700) + '...';
|
|
276
|
-
const buildInfo = getBuildInfo();
|
|
277
258
|
array.push({
|
|
278
259
|
type: 'section',
|
|
279
260
|
text: {
|
|
280
261
|
type: 'mrkdwn',
|
|
281
|
-
text: text + '\n\n\n' +
|
|
282
|
-
|
|
262
|
+
text: text + '\n\n\n' + '⚠️ *There are too many items to display here. ' +
|
|
263
|
+
'You can view more on Github Actions* ⚠️',
|
|
283
264
|
},
|
|
284
265
|
},
|
|
285
266
|
{
|
|
@@ -290,7 +271,7 @@ const generateProblemCaseList = async (summaryResults) => {
|
|
|
290
271
|
type: 'plain_text',
|
|
291
272
|
text: 'Go to Build Details'
|
|
292
273
|
},
|
|
293
|
-
url:
|
|
274
|
+
url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
294
275
|
}]
|
|
295
276
|
});
|
|
296
277
|
} else {
|
|
@@ -375,13 +356,12 @@ const generateFailuresReasons = async (summaryResults) => {
|
|
|
375
356
|
},
|
|
376
357
|
});
|
|
377
358
|
if (i === summaryResults.failures.length-1) {
|
|
378
|
-
const buildInfo = getBuildInfo();
|
|
379
359
|
failsList.push({
|
|
380
360
|
type: 'section',
|
|
381
361
|
text: {
|
|
382
362
|
type: 'mrkdwn',
|
|
383
363
|
text: '⚠️ *There are too many failures to display in full detail. '+
|
|
384
|
-
|
|
364
|
+
'You can view more on Github Actions* ⚠️',
|
|
385
365
|
},
|
|
386
366
|
},
|
|
387
367
|
{
|
|
@@ -393,7 +373,7 @@ const generateFailuresReasons = async (summaryResults) => {
|
|
|
393
373
|
type: 'plain_text',
|
|
394
374
|
text: 'Go to Build Details'
|
|
395
375
|
},
|
|
396
|
-
url:
|
|
376
|
+
url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
397
377
|
}
|
|
398
378
|
]
|
|
399
379
|
});
|
|
@@ -411,13 +391,12 @@ const generateFailuresReasons = async (summaryResults) => {
|
|
|
411
391
|
},
|
|
412
392
|
});
|
|
413
393
|
if (i > 23) {
|
|
414
|
-
const buildInfo = getBuildInfo();
|
|
415
394
|
failsList.push({
|
|
416
395
|
type: 'section',
|
|
417
396
|
text: {
|
|
418
397
|
type: 'mrkdwn',
|
|
419
398
|
text: '⚠️ *There are too many failures to display here '+
|
|
420
|
-
|
|
399
|
+
'You can view more on Github Actions* ⚠️',
|
|
421
400
|
},
|
|
422
401
|
},
|
|
423
402
|
{
|
|
@@ -428,7 +407,7 @@ const generateFailuresReasons = async (summaryResults) => {
|
|
|
428
407
|
type: 'plain_text',
|
|
429
408
|
text: 'Go to Build Details'
|
|
430
409
|
},
|
|
431
|
-
url:
|
|
410
|
+
url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
432
411
|
}]
|
|
433
412
|
});
|
|
434
413
|
break;
|
|
@@ -18,29 +18,61 @@ try {
|
|
|
18
18
|
AdmZip = null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// CONSTANTS
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
// Directory paths
|
|
26
|
+
const SUMMARIES_DIR = path.join('./', 'playwright-artifacts');
|
|
27
|
+
const PLAYWRIGHT_REPORT_DIR = path.join('./', 'playwright-report');
|
|
28
|
+
|
|
29
|
+
// Environment variables
|
|
30
|
+
const IS_CI = !!process.env.CI;
|
|
31
|
+
const GITHUB_TOKEN = process.env.SAFETYWINGTEST_GITHUB_TOKEN;
|
|
32
|
+
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
|
|
33
|
+
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || 'local';
|
|
34
|
+
const GITHUB_RUN_ATTEMPT = process.env.GITHUB_RUN_ATTEMPT || '1';
|
|
35
|
+
|
|
36
|
+
// Google Cloud Storage configuration
|
|
37
|
+
const GCS_BUCKET_NAME = 'sw-automation-tests';
|
|
38
|
+
const GCS_SERVICE_ACCOUNT_KEY = process.env.GCS_SERVICE_ACCOUNT_KEY;
|
|
39
|
+
const GCS_REPORTS_FOLDER_PREFIX = 'playwright-reports';
|
|
40
|
+
const GCS_UPLOAD_API_BASE_URL = 'https://storage.googleapis.com/upload/storage/v1';
|
|
41
|
+
|
|
42
|
+
// Artifact patterns
|
|
43
|
+
const ARTIFACT_NAME_PATTERNS = [
|
|
44
|
+
'html-report',
|
|
45
|
+
'blob-report-node',
|
|
46
|
+
'test-results',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// GitHub API endpoints (for artifact fetching)
|
|
50
|
+
const GITHUB_API_BASE_URL = 'https://api.github.com';
|
|
51
|
+
const GITHUB_API_ARTIFACTS_ENDPOINT = '/actions/artifacts';
|
|
52
|
+
|
|
53
|
+
// Helper functions for building GitHub API URLs and patterns
|
|
54
|
+
const getGitHubArtifactsApiUrl = () => `${GITHUB_API_BASE_URL}/repos/${GITHUB_REPOSITORY}${GITHUB_API_ARTIFACTS_ENDPOINT}`;
|
|
55
|
+
const getArtifactNamePatterns = (shardIndex) => ARTIFACT_NAME_PATTERNS.map(pattern => `${pattern}-${shardIndex}`);
|
|
56
|
+
|
|
57
|
+
// Helper functions for report paths
|
|
58
|
+
const getRunFolder = () => `${GCS_REPORTS_FOLDER_PREFIX}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`;
|
|
59
|
+
const getGcsObjectPath = (relativePath) => `${getRunFolder()}/${relativePath}`;
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
21
63
|
class ResultsParser {
|
|
22
64
|
result;
|
|
23
65
|
separateFlakyTests;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
shardIndex = process.env.
|
|
28
|
-
? parseInt(process.env.
|
|
29
|
-
:
|
|
66
|
+
totalShardCount = process.env.TOTAL_SHARDS
|
|
67
|
+
? parseInt(process.env.TOTAL_SHARDS, 10)
|
|
68
|
+
: 1;
|
|
69
|
+
shardIndex = process.env.SHARD_INDEX !== undefined
|
|
70
|
+
? parseInt(process.env.SHARD_INDEX, 10)
|
|
71
|
+
: 1;
|
|
30
72
|
|
|
31
|
-
// Determine if we're using 1-based indexing (workflow-style) or 0-based
|
|
32
|
-
// This is detected by checking if artifacts exist with 1-based naming
|
|
33
|
-
isOneBasedIndexing = false;
|
|
34
73
|
constructor(options = { separateFlakyTests: false }) {
|
|
35
74
|
this.result = [];
|
|
36
75
|
this.separateFlakyTests = options.separateFlakyTests;
|
|
37
|
-
// Log shard detection in ResultsParser
|
|
38
|
-
console.log('🔍 [ResultsParser] Initialized with shard detection:');
|
|
39
|
-
console.log(`🔍 MATRIX_SHARD env: ${process.env.MATRIX_SHARD !== undefined ? `"${process.env.MATRIX_SHARD}"` : 'undefined'}`);
|
|
40
|
-
console.log(`🔍 MATRIX_INDEX env: ${process.env.MATRIX_INDEX !== undefined ? `"${process.env.MATRIX_INDEX}"` : 'undefined'}`);
|
|
41
|
-
console.log(`🔍 MATRIX_COUNT env: ${process.env.MATRIX_COUNT !== undefined ? `"${process.env.MATRIX_COUNT}"` : 'undefined'}`);
|
|
42
|
-
console.log(`🔍 Calculated shardIndex: ${this.shardIndex}`);
|
|
43
|
-
console.log(`🔍 Calculated totalShardCount: ${this.totalShardCount}`);
|
|
44
76
|
}
|
|
45
77
|
async getParsedResults() {
|
|
46
78
|
const summary = {
|
|
@@ -60,17 +92,15 @@ class ResultsParser {
|
|
|
60
92
|
}*/
|
|
61
93
|
|
|
62
94
|
// Define the directory to store node summaries
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!fs.existsSync(summariesDir)) {
|
|
66
|
-
fs.mkdirSync(summariesDir, { recursive: true });
|
|
95
|
+
if (!fs.existsSync(PLAYWRIGHT_REPORT_DIR)) {
|
|
96
|
+
fs.mkdirSync(PLAYWRIGHT_REPORT_DIR, { recursive: true });
|
|
67
97
|
}
|
|
68
98
|
|
|
69
99
|
// Determine the current node index
|
|
70
100
|
const currentNodeIndex = this.shardIndex;
|
|
71
101
|
|
|
72
102
|
// Define the file for the current node's summary
|
|
73
|
-
const nodeSummaryFile = path.join(
|
|
103
|
+
const nodeSummaryFile = path.join(PLAYWRIGHT_REPORT_DIR, `node_summary_${currentNodeIndex}.json`);
|
|
74
104
|
|
|
75
105
|
const totalTestCasesForNode = this.result.reduce((acc, suite) => acc + suite.testSuite.tests.length, 0);
|
|
76
106
|
|
|
@@ -97,30 +127,22 @@ class ResultsParser {
|
|
|
97
127
|
// Create the file
|
|
98
128
|
fs.writeFileSync(nodeSummaryFile, JSON.stringify(nodeSummary, null, 2));
|
|
99
129
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (this.shardIndex === 0 && this.totalShardCount > 1) {
|
|
104
|
-
console.log(`🔍 [ResultsParser] Shard 0 detected with ${this.totalShardCount} total shards - will fetch artifacts from other shards`);
|
|
105
|
-
if (process.env.CI) {
|
|
106
|
-
console.log('🔍 [ResultsParser] CI environment detected - fetching all artifacts from GitHub Actions...');
|
|
130
|
+
if (this.shardIndex === 1 && this.totalShardCount > 1) {
|
|
131
|
+
if (IS_CI) {
|
|
132
|
+
console.log('Fetching all artifacts...');
|
|
107
133
|
await this.fetchAllArtifacts();
|
|
108
134
|
} else {
|
|
109
|
-
console.log('🔍 [ResultsParser] Local environment - waiting for artifacts to appear...');
|
|
110
135
|
while (!this.allNodeSummaryFilesExist() || !this.allBlobZipsExist()) {
|
|
111
|
-
console.log('
|
|
136
|
+
console.log('Waiting for all both to exist...');
|
|
112
137
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for 1 second
|
|
113
138
|
}
|
|
114
139
|
}
|
|
115
|
-
} else {
|
|
116
|
-
console.log(`🔍 [ResultsParser] Not shard 0 (current: ${this.shardIndex}) or single shard (total: ${this.totalShardCount}) - skipping artifact fetch`);
|
|
117
140
|
}
|
|
118
141
|
|
|
119
|
-
if (this.shardIndex ===
|
|
120
|
-
console.log(`🔍 [ResultsParser] Shard 0: All artifacts available, merging results from all shards...`);
|
|
142
|
+
if (this.shardIndex === 1 && this.allNodeSummaryFilesExist() && this.allBlobZipsExist()) {
|
|
121
143
|
// Merge all node summaries into the final summary
|
|
122
|
-
for (let i =
|
|
123
|
-
const nodeSummaryFile = path.join(
|
|
144
|
+
for (let i = 1; i <= this.totalShardCount; i++) {
|
|
145
|
+
const nodeSummaryFile = path.join(SUMMARIES_DIR, `node_summary_${i}.json`);
|
|
124
146
|
const fileToRead = nodeSummaryFile;
|
|
125
147
|
|
|
126
148
|
if (fs.existsSync(fileToRead)) {
|
|
@@ -155,7 +177,8 @@ class ResultsParser {
|
|
|
155
177
|
}
|
|
156
178
|
}
|
|
157
179
|
|
|
158
|
-
this.mergeReports();
|
|
180
|
+
await this.mergeReports();
|
|
181
|
+
await this.pushReportsToGCS();
|
|
159
182
|
}
|
|
160
183
|
return summary;
|
|
161
184
|
}
|
|
@@ -332,10 +355,9 @@ class ResultsParser {
|
|
|
332
355
|
}*/
|
|
333
356
|
allNodeSummaryFilesExist() {
|
|
334
357
|
console.log('Checking if all node summary files exist...');
|
|
335
|
-
const summariesDir = path.join('./', 'playwright-report');
|
|
336
358
|
|
|
337
|
-
for (let i =
|
|
338
|
-
const nodeSummaryFile = path.join(
|
|
359
|
+
for (let i = 1; i <= this.totalShardCount; i++) {
|
|
360
|
+
const nodeSummaryFile = path.join(SUMMARIES_DIR, `node_summary_${i}.json`);
|
|
339
361
|
if (!fs.existsSync(nodeSummaryFile)) {
|
|
340
362
|
return false;
|
|
341
363
|
}
|
|
@@ -345,10 +367,9 @@ class ResultsParser {
|
|
|
345
367
|
|
|
346
368
|
allBlobZipsExist() {
|
|
347
369
|
console.log('Checking if all blob zips exist...');
|
|
348
|
-
const summariesDir = path.join('./', 'playwright-report');
|
|
349
370
|
|
|
350
|
-
for (let i =
|
|
351
|
-
const blobZipFile = path.join(
|
|
371
|
+
for (let i = 1; i <= this.totalShardCount; i++) {
|
|
372
|
+
const blobZipFile = path.join(SUMMARIES_DIR, `blob-report-node-${i}.zip`);
|
|
352
373
|
if (!fs.existsSync(blobZipFile)) {
|
|
353
374
|
return false;
|
|
354
375
|
}
|
|
@@ -357,109 +378,49 @@ class ResultsParser {
|
|
|
357
378
|
}
|
|
358
379
|
|
|
359
380
|
async fetchAllArtifacts() {
|
|
360
|
-
|
|
361
|
-
if (!fs.existsSync(
|
|
362
|
-
fs.mkdirSync(
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
console.log(`Shard 0: Waiting for artifacts from ${this.totalShardCount - 1} other shard(s)...`);
|
|
366
|
-
|
|
367
|
-
// Fetch artifacts for shards 1 to totalShardCount-1 (since we're shard 0)
|
|
368
|
-
// We need to fetch from all other shards before proceeding
|
|
369
|
-
for (let i = 1; i < this.totalShardCount; i++) {
|
|
370
|
-
console.log(`Shard 0: Fetching artifacts from shard ${i}...`);
|
|
371
|
-
await this.fetchArtifact(i, `node_summary_${i}.json`, summariesDir);
|
|
372
|
-
await this.fetchArtifact(i, `blob-report-node-${i}.zip`, summariesDir);
|
|
381
|
+
// Ensure summaries directory exists
|
|
382
|
+
if (!fs.existsSync(SUMMARIES_DIR)) {
|
|
383
|
+
fs.mkdirSync(SUMMARIES_DIR, { recursive: true });
|
|
373
384
|
}
|
|
374
385
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
console.log(`
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
for (let i = 1; i < this.totalShardCount; i++) {
|
|
384
|
-
const nodeSummaryFile = path.join(summariesDir, `node_summary_${i}.json`);
|
|
385
|
-
const blobZipFile = path.join(summariesDir, `blob-report-node-${i}.zip`);
|
|
386
|
-
|
|
387
|
-
if (!fs.existsSync(nodeSummaryFile)) {
|
|
388
|
-
console.log(`Shard 0: Re-fetching missing node_summary_${i}.json...`);
|
|
389
|
-
await this.fetchArtifact(i, `node_summary_${i}.json`, summariesDir);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (!fs.existsSync(blobZipFile)) {
|
|
393
|
-
console.log(`Shard 0: Re-fetching missing blob-report-node-${i}.zip...`);
|
|
394
|
-
await this.fetchArtifact(i, `blob-report-node-${i}.zip`, summariesDir);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
retryCount++;
|
|
399
|
-
if (!this.allNodeSummaryFilesExist() || !this.allBlobZipsExist()) {
|
|
400
|
-
await new Promise(resolve => setTimeout(resolve, 10000)); // Wait 10 seconds before retry
|
|
401
|
-
}
|
|
386
|
+
// Copy entire contents of playwright-report to SUMMARIES_DIR
|
|
387
|
+
// This copies both blob-report-node-1.zip and node_summary_1.json to SUMMARIES_DIR
|
|
388
|
+
const playwrightReportDir = path.join('./', 'playwright-report');
|
|
389
|
+
if (fs.existsSync(playwrightReportDir)) {
|
|
390
|
+
this.copyDirectoryRecursive(playwrightReportDir, SUMMARIES_DIR);
|
|
391
|
+
console.log(`Copied entire contents of playwright-report to ${SUMMARIES_DIR}`);
|
|
392
|
+
} else {
|
|
393
|
+
console.warn('Warning: playwright-report directory not found');
|
|
402
394
|
}
|
|
403
395
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
396
|
+
while (!this.allNodeSummaryFilesExist() || !this.allBlobZipsExist()) {
|
|
397
|
+
console.log('Checking if all node summary files exist...');
|
|
398
|
+
console.log('Waiting for all blob zips to exist...');
|
|
399
|
+
if (!fs.existsSync(SUMMARIES_DIR)) {
|
|
400
|
+
fs.mkdirSync(SUMMARIES_DIR, { recursive: true });
|
|
401
|
+
}
|
|
402
|
+
for (let i = 2; i <= this.totalShardCount; i++) {
|
|
403
|
+
await this.fetchArtifact(i, `node_summary_${i}.json`, SUMMARIES_DIR);
|
|
404
|
+
await this.fetchArtifact(i, `blob-report-node-${i}.zip`, SUMMARIES_DIR);
|
|
405
|
+
}
|
|
408
406
|
}
|
|
409
407
|
}
|
|
410
408
|
|
|
411
|
-
async fetchArtifact(i, file,
|
|
412
|
-
const filePath = path.join(
|
|
409
|
+
async fetchArtifact(i, file, targetDir) {
|
|
410
|
+
const filePath = path.join(targetDir, file);
|
|
413
411
|
await this.fetchArtifactFromGitHubActions(i, file, filePath);
|
|
414
412
|
}
|
|
415
413
|
|
|
416
|
-
// Helper to extract shard index from artifact name
|
|
417
|
-
extractShardIndexFromArtifactName(artifactName, i) {
|
|
418
|
-
// Try to extract number from artifact name (e.g., html-report-1 -> 1)
|
|
419
|
-
const match = artifactName.match(/-(\d+)$/);
|
|
420
|
-
if (match) {
|
|
421
|
-
const artifactShard = parseInt(match[1], 10);
|
|
422
|
-
// If artifact uses 1-based indexing, convert to 0-based for internal use
|
|
423
|
-
// html-report-1 (workflow) -> shard 0 (internal)
|
|
424
|
-
// html-report-2 (workflow) -> shard 1 (internal)
|
|
425
|
-
return artifactShard - 1;
|
|
426
|
-
}
|
|
427
|
-
// Fallback to the provided index
|
|
428
|
-
return i;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
414
|
async fetchArtifactFromGitHubActions(i, file, filePath) {
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const runId = process.env.GITHUB_RUN_ID;
|
|
435
|
-
|
|
436
|
-
if (!githubToken || !repo) {
|
|
437
|
-
console.error('GitHub Actions: Missing SAFETYWINGTEST_GITHUB_TOKEN or GITHUB_REPOSITORY');
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// GitHub Actions artifact API endpoint
|
|
442
|
-
const githubApiUrl = `https://api.github.com/repos/${repo}/actions/artifacts`;
|
|
443
|
-
|
|
444
|
-
// Try multiple artifact naming patterns:
|
|
445
|
-
// 1. html-report-{i} (1-based workflow style)
|
|
446
|
-
// 2. blob-report-node-{i} (direct blob reports)
|
|
447
|
-
// 3. test-results-{i} (alternative naming)
|
|
448
|
-
// Note: i is 0-based internally, so for 1-based workflows we need i+1
|
|
449
|
-
const artifactNamePatterns = [
|
|
450
|
-
`html-report-${i + 1}`, // 1-based workflow style (most common)
|
|
451
|
-
`blob-report-node-${i}`, // 0-based direct blob
|
|
452
|
-
`blob-report-node-${i + 1}`, // 1-based direct blob
|
|
453
|
-
`html-report-${i}`, // 0-based html-report
|
|
454
|
-
`test-results-${i + 1}`, // Alternative naming
|
|
455
|
-
];
|
|
415
|
+
const githubApiUrl = getGitHubArtifactsApiUrl();
|
|
416
|
+
const artifactNamePatterns = getArtifactNamePatterns(i);
|
|
456
417
|
|
|
457
418
|
while (true) {
|
|
458
419
|
try {
|
|
459
420
|
// List all artifacts to find the one we need
|
|
460
421
|
const listResponse = await axios.get(githubApiUrl, {
|
|
461
422
|
headers: {
|
|
462
|
-
'Authorization': `token ${
|
|
423
|
+
'Authorization': `token ${GITHUB_TOKEN}`,
|
|
463
424
|
'Accept': 'application/vnd.github.v3+json'
|
|
464
425
|
},
|
|
465
426
|
params: {
|
|
@@ -472,22 +433,17 @@ class ResultsParser {
|
|
|
472
433
|
for (const pattern of artifactNamePatterns) {
|
|
473
434
|
artifact = listResponse.data.artifacts.find(
|
|
474
435
|
(a) => a.name === pattern ||
|
|
475
|
-
a.name.includes(`shard-${i}`) ||
|
|
476
|
-
a.name.includes(`
|
|
477
|
-
a.name.includes(`node-${i}`) ||
|
|
478
|
-
a.name.includes(`node-${i + 1}`)
|
|
436
|
+
a.name.includes(`shard-${i}`) ||
|
|
437
|
+
a.name.includes(`node-${i}`)
|
|
479
438
|
);
|
|
480
|
-
if (artifact)
|
|
481
|
-
console.log(`Found artifact: ${artifact.name} (matched pattern: ${pattern})`);
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
439
|
+
if (artifact) break;
|
|
484
440
|
}
|
|
485
441
|
|
|
486
442
|
if (artifact && artifact.archive_download_url) {
|
|
487
443
|
// Download the artifact ZIP
|
|
488
444
|
const downloadResponse = await axios.get(artifact.archive_download_url, {
|
|
489
445
|
headers: {
|
|
490
|
-
'Authorization': `token ${
|
|
446
|
+
'Authorization': `token ${GITHUB_TOKEN}`,
|
|
491
447
|
'Accept': 'application/vnd.github.v3+json'
|
|
492
448
|
},
|
|
493
449
|
responseType: 'arraybuffer'
|
|
@@ -500,113 +456,15 @@ class ResultsParser {
|
|
|
500
456
|
|
|
501
457
|
// Extract ZIP and find the specific file
|
|
502
458
|
const zip = new AdmZip(downloadResponse.data);
|
|
503
|
-
|
|
504
|
-
// Try direct file match first
|
|
505
|
-
let zipEntry = zip.getEntry(file);
|
|
506
|
-
|
|
507
|
-
// If not found, try common path variations
|
|
508
|
-
if (!zipEntry) {
|
|
509
|
-
const possiblePaths = [
|
|
510
|
-
file,
|
|
511
|
-
`playwright-report/${file}`,
|
|
512
|
-
`playwright-report/${file}`,
|
|
513
|
-
`html-report-${i + 1}/${file}`,
|
|
514
|
-
`html-report-${i}/${file}`,
|
|
515
|
-
];
|
|
516
|
-
for (const possiblePath of possiblePaths) {
|
|
517
|
-
zipEntry = zip.getEntry(possiblePath);
|
|
518
|
-
if (zipEntry) break;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
459
|
+
const zipEntry = zip.getEntry(file) || zip.getEntry(`playwright-artifacts/${file}`);
|
|
521
460
|
|
|
522
461
|
if (zipEntry) {
|
|
523
462
|
fs.writeFileSync(filePath, zipEntry.getData());
|
|
524
|
-
console.log(`Successfully fetched file ${file} from
|
|
463
|
+
console.log(`Successfully fetched file ${file} from shard ${i}`);
|
|
525
464
|
break;
|
|
526
465
|
} else {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
zip.extractAllTo(extractPath, true);
|
|
530
|
-
|
|
531
|
-
// Extract shard index from artifact name to handle files named with index 0
|
|
532
|
-
const artifactShardIndex = this.extractShardIndexFromArtifactName(artifact.name, i);
|
|
533
|
-
|
|
534
|
-
// Look for the file recursively, also try with shard index from artifact name
|
|
535
|
-
const searchInDir = (dir, targetFile, alternateTargetFile = null) => {
|
|
536
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
537
|
-
for (const entry of entries) {
|
|
538
|
-
const fullPath = path.join(dir, entry.name);
|
|
539
|
-
if (entry.isDirectory()) {
|
|
540
|
-
const found = searchInDir(fullPath, targetFile, alternateTargetFile);
|
|
541
|
-
if (found) return found;
|
|
542
|
-
} else {
|
|
543
|
-
// Check exact match
|
|
544
|
-
if (entry.name === targetFile) {
|
|
545
|
-
return fullPath;
|
|
546
|
-
}
|
|
547
|
-
// Check alternate filename (e.g., node_summary_0.json when we need node_summary_1.json)
|
|
548
|
-
if (alternateTargetFile && entry.name === alternateTargetFile) {
|
|
549
|
-
return fullPath;
|
|
550
|
-
}
|
|
551
|
-
// Check if ends with target file
|
|
552
|
-
if (entry.name.endsWith(targetFile)) {
|
|
553
|
-
return fullPath;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
return null;
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
// Try to find the file, also try with shard index from artifact
|
|
561
|
-
let foundPath = searchInDir(extractPath, file);
|
|
562
|
-
|
|
563
|
-
// If file uses index 0 pattern (like node_summary_0.json or blob-report-node-0.zip)
|
|
564
|
-
// but we need it for a different shard, try to find any matching pattern
|
|
565
|
-
if (!foundPath && (file.includes('_0') || file.includes('-node-0'))) {
|
|
566
|
-
// Recursively search for any node_summary or blob-report file
|
|
567
|
-
const findAllFiles = (dir, pattern) => {
|
|
568
|
-
const results = [];
|
|
569
|
-
try {
|
|
570
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
571
|
-
for (const entry of entries) {
|
|
572
|
-
const fullPath = path.join(dir, entry.name);
|
|
573
|
-
if (entry.isDirectory()) {
|
|
574
|
-
results.push(...findAllFiles(fullPath, pattern));
|
|
575
|
-
} else if (entry.name.includes(pattern)) {
|
|
576
|
-
results.push(fullPath);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
} catch (e) {
|
|
580
|
-
// Ignore errors
|
|
581
|
-
}
|
|
582
|
-
return results;
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
// Find any node_summary or blob-report files
|
|
586
|
-
const matchingFiles = findAllFiles(extractPath, file.includes('node_summary') ? 'node_summary' : 'blob-report');
|
|
587
|
-
|
|
588
|
-
if (matchingFiles.length > 0) {
|
|
589
|
-
foundPath = matchingFiles[0];
|
|
590
|
-
// Copy to the expected path with correct shard index
|
|
591
|
-
fs.copyFileSync(foundPath, filePath);
|
|
592
|
-
console.log(`Found and copied file ${path.basename(foundPath)} -> ${file} from artifact ${artifact.name}`);
|
|
593
|
-
// Clean up temp directory
|
|
594
|
-
fs.rmSync(extractPath, { recursive: true, force: true });
|
|
595
|
-
break;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (foundPath) {
|
|
600
|
-
fs.copyFileSync(foundPath, filePath);
|
|
601
|
-
console.log(`Successfully fetched file ${file} from GitHub Actions shard ${i} (artifact: ${artifact.name})`);
|
|
602
|
-
// Clean up temp directory
|
|
603
|
-
fs.rmSync(extractPath, { recursive: true, force: true });
|
|
604
|
-
break;
|
|
605
|
-
} else {
|
|
606
|
-
console.warn(`File ${file} not found in artifact ${artifact.name}. Retrying in 10 seconds...`);
|
|
607
|
-
fs.rmSync(extractPath, { recursive: true, force: true });
|
|
608
|
-
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
609
|
-
}
|
|
466
|
+
console.warn(`File ${file} not found in artifact ${artifact.name}. Retrying in 10 seconds...`);
|
|
467
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
610
468
|
}
|
|
611
469
|
} else {
|
|
612
470
|
console.warn(`Artifact not found (tried: ${artifactNamePatterns.join(', ')}). Retrying in 10 seconds...`);
|
|
@@ -614,22 +472,39 @@ class ResultsParser {
|
|
|
614
472
|
}
|
|
615
473
|
} catch (error) {
|
|
616
474
|
if (error.response && error.response.status === 404) {
|
|
617
|
-
console.warn(`
|
|
475
|
+
console.warn(`File ${file} not found. Retrying in 10 seconds...`);
|
|
618
476
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
619
477
|
} else {
|
|
620
|
-
console.error(`Failed to fetch
|
|
621
|
-
|
|
478
|
+
console.error(`Failed to fetch file ${file} from shard ${i}!`, error);
|
|
479
|
+
break;
|
|
622
480
|
}
|
|
623
481
|
}
|
|
624
482
|
}
|
|
625
483
|
}
|
|
626
|
-
mergeReports() {
|
|
484
|
+
async mergeReports() {
|
|
627
485
|
let breakMergeWaiter = false;
|
|
628
486
|
try {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
487
|
+
|
|
488
|
+
// Copy all blob files to playwright-report before merging
|
|
489
|
+
for (let i = 1; i <= this.totalShardCount; i++) {
|
|
490
|
+
const blobZipFile = path.join(SUMMARIES_DIR, `blob-report-node-${i}.zip`);
|
|
491
|
+
if (fs.existsSync(blobZipFile)) {
|
|
492
|
+
try {
|
|
493
|
+
const destFile = path.join(PLAYWRIGHT_REPORT_DIR, `blob-report-node-${i}.zip`);
|
|
494
|
+
fs.copyFileSync(blobZipFile, destFile);
|
|
495
|
+
console.log(`Copied blob-report-node-${i}.zip to playwright-report for merging`);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
console.warn(`Failed to copy blob-report-node-${i}.zip for merging:`, error.message);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Execute the command to merge reports FROM playwright-report
|
|
503
|
+
// Playwright will output to playwright-report directory
|
|
504
|
+
exec(`npx playwright merge-reports --reporter html ${PLAYWRIGHT_REPORT_DIR}`);
|
|
505
|
+
|
|
506
|
+
// Wait until index.html exists in playwright-report
|
|
507
|
+
while (!fs.existsSync(path.join(PLAYWRIGHT_REPORT_DIR, 'index.html'))) {
|
|
633
508
|
console.log('Waiting 2 seconds for merged html report to be generated...');
|
|
634
509
|
const currentTime = new Date().getTime();
|
|
635
510
|
breakMergeWaiter = false;
|
|
@@ -640,10 +515,209 @@ class ResultsParser {
|
|
|
640
515
|
}
|
|
641
516
|
}
|
|
642
517
|
console.log('Reports merged successfully.');
|
|
518
|
+
|
|
519
|
+
// After merge, extract all blobs from SUMMARIES_DIR to playwright-report
|
|
520
|
+
this.unzipBlobsAndCopyResources(SUMMARIES_DIR, PLAYWRIGHT_REPORT_DIR);
|
|
643
521
|
} catch (error) {
|
|
644
522
|
// Log a warning instead of throwing an error
|
|
645
523
|
console.warn('Warning: Failed to merge reports. This may not affect the overall process.', error.message);
|
|
646
524
|
}
|
|
647
525
|
}
|
|
526
|
+
|
|
527
|
+
copyDirectoryRecursive(srcDir, destDir) {
|
|
528
|
+
if (!fs.existsSync(destDir)) {
|
|
529
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
533
|
+
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
536
|
+
const destPath = path.join(destDir, entry.name);
|
|
537
|
+
|
|
538
|
+
if (entry.isDirectory()) {
|
|
539
|
+
this.copyDirectoryRecursive(srcPath, destPath);
|
|
540
|
+
} else {
|
|
541
|
+
fs.copyFileSync(srcPath, destPath);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
unzipBlobsAndCopyResources(ARTIFACTS_DIR, FINAL_OUTPUT_DIR) {
|
|
547
|
+
if (!AdmZip) {
|
|
548
|
+
console.error('adm-zip is required for blob extraction. Please install it: npm install adm-zip');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Unzip all blob files into separate folders
|
|
553
|
+
for (let i = 1; i <= this.totalShardCount; i++) {
|
|
554
|
+
const blobZipFile = path.join(ARTIFACTS_DIR, `blob-report-node-${i}.zip`);
|
|
555
|
+
if (fs.existsSync(blobZipFile)) {
|
|
556
|
+
const extractDir = path.join(ARTIFACTS_DIR, `${GITHUB_RUN_ID}-blob-contents-node-${i}`);
|
|
557
|
+
if (!fs.existsSync(extractDir)) {
|
|
558
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const zip = new AdmZip(blobZipFile);
|
|
563
|
+
zip.extractAllTo(extractDir, true);
|
|
564
|
+
console.log(`Extracted blob-report-node-${i}.zip to ${extractDir}`);
|
|
565
|
+
|
|
566
|
+
// Check if this folder contains a "resources" folder with zip files
|
|
567
|
+
const resourcesDir = path.join(extractDir, 'resources');
|
|
568
|
+
if (fs.existsSync(resourcesDir) && fs.statSync(resourcesDir).isDirectory()) {
|
|
569
|
+
const files = fs.readdirSync(resourcesDir);
|
|
570
|
+
const zipFiles = files.filter(file => file.endsWith('.zip'));
|
|
571
|
+
|
|
572
|
+
if (zipFiles.length > 0) {
|
|
573
|
+
// Copy the resources folder to finalOutput
|
|
574
|
+
const finalResourcesDir = path.join(FINAL_OUTPUT_DIR, 'resources');
|
|
575
|
+
if (!fs.existsSync(finalResourcesDir)) {
|
|
576
|
+
fs.mkdirSync(finalResourcesDir, { recursive: true });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Copy each zip file to finalOutput/resources
|
|
580
|
+
for (const zipFile of zipFiles) {
|
|
581
|
+
const sourceFile = path.join(resourcesDir, zipFile);
|
|
582
|
+
const destFile = path.join(finalResourcesDir, zipFile);
|
|
583
|
+
fs.copyFileSync(sourceFile, destFile);
|
|
584
|
+
console.log(`Copied ${zipFile} to finalOutput/resources`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.warn(`Failed to extract or process blob-report-node-${i}.zip:`, error.message);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async getGcsAccessToken() {
|
|
596
|
+
if (!GCS_SERVICE_ACCOUNT_KEY) {
|
|
597
|
+
throw new Error('GCS_SERVICE_ACCOUNT_KEY environment variable is required');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
let serviceAccount;
|
|
601
|
+
try {
|
|
602
|
+
serviceAccount = JSON.parse(GCS_SERVICE_ACCOUNT_KEY);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
// Try reading from file path
|
|
605
|
+
if (fs.existsSync(GCS_SERVICE_ACCOUNT_KEY)) {
|
|
606
|
+
serviceAccount = JSON.parse(fs.readFileSync(GCS_SERVICE_ACCOUNT_KEY, 'utf-8'));
|
|
607
|
+
} else {
|
|
608
|
+
throw new Error('Invalid GCS_SERVICE_ACCOUNT_KEY: must be JSON string or file path');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Request OAuth2 token from Google
|
|
613
|
+
const jwt = require('jsonwebtoken');
|
|
614
|
+
const now = Math.floor(Date.now() / 1000);
|
|
615
|
+
const token = jwt.sign(
|
|
616
|
+
{
|
|
617
|
+
iss: serviceAccount.client_email,
|
|
618
|
+
scope: 'https://www.googleapis.com/auth/devstorage.full_control',
|
|
619
|
+
aud: 'https://oauth2.googleapis.com/token',
|
|
620
|
+
exp: now + 3600,
|
|
621
|
+
iat: now
|
|
622
|
+
},
|
|
623
|
+
serviceAccount.private_key,
|
|
624
|
+
{ algorithm: 'RS256' }
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', {
|
|
628
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
629
|
+
assertion: token
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
return tokenResponse.data.access_token;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async pushReportsToGCS() {
|
|
636
|
+
// Only push in CI environment with required configuration
|
|
637
|
+
if (!IS_CI || !GCS_SERVICE_ACCOUNT_KEY) {
|
|
638
|
+
console.log('Skipping GCS push: not in CI or missing GCS_SERVICE_ACCOUNT_KEY');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
console.log(`Uploading playwright-report files to GCS bucket: ${GCS_BUCKET_NAME} in folder: ${getRunFolder()}`);
|
|
644
|
+
|
|
645
|
+
// Get access token
|
|
646
|
+
const accessToken = await this.getGcsAccessToken();
|
|
647
|
+
|
|
648
|
+
// Collect all files from playwright-report directory
|
|
649
|
+
const files = this.getAllFilesRecursive(PLAYWRIGHT_REPORT_DIR);
|
|
650
|
+
|
|
651
|
+
// Normalize the base directory path for path replacement
|
|
652
|
+
const baseDirPath = path.resolve(PLAYWRIGHT_REPORT_DIR);
|
|
653
|
+
|
|
654
|
+
// Upload all files to GCS
|
|
655
|
+
for (const file of files) {
|
|
656
|
+
// Get relative path from playwright-report directory
|
|
657
|
+
const relativePath = path.relative(baseDirPath, file).replace(/\\/g, '/');
|
|
658
|
+
const objectPath = getGcsObjectPath(relativePath);
|
|
659
|
+
const content = fs.readFileSync(file);
|
|
660
|
+
|
|
661
|
+
// Upload file to GCS
|
|
662
|
+
const uploadUrl = `${GCS_UPLOAD_API_BASE_URL}/b/${GCS_BUCKET_NAME}/o?uploadType=media&name=${encodeURIComponent(objectPath)}`;
|
|
663
|
+
|
|
664
|
+
await axios.post(uploadUrl, content, {
|
|
665
|
+
headers: {
|
|
666
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
667
|
+
'Content-Type': this.getContentType(file)
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
console.log(`Uploaded ${objectPath} to GCS`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
console.log(`Successfully uploaded ${files.length} files to GCS bucket: ${GCS_BUCKET_NAME} in folder: ${getRunFolder()}`);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.warn('Warning: Failed to push reports to GCS. This may not affect the overall process.', error.message);
|
|
677
|
+
if (error.response) {
|
|
678
|
+
console.warn('GCS API error:', error.response.data);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
getContentType(filePath) {
|
|
684
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
685
|
+
const contentTypes = {
|
|
686
|
+
'.html': 'text/html',
|
|
687
|
+
'.css': 'text/css',
|
|
688
|
+
'.js': 'application/javascript',
|
|
689
|
+
'.json': 'application/json',
|
|
690
|
+
'.png': 'image/png',
|
|
691
|
+
'.jpg': 'image/jpeg',
|
|
692
|
+
'.jpeg': 'image/jpeg',
|
|
693
|
+
'.gif': 'image/gif',
|
|
694
|
+
'.svg': 'image/svg+xml',
|
|
695
|
+
'.zip': 'application/zip',
|
|
696
|
+
'.txt': 'text/plain',
|
|
697
|
+
'.xml': 'application/xml'
|
|
698
|
+
};
|
|
699
|
+
return contentTypes[ext] || 'application/octet-stream';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
getAllFilesRecursive(dir, fileList = []) {
|
|
703
|
+
if (!fs.existsSync(dir)) {
|
|
704
|
+
return fileList;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const files = fs.readdirSync(dir);
|
|
708
|
+
|
|
709
|
+
for (const file of files) {
|
|
710
|
+
const filePath = path.join(dir, file);
|
|
711
|
+
const stat = fs.statSync(filePath);
|
|
712
|
+
|
|
713
|
+
if (stat.isDirectory()) {
|
|
714
|
+
this.getAllFilesRecursive(filePath, fileList);
|
|
715
|
+
} else {
|
|
716
|
+
fileList.push(filePath);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return fileList;
|
|
721
|
+
}
|
|
648
722
|
}
|
|
649
723
|
exports.default = ResultsParser;
|
|
@@ -6,13 +6,7 @@ const webhook_1 = require("@slack/webhook");
|
|
|
6
6
|
const ResultsParser_1 = require("./ResultsParser");
|
|
7
7
|
const SlackClient_1 = require("./SlackClient");
|
|
8
8
|
const SlackWebhookClient_1 = require("./SlackWebhookClient");
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
axios = require('axios');
|
|
12
|
-
} catch (e) {
|
|
13
|
-
// axios optional for GitHub Actions API detection
|
|
14
|
-
axios = null;
|
|
15
|
-
}
|
|
9
|
+
|
|
16
10
|
class SlackReporter {
|
|
17
11
|
customLayout;
|
|
18
12
|
customLayoutAsync;
|
|
@@ -71,303 +65,18 @@ class SlackReporter {
|
|
|
71
65
|
onTestEnd(test, result) {
|
|
72
66
|
this.resultsParser.addTestResult(test.parent.title, test, this.browsers);
|
|
73
67
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Detect GitHub Actions shard information using GitHub API
|
|
76
|
-
* Returns { shardIndex: number, totalShards: number } or null if detection fails
|
|
77
|
-
*/
|
|
78
|
-
async detectGitHubActionsShardInfo() {
|
|
79
|
-
// Check if we're in GitHub Actions
|
|
80
|
-
if (!process.env.GITHUB_ACTIONS || process.env.GITHUB_ACTIONS !== 'true') {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
// Try GITHUB_TOKEN first (automatic), then fallback to custom token
|
|
84
|
-
const githubToken = process.env.GITHUB_TOKEN || process.env.SAFETYWINGTEST_GITHUB_TOKEN;
|
|
85
|
-
const repo = process.env.GITHUB_REPOSITORY;
|
|
86
|
-
const runId = process.env.GITHUB_RUN_ID;
|
|
87
|
-
const currentJobId = process.env.GITHUB_JOB;
|
|
88
|
-
if (!githubToken || !repo || !runId) {
|
|
89
|
-
this.log(`🔍 [GitHub Actions Detection] Missing required env vars: GITHUB_TOKEN=${!!process.env.GITHUB_TOKEN}, SAFETYWINGTEST_GITHUB_TOKEN=${!!process.env.SAFETYWINGTEST_GITHUB_TOKEN}, GITHUB_REPOSITORY=${!!repo}, GITHUB_RUN_ID=${!!runId}`);
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
this.log(`🔍 [GitHub Actions Detection] Using token: ${process.env.GITHUB_TOKEN ? 'GITHUB_TOKEN' : 'SAFETYWINGTEST_GITHUB_TOKEN'}`);
|
|
93
|
-
this.log(`🔍 [GitHub Actions Detection] Repository: ${repo}, Run ID: ${runId}`);
|
|
94
|
-
if (!axios) {
|
|
95
|
-
this.log(`🔍 [GitHub Actions Detection] axios not available, skipping API detection`);
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
try {
|
|
99
|
-
// Get workflow run details
|
|
100
|
-
const apiUrl = `https://api.github.com/repos/${repo}/actions/runs/${runId}/jobs`;
|
|
101
|
-
this.log(`🔍 [GitHub Actions Detection] API URL: ${apiUrl}`);
|
|
102
|
-
const response = await axios.get(apiUrl, {
|
|
103
|
-
headers: {
|
|
104
|
-
'Accept': 'application/vnd.github+json',
|
|
105
|
-
'Authorization': `Bearer ${githubToken}`,
|
|
106
|
-
'X-GitHub-Api-Version': '2022-11-28'
|
|
107
|
-
},
|
|
108
|
-
params: {
|
|
109
|
-
per_page: 100
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
const jobs = response.data.jobs || [];
|
|
113
|
-
if (jobs.length === 0) {
|
|
114
|
-
this.log(`🔍 [GitHub Actions Detection] No jobs found in workflow run`);
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
this.log(`🔍 [GitHub Actions Detection] Found ${jobs.length} total jobs in workflow run`);
|
|
119
|
-
|
|
120
|
-
// Helper function to normalize job names by removing shard indicators
|
|
121
|
-
// e.g., "Run tests (shard 3/4)" -> "Run tests"
|
|
122
|
-
const normalizeJobName = (name) => {
|
|
123
|
-
if (!name) return '';
|
|
124
|
-
// Remove common matrix patterns: (shard X/Y), (X, Y), [X], etc.
|
|
125
|
-
return name
|
|
126
|
-
.replace(/\s*\(shard\s+\d+\/\d+\)/gi, '')
|
|
127
|
-
.replace(/\s*\(\d+\/\d+\)/g, '')
|
|
128
|
-
.replace(/\s*\(\d+,\s*\d+\)/g, '')
|
|
129
|
-
.replace(/\s*\[\d+\]/g, '')
|
|
130
|
-
.trim();
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// Group jobs by normalized name to find matrix jobs
|
|
134
|
-
const jobGroups = {};
|
|
135
|
-
for (const job of jobs) {
|
|
136
|
-
const jobName = job.name || '';
|
|
137
|
-
const normalizedName = normalizeJobName(jobName);
|
|
138
|
-
if (!jobGroups[normalizedName]) {
|
|
139
|
-
jobGroups[normalizedName] = [];
|
|
140
|
-
}
|
|
141
|
-
jobGroups[normalizedName].push(job);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
this.log(`🔍 [GitHub Actions Detection] Grouped into ${Object.keys(jobGroups).length} job group(s)`);
|
|
145
|
-
for (const [groupName, groupJobs] of Object.entries(jobGroups)) {
|
|
146
|
-
this.log(`🔍 Group "${groupName}": ${groupJobs.length} job(s)`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Find the job group that contains the current job
|
|
150
|
-
let currentJob = null;
|
|
151
|
-
let jobGroup = null;
|
|
152
|
-
|
|
153
|
-
// First, try to find by job name match
|
|
154
|
-
for (const job of jobs) {
|
|
155
|
-
if (job.name && (job.name.includes(currentJobId) || currentJobId.includes(job.name))) {
|
|
156
|
-
currentJob = job;
|
|
157
|
-
const normalizedName = normalizeJobName(job.name);
|
|
158
|
-
jobGroup = jobGroups[normalizedName];
|
|
159
|
-
this.log(`🔍 [GitHub Actions Detection] Matched current job by name: "${job.name}"`);
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// If still no match, use all jobs as fallback
|
|
165
|
-
if (!jobGroup || jobGroup.length === 0) {
|
|
166
|
-
this.log(`🔍 [GitHub Actions Detection] Could not match job, using all jobs as group`);
|
|
167
|
-
jobGroup = jobs;
|
|
168
|
-
currentJob = jobs[0];
|
|
169
|
-
}
|
|
170
|
-
// Sort jobs by name or ID to get consistent ordering
|
|
171
|
-
jobGroup.sort((a, b) => {
|
|
172
|
-
if (a.name && b.name) {
|
|
173
|
-
return a.name.localeCompare(b.name);
|
|
174
|
-
}
|
|
175
|
-
return a.id - b.id;
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Find index of current job in the sorted group
|
|
179
|
-
const currentJobIndex = jobGroup.findIndex(j => j.id === currentJob?.id || j.name === currentJob?.name);
|
|
180
|
-
|
|
181
|
-
// Try to extract shard number from job name (e.g., "shard 3/4" -> index 2)
|
|
182
|
-
let shardIndex = currentJobIndex >= 0 ? currentJobIndex : 0;
|
|
183
|
-
if (currentJob && currentJob.name) {
|
|
184
|
-
const shardMatch = currentJob.name.match(/shard\s+(\d+)\/(\d+)/i);
|
|
185
|
-
if (shardMatch) {
|
|
186
|
-
const shardNum = parseInt(shardMatch[1], 10);
|
|
187
|
-
const totalFromName = parseInt(shardMatch[2], 10);
|
|
188
|
-
// Convert to 0-based index (shard 3/4 -> index 2)
|
|
189
|
-
shardIndex = shardNum - 1;
|
|
190
|
-
this.log(`🔍 [GitHub Actions Detection] Extracted shard info from name: ${shardNum}/${totalFromName} -> index ${shardIndex}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const totalShards = jobGroup.length;
|
|
195
|
-
this.log(`🔍 [GitHub Actions Detection] Detected via API:`);
|
|
196
|
-
this.log(`🔍 Total jobs in group: ${totalShards}`);
|
|
197
|
-
this.log(`🔍 Current job index: ${shardIndex}`);
|
|
198
|
-
this.log(`🔍 Current job ID: ${currentJobId}`);
|
|
199
|
-
this.log(`🔍 Current job name: ${currentJob?.name || 'unknown'}`);
|
|
200
|
-
return {
|
|
201
|
-
shardIndex,
|
|
202
|
-
totalShards
|
|
203
|
-
};
|
|
204
|
-
} catch (error) {
|
|
205
|
-
const apiUrl = `https://api.github.com/repos/${repo}/actions/runs/${runId}/jobs`;
|
|
206
|
-
this.log(`🔍 [GitHub Actions Detection] Failed to detect via API: ${error.message}`);
|
|
207
|
-
if (error.response) {
|
|
208
|
-
const status = error.response.status;
|
|
209
|
-
const data = error.response.data;
|
|
210
|
-
this.log(`🔍 Status: ${status}, URL: ${apiUrl}`);
|
|
211
|
-
|
|
212
|
-
if (status === 404) {
|
|
213
|
-
this.log(`🔍 404 Error Details:`);
|
|
214
|
-
this.log(`🔍 - Repository: ${repo}`);
|
|
215
|
-
this.log(`🔍 - Run ID: ${runId}`);
|
|
216
|
-
this.log(`🔍 - Possible causes:`);
|
|
217
|
-
this.log(`🔍 1. Token lacks 'actions: read' permission`);
|
|
218
|
-
this.log(`🔍 2. Workflow run doesn't exist or isn't accessible`);
|
|
219
|
-
this.log(`🔍 3. Jobs not available yet (try accessing after workflow starts)`);
|
|
220
|
-
this.log(`🔍 - Recommendation: Set MATRIX_SHARD and MATRIX_COUNT env vars as fallback`);
|
|
221
|
-
} else if (status === 401 || status === 403) {
|
|
222
|
-
this.log(`🔍 Authentication/Authorization Error:`);
|
|
223
|
-
this.log(`🔍 - Token may be invalid or lack required permissions`);
|
|
224
|
-
this.log(`🔍 - Required permission: 'actions: read' or 'repo' scope`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (data && data.message) {
|
|
228
|
-
this.log(`🔍 Error message: ${data.message}`);
|
|
229
|
-
}
|
|
230
|
-
} else {
|
|
231
|
-
this.log(`🔍 Network error or no response received`);
|
|
232
|
-
}
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
68
|
async onEnd() {
|
|
237
69
|
const { okToProceed, message } = this.preChecks();
|
|
238
70
|
if (!okToProceed) {
|
|
239
71
|
this.log(message);
|
|
240
72
|
return;
|
|
241
73
|
}
|
|
242
|
-
// SHARDING SUPPORT - Stop slack messages for non-zero index shard(s)
|
|
243
|
-
// Only shard 0 (0-based) should post when aggregating results from multiple shards
|
|
244
|
-
|
|
245
|
-
this.log('🔍 [Shard Detection] Starting shard detection logic...');
|
|
246
|
-
|
|
247
|
-
// Log all relevant environment variables
|
|
248
|
-
this.log(`🔍 [Shard Detection] Environment variables:`);
|
|
249
|
-
this.log(`🔍 CI: ${process.env.CI !== undefined ? `"${process.env.CI}"` : 'undefined'}`);
|
|
250
|
-
this.log(`🔍 GITHUB_ACTIONS: ${process.env.GITHUB_ACTIONS !== undefined ? `"${process.env.GITHUB_ACTIONS}"` : 'undefined'}`);
|
|
251
|
-
this.log(`🔍 GITHUB_JOB: ${process.env.GITHUB_JOB !== undefined ? `"${process.env.GITHUB_JOB}"` : 'undefined'}`);
|
|
252
|
-
this.log(`🔍 MATRIX_SHARD: ${process.env.MATRIX_SHARD !== undefined ? `"${process.env.MATRIX_SHARD}"` : 'undefined'}`);
|
|
253
|
-
this.log(`🔍 MATRIX_INDEX: ${process.env.MATRIX_INDEX !== undefined ? `"${process.env.MATRIX_INDEX}"` : 'undefined'}`);
|
|
254
|
-
this.log(`🔍 MATRIX_COUNT: ${process.env.MATRIX_COUNT !== undefined ? `"${process.env.MATRIX_COUNT}"` : 'undefined'}`);
|
|
255
|
-
|
|
256
|
-
// Step 1: Try GitHub Actions API detection (most robust for GitHub Actions)
|
|
257
|
-
let githubApiShardInfo = null;
|
|
258
|
-
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
259
|
-
this.log(`🔍 [Shard Detection] Attempting GitHub Actions API detection...`);
|
|
260
|
-
githubApiShardInfo = await this.detectGitHubActionsShardInfo();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Step 2: Detect shard index from environment variables (priority)
|
|
264
|
-
let currentShardIndex = process.env.MATRIX_SHARD !== undefined
|
|
265
|
-
? process.env.MATRIX_SHARD
|
|
266
|
-
: (process.env.MATRIX_INDEX !== undefined ? process.env.MATRIX_INDEX : undefined);
|
|
267
|
-
|
|
268
|
-
// Step 3: Use GitHub API detection if env vars not set
|
|
269
|
-
if (currentShardIndex === undefined && githubApiShardInfo) {
|
|
270
|
-
currentShardIndex = githubApiShardInfo.shardIndex.toString();
|
|
271
|
-
this.log(`🔍 [Shard Detection] Using GitHub API detected shardIndex: "${currentShardIndex}"`);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
this.log(`🔍 [Shard Detection] Initial currentShardIndex: ${currentShardIndex !== undefined ? `"${currentShardIndex}"` : 'undefined'}`);
|
|
275
|
-
|
|
276
|
-
// Step 4: Get shard count from multiple sources (priority order)
|
|
277
|
-
let totalShards;
|
|
278
|
-
if (process.env.MATRIX_COUNT !== undefined) {
|
|
279
|
-
totalShards = parseInt(process.env.MATRIX_COUNT, 10);
|
|
280
|
-
this.log(`🔍 [Shard Detection] Using MATRIX_COUNT env var: ${totalShards}`);
|
|
281
|
-
} else if (githubApiShardInfo && githubApiShardInfo.totalShards > 1) {
|
|
282
|
-
totalShards = githubApiShardInfo.totalShards;
|
|
283
|
-
this.log(`🔍 [Shard Detection] Using GitHub API detected totalShards: ${totalShards}`);
|
|
284
|
-
} else {
|
|
285
|
-
// Fallback to ResultsParser
|
|
286
|
-
const parserTotalShards = this.resultsParser.totalShardCount;
|
|
287
|
-
totalShards = parserTotalShards;
|
|
288
|
-
this.log(`🔍 [Shard Detection] Using ResultsParser totalShards: ${totalShards}`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Get shard info from ResultsParser for fallback
|
|
292
|
-
const parserShardIndex = this.resultsParser.shardIndex;
|
|
293
|
-
const parserTotalShards = this.resultsParser.totalShardCount;
|
|
294
|
-
|
|
295
|
-
this.log(`🔍 [Shard Detection] ResultsParser values:`);
|
|
296
|
-
this.log(`🔍 parserShardIndex: ${parserShardIndex !== undefined ? parserShardIndex : 'undefined'}`);
|
|
297
|
-
this.log(`🔍 parserTotalShards: ${parserTotalShards !== undefined ? parserTotalShards : 'undefined'}`);
|
|
298
|
-
|
|
299
|
-
this.log(`🔍 [Shard Detection] Calculated totalShards: ${totalShards}`);
|
|
300
|
-
|
|
301
|
-
// If we still don't have shard index, try to infer from parser or GitHub API
|
|
302
|
-
if (currentShardIndex === undefined) {
|
|
303
|
-
if (githubApiShardInfo && githubApiShardInfo.shardIndex !== undefined) {
|
|
304
|
-
currentShardIndex = githubApiShardInfo.shardIndex.toString();
|
|
305
|
-
this.log(`🔍 [Shard Detection] Using GitHub API shardIndex as fallback: "${currentShardIndex}"`);
|
|
306
|
-
} else if (parserShardIndex !== undefined) {
|
|
307
|
-
currentShardIndex = parserShardIndex.toString();
|
|
308
|
-
this.log(`🔍 [Shard Detection] Using parserShardIndex as fallback: "${currentShardIndex}"`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// CRITICAL: In CI (especially GitHub Actions), if we can't detect shard info, block posting
|
|
313
|
-
// This prevents duplicate messages when running with multiple shards
|
|
314
|
-
if (process.env.CI && currentShardIndex === undefined) {
|
|
315
|
-
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
316
|
-
this.log(`❌ [Shard Detection] BLOCKING: GitHub Actions detected but shard index cannot be determined.`);
|
|
317
|
-
this.log(`❌ Attempted: GitHub API detection ${githubApiShardInfo ? 'succeeded' : 'failed'}, env vars not set`);
|
|
318
|
-
this.log(`❌ This prevents duplicate messages when running with multiple shards.`);
|
|
319
|
-
} else {
|
|
320
|
-
this.log(`❌ [Shard Detection] BLOCKING: CI environment detected but shard info not set.`);
|
|
321
|
-
this.log(`❌ MATRIX_COUNT: undefined, MATRIX_SHARD: undefined`);
|
|
322
|
-
this.log(`❌ This prevents duplicate messages when running with multiple shards.`);
|
|
323
|
-
}
|
|
324
|
-
this.log(`❌ Skipping Slack report to prevent duplicate messages from all shards.`);
|
|
325
|
-
this.log(`💡 Fix: Add these env vars to your workflow:`);
|
|
326
|
-
this.log(`💡 env:`);
|
|
327
|
-
this.log(`💡 MATRIX_SHARD: $\{\{ strategy.job-index \}\} # 0-based (only shard 0 posts)`);
|
|
328
|
-
this.log(`💡 MATRIX_COUNT: $\{\{ strategy.job-total \}\}`);
|
|
329
|
-
this.log(`💡 OR ensure SAFETYWINGTEST_GITHUB_TOKEN has workflow permissions for API detection`);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Convert to number for comparison (default to 0 if undefined)
|
|
334
|
-
const shardIndexNum = currentShardIndex !== undefined ? parseInt(currentShardIndex, 10) : 0;
|
|
335
|
-
|
|
336
|
-
this.log(`🔍 [Shard Detection] Final values:`);
|
|
337
|
-
this.log(`🔍 currentShardIndex (string): ${currentShardIndex !== undefined ? `"${currentShardIndex}"` : 'undefined'}`);
|
|
338
|
-
this.log(`🔍 shardIndexNum (number): ${shardIndexNum}`);
|
|
339
|
-
this.log(`🔍 totalShards: ${totalShards}`);
|
|
340
|
-
|
|
341
|
-
// CRITICAL: For GitHub Actions with multiple shards, only shard 0 should post
|
|
342
|
-
// If we're in CI with multiple shards and don't know which shard we are, block all posts
|
|
343
|
-
// This prevents duplicate messages when MATRIX_SHARD/MATRIX_INDEX is not set
|
|
344
|
-
if (process.env.CI && totalShards > 1 && currentShardIndex === undefined) {
|
|
345
|
-
this.log(`❌ [Shard Detection] BLOCKING: CI environment detected with ${totalShards} shards but shard index not set (MATRIX_SHARD/MATRIX_INDEX missing).`);
|
|
346
|
-
this.log(`❌ Skipping Slack report to prevent duplicate messages from all shards.`);
|
|
347
|
-
this.log(`💡 Fix: Add these env vars to your workflow:`);
|
|
348
|
-
this.log(`💡 env:`);
|
|
349
|
-
this.log(`💡 MATRIX_SHARD: $\{\{ strategy.job-index \}\} # 0-based (only shard 0 posts)`);
|
|
350
|
-
this.log(`💡 MATRIX_COUNT: $\{\{ strategy.job-total \}\}`);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Only allow shard 0 (0-based) to post when there are multiple shards
|
|
355
|
-
// This ensures only one shard posts the aggregated report
|
|
356
|
-
if (totalShards > 1 && shardIndexNum !== 0) {
|
|
357
|
-
this.log(`❌ [Shard Detection] BLOCKING: Non-zero shard detected (shard ${currentShardIndex} of ${totalShards})`);
|
|
358
|
-
this.log(`❌ Stopping reporter for non-zero index shard ${currentShardIndex} of ${totalShards}`);
|
|
359
|
-
this.log(`ℹ️ Only shard 0 will post the aggregated report.`);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Single shard - always allow posting
|
|
364
|
-
if (totalShards === 1) {
|
|
365
|
-
this.log(`✅ [Shard Detection] ALLOWING: Single shard detected. Posting Slack report.`);
|
|
366
|
-
} else {
|
|
367
|
-
this.log(`✅ [Shard Detection] ALLOWING: Multiple shards detected (${totalShards}). Shard ${currentShardIndex} (index ${shardIndexNum}) will post aggregated report.`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
74
|
const resultSummary = await this.resultsParser.getParsedResults();
|
|
75
|
+
// SHARDING SUPPORT - Stop slack messages for non-shard-1 shard(s)
|
|
76
|
+
if (process.env.SHARD_INDEX && process.env.SHARD_INDEX !== '1') {
|
|
77
|
+
this.log(`❌ Stopping reporter for non-shard-1 index ${process.env.SHARD_INDEX} of ${process.env.TOTAL_SHARDS}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
371
80
|
resultSummary.meta = this.meta;
|
|
372
81
|
const maxRetry = Math.max(...resultSummary.tests.map((o) => o.retry));
|
|
373
82
|
if (this.sendResults === 'on-failure'
|
package/package.json
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
"@slack/webhook": "^6.1.0",
|
|
5
5
|
"https-proxy-agent": "^7.0.1",
|
|
6
6
|
"adm-zip": "^0.5.10",
|
|
7
|
-
"axios": "^1.0.0"
|
|
7
|
+
"axios": "^1.0.0",
|
|
8
|
+
"jsonwebtoken": "^9.0.0"
|
|
8
9
|
},
|
|
9
10
|
"devDependencies": {
|
|
10
11
|
"@playwright/test": "^1.23.3",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"lint-fix": "npx eslint . --ext .ts --fix"
|
|
33
34
|
},
|
|
34
35
|
"name": "playwright-slack-report-burak",
|
|
35
|
-
"version": "3.0
|
|
36
|
+
"version": "3.1.0",
|
|
36
37
|
"main": "index.js",
|
|
37
38
|
"types": "dist/index.d.ts",
|
|
38
39
|
"author": "Burak B. <burak.boluk@hotmail.com>",
|