Files
pleno-vue/scripts/run-playwright-full-slice.mjs
T

380 lines
11 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 roles = ["customer", "subuser", "admin", "superuser"];
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",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-catalog-switch.spec.ts",
"i18n-v2-integrity.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.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",
"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-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",
"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-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-products-layout.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],
},
{ 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.");
}
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", `--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();
});
});
}
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();
}