import { pathToFileURL } from "node:url"; const roleShards = new Map([ ["superuser", 2], ["admin", 2], ["customer", 1], ["subuser", 1], ]); export function expectedStoreGateJobs(platform) { const normalized = String(platform || "") .trim() .toLowerCase(); const browser = normalized === "android" ? "Chromium" : normalized === "apple" ? "WebKit" : null; if (!browser) { throw new Error(`Unsupported store platform: ${platform || ""}`); } return [...roleShards].flatMap(([role, shardTotal]) => Array.from( { length: shardTotal }, (_, index) => `E2E-full-${browser}-mobile-${role}-shard-${index + 1}-of-${shardTotal}` ) ); } function isNewerJobExecution(candidate, current) { const candidateAttempt = Number(candidate?.run_attempt || 0); const currentAttempt = Number(current?.run_attempt || 0); if (candidateAttempt !== currentAttempt) { return candidateAttempt > currentAttempt; } return Number(candidate?.id || 0) > Number(current?.id || 0); } export function evaluateStoreGate(platform, jobs) { const required = expectedStoreGateJobs(platform); const latestByName = new Map(); for (const job of jobs || []) { if (job?.name && (!latestByName.has(job.name) || isNewerJobExecution(job, latestByName.get(job.name)))) { latestByName.set(job.name, job); } } const failures = required.flatMap((name) => { const job = latestByName.get(name); if (!job) { return [`${name}:missing`]; } if (job.status !== "completed" || job.conclusion !== "success") { return [`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`]; } return []; }); return { required, failures, passed: failures.length === 0 }; } function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (!value.startsWith("--")) { throw new Error(`Unexpected argument: ${value}`); } const [rawName, inlineValue] = value.slice(2).split("=", 2); const nextValue = inlineValue ?? argv[index + 1]; if (inlineValue === undefined) { index += 1; } args[rawName] = nextValue; } return args; } function required(value, name) { const normalized = String(value || "").trim(); if (!normalized) { throw new Error(`${name} is required.`); } return normalized; } function createGitHubClient({ apiUrl, repository, token, fetchImpl = fetch }) { const request = async (path) => { const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28", }, }); if (!response.ok) { throw new Error(`GitHub API ${response.status} for ${path}: ${await response.text()}`); } return response.json(); }; return { request }; } function validateRun(run, { sourceSha, defaultBranch }) { if (run?.name !== "Automated Tests") { throw new Error(`Run ${run?.id || ""} is not the Automated Tests workflow.`); } if (run.head_sha !== sourceSha) { throw new Error(`Run ${run.id} tested ${run.head_sha || ""}, not ${sourceSha}.`); } if (run.event !== "push" || run.head_branch !== defaultBranch) { throw new Error(`Run ${run.id} is not a push run for ${defaultBranch}.`); } if (run.status !== "completed") { throw new Error(`Run ${run.id} is not complete.`); } if (run.conclusion !== "success") { throw new Error(`Run ${run.id} did not succeed (${run.conclusion || "none"}).`); } return run; } async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) { if (runId) { const run = await client.request(`/actions/runs/${encodeURIComponent(runId)}`); return validateRun(run, { sourceSha, defaultBranch }); } const query = new URLSearchParams({ branch: defaultBranch, event: "push", status: "completed", per_page: "100", }); const response = await client.request(`/actions/workflows/tests.yml/runs?${query}`); const run = response.workflow_runs?.find((candidate) => candidate.head_sha === sourceSha); if (!run) { throw new Error(`No completed Automated Tests push run was found for ${sourceSha} on ${defaultBranch}.`); } return validateRun(run, { sourceSha, defaultBranch }); } async function readAllAttemptJobs(client, runId) { const jobs = []; for (let page = 1; ; page += 1) { const query = new URLSearchParams({ filter: "all", per_page: "100", page: String(page) }); const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`); const pageJobs = response.jobs || []; jobs.push(...pageJobs); if (pageJobs.length < 100) { return jobs; } } } export async function verifyStoreTestGate({ platform, sourceSha, runId, defaultBranch = "master", apiUrl = "https://api.github.com", repository, token, fetchImpl, }) { const normalizedSha = required(sourceSha, "source SHA").toLowerCase(); if (!/^[0-9a-f]{40}$/u.test(normalizedSha)) { throw new Error("source SHA must be a full lowercase commit SHA."); } const client = createGitHubClient({ apiUrl: required(apiUrl, "GitHub API URL").replace(/\/$/u, ""), repository: required(repository, "GitHub repository"), token: required(token, "GitHub token"), fetchImpl, }); const run = await resolveTestRun(client, { runId: String(runId || "").trim(), sourceSha: normalizedSha, defaultBranch: required(defaultBranch, "default branch"), }); const jobs = await readAllAttemptJobs(client, run.id); const result = evaluateStoreGate(platform, jobs); if (!result.passed) { throw new Error(`${platform} store test gate failed for run ${run.id}: ${result.failures.join(", ")}`); } return { ...result, runId: run.id, sourceSha: normalizedSha }; } async function main() { const args = parseArgs(process.argv.slice(2)); const result = await verifyStoreTestGate({ platform: args.platform, sourceSha: args["source-sha"] || process.env.STORE_SOURCE_SHA, runId: args["run-id"] || process.env.TEST_WORKFLOW_RUN_ID, defaultBranch: args["default-branch"] || process.env.DEFAULT_BRANCH || "master", apiUrl: process.env.GITHUB_API_URL, repository: process.env.GITHUB_REPOSITORY, token: process.env.GH_TOKEN, }); console.log( `${args.platform} store gate passed for Automated Tests run ${result.runId}: ${result.required.join(", ")}` ); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); }