418 lines
13 KiB
JavaScript
418 lines
13 KiB
JavaScript
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",
|
||
"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-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: [
|
||
"coolify-infrastructure.spec.js",
|
||
"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-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-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();
|
||
}
|