UNPKG

@quenty/cli-output-helpers

Version:

Helpers to generate Nevermore package and game templates

231 lines 9.34 kB
/** * Shared types and formatting helpers for GitHub-based reporters. * * Both the PR comment reporter and job summary reporter use these to * render identical markdown tables from batch run state. */ import { formatDurationMs } from '../../cli-utils.js'; import { formatProgressInline, formatProgressResult, isEmptyTestRun } from '../progress-format.js'; // ── Error summarization ───────────────────────────────────────────────────── /** * Summarize an error string for display in compact contexts (tables, etc.). * Parses JSON API error bodies and truncates long messages. */ export function summarizeError(error, failedPhase) { const firstLine = error.split('\n')[0]; // Try to extract JSON error body from API responses // Format: "Action failed: STATUS TEXT: {json}" const jsonMatch = firstLine.match(/^(.+?failed): (\d{3}) \w+: (.+)$/); if (jsonMatch) { const [, action, status, jsonBody] = jsonMatch; const message = _extractJsonMessage(jsonBody); if (message) { return `${action} (${status}): ${message}`; } } if (firstLine.length > 80) { return firstLine.slice(0, 77) + '...'; } return firstLine; } function _extractJsonMessage(text) { try { const parsed = JSON.parse(text); if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) { return parsed.errors[0].message; } if (typeof parsed.message === 'string') { return parsed.message; } } catch { // Not JSON } return undefined; } // ── Table rendering ───────────────────────────────────────────────────────── const RUNNING_PHASE_LABELS = { building: '🔨 Building...', uploading: '📤 Uploading...', scheduling: '⏳ Scheduling...', launching: '🚀 Launching...', connecting: '🔌 Connecting...', executing: '🔄 Executing...', }; export function formatRunningStatus(phase, progress) { const label = RUNNING_PHASE_LABELS[phase] ?? '🔄 Running...'; if (progress) { const progressText = formatProgressInline(progress); return progressText ? `${label} ${progressText}` : label; } return label; } export function formatResultStatus(pkg, successLabel, failureLabel) { const duration = formatDurationMs(pkg.durationMs); const progressText = formatProgressResult(pkg.progressSummary); const empty = isEmptyTestRun(pkg.progressSummary); if (pkg.success) { const label = progressText ? `${successLabel} ${progressText}` : successLabel; return empty ? `⚠️ ${label} (${duration})` : `✅ ${label} (${duration})`; } const failedPhase = pkg.failedPhase; const label = failedPhase ? `**${failureLabel}** at ${failedPhase}` : `**${failureLabel}**`; return `❌ ${label} (${duration})`; } export function getActionsRunUrl() { const serverUrl = process.env.GITHUB_SERVER_URL; const repository = process.env.GITHUB_REPOSITORY; const runId = process.env.GITHUB_RUN_ID; if (serverUrl && repository && runId) { return `${serverUrl}/${repository}/actions/runs/${runId}`; } return undefined; } /** Render a markdown table with header, data rows, and footer. */ export function formatGithubTable(config, rows, extraColumns, footer) { // Determine which auto-visibility columns have any content const visibleExtras = extraColumns.filter((col) => { if (col.visibility === 'auto') { return rows.some((r) => { const idx = extraColumns.indexOf(col); return r.extraCells[idx].length > 0; }); } return true; }); const visibleIndices = visibleExtras.map((col) => extraColumns.indexOf(col)); const actionsRunUrl = getActionsRunUrl(); let body = config.commentMarker + '\n'; body += `## ${config.heading}\n\n`; // Header row const headers = ['Package', 'Status', ...visibleExtras.map((c) => c.header)]; body += '| ' + headers.join(' | ') + ' |\n'; body += '|' + headers.map(() => '--------').join('|') + '|\n'; // Data rows for (const row of rows) { const cells = [row.packageName, row.status]; for (const idx of visibleIndices) { cells.push(row.extraCells[idx]); } body += '| ' + cells.join(' | ') + ' |\n'; } body += '\n'; body += footer; if (actionsRunUrl) { body += ` · [View logs](${actionsRunUrl})`; } body += '\n'; return body; } // ── Body composition ──────────────────────────────────────────────────────── function _getAvgDurationMs(state) { const results = state.getResults(); if (results.length === 0) return undefined; const totalMs = results.reduce((sum, r) => sum + r.durationMs, 0); return totalMs / results.length; } function _formatPendingStatus(state, concurrency, queueIndex, totalPending) { const avgMs = _getAvgDurationMs(state); if (avgMs !== undefined) { const roundsAhead = Math.floor(queueIndex / concurrency); const etaMs = avgMs * (roundsAhead + 1); return `⏳ Pending (${queueIndex + 1}/${totalPending} in ~${formatDurationMs(etaMs)})`; } return `⏳ Pending (${queueIndex + 1}/${totalPending})`; } /** * Format the full table body from batch run state. * Used by both the PR comment reporter and the job summary reporter. */ export function formatGithubTableBody(state, config, concurrency) { const extraColumns = config.extraColumns ?? []; const packages = state.getAllPackages(); const allDone = packages.every((p) => p.status === 'passed' || p.status === 'failed'); const elapsedMs = Date.now() - state.startTimeMs; let pendingIndex = 0; const totalPending = packages.filter((p) => p.status === 'pending').length; const rows = packages.map((pkg) => { let statusText; switch (pkg.status) { case 'pending': statusText = _formatPendingStatus(state, concurrency, pendingIndex++, totalPending); break; case 'passed': case 'failed': statusText = formatResultStatus(pkg.result, config.successLabel ?? 'Passed', config.failureLabel ?? 'Failed'); break; default: statusText = formatRunningStatus(pkg.status, pkg.progress); break; } const extraCells = extraColumns.map((col) => col.render(pkg)); return { packageName: pkg.name, status: statusText, extraCells, }; }); let footer; if (allDone) { const passed = packages.filter((p) => p.status === 'passed').length; const failed = packages.filter((p) => p.status === 'failed').length; const emptyRuns = packages.filter((p) => isEmptyTestRun(p.progress)).length; const verb = config.summaryVerb ?? 'tested'; footer = `**${packages.length} ${verb}, ${passed} passed, ${failed} failed** in ${formatDurationMs(elapsedMs)}`; if (emptyRuns > 0) { footer += `\n⚠️ ${emptyRuns} package(s) ran 0 tests — check test discovery`; } } else { const done = packages.filter((p) => p.status === 'passed' || p.status === 'failed').length; const running = packages.filter((p) => p.status !== 'pending' && p.status !== 'passed' && p.status !== 'failed').length; const pending = packages.filter((p) => p.status === 'pending').length; const parts = []; if (done > 0) parts.push(`${done} done`); if (running > 0) parts.push(`${running} running`); if (pending > 0) parts.push(`${pending} pending`); footer = `**${packages.length} packages** · ${parts.join(', ')}`; } return formatGithubTable(config, rows, extraColumns, footer); } /** * Format an informational body when no tests were discovered for the run. */ export function formatGithubNoTestsBody(config, message) { const actionsRunUrl = getActionsRunUrl(); const heading = config.heading; let body = config.commentMarker + '\n'; body += `## ${heading}\n\n`; body += `ℹ️ **No tests to run**\n\n`; body += `${message}\n`; if (actionsRunUrl) { body += `\n[View logs](${actionsRunUrl})\n`; } return body; } /** * Format an error-only body (when the run failed before producing results). */ export function formatGithubErrorBody(config, error) { const actionsRunUrl = getActionsRunUrl(); const heading = config.errorHeading ?? config.heading; let body = config.commentMarker + '\n'; body += `## ${heading}\n\n`; body += `❌ **Run failed before producing results**\n\n`; body += `\`\`\`\n${error}\n\`\`\`\n`; if (actionsRunUrl) { body += `\n[View logs](${actionsRunUrl})\n`; } return body; } //# sourceMappingURL=formatting.js.map