Run frontend tests concurrently on GitHub-hosted runners (#209)

Parallelize quality and Playwright jobs while preserving the existing test suite and required CI contracts.
This commit is contained in:
Jeppe B
2026-07-21 16:03:57 +02:00
committed by GitHub
parent a0c11e4bb7
commit de3f067372
2 changed files with 273 additions and 46 deletions
+134 -6
View File
@@ -182,6 +182,11 @@ function parseCliArgs(argv) {
role: "",
project: "",
listOnly: false,
shard: {
current: 1,
total: 1,
explicit: false,
},
};
for (let index = 0; index < optionArgs.length; index += 1) {
@@ -214,12 +219,38 @@ function parseCliArgs(argv) {
continue;
}
if (arg === "--shard") {
options.shard = parseShard(optionArgs[index + 1] || "");
index += 1;
continue;
}
if (arg.startsWith("--shard=")) {
options.shard = parseShard(arg.slice("--shard=".length));
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { options, forwardedArgs };
}
function parseShard(value) {
const match = value.match(/^(\d+)\/(\d+)$/u);
if (!match) {
throw new Error("--shard must use the form <current>/<total>, for example 1/2.");
}
const current = Number(match[1]);
const total = Number(match[2]);
if (!Number.isSafeInteger(current) || !Number.isSafeInteger(total) || total < 1 || current < 1 || current > total) {
throw new Error("--shard requires positive integers with current less than or equal to total.");
}
return { current, total, explicit: true };
}
function validateOptions(options, forwardedArgs) {
if (!roles.includes(options.role)) {
throw new Error(`--role must be one of: ${roles.join(", ")}`);
@@ -234,11 +265,16 @@ function validateOptions(options, forwardedArgs) {
}
for (const arg of forwardedArgs) {
if (arg === "--list" || arg === "--test-list" || arg === "--project") {
if (arg === "--list" || arg === "--test-list" || arg === "--project" || arg === "--shard") {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
}
if (arg.startsWith("--list=") || arg.startsWith("--test-list=") || arg.startsWith("--project=")) {
if (
arg.startsWith("--list=") ||
arg.startsWith("--test-list=") ||
arg.startsWith("--project=") ||
arg.startsWith("--shard=")
) {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
}
}
@@ -317,6 +353,32 @@ async function listProjectTests(project, forwardedArgs) {
return stdout;
}
async function listProjectShardTests(project, testListPath, shard, forwardedArgs) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[
playwrightCliPath,
"test",
"--list",
"--reporter=list",
`--project=${project}`,
`--test-list=${testListPath}`,
`--shard=${shard.current}/${shard.total}`,
...forwardedArgs,
],
{
cwd: workingDirectory,
maxBuffer: 64 * 1024 * 1024,
}
);
if (stderr.trim()) {
process.stderr.write(stderr);
}
return stdout;
}
const getTestListDirectory = () =>
process.env.PLAYWRIGHT_TEST_LIST_DIR || path.join(workingDirectory, "output", "playwright", "test-lists");
@@ -328,6 +390,10 @@ export function getLegacyTestListPath(role, project) {
return path.join(getTestListDirectory(), `${role}-${project}.txt`);
}
export function getShardTestListPath(project, role, shard) {
return path.join(getTestListDirectory(), `${project}-${role}-shard-${shard.current}-of-${shard.total}.txt`);
}
export async function writeTestList(role, project, matchingTests) {
const outputDirectory = getTestListDirectory();
await fs.mkdir(outputDirectory, { recursive: true });
@@ -340,6 +406,40 @@ export async function writeTestList(role, project, matchingTests) {
return testListPath;
}
export async function writeShardTestList(role, project, shard, matchingTests) {
const outputDirectory = getTestListDirectory();
await fs.mkdir(outputDirectory, { recursive: true });
const contents = `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`;
const testListPath = getShardTestListPath(project, role, shard);
await fs.writeFile(testListPath, contents, "utf8");
return testListPath;
}
function getTestIdentity(testEntry) {
return `${testEntry.relativeFile}:${testEntry.lineNumber}:${testEntry.columnNumber} ${testEntry.title}`;
}
function validateShardSelection(role, matchingTests, shardTests) {
if (shardTests.length === 0) {
throw new Error(`Shard contains no ${role} tests.`);
}
const matchingIdentities = new Set(matchingTests.map(getTestIdentity));
const shardIdentities = new Set();
for (const testEntry of shardTests) {
const identity = getTestIdentity(testEntry);
if (!matchingIdentities.has(identity)) {
throw new Error(`Shard selected a test outside the ${role} role: ${identity}`);
}
if (shardIdentities.has(identity)) {
throw new Error(`Shard selected a duplicate test: ${identity}`);
}
shardIdentities.add(identity);
}
}
async function runPlaywright(project, testListPath, forwardedArgs) {
const args = ["test", `--project=${project}`, `--test-list=${testListPath}`, ...forwardedArgs];
@@ -392,16 +492,44 @@ export async function main(argv = process.argv.slice(2)) {
return;
}
if (options.listOnly) {
if (options.listOnly && !options.shard.explicit) {
for (const testEntry of matchingTests) {
console.log(testEntry.listLine);
}
return;
}
const testListPath = await writeTestList(options.role, options.project, matchingTests);
console.log(`Using generated test list: ${path.relative(workingDirectory, testListPath)}`);
await runPlaywright(options.project, testListPath, forwardedArgs);
const roleTestListPath = await writeTestList(options.role, options.project, matchingTests);
let executionTests = matchingTests;
let executionTestListPath = roleTestListPath;
if (options.shard.explicit) {
if (options.shard.total > 1) {
const shardListOutput = await listProjectShardTests(
options.project,
roleTestListPath,
options.shard,
forwardedArgs
);
executionTests = parseListedTests(shardListOutput);
validateShardSelection(options.role, matchingTests, executionTests);
}
executionTestListPath = await writeShardTestList(options.role, options.project, options.shard, executionTests);
console.log(
`Resolved ${executionTests.length} test(s) for ${options.role} shard ${options.shard.current}/${options.shard.total} on ${options.project}.`
);
}
if (options.listOnly) {
for (const testEntry of executionTests) {
console.log(testEntry.listLine);
}
return;
}
console.log(`Using generated test list: ${path.relative(workingDirectory, executionTestListPath)}`);
await runPlaywright(options.project, executionTestListPath, forwardedArgs);
}
async function isDirectRun() {