Files
pleno-vue/scripts/run-playwright-full-slice.mjs
T
Jeppe B de3f067372 Run frontend tests concurrently on GitHub-hosted runners (#209)
Parallelize quality and Playwright jobs while preserving the existing test suite and required CI contracts.
2026-07-21 16:03:57 +02:00

549 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile);
export const browserEngines = ["chromium", "webkit", "firefox"];
export const deviceClasses = ["mobile", "desktop", "tablet"];
export const roles = ["superuser", "admin", "customer", "subuser"];
export const fullSuiteProjects = browserEngines.flatMap((browser) =>
deviceClasses.map((device) => `${browser}-${device}`)
);
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
export const ownedFilesByRole = {
customer: [
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"date-period-selector.smoke.spec.js",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-v2-integrity.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"limited-backoffice.spec.ts",
"mobile-header-safe-area.spec.ts",
"navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
"release-channel-switched.spec.js",
"release-channel-unavailable.spec.js",
"release-update-widget.spec.js",
"self-serve-wash.spec.js",
"session-release-runtime.spec.ts",
"user-orders.spec.ts",
"userBookings.spec.ts",
"userBookWash.spec.ts",
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
"userVehicles.spec.ts",
],
subuser: [
"subuserCompleteRegistration.spec.ts",
"subuserProfileContact.spec.ts",
"subuserProfileGrant.spec.ts",
"subuserProfileInformation.spec.ts",
"subuserProfileSecurity.spec.ts",
"subuserProfileUsername.spec.ts",
],
admin: [
"admin-bookings-mobile.spec.ts",
"admin-daily-report.spec.ts",
"admin-department-notifications.spec.ts",
"admin-department-visibility.spec.ts",
"admin-overview-mobile.spec.ts",
"admin-overview-night-washes.spec.ts",
"admin-pos-order-filters.spec.ts",
"admin-pos-drafts.spec.ts",
"admin-pos-orders.spec.ts",
"adminModuleGoals.spec.ts",
"adminModulePosMobileOrderFlow.spec.ts",
"assign-draft-order-modal-create.spec.ts",
"assign-draft-order-modal-layout.spec.ts",
"change-invoice-collection.spec.ts",
"change-customer.spec.ts",
"default-mobile-redirect.spec.ts",
"economic-queue-workflow.spec.js",
"pos-customer-rules.spec.js",
"pos-desktop-card-payments.spec.js",
"pos-flow.spec.js",
"pos-mobile-card-payments.spec.js",
"pos-mobile-order-flow.spec.js",
"pos.visual.spec.js",
],
superuser: [
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js",
"issue-repro-duplicates-date.spec.js",
"release-manager.spec.js",
"self-serve-sessions.spec.js",
"self-serve-studio-audit-navigation.spec.js",
"self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"superuser-customer-rules.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-shells.spec.js",
"superuser-department-overview.spec.js",
"superuser-department-employees.spec.ts",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-security.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
export const titleRules = [
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
{ role: "customer", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[User\]/u] },
{ role: "subuser", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[Subuser\]/u] },
{
role: "customer",
file: "passkeyAuth.spec.ts",
patterns: [
/\[AUTH\]\[Passkey\]\[User\]/u,
/\[AUTH\]\[Passkey\]\[Browser Support\]/u,
/\[AUTH\]\[Passkey\]\[Button State\]/u,
],
},
{ role: "subuser", file: "passkeyAuth.spec.ts", patterns: [/\[AUTH\]\[Passkey\]\[Subuser\]/u] },
{
role: "customer",
file: "subuser-management.spec.ts",
patterns: [
/^customer user can invite and manage grant access/i,
/^customer user with own-subuser permissions /i,
/^customer user without own-subuser permissions /i,
],
},
{ role: "customer", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[User\]\[Visibility\]/u] },
{
role: "subuser",
file: "subuser-management.spec.ts",
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
},
{
role: "superuser",
file: "subuser-management.spec.ts",
patterns: [/^superusers can list and invite chauffeurs/i],
},
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
];
const ownedFileToRole = new Map();
for (const role of roles) {
for (const file of ownedFilesByRole[role]) {
if (ownedFileToRole.has(file)) {
throw new Error(`Duplicate role ownership for ${file}.`);
}
ownedFileToRole.set(file, role);
}
}
function parseCliArgs(argv) {
const separatorIndex = argv.indexOf("--");
const optionArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const forwardedArgs = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
const options = {
role: "",
project: "",
listOnly: false,
shard: {
current: 1,
total: 1,
explicit: false,
},
};
for (let index = 0; index < optionArgs.length; index += 1) {
const arg = optionArgs[index];
if (arg === "--role") {
options.role = optionArgs[index + 1] || "";
index += 1;
continue;
}
if (arg.startsWith("--role=")) {
options.role = arg.slice("--role=".length);
continue;
}
if (arg === "--project") {
options.project = optionArgs[index + 1] || "";
index += 1;
continue;
}
if (arg.startsWith("--project=")) {
options.project = arg.slice("--project=".length);
continue;
}
if (arg === "--list-only") {
options.listOnly = true;
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(", ")}`);
}
if (!options.project) {
throw new Error("--project is required.");
}
if (!fullSuiteProjects.includes(options.project)) {
throw new Error(`--project must be one of: ${fullSuiteProjects.join(", ")}`);
}
for (const arg of forwardedArgs) {
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=") ||
arg.startsWith("--shard=")
) {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
}
}
}
function toBaseName(filePath) {
return filePath.split(/[\\/]/u).pop() || filePath;
}
export function parseListedTests(listOutput) {
return listOutput
.split(/\r?\n/u)
.map((line) => {
const match = line.match(listEntryPattern);
if (!match) {
return null;
}
const [, relativeFile, lineNumber, columnNumber, title] = match;
return {
relativeFile,
fileName: toBaseName(relativeFile),
lineNumber: Number(lineNumber),
columnNumber: Number(columnNumber),
title,
listLine: line.trim(),
};
})
.filter(Boolean);
}
export function classifyTest(testEntry) {
const matches = new Set();
const directOwner = ownedFileToRole.get(testEntry.fileName);
if (directOwner) {
matches.add(directOwner);
}
for (const rule of titleRules) {
if (rule.file !== testEntry.fileName) {
continue;
}
if (rule.patterns.some((pattern) => pattern.test(testEntry.title))) {
matches.add(rule.role);
}
}
if (matches.size !== 1) {
const location = `${testEntry.relativeFile}:${testEntry.lineNumber}:${testEntry.columnNumber}`;
if (matches.size === 0) {
throw new Error(`Unclassified test: ${location} ${testEntry.title}`);
}
throw new Error(`Ambiguous role ownership for test: ${location} ${testEntry.title}`);
}
return [...matches][0];
}
async function listProjectTests(project, forwardedArgs) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[playwrightCliPath, "test", "--list", "--reporter=list", `--project=${project}`, ...forwardedArgs],
{
cwd: workingDirectory,
maxBuffer: 64 * 1024 * 1024,
}
);
if (stderr.trim()) {
process.stderr.write(stderr);
}
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");
export function getPrimaryTestListPath(project, role) {
return path.join(getTestListDirectory(), `${project}-${role}.txt`);
}
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 });
const contents = `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`;
const testListPath = getPrimaryTestListPath(project, role);
const legacyTestListPath = getLegacyTestListPath(role, project);
await fs.writeFile(testListPath, contents, "utf8");
await fs.writeFile(legacyTestListPath, contents, "utf8");
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];
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "inherit",
windowsHide: true,
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (signal) {
reject(new Error(`Playwright exited due to signal ${signal}.`));
return;
}
if ((code ?? 1) !== 0) {
reject(new Error(`Playwright exited with code ${code ?? 1}.`));
return;
}
resolve();
});
});
}
export async function main(argv = process.argv.slice(2)) {
const { options, forwardedArgs } = parseCliArgs(argv);
validateOptions(options, forwardedArgs);
const listOutput = await listProjectTests(options.project, forwardedArgs);
const listedTests = parseListedTests(listOutput);
const classifiedTests = listedTests.map((testEntry) => ({
...testEntry,
role: classifyTest(testEntry),
}));
const matchingTests = classifiedTests.filter((testEntry) => testEntry.role === options.role);
console.log(
`Resolved ${matchingTests.length} ${options.role} test(s) out of ${classifiedTests.length} listed test(s) for ${options.project}.`
);
if (matchingTests.length === 0) {
console.log(`No ${options.role} tests matched for ${options.project}. Nothing to run.`);
return;
}
if (options.listOnly && !options.shard.explicit) {
for (const testEntry of matchingTests) {
console.log(testEntry.listLine);
}
return;
}
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() {
if (!process.argv[1]) {
return false;
}
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
return currentPath === invokedPath;
}
if (await isDirectRun()) {
await main();
}