Files
pleno-vue/scripts/run-playwright-full-slice.mjs
7c9eb8b644 chore(release): merge develop into master — XL Vask integration fixes (Aug 15 2026) (#312)
Brings all of the develop branch's commits into master.

## What this contains

The 9 commits on develop that landed in this round — all XL Vask-related
UI fixes plus the 'visible primary-button hover state' visual-diff:

- **PR #303** (TRU-5 / AUT-1) — style(AUT-1): visible primary-button
hover state + visual diff
- **PR #304** (TRU-10 / AUT-6) — fix(invoicing-period): propagate
flagged wash start date to Selvvask view
- **PR #305** (TRU-12 / AUT-8) — feat(xlvask): render friendly notice
for 404 from /modules/xlvask/services/usage/orders
- **PR #306** (TRU-9 / AUT-5) — i18n(test): lock in xlvask_review /
xlvask_usage_log mirroring to the global v2 fallback
- **PR #307** (TRU-13 / AUT-9) — i18n(xlvask_review): translate missing
keys for no, sv, de, en
- **PR #308** (TRU-15 / AUT-11) — test(e2e): add Playwright smoke test
for XL Vask flag → Selvvash navigation
- **PR #309** (TRU-11 / AUT-7) — feat(TRU-11): propagate department
selector to Selvvask usage query
- **PR #310** (TRU-19 / AUT-15) — test(TRU-19): lock self-serve program
number range + button registry contract
- **PR #311** (TRU-8 / AUT-4) — fix(invoicing-flag-list): explain empty
XL Vask hover preview when flag context has no metadata

## Why

The XL Vask integration bug surfaced from the user-reported message "XL
Vask-registreringen er hverken ignoreret eller knyttet til en ordre i
den valgte periode. doesn't show the wash." After dispatching 9
diagnostic + fix tasks and merging all 9 PRs into develop via the
OpenSymphony orchestrator running against MiniMax M3, this PR is the
canonical release to bring develop's accumulated changes into master.

No new code in this PR — just the squash-merged output of the 9 source
PRs combined into a single develop→master merge.

## Verification

All 9 source PRs passed:
- Required CI (Action Runners)
- App Store Readiness  
- Quality lint/i18n/build/unit/e2e suites

The required checks on this PR will run the same gate.

## Notes

- The api repo has its own equivalent PR/merge — see CHANGELOG for that
side.

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 08:58:06 +02:00

556 lines
17 KiB
JavaScript
Raw Permalink 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 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 <current>/<total>, 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();
}