@quenty/cli-output-helpers
Version:
Helpers to generate Nevermore package and game templates
366 lines (317 loc) • 11.3 kB
text/typescript
/**
* 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 {
type PackageResult,
type PackageStatus,
type ProgressSummary,
type JobPhase,
} from '../reporter.js';
import {
type IStateTracker,
type PackageState,
} from '../state/state-tracker.js';
import { formatProgressInline, formatProgressResult, isEmptyTestRun } from '../progress-format.js';
// ── Public types ────────────────────────────────────────────────────────────
/** A column to render in the GitHub comment table. */
export interface GithubCommentColumn {
header: string;
render(pkg: PackageState): string;
/** 'auto' = hidden when all cells are empty. Default: 'always' */
visibility?: 'always' | 'auto';
}
/** Configuration for GitHub table reporters. */
export interface GithubCommentTableConfig {
/** Heading displayed above the table, e.g. "Test Results". */
heading: string;
/** HTML comment marker for finding/updating existing comments. */
commentMarker: string;
/** Extra columns beyond the built-in Package + Status columns. */
extraColumns?: GithubCommentColumn[];
/** Heading for error-only comment (when setError is used). */
errorHeading?: string;
/** Label for successful results, e.g. "Deployed". Default: "Passed" */
successLabel?: string;
/** Label for failed results, e.g. "Failed". Default: "Failed" */
failureLabel?: string;
/** Verb in the footer, e.g. "tested" in "X tested, Y passed, Z failed". Default: "tested" */
summaryVerb?: string;
/**
* When set, the PR comment reporter uses section-based merging.
* Multiple configs with different sectionIds share a single PR comment,
* each managing their own section independently.
*/
sectionId?: string;
}
/** A single row in the rendered GitHub table. */
export interface GithubTableRow {
packageName: string;
status: string;
extraCells: string[];
}
// ── 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: string, failedPhase?: JobPhase): string {
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: string): string | undefined {
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: Record<string, string> = {
building: '🔨 Building...',
uploading: '📤 Uploading...',
scheduling: '⏳ Scheduling...',
launching: '🚀 Launching...',
connecting: '🔌 Connecting...',
executing: '🔄 Executing...',
};
export function formatRunningStatus(phase: PackageStatus, progress?: ProgressSummary): string {
const label = RUNNING_PHASE_LABELS[phase] ?? '🔄 Running...';
if (progress) {
const progressText = formatProgressInline(progress);
return progressText ? `${label} ${progressText}` : label;
}
return label;
}
export function formatResultStatus(
pkg: PackageResult,
successLabel: string,
failureLabel: string
): string {
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(): string | undefined {
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: GithubCommentTableConfig,
rows: GithubTableRow[],
extraColumns: GithubCommentColumn[],
footer: string
): string {
// 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: IStateTracker): number | undefined {
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: IStateTracker,
concurrency: number,
queueIndex: number,
totalPending: number
): string {
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: IStateTracker,
config: GithubCommentTableConfig,
concurrency: number
): string {
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: GithubTableRow[] = packages.map((pkg: PackageState) => {
let statusText: string;
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: string;
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: string[] = [];
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: GithubCommentTableConfig,
message: string
): string {
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: GithubCommentTableConfig,
error: string
): string {
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;
}