release-gate.ts

3.7 KB · raw

1/**2 * PB-26-210: Release browser-gate validation.3 *4 * Pure functions that verify a gate result has no silent failures.5 * A release cannot report success with zero tests, unexpected skips,6 * flaky retries, or missing required projects.7 */89export interface ProjectResult {10  name: string;11  totalTests: number;12  passed: number;13  skipped: number;14  flaky: number;15}1617export interface GateResult {18  projects: ProjectResult[];19}2021export const REQUIRED_BROWSER_PROJECTS = ["chrome-release"] as const;2223export interface PlaywrightJsonReport {24  suites?: Array<{25    title?: string;26    specs?: Array<{27      tests?: Array<{28        projectName?: string;29        status?: string;30        results?: Array<{ status?: string }>;31      }>;32    }>;33    suites?: PlaywrightJsonReport["suites"];34  }>;35}3637export function gateResultFromPlaywright(report: PlaywrightJsonReport): GateResult {38  const projects = new Map<string, ProjectResult>();39  const visit = (suites: PlaywrightJsonReport["suites"] = []) => {40    for (const suite of suites) {41      for (const spec of suite.specs ?? []) {42        for (const test of spec.tests ?? []) {43          const projectName = test.projectName;44          if (!projectName || !REQUIRED_BROWSER_PROJECTS.includes(projectName as (typeof REQUIRED_BROWSER_PROJECTS)[number])) {45            continue;46          }47          const project = projects.get(projectName) ?? {48            name: projectName,49            totalTests: 0,50            passed: 0,51            skipped: 0,52            flaky: 0,53          };54          project.totalTests++;55          const results = test.results ?? [];56          const status = results.at(-1)?.status ?? test.status;57          if (status === "skipped") project.skipped++;58          else if (status === "passed") project.passed++;59          if (results.length > 1 && results.some((result) => result.status === "passed")) project.flaky++;60          projects.set(projectName, project);61        }62      }63      visit(suite.suites);64    }65  };66  visit(report.suites);67  return { projects: [...projects.values()] };68}6970export function validatePlaywrightJsonReport(report: PlaywrightJsonReport): string[] {71  return validateGateResult(gateResultFromPlaywright(report));72}7374export function formatGateErrors(errors: string[]): never {75  throw new Error(`Release browser gate validation failed:\n  ${errors.join("\n  ")}`);76}7778export function assertValidPlaywrightJsonReport(report: PlaywrightJsonReport): void {79  const errors = validatePlaywrightJsonReport(report);80  if (errors.length > 0) formatGateErrors(errors);81}828384/**85 * Validate a gate result. Returns a list of error strings — empty means pass.86 * The release gate fails (non-zero) on any error.87 */88export function validateGateResult(89  result: GateResult,90  requiredProjects: readonly string[] = REQUIRED_BROWSER_PROJECTS,91): string[] {92  const errors: string[] = [];9394  const totalTests = result.projects.reduce((sum, p) => sum + p.totalTests, 0);95  if (totalTests === 0) {96    errors.push("zero tests discovered across all projects");97  }9899  for (const name of requiredProjects) {100    const project = result.projects.find((p) => p.name === name);101    if (!project) {102      errors.push(`required project "${name}" is missing`);103      continue;104    }105    if (project.totalTests === 0) {106      errors.push(`project "${name}" has zero tests`);107    }108    if (project.skipped > 0) {109      errors.push(`project "${name}" has ${project.skipped} skipped test(s)`);110    }111    if (project.flaky > 0) {112      errors.push(`project "${name}" has ${project.flaky} flaky retry/retr(y)ies`);113    }114    if (project.passed !== project.totalTests) {115      errors.push(`project "${name}" has ${project.totalTests - project.passed} test(s) that did not pass`);116    }117  }118119  return errors;120}