1import { readFileSync, existsSync } from 'fs' 7type ActionHistoryRow = { 10 suites?: Record<string, { passed: boolean }> 11 flags?: Record<string, boolean> 20export const recordTestResult = ({ ep, ptenv, ranSuites, durationSec, flags }: { 23 ranSuites: Record<string, { passed: boolean; message: string; durationSec?: number }> 25 flags?: Record<string, boolean> 27 const suites: Record<string, { passed: boolean; message: string; durationSec?: number }> = {} 29 for (const [name, r] of Object.entries(ranSuites)) { 30 suites[name] = { passed: r.passed, message: r.message, durationSec: r.durationSec } 31 if (!r.passed) allPassed = false 36 ts: new Date().toISOString(), 37 durationSec: durationSec || 0, 46export const getLastPassingSha = ({ep, suite, preferBuilt}: {ep: string, suite?: string, preferBuilt?: boolean}): string | null => { 47 if (!existsSync(ledgerPath)) return null 49 try { lines = readFileSync(ledgerPath, 'utf8').trim().split('\n').filter(Boolean) } 53 const rows = lines.map(l => JSON.parse(l) as ActionHistoryRow).filter(r => r.ep === epRel && r.action === 'runtests') 55 const findPassing = (skipCached: boolean) => { 56 for (const row of rows.toReversed()) { 57 const allPassed = suite 58 ? row.suites?.[suite]?.passed 59 : Object.values(row.suites || {}).every((s: {passed: boolean}) => s.passed) 60 if (!allPassed || !row.gitSha) continue 61 if (skipCached && row.imageReused) continue 62 const deployRow = lines.map(l => JSON.parse(l) as ActionHistoryRow).find(r => r.gitSha === row.gitSha && r.ep === epRel && (r.action === 'apply' || r.action === 'testdeploy') && r.imageReused) 63 if (skipCached && deployRow) continue 70 const built = findPassing(true) 71 if (built) return built 73 return findPassing(false) 76export const historyHeaderMarker = 'SHA' 78export type DeployStatsRow = { gitSha: string; value: number } 80let historyPrinted = false 81export const applyHistoryGuard = async (ep: string | undefined, statsRows?: DeployStatsRow[] | null, statsLabel?: string): Promise<boolean> => { 82 if (historyPrinted) return false 84 printTestHistory({ep, actions: ['apply'], statsRows, statsLabel}) 88export const printTestHistory = ({ ep, suites, actions, statsRows, statsLabel = 'REVENUE', flagCols }: { ep?: string; suites?: string[]; actions?: string[]; statsRows?: DeployStatsRow[] | null; statsLabel?: string; flagCols?: string[] } = {}) => { 90 if (!existsSync(ledgerPath)) { console.log(chalkGray('No history found.')); return } 91 try { lines = readFileSync(ledgerPath, 'utf8').trim().split('\n').filter(Boolean) } 92 catch { console.log(chalkGray('No history found.')); return } 94 const filterActions = actions || ['runtests', 'buildapk'] 95 let rows: ActionHistoryRow[] = lines.map(l => JSON.parse(l) as ActionHistoryRow).filter(r => filterActions.includes(r.action || '')) 98 rows = rows.filter(r => r.ep === epRel) 101 const last30 = rows.slice(-30).toReversed() 102 if (!last30.length) { console.log(chalkGray('No matching history.')); return } 104 const allSuiteNames = suites?.length 106 : [...new Set(last30.flatMap(r => Object.keys(r.suites || {})))] 108 const hasBuilder = last30.some(r => r.builder) 109 const hasStats = statsRows && statsRows.length > 0 110 const hasFlags = flagCols && flagCols.length > 0 111 const statsBySha = hasStats ? Object.fromEntries(statsRows!.map(r => [r.gitSha, r.value])) : null 112 const shaW = 9, actionW = 10, dateW = 12, agoW = 6, elapsedW = 7, builderW = 20, suiteW = 8, statsW = 10, flagW = 8 113 const padSha = (s: string) => s.slice(0, 8).padEnd(shaW), padAction = (s: string) => s.padEnd(actionW), padDate = (s: string) => s.padEnd(dateW), padAgo = (s: string) => s.padEnd(agoW), padElapsed = (s: string) => s.padEnd(elapsedW), padBuilder = (s: string) => s.padEnd(builderW), padSuite = (s: string) => s.padEnd(suiteW), padStats = (s: string) => s.padEnd(statsW), padFlag = (s: string) => s.padEnd(flagW) 114 const headerCols = [padSha('SHA'), padAction('ACTION'), padDate('DATE'), padAgo('AGO'), padElapsed('ELAPSED'), ...(hasBuilder ? [padBuilder('BUILDER')] : []), ...(hasStats ? [padStats(statsLabel.slice(0, 9))] : []), ...(hasFlags ? flagCols!.map(f => padFlag(f.slice(0, 7))) : []), ...allSuiteNames.map(s => padSuite(s.slice(0, 7)))] 117 for (const row of last30) { 118 const datePart = row.ts?.slice(5, 16).replace('T', ' ') || '?' 121 const actionLabel = row.action === 'apply' ? chalkCyan(padAction('apply')) 124 : row.ptenv === ptenvTestprod ? chalkGreen(padAction('testprod')) 126 : padAction(row.ptenv || '?') 128 padSha((row.gitSha || '?') + (row.imageReused ? '*' : '')), 133 ...(hasBuilder && row.builder ? [padBuilder(row.builder)] : hasBuilder ? [padBuilder('')] : []), 134 ...(hasStats ? [padStats(statsBySha![row.gitSha || ''] != null ? (statsLabel.startsWith('#') ? String(statsBySha![row.gitSha!]) : `$${statsBySha![row.gitSha!].toFixed(0)}`) : '')] : []), 135 ...(hasFlags ? flagCols!.map(f => { const v = row.flags?.[f]; return v == null ? chalkGray(padFlag('-')) : v ? chalkGreen(padFlag('yes')) : chalkYellow(padFlag('no')) }) : []), 136 ...allSuiteNames.map(s => { 137 const sr = row.suites?.[s] 142 console.log(cols.join(''))