Add iOS safe-area viewport support and inset-aware mobile header/spacer sizing so controls clear the notch or Dynamic Island. Desktop and tablet headers remain unchanged. Includes focused unit and mobile browser regression coverage.
421 lines
13 KiB
JavaScript
421 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",
|
||
"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();
|
||
}
|