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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user