Add unit tests and new components for edge gateway workflows:

- Introduced unit tests for edge gateway workflow helpers, including workflow step resolution, incident action mapping, relay health row formatting, and workspace state merging.
- Added new components for advanced operations, configuration panel, context panel, fleet rail, and health summary.
- Enhanced gateway management UI with support for advanced actions, fallback operations, relay health visualization, and device binding features.
This commit is contained in:
Jeppe Bundgaard
2026-04-16 13:44:36 +02:00
parent 2d94322021
commit bef9eaeb72
34 changed files with 5296 additions and 2369 deletions
+327
View File
@@ -0,0 +1,327 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile);
const roles = ["customer", "subuser", "admin", "superuser"];
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
const ownedFilesByRole = {
customer: [
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"example.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"navigation.smoke.spec.js",
"self-serve-wash.spec.js",
"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-orders.spec.ts",
"adminModuleGoals.spec.ts",
"adminModulePosMobileOrderFlow.spec.ts",
"change-invoice-collection.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.routes.spec.js",
"edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js",
"superuser-customer-complaints.spec.ts",
"superuser-department-gates.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
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: "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;
}
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);
}
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();
});
});
}
async function main() {
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
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);
}
await main();