Files
pleno-vue/scripts/mobile/verify-store-test-gate.mjs
T

214 lines
6.6 KiB
JavaScript

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 || "<empty>"}`);
}
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 || "<unknown>"} is not the Automated Tests workflow.`);
}
if (run.head_sha !== sourceSha) {
throw new Error(`Run ${run.id} tested ${run.head_sha || "<unknown>"}, 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;
});
}