import { execFile, spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; const workingDirectory = process.cwd(); const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); const execFileAsync = promisify(execFile); const roles = ["customer", "subuser", "admin", "superuser"]; const listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u; const ownedFilesByRole = { customer: [ "auth.smoke.spec.js", "booking-selfserve.smoke.spec.js", "connectivityIssue.spec.ts", "example.spec.ts", "i18n.smoke.spec.ts", "i18n.views.spec.ts", "navigation.smoke.spec.js", "self-serve-wash.spec.js", "user-orders.spec.ts", "userBookings.spec.ts", "userBookWash.spec.ts", "userHome.spec.ts", "userInvoices.spec.ts", "userMyWashStart.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-visibility.spec.ts", "admin-overview-mobile.spec.ts", "admin-overview-night-washes.spec.ts", "admin-pos-orders.spec.ts", "adminModuleGoals.spec.ts", "adminModulePosMobileOrderFlow.spec.ts", "change-invoice-collection.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.routes.spec.js", "edge-gateways.smoke.spec.js", "edge-gateways.visual.spec.js", "invoice-distribution.smoke.spec.js", "invoice-transfer-queue-history.spec.js", "invoicing-period.smoke.spec.js", "superuser-customer-complaints.spec.ts", "superuser-department-gates.spec.ts", "superuser-system-status.smoke.spec.js", "superuser-vehicles.smoke.spec.js", "workfeed-config.smoke.spec.js", ], }; 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], }, { 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: "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, }; 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; } throw new Error(`Unknown argument: ${arg}`); } return { options, forwardedArgs }; } 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."); } for (const arg of forwardedArgs) { if (arg === "--list" || arg === "--test-list" || arg === "--project") { throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`); } if (arg.startsWith("--list=") || arg.startsWith("--test-list=") || arg.startsWith("--project=")) { throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`); } } } function toBaseName(filePath) { return filePath.split(/[\\/]/u).pop() || filePath; } 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); } 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", `--project=${project}`, ...forwardedArgs], { cwd: workingDirectory, maxBuffer: 64 * 1024 * 1024, } ); if (stderr.trim()) { process.stderr.write(stderr); } return stdout; } async function writeTestList(role, project, matchingTests) { const outputDirectory = path.join(workingDirectory, "output", "playwright", "test-lists"); await fs.mkdir(outputDirectory, { recursive: true }); const testListPath = path.join(outputDirectory, `${role}-${project}.txt`); await fs.writeFile(testListPath, `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`, "utf8"); return testListPath; } 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(); }); }); } async function main() { const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2)); 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) { 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); } await main();