214 lines
6.6 KiB
JavaScript
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;
|
|
});
|
|
}
|