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: [ "driverAuth.spec.ts", "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", "xlvask-flag-to-selvvash-navigation.spec.ts", ], }; 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 /, 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); } } export function requireMatchingTests(role, project, matchingTests) { if (matchingTests.length === 0) { throw new Error(`Full Playwright slice resolved zero ${role} tests for ${project}.`); } return matchingTests; } 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}.` ); requireMatchingTests(options.role, options.project, matchingTests); 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(); }