mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
cd4abd80fe
* test: improve e2e test failure diagnostics with run context and GitHub annotations When e2e tests fail, automatically dump workflow run diagnostics (status, input/output, error details, event timeline, dashboard link) to the CI logs. Emit GitHub Actions annotations that surface on PR file diffs. Fix collectedRunIds which was declared but never populated, enabling observability links in the PR comment. Enrich the aggregation script to include run IDs and dashboard URLs for failed tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: increase diagnostics hook timeout and fix flaky Vercel Prod tests - Increase onTestFailed hook timeout to 30s (default was 10s) so diagnostics can fetch run data even after slow test timeouts - parallelSleepWorkflow: increase elapsed threshold from 10s to 25s to accommodate Vercel cold start latency - webhookWorkflow: increase hook polling deadline from 30s to 60s and test timeout from 60s to 120s for slow Vercel webhook registration - readableStreamWorkflow: stop reading once expected content is received instead of waiting for stream close (which can hang on Vercel), and increase test timeout to 120s Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: emit ::error annotations via process.stdout.write to bypass vitest ANSI prefix Vitest's console interceptor prepends ANSI escape codes to console.log output, which prevents GitHub Actions from parsing ::error workflow commands. Use process.stdout.write() directly to ensure clean output. Also enhance the custom reporter to emit annotations in onFinished (which runs after vitest output is complete) as a reliable fallback, and enrich failure data from the diagnostics sidecar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only show observability links for vercel-prod test failures Community world and local tests don't run on Vercel's backend, so dashboard links are meaningless for those categories. Previously, test name collisions across sidecar files could cause community test failures to show Vercel dashboard URLs from vercel-prod runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: link annotations to test files instead of symlinked workflow sources The workflow source files in workbench/ are symlinks that GitHub can't resolve, causing annotations to show raw paths like #L0 instead of linking to code. Now: - utils.ts: omit file= from onTestFailed annotations (just show title) - github-reporter.ts: use the actual test file path (e.g. packages/core/e2e/e2e.test.ts) which GitHub can resolve Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
602 lines
18 KiB
JavaScript
602 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Parse command line arguments
|
|
const args = process.argv.slice(2);
|
|
let resultsDir = '.';
|
|
let jobName = 'E2E Tests';
|
|
let mode = 'single'; // 'single' for step summary, 'aggregate' for PR comment
|
|
let runUrl = '';
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--job-name' && args[i + 1]) {
|
|
jobName = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === '--mode' && args[i + 1]) {
|
|
mode = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === '--run-url' && args[i + 1]) {
|
|
runUrl = args[i + 1];
|
|
i++;
|
|
} else if (!args[i].startsWith('--')) {
|
|
resultsDir = args[i];
|
|
}
|
|
}
|
|
|
|
// Find JSON files by prefix pattern
|
|
function findJsonFiles(dir, prefix, excludePrefixes = []) {
|
|
const files = [];
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...findJsonFiles(fullPath, prefix, excludePrefixes));
|
|
} else if (
|
|
entry.name.startsWith(prefix) &&
|
|
entry.name.endsWith('.json') &&
|
|
!excludePrefixes.some((ep) => entry.name.startsWith(ep))
|
|
) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Directory doesn't exist or can't be read
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Find all e2e result JSON files
|
|
function findResultFiles(dir) {
|
|
return findJsonFiles(dir, 'e2e-', [
|
|
'e2e-metadata-',
|
|
'e2e-failures-',
|
|
'e2e-diagnostics-',
|
|
]);
|
|
}
|
|
|
|
// Find all e2e metadata JSON files
|
|
function findMetadataFiles(dir) {
|
|
const files = [];
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...findMetadataFiles(fullPath));
|
|
} else if (
|
|
entry.name.startsWith('e2e-metadata-') &&
|
|
entry.name.endsWith('.json')
|
|
) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Directory doesn't exist or can't be read
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Load metadata indexed by app name
|
|
function loadMetadata(dir) {
|
|
const metadata = new Map(); // app -> { runIds, vercel }
|
|
const metadataFiles = findMetadataFiles(dir);
|
|
|
|
for (const file of metadataFiles) {
|
|
try {
|
|
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
// Extract app name from filename: e2e-metadata-{app}-vercel.json
|
|
const basename = path.basename(file, '.json');
|
|
const match = basename.match(/^e2e-metadata-(.+)-vercel$/);
|
|
if (match && content.vercel) {
|
|
const appName = match[1];
|
|
metadata.set(appName, content);
|
|
}
|
|
} catch (e) {
|
|
// Skip invalid metadata files
|
|
}
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
// Load diagnostics sidecar files (per-test run ID + dashboard URL mapping)
|
|
function loadDiagnostics(dir) {
|
|
// Map of testName -> { runId, dashboardUrl, timestamp }
|
|
const diagnostics = new Map();
|
|
const files = findJsonFiles(dir, 'e2e-diagnostics-');
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const entries = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
for (const entry of entries) {
|
|
if (entry.testName && entry.runId) {
|
|
diagnostics.set(entry.testName, entry);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Skip invalid files
|
|
}
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
// Load failure sidecar files (enriched per-test failure info from github-reporter)
|
|
function loadFailures(dir) {
|
|
// Map of testName -> { runId, dashboardUrl, status, errorMessage }
|
|
const failures = new Map();
|
|
const files = findJsonFiles(dir, 'e2e-failures-');
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const entries = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
for (const entry of entries) {
|
|
if (entry.testName) {
|
|
failures.set(entry.testName, entry);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Skip invalid files
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
// Generate observability URL for a test
|
|
function getObservabilityUrl(metadata, appName, testName) {
|
|
const appMetadata = metadata.get(appName);
|
|
if (!appMetadata || !appMetadata.vercel) return null;
|
|
|
|
const { vercel, runIds } = appMetadata;
|
|
if (!vercel.teamSlug || !vercel.projectSlug) return null;
|
|
|
|
// Find the runId for this test
|
|
const runInfo = runIds?.find((r) => r.testName === testName);
|
|
if (!runInfo) return null;
|
|
|
|
const env = vercel.environment === 'production' ? 'production' : 'preview';
|
|
return `https://vercel.com/${vercel.teamSlug}/${vercel.projectSlug}/observability/workflows/runs/${runInfo.runId}?environment=${env}`;
|
|
}
|
|
|
|
// Parse vitest JSON output
|
|
function parseVitestResults(file) {
|
|
try {
|
|
const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
const results = {
|
|
file: path.basename(file),
|
|
passed: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
duration: 0,
|
|
failedTests: [],
|
|
};
|
|
|
|
// Handle vitest JSON reporter format
|
|
if (content.testResults) {
|
|
for (const testFile of content.testResults) {
|
|
results.duration += testFile.duration || 0;
|
|
for (const assertionResult of testFile.assertionResults || []) {
|
|
if (assertionResult.status === 'passed') {
|
|
results.passed++;
|
|
} else if (assertionResult.status === 'failed') {
|
|
results.failed++;
|
|
results.failedTests.push({
|
|
name: assertionResult.fullName || assertionResult.title,
|
|
file: testFile.name,
|
|
message:
|
|
assertionResult.failureMessages?.join('\n').slice(0, 200) || '',
|
|
});
|
|
} else if (assertionResult.status === 'skipped') {
|
|
results.skipped++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
} catch (e) {
|
|
console.error(`Warning: Could not parse ${file}: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Parse job info from filename (e.g., e2e-local-dev-nextjs-turbopack.json)
|
|
function parseJobInfo(filename) {
|
|
// Pattern: e2e-{category}-{app}.json or e2e-{category}-{subcategory}-{app}.json
|
|
const base = path.basename(filename, '.json');
|
|
const parts = base.split('-');
|
|
|
|
if (parts.length >= 3) {
|
|
// e2e-vercel-prod-nextjs-turbopack -> category: vercel-prod, app: nextjs-turbopack
|
|
// e2e-local-dev-nextjs-turbopack -> category: local-dev, app: nextjs-turbopack
|
|
// e2e-community-turso -> category: community, app: turso
|
|
const categoryEndIndex = parts.findIndex(
|
|
(p, i) =>
|
|
i > 1 &&
|
|
[
|
|
'nextjs',
|
|
'nitro',
|
|
'vite',
|
|
'nuxt',
|
|
'sveltekit',
|
|
'hono',
|
|
'express',
|
|
'fastify',
|
|
'astro',
|
|
'example',
|
|
'turso',
|
|
'mongodb',
|
|
'redis',
|
|
'starter',
|
|
].some((app) => p.startsWith(app))
|
|
);
|
|
|
|
if (categoryEndIndex > 1) {
|
|
return {
|
|
category: parts.slice(1, categoryEndIndex).join('-'),
|
|
app: parts.slice(categoryEndIndex).join('-'),
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
category: 'other',
|
|
app: base,
|
|
};
|
|
}
|
|
|
|
// Aggregate all results
|
|
function aggregateResults(files) {
|
|
const summary = {
|
|
totalPassed: 0,
|
|
totalFailed: 0,
|
|
totalSkipped: 0,
|
|
totalDuration: 0,
|
|
fileResults: [],
|
|
allFailedTests: [],
|
|
};
|
|
|
|
for (const file of files) {
|
|
const results = parseVitestResults(file);
|
|
if (results) {
|
|
summary.totalPassed += results.passed;
|
|
summary.totalFailed += results.failed;
|
|
summary.totalSkipped += results.skipped;
|
|
summary.totalDuration += results.duration;
|
|
summary.fileResults.push(results);
|
|
summary.allFailedTests.push(...results.failedTests);
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
// Aggregate results grouped by job category
|
|
function aggregateByCategory(files) {
|
|
const categories = new Map();
|
|
const overallSummary = {
|
|
totalPassed: 0,
|
|
totalFailed: 0,
|
|
totalSkipped: 0,
|
|
allFailedTests: [],
|
|
};
|
|
|
|
for (const file of files) {
|
|
const { category, app } = parseJobInfo(file);
|
|
const results = parseVitestResults(file);
|
|
|
|
if (!results) continue;
|
|
|
|
if (!categories.has(category)) {
|
|
categories.set(category, {
|
|
name: category,
|
|
passed: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
apps: [],
|
|
failedTests: [],
|
|
});
|
|
}
|
|
|
|
const cat = categories.get(category);
|
|
cat.passed += results.passed;
|
|
cat.failed += results.failed;
|
|
cat.skipped += results.skipped;
|
|
cat.apps.push({
|
|
name: app,
|
|
passed: results.passed,
|
|
failed: results.failed,
|
|
skipped: results.skipped,
|
|
});
|
|
cat.failedTests.push(
|
|
...results.failedTests.map((t) => ({ ...t, app, category }))
|
|
);
|
|
|
|
overallSummary.totalPassed += results.passed;
|
|
overallSummary.totalFailed += results.failed;
|
|
overallSummary.totalSkipped += results.skipped;
|
|
overallSummary.allFailedTests.push(
|
|
...results.failedTests.map((t) => ({ ...t, app, category }))
|
|
);
|
|
}
|
|
|
|
return { categories, overallSummary };
|
|
}
|
|
|
|
// Render markdown summary for single job (step summary)
|
|
function renderSingleJobSummary(summary) {
|
|
const total =
|
|
summary.totalPassed + summary.totalFailed + summary.totalSkipped;
|
|
const statusEmoji = summary.totalFailed > 0 ? '❌' : '✅';
|
|
const statusText =
|
|
summary.totalFailed > 0 ? 'Some tests failed' : 'All tests passed';
|
|
|
|
console.log(`## ${statusEmoji} ${jobName}\n`);
|
|
console.log(`**Status:** ${statusText}\n`);
|
|
|
|
// Summary table
|
|
console.log('| Metric | Count |');
|
|
console.log('|:-------|------:|');
|
|
console.log(`| ✅ Passed | ${summary.totalPassed} |`);
|
|
console.log(`| ❌ Failed | ${summary.totalFailed} |`);
|
|
console.log(`| ⏭️ Skipped | ${summary.totalSkipped} |`);
|
|
console.log(`| **Total** | **${total}** |`);
|
|
console.log('');
|
|
|
|
// Duration
|
|
const durationSec = (summary.totalDuration / 1000).toFixed(2);
|
|
console.log(`_Duration: ${durationSec}s_\n`);
|
|
|
|
// Failed tests details
|
|
if (summary.allFailedTests.length > 0) {
|
|
console.log('### Failed Tests\n');
|
|
for (const test of summary.allFailedTests) {
|
|
console.log(`<details>`);
|
|
console.log(`<summary>❌ ${test.name}</summary>\n`);
|
|
console.log(`**File:** \`${test.file}\`\n`);
|
|
if (test.message) {
|
|
console.log('```');
|
|
console.log(test.message);
|
|
console.log('```');
|
|
}
|
|
console.log('</details>\n');
|
|
}
|
|
}
|
|
|
|
// Results by file
|
|
if (summary.fileResults.length > 1) {
|
|
console.log('<details>');
|
|
console.log('<summary>Results by File</summary>\n');
|
|
console.log('| File | Passed | Failed | Skipped |');
|
|
console.log('|:-----|-------:|-------:|--------:|');
|
|
for (const result of summary.fileResults) {
|
|
const fileStatus = result.failed > 0 ? '❌' : '✅';
|
|
console.log(
|
|
`| ${fileStatus} ${result.file} | ${result.passed} | ${result.failed} | ${result.skipped} |`
|
|
);
|
|
}
|
|
console.log('</details>');
|
|
}
|
|
}
|
|
|
|
// Category display names
|
|
const categoryNames = {
|
|
'vercel-prod': '▲ Vercel Production',
|
|
'local-dev': '💻 Local Development',
|
|
'local-prod': '📦 Local Production',
|
|
'local-postgres': '🐘 Local Postgres',
|
|
windows: '🪟 Windows',
|
|
community: '🌍 Community Worlds',
|
|
other: '📋 Other',
|
|
};
|
|
|
|
// Category order for display
|
|
const categoryOrder = [
|
|
'vercel-prod',
|
|
'local-dev',
|
|
'local-prod',
|
|
'local-postgres',
|
|
'windows',
|
|
'community',
|
|
'other',
|
|
];
|
|
|
|
// Render aggregated PR comment summary
|
|
function renderAggregatedSummary(
|
|
categories,
|
|
overallSummary,
|
|
metadata,
|
|
diagnostics,
|
|
failures
|
|
) {
|
|
const total =
|
|
overallSummary.totalPassed +
|
|
overallSummary.totalFailed +
|
|
overallSummary.totalSkipped;
|
|
const statusEmoji = overallSummary.totalFailed > 0 ? '❌' : '✅';
|
|
const statusText =
|
|
overallSummary.totalFailed > 0 ? 'Some tests failed' : 'All tests passed';
|
|
|
|
console.log('<!-- e2e-test-results -->');
|
|
console.log(`## 🧪 E2E Test Results\n`);
|
|
console.log(`${statusEmoji} **${statusText}**\n`);
|
|
|
|
// Overall summary table
|
|
console.log('### Summary\n');
|
|
console.log('| | Passed | Failed | Skipped | Total |');
|
|
console.log('|:--|------:|-------:|--------:|------:|');
|
|
|
|
// Sort categories by defined order
|
|
const sortedCategories = Array.from(categories.entries()).sort(
|
|
([a], [b]) =>
|
|
(categoryOrder.indexOf(a) === -1 ? 999 : categoryOrder.indexOf(a)) -
|
|
(categoryOrder.indexOf(b) === -1 ? 999 : categoryOrder.indexOf(b))
|
|
);
|
|
|
|
for (const [catName, cat] of sortedCategories) {
|
|
const catTotal = cat.passed + cat.failed + cat.skipped;
|
|
const catStatus = cat.failed > 0 ? '❌' : '✅';
|
|
const displayName = categoryNames[catName] || catName;
|
|
console.log(
|
|
`| ${catStatus} ${displayName} | ${cat.passed} | ${cat.failed} | ${cat.skipped} | ${catTotal} |`
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`| **Total** | **${overallSummary.totalPassed}** | **${overallSummary.totalFailed}** | **${overallSummary.totalSkipped}** | **${total}** |`
|
|
);
|
|
console.log('');
|
|
|
|
// Failed tests section - grouped by category and app
|
|
if (overallSummary.allFailedTests.length > 0) {
|
|
console.log('### ❌ Failed Tests\n');
|
|
|
|
// Group failed tests by category, then by app
|
|
const failedByCategory = new Map();
|
|
for (const test of overallSummary.allFailedTests) {
|
|
if (!failedByCategory.has(test.category)) {
|
|
failedByCategory.set(test.category, new Map());
|
|
}
|
|
const catMap = failedByCategory.get(test.category);
|
|
if (!catMap.has(test.app)) {
|
|
catMap.set(test.app, []);
|
|
}
|
|
catMap.get(test.app).push(test);
|
|
}
|
|
|
|
// Sort categories by defined order
|
|
const sortedFailedCategories = Array.from(failedByCategory.entries()).sort(
|
|
([a], [b]) =>
|
|
(categoryOrder.indexOf(a) === -1 ? 999 : categoryOrder.indexOf(a)) -
|
|
(categoryOrder.indexOf(b) === -1 ? 999 : categoryOrder.indexOf(b))
|
|
);
|
|
|
|
for (const [catName, appsMap] of sortedFailedCategories) {
|
|
const catDisplay = categoryNames[catName] || catName;
|
|
const catFailedCount = Array.from(appsMap.values()).reduce(
|
|
(sum, tests) => sum + tests.length,
|
|
0
|
|
);
|
|
|
|
console.log(`<details>`);
|
|
console.log(
|
|
`<summary>${catDisplay} (${catFailedCount} failed)</summary>\n`
|
|
);
|
|
|
|
for (const [appName, tests] of appsMap.entries()) {
|
|
console.log(`**${appName}** (${tests.length} failed):\n`);
|
|
for (const test of tests) {
|
|
// Extract just the test name without "e2e " prefix if present
|
|
const testName = test.name.replace(/^e2e\s+/, '');
|
|
|
|
// Look up enriched diagnostics for this test.
|
|
// Only show observability links for vercel-prod tests — other
|
|
// categories (local, community) don't run on Vercel's world
|
|
// backend so there's no dashboard to link to.
|
|
const isVercelProd = catName === 'vercel-prod';
|
|
const diag = diagnostics.get(test.name) || diagnostics.get(testName);
|
|
const failureInfo = failures.get(testName) || failures.get(test.name);
|
|
const obsUrl = isVercelProd
|
|
? getObservabilityUrl(metadata, appName, test.name)
|
|
: null;
|
|
const dashboardUrl = isVercelProd
|
|
? diag?.dashboardUrl || failureInfo?.dashboardUrl || obsUrl
|
|
: null;
|
|
const runId = diag?.runId || failureInfo?.runId;
|
|
const runStatus = failureInfo?.status;
|
|
|
|
// Build the line with available info
|
|
const links = [];
|
|
if (dashboardUrl) links.push(`[🔍 observability](${dashboardUrl})`);
|
|
|
|
if (links.length > 0 || runId) {
|
|
const parts = [`\`${testName}\``];
|
|
if (runId) parts.push(`\`${runId}\``);
|
|
if (runStatus) parts.push(`status: \`${runStatus}\``);
|
|
if (links.length > 0) parts.push(links.join(' '));
|
|
console.log(`- ${parts.join(' | ')}`);
|
|
} else {
|
|
console.log(`- \`${testName}\``);
|
|
}
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
console.log('</details>\n');
|
|
}
|
|
}
|
|
|
|
// Detailed breakdown by category
|
|
console.log('### Details by Category\n');
|
|
|
|
for (const [catName, cat] of sortedCategories) {
|
|
const catStatus = cat.failed > 0 ? '❌' : '✅';
|
|
const displayName = categoryNames[catName] || catName;
|
|
|
|
console.log(`<details>`);
|
|
console.log(`<summary>${catStatus} ${displayName}</summary>\n`);
|
|
console.log('| App | Passed | Failed | Skipped |');
|
|
console.log('|:----|-------:|-------:|--------:|');
|
|
for (const app of cat.apps) {
|
|
const appStatus = app.failed > 0 ? '❌' : '✅';
|
|
console.log(
|
|
`| ${appStatus} ${app.name} | ${app.passed} | ${app.failed} | ${app.skipped} |`
|
|
);
|
|
}
|
|
console.log('</details>\n');
|
|
}
|
|
|
|
// Add link to workflow run
|
|
if (runUrl) {
|
|
console.log('---');
|
|
console.log(`📋 [View full workflow run](${runUrl})`);
|
|
}
|
|
}
|
|
|
|
// Main
|
|
const resultFiles = findResultFiles(resultsDir);
|
|
|
|
if (resultFiles.length === 0) {
|
|
// No results found, output a simple message
|
|
if (mode === 'aggregate') {
|
|
console.log('<!-- e2e-test-results -->');
|
|
console.log('## 🧪 E2E Test Results\n');
|
|
console.log('_No test result files found._\n');
|
|
} else {
|
|
console.log(`## ${jobName}\n`);
|
|
console.log('_No test result files found._\n');
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
if (mode === 'aggregate') {
|
|
const { categories, overallSummary } = aggregateByCategory(resultFiles);
|
|
const metadata = loadMetadata(resultsDir);
|
|
const diagnostics = loadDiagnostics(resultsDir);
|
|
const failures = loadFailures(resultsDir);
|
|
renderAggregatedSummary(
|
|
categories,
|
|
overallSummary,
|
|
metadata,
|
|
diagnostics,
|
|
failures
|
|
);
|
|
|
|
// Exit with non-zero if any tests failed
|
|
if (overallSummary.totalFailed > 0) {
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
const summary = aggregateResults(resultFiles);
|
|
renderSingleJobSummary(summary);
|
|
|
|
// Exit with non-zero if any tests failed
|
|
if (summary.totalFailed > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|