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, }; 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."); } 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") { 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; } 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; } 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 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; } 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) { 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); } 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(); }