1import { readFileSync } from "node:fs";2import { fileURLToPath } from "node:url";3import { dirname, join, resolve } from "node:path";4import { describe, expect, it } from "vitest";56import {7 gateResultFromPlaywright,8 validateGateResult,9 REQUIRED_BROWSER_PROJECTS,10 type GateResult,11 type ProjectResult,12} from "./release-gate";1314const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");1516function project(name: string, overrides: Partial<ProjectResult> = {}): ProjectResult {17 return { name, totalTests: 5, passed: 5, skipped: 0, flaky: 0, ...overrides };18}1920describe("PB-26-210: gate validation — fail-closed conditions", () => {21 it("reads the project name from Playwright test records", () => {22 expect(gateResultFromPlaywright({23 suites: [{24 title: "security-sentinels.spec.ts",25 specs: [{ tests: [{ projectName: "chrome-release", results: [{ status: "passed" }] }] }],26 }],27 })).toEqual({ projects: [project("chrome-release", { totalTests: 1, passed: 1 })] });28 });2930 it("passes a clean result with the required project and no skips", () => {31 expect(validateGateResult({ projects: [project("chrome-release")] })).toEqual([]);32 });3334 it("fails on zero discovered tests across all projects", () => {35 const result: GateResult = {36 projects: [project("chrome-release", { totalTests: 0 })],37 };38 const errors = validateGateResult(result);39 expect(errors).toContain("zero tests discovered across all projects");40 expect(errors.some((e) => e.includes("zero tests"))).toBe(true);41 });4243 it("fails on a skipped required test", () => {44 const result: GateResult = {45 projects: [project("chrome-release", { skipped: 2 })],46 };47 const errors = validateGateResult(result);48 expect(errors.some((e) => e.includes("chrome-release") && e.includes("skipped"))).toBe(true);49 });5051 it("fails on an empty project (zero tests in one required project)", () => {52 const result: GateResult = {53 projects: [project("chrome-release", { totalTests: 0, passed: 0 })],54 };55 const errors = validateGateResult(result);56 expect(errors.some((e) => e.includes("chrome-release") && e.includes("zero tests"))).toBe(true);57 });5859 it("fails on a missing required project name", () => {60 const result: GateResult = {61 projects: [],62 };63 const errors = validateGateResult(result);64 expect(errors.some((e) => e.includes("chrome-release") && e.includes("missing"))).toBe(true);65 });6667 it("fails on flaky retries that remain flaky", () => {68 const result: GateResult = {69 projects: [project("chrome-release", { flaky: 1 })],70 };71 const errors = validateGateResult(result);72 expect(errors.some((e) => e.includes("chrome-release") && e.includes("flaky"))).toBe(true);73 });7475 it("fails when any required-project test did not pass", () => {76 const errors = validateGateResult({77 projects: [project("chrome-release", { totalTests: 5, passed: 4 })],78 });79 expect(errors.some((error) => error.includes("did not pass"))).toBe(true);80 });8182 it("fails on a simulated missing fixture (project exists but zero tests and zero passed)", () => {83 const result: GateResult = {84 projects: [project("chrome-release", { totalTests: 0, passed: 0, skipped: 0 })],85 };86 const errors = validateGateResult(result);87 expect(errors.length).toBeGreaterThan(0);88 });89});9091describe("PB-26-210: browser gate is release-blocking", () => {92 const RELEASE_SCRIPTS = ["scripts/release.ts", "scripts/release-firefox.ts"];9394 for (const scriptPath of RELEASE_SCRIPTS) {95 it(`${scriptPath} calls the CI gate before any packaging or upload`, () => {96 const source = readFileSync(join(ROOT, scriptPath), "utf8");97 98 99 const ciIndex = source.indexOf('"run", "ci"');100 expect(ciIndex).toBeGreaterThan(-1);101102 103 104 105 const packageIndex = source.search(/uploadChrome\(|signFirefox\(|packageSourceArchive\(/);106 if (packageIndex > -1) {107 expect(ciIndex).toBeLessThan(packageIndex);108 }109 });110 }111112 it("scripts/ci.ts invokes the browser gate after both builds", () => {113 const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");114 const firefoxBuildIndex = source.indexOf("['npm', 'run', 'build:firefox']");115 const browserGateIndex = source.indexOf("scripts/run-release-browser-gate.ts");116 const testsIndex = source.indexOf("['npm', 'test'");117118 expect(browserGateIndex).toBeGreaterThan(firefoxBuildIndex);119 expect(browserGateIndex).toBeLessThan(testsIndex);120 });121122 it("the mandatory wrapper runs real Chrome and combined real-Firefox evidence", () => {123 const source = readFileSync(join(ROOT, "scripts/run-release-browser-gate.ts"), "utf8");124 expect(source).toContain("--project=chrome-release");125 expect(source).not.toContain("--project=firefox-release");126 expect(source).toContain("scripts/test-firefox-sender-context.mjs");127 expect(source).toContain("scripts/test-firefox-passkeys.mjs");128 expect(source).toContain("process.stderr.write(chromeResult.stdout)");129130 const firefoxSource = readFileSync(join(ROOT, "scripts/test-firefox-passkeys.mjs"), "utf8");131 expect(firefoxSource).toContain("firefox-sender-probe.js");132 expect(firefoxSource).toContain('sendSenderMessage({ type: "GET_RECOVERY_WORDS" })');133 expect(firefoxSource).toContain("peach_session_dk");134 });135136 it("the Chrome context honors headless release-gate execution", () => {137 const source = readFileSync(join(ROOT, "tests/helpers/vault.ts"), "utf8");138 expect(source).toContain('headless: process.env.PEACH_RELEASE_BROWSER_GATE === "1"');139 });140});141142describe("PB-26-210: playwright config defines required release project", () => {143 it("playwright.config.ts defines a chrome-release project", () => {144 const source = readFileSync(join(ROOT, "playwright.config.ts"), "utf8");145 expect(source).toContain("chrome-release");146 });147148 it("REQUIRED_BROWSER_PROJECTS matches the projects Playwright actually defines", () => {149 expect(REQUIRED_BROWSER_PROJECTS).toEqual(["chrome-release"]);150 const source = readFileSync(join(ROOT, "playwright.config.ts"), "utf8");151 expect(source).toContain('name: "chrome-release"');152 expect(source).not.toContain('name: "firefox-release"');153 });154155 it("release entry points do not force the browser gate in their CI child environment", () => {156 for (const scriptPath of ["scripts/release.ts", "scripts/release-firefox.ts"]) {157 const source = readFileSync(join(ROOT, scriptPath), "utf8");158 expect(source).not.toContain("PEACH_RELEASE_BROWSER_GATE");159 expect(source).toContain("env: safeEnv()");160 }161 });162163 it("CI invokes the validator against Playwright JSON output", () => {164 const source = readFileSync(join(ROOT, "scripts/run-release-browser-gate.ts"), "utf8");165 expect(source).toContain("assertValidPlaywrightJsonReport");166 });167});168169describe("PB-26-209: Firefox passkey browser probe", () => {170 it("is a valid WebDriver async-script function body", () => {171 const source = readFileSync(join(ROOT, "scripts/test-firefox-passkeys.mjs"), "utf8");172 const probe = source.match(/const PROBE_SCRIPT = `([\s\S]*?)`;\n/);173174 expect(probe?.[1]).toBeDefined();175 expect(() => new Function(probe![1]!)).not.toThrow();176 });177});