From bef9eaeb72fca4cbe851943e95ac326fff6e3f93 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 16 Apr 2026 13:44:36 +0200 Subject: [PATCH] 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. --- .github/workflows/tests.yml | 37 +- README.md | 15 +- package.json | 1 + playwright.config.ts | 70 +- playwright.global-setup.mjs | 84 +- scripts/run-playwright-ci-parallel.mjs | 23 +- scripts/run-playwright-full-slice.mjs | 327 ++ .../EdgeGatewayAdvancedOperations.vue | 307 ++ .../edgeGateway/EdgeGatewayConfigurePanel.vue | 203 ++ .../edgeGateway/EdgeGatewayContextPanel.vue | 107 + .../edgeGateway/EdgeGatewayFleetRail.vue | 121 + .../edgeGateway/EdgeGatewayHealthSummary.vue | 135 + .../EdgeGatewayMaintenancePanel.vue | 196 ++ .../edgeGateway/EdgeGatewayTerminal.vue | 92 +- .../edgeGateway/EdgeGatewayWorkspace.vue | 3029 +++++------------ .../edgeGatewayWorkspace.helpers.js | 841 +++++ src/composables/useEdgeGatewayTerminal.js | 20 + src/composables/useEdgeGatewayWorkspace.js | 1235 +++++++ src/services/edgeGateways.js | 6 + .../superUserDashboard/EdgeGateways.vue | 2 +- .../department/DepartmentGateways.vue | 4 +- tests/e2e/admin-daily-report.spec.ts | 3 +- tests/e2e/admin-department-visibility.spec.ts | 5 +- tests/e2e/admin-overview-mobile.spec.ts | 3 +- tests/e2e/admin-overview-night-washes.spec.ts | 23 +- tests/e2e/admin-pos-orders.spec.ts | 88 +- tests/e2e/edge-gateways.routes.spec.js | 38 +- tests/e2e/edge-gateways.smoke.spec.js | 144 +- tests/e2e/edge-gateways.visual.spec.js | 34 +- tests/e2e/support/network.js | 188 +- tests/e2e/support/projects.ts | 9 + .../edge-gateway-workflow-helpers.spec.js | 92 + tests/unit/edge-gateway-workspace.spec.js | 125 +- tests/unit/use-edge-gateway-terminal.spec.js | 58 + 34 files changed, 5296 insertions(+), 2369 deletions(-) create mode 100644 scripts/run-playwright-full-slice.mjs create mode 100644 src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue create mode 100644 src/components/displays/edgeGateway/EdgeGatewayConfigurePanel.vue create mode 100644 src/components/displays/edgeGateway/EdgeGatewayContextPanel.vue create mode 100644 src/components/displays/edgeGateway/EdgeGatewayFleetRail.vue create mode 100644 src/components/displays/edgeGateway/EdgeGatewayHealthSummary.vue create mode 100644 src/components/displays/edgeGateway/EdgeGatewayMaintenancePanel.vue create mode 100644 src/components/displays/edgeGateway/edgeGatewayWorkspace.helpers.js create mode 100644 src/composables/useEdgeGatewayWorkspace.js create mode 100644 tests/e2e/support/projects.ts create mode 100644 tests/unit/edge-gateway-workflow-helpers.spec.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c511d859..47f8aa46 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,7 +98,27 @@ jobs: e2e-full: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch needs: build-and-unit + name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + role: [customer, subuser, admin, superuser] + browser: [chromium, firefox, webkit] + device: [mobile, tablet, desktop] + include: + - browser: chromium + browser_label: Chromium + browser_install: chromium + - browser: firefox + browser_label: Firefox + browser_install: firefox + - browser: webkit + browser_label: WebKit + browser_install: webkit + env: + PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }} + PLAYWRIGHT_REPORTER_MODE: line-html steps: - name: Checkout repository uses: actions/checkout@v5 @@ -113,22 +133,19 @@ jobs: run: npm ci --legacy-peer-deps - name: Install Playwright browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps ${{ matrix.browser_install }} - - name: Run full Playwright suite - run: npm run test:e2e:ci - env: - PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM: 2 - PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX: 1 - PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT: 2 + - name: Run full Playwright slice + run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}" - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-full + name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }} path: | - output/playwright/ci-parallel-report - output/playwright/ci-parallel-* + output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report + output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results + output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt if-no-files-found: ignore retention-days: 14 diff --git a/README.md b/README.md index 008e2ef6..13825300 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ npm run test:e2e:batched:chromium -- --grep @smoke ``` Artifacts and summaries: + - `output/playwright/batched-chromium/last-run.json` - `output/playwright/batched-chromium/failed-shards.json` - `output/playwright/batched-chromium/report-index.html` @@ -65,12 +66,22 @@ Artifacts and summaries: ## Playwright Full E2E -Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers: +Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers across: + +- `chromium-(desktop|tablet|mobile)` +- `firefox-(desktop|tablet|mobile)` +- `webkit-(desktop|tablet|mobile)` ```sh npm run test:e2e:ci ``` +Run a single full-suite slice for one role and one Playwright project: + +```sh +npm run test:e2e:full:slice -- --role=admin --project=webkit-tablet +``` + Default worker allocation: ```sh @@ -91,10 +102,12 @@ PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2 The runner fails fast if the combined worker count exceeds 5. Artifacts and summaries: + - `output/playwright/ci-parallel-report/index.html` - `output/playwright/ci-parallel-chromium/report/index.html` - `output/playwright/ci-parallel-firefox/report/index.html` - `output/playwright/ci-parallel-webkit/report/index.html` +- `output/playwright/test-lists/-.txt` ## Bubblewrap (TWA) Build and Install diff --git a/package.json b/package.json index b7487739..896c97c9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:e2e": "playwright test", "test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop", "test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs", + "test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs", "test:e2e:ci:serial": "playwright test --reporter=line,html", "test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs", "test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed", diff --git a/playwright.config.ts b/playwright.config.ts index c26b50b3..59523327 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,14 +14,25 @@ const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? co const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim(); const reporter = reporterMode === "line-html" - ? [ - ["line"], - ["html", { open: "never", outputFolder: htmlReportOutputFolder }], - ] - : [ - ["list"], - ["html", { open: "never", outputFolder: htmlReportOutputFolder }], - ]; + ? [["line"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]] + : [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]]; + +function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) { + const { defaultBrowserType, ...device } = devices[deviceName]; + const use = { + browserName, + ...device, + }; + + if (browserName === "firefox") { + delete use.isMobile; + } + + return { + name, + use, + }; +} export default defineConfig({ testDir: "./tests/e2e", @@ -43,38 +54,17 @@ export default defineConfig({ baseURL, trace: "retain-on-failure", screenshot: "only-on-failure", - video: "retain-on-failure" + video: "retain-on-failure", }, projects: [ - { - name: "chromium-desktop", - use: { - ...devices["Desktop Chrome"] - } - }, - { - name: "chromium-mobile", - use: { - ...devices["Pixel 5"] - } - }, - { - name: "firefox-desktop", - use: { - ...devices["Desktop Firefox"] - } - }, - { - name: "webkit-desktop", - use: { - ...devices["Desktop Safari"] - } - }, - { - name: "webkit-mobile", - use: { - ...devices["iPhone 12"] - } - } - ] + buildProject("chromium-desktop", "chromium", "Desktop Chrome"), + buildProject("chromium-tablet", "chromium", "iPad Mini"), + buildProject("chromium-mobile", "chromium", "Pixel 5"), + buildProject("firefox-desktop", "firefox", "Desktop Firefox"), + buildProject("firefox-tablet", "firefox", "iPad Mini"), + buildProject("firefox-mobile", "firefox", "Pixel 5"), + buildProject("webkit-desktop", "webkit", "Desktop Safari"), + buildProject("webkit-tablet", "webkit", "iPad Mini"), + buildProject("webkit-mobile", "webkit", "iPhone 12"), + ], }); diff --git a/playwright.global-setup.mjs b/playwright.global-setup.mjs index ec5252d0..614bee2c 100644 --- a/playwright.global-setup.mjs +++ b/playwright.global-setup.mjs @@ -10,6 +10,8 @@ const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `po .trim() .replace(/[^a-zA-Z0-9._-]+/g, "-"); const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`); +const posViewsDirectory = path.resolve(process.cwd(), "src/views/dashboards/departmentDashboard/modules/Pos"); +const shopComponentsDirectory = path.resolve(process.cwd(), "src/components/shop"); async function getListeningProcessOnWindows(port) { const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { @@ -102,6 +104,63 @@ async function waitForServerReady(url, timeoutMs = 120_000) { throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`); } +async function buildWarmupTargets() { + const [posViewFiles, shopComponentFiles] = await Promise.all([ + fs.readdir(posViewsDirectory), + fs.readdir(shopComponentsDirectory), + ]); + + return [ + { pathname: "/admin/12/modules/pos?step=1", expectedContentType: "text/html" }, + { pathname: "/admin/12/modules/pos/orders/54518", expectedContentType: "text/html" }, + { pathname: "/@vite/client", expectedContentType: "javascript" }, + { pathname: "/src/main.js", expectedContentType: "javascript" }, + { pathname: "/src/router.js", expectedContentType: "javascript" }, + ...posViewFiles + .filter((fileName) => fileName.endsWith(".vue")) + .map((fileName) => ({ + pathname: `/src/views/dashboards/departmentDashboard/modules/Pos/${fileName}`, + expectedContentType: "javascript", + })), + ...shopComponentFiles + .filter((fileName) => fileName.endsWith(".vue")) + .map((fileName) => ({ + pathname: `/src/components/shop/${fileName}`, + expectedContentType: "javascript", + })), + ]; +} + +async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: "manual" }); + const contentType = response.headers.get("content-type") || ""; + + if (response.ok && contentType.toLowerCase().includes(expectedContentType)) { + await response.arrayBuffer(); + return; + } + } catch { + // keep polling until the Vite transform is ready + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out warming Playwright dev asset ${url}.`); +} + +async function warmUpDevServer(url) { + const warmupTargets = await buildWarmupTargets(); + + for (const target of warmupTargets) { + await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType); + } +} + export default async function globalSetup() { if (process.env.PLAYWRIGHT_BASE_URL) { return; @@ -121,20 +180,16 @@ export default async function globalSetup() { const serverProcess = process.platform === "win32" - ? spawn( - "cmd.exe", - ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], - { - cwd: process.cwd(), - detached: true, - env: { - ...process.env, - PLAYWRIGHT: "1", - }, - stdio: "ignore", - windowsHide: true, - } - ) + ? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], { + cwd: process.cwd(), + detached: true, + env: { + ...process.env, + PLAYWRIGHT: "1", + }, + stdio: "ignore", + windowsHide: true, + }) : spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], { cwd: process.cwd(), detached: true, @@ -151,6 +206,7 @@ export default async function globalSetup() { try { await waitForServerReady(baseURL); + await warmUpDevServer(baseURL); } catch (error) { await killProcessTree(serverProcess.pid); throw error; diff --git a/scripts/run-playwright-ci-parallel.mjs b/scripts/run-playwright-ci-parallel.mjs index 2c750346..09b0f27a 100644 --- a/scripts/run-playwright-ci-parallel.mjs +++ b/scripts/run-playwright-ci-parallel.mjs @@ -17,17 +17,17 @@ let isShuttingDown = false; const groups = [ { name: "chromium", - projects: ["chromium-desktop", "chromium-mobile"], + projects: ["chromium-desktop", "chromium-tablet", "chromium-mobile"], defaultWorkers: 2, }, { name: "firefox", - projects: ["firefox-desktop"], + projects: ["firefox-desktop", "firefox-tablet", "firefox-mobile"], defaultWorkers: 1, }, { name: "webkit", - projects: ["webkit-desktop", "webkit-mobile"], + projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"], defaultWorkers: 2, }, ]; @@ -130,11 +130,7 @@ function prefixStream(stream, prefix) { function spawnGroup(group, index) { const devPort = basePort + index; const artifactNamespace = getArtifactNamespace(group); - const args = [ - "test", - ...forwardedArgs, - ...group.projects.flatMap((project) => ["--project", project]), - ]; + const args = ["test", ...forwardedArgs, ...group.projects.flatMap((project) => ["--project", project])]; const child = spawn(process.execPath, [playwrightCliPath, ...args], { cwd: workingDirectory, @@ -257,7 +253,11 @@ async function main() { const totalWorkers = validateWorkerCap(configuredGroups); console.log( - `Starting Playwright CI in parallel with ${configuredGroups.length} processes and ${totalWorkers} total worker(s): ${configuredGroups.map((group) => `${group.name}=${group.workers}`).join(", ")}.` + `Starting Playwright CI in parallel with ${ + configuredGroups.length + } processes and ${totalWorkers} total worker(s): ${configuredGroups + .map((group) => `${group.name}=${group.workers}`) + .join(", ")}.` ); await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); @@ -274,7 +274,10 @@ async function main() { } console.log( - `Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}` + `Parallel Playwright CI passed. Combined report index: ${path.relative( + workingDirectory, + path.join(reportIndexDirectory, "index.html") + )}` ); } diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs new file mode 100644 index 00000000..c0fd8489 --- /dev/null +++ b/scripts/run-playwright-full-slice.mjs @@ -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(); diff --git a/src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue b/src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue new file mode 100644 index 00000000..c280a8a2 --- /dev/null +++ b/src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue @@ -0,0 +1,307 @@ + + +