From 6c4b9cb5996f0d5705993d1b007a929c4c4fe73f Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 16 Apr 2026 10:52:56 +0200 Subject: [PATCH] Add and improve e2e tests for Edge Gateway flows and configure pre-commit hooks: - Introduced live smoke tests for Edge Gateways, verifying gateway routes and destructive-action prevention. - Added e2e scenarios for deep-link navigation, unavailable gateway recovery, token rotation, and background page refresh. - Refactored test helpers for streamlined functional validation in gateway scenarios. - Updated `EdgeGatewayTerminal.vue` with test IDs for enhanced testability. - Added and configured Husky pre-commit hooks for automated test file formatting and validation. --- .github/workflows/tests.yml | 10 +- .husky/pre-commit | 2 + README.md | 33 ++ package-lock.json | 163 +++--- package.json | 3 + scripts/pre-commit-format-tests.mjs | 111 ++++ scripts/prepare-husky.mjs | 38 ++ scripts/run-playwright-ci-parallel.mjs | 67 ++- .../edgeGateway/EdgeGatewayTerminal.vue | 15 +- .../edgeGateway/EdgeGatewayWorkspace.vue | 506 +++++++++++++++--- src/router.js | 6 + .../superUserDashboard/EdgeGateways.vue | 22 +- .../department/DepartmentGateways.vue | 21 +- tests/e2e/admin-overview-night-washes.spec.ts | 3 +- tests/e2e/admin-pos-orders.spec.ts | 23 +- tests/e2e/edge-gateways.routes.spec.js | 136 +++++ tests/e2e/edge-gateways.smoke.spec.js | 25 +- tests/e2e/fixtures/authHelpers.ts | 304 ++++++++++- tests/e2e/passkeyAuth.spec.ts | 149 +++--- tests/e2e/pos.visual.spec.js | 95 +++- ...add-items-mobile-chromium-mobile-win32.png | Bin 46319 -> 17404 bytes ...l-add-items-mobile-webkit-mobile-win32.png | Bin 24558 -> 17292 bytes ...plicate-warning-chromium-desktop-win32.png | Bin 40970 -> 16327 bytes ...uplicate-warning-firefox-desktop-win32.png | Bin 54871 -> 16392 bytes ...duplicate-warning-webkit-desktop-win32.png | Bin 44337 -> 15153 bytes ...-step-3-desktop-chromium-desktop-win32.png | Bin 77954 -> 18118 bytes ...os-step-3-desktop-webkit-desktop-win32.png | Bin 70010 -> 19689 bytes ...-step-4-desktop-chromium-desktop-win32.png | Bin 77429 -> 15895 bytes ...os-step-4-desktop-webkit-desktop-win32.png | Bin 70010 -> 17286 bytes .../release/edge-gateways.live-smoke.spec.ts | 52 ++ tests/e2e/support/network.js | 298 +++++++---- tests/e2e/userHome.spec.ts | 3 +- tests/unit/edge-gateway-workspace.spec.js | 37 +- 33 files changed, 1737 insertions(+), 385 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 scripts/pre-commit-format-tests.mjs create mode 100644 scripts/prepare-husky.mjs create mode 100644 tests/e2e/edge-gateways.routes.spec.js create mode 100644 tests/e2e/release/edge-gateways.live-smoke.spec.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5745064..c511d859 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -96,7 +96,7 @@ jobs: retention-days: 7 e2e-full: - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch needs: build-and-unit runs-on: ubuntu-latest steps: @@ -117,6 +117,10 @@ jobs: - 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: Upload Playwright report if: failure() @@ -124,7 +128,7 @@ jobs: with: name: playwright-report-full path: | - output/playwright/report - output/playwright/test-results + output/playwright/ci-parallel-report + output/playwright/ci-parallel-* if-no-files-found: ignore retention-days: 14 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..554e8d52 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +cd -- "$(dirname -- "$0")/.." +npm run format:tests:commit diff --git a/README.md b/README.md index 62d25076..008e2ef6 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,39 @@ Artifacts and summaries: - `output/playwright/batched-chromium-shard--of-/report/index.html` - `output/playwright/batched-chromium-rerun-shard--of-/report/index.html` +## Playwright Full E2E + +Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers: + +```sh +npm run test:e2e:ci +``` + +Default worker allocation: + +```sh +PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2 +PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1 +PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2 +``` + +Optional overrides: + +```sh +PLAYWRIGHT_PARALLEL_BASE_PORT=5191 +PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2 +PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1 +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` + ## Bubblewrap (TWA) Build and Install To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands: diff --git a/package-lock.json b/package-lock.json index 86b0e07a..27a48e75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/test-utils": "^2.4.6", + "husky": "^9.1.7", "jsdom": "^29.0.0", "otpauth": "^9.5.0", "prettier": "2.8.8", @@ -230,8 +231,8 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -261,6 +262,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -556,6 +558,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -1947,13 +1950,6 @@ "node": ">=0.12.0" } }, - "node_modules/@bubblewrap/core/node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT", - "optional": true - }, "node_modules/@bubblewrap/validator": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/@bubblewrap/validator/-/validator-1.22.0.tgz", @@ -1971,7 +1967,7 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "devOptional": true, + "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@creativebulma/bulma-badge": { @@ -2075,7 +2071,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -2124,7 +2119,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2136,6 +2130,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2152,6 +2147,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2168,6 +2164,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2184,6 +2181,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2200,6 +2198,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2216,6 +2215,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2232,6 +2232,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2248,6 +2249,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2264,6 +2266,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2280,6 +2283,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2296,6 +2300,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2312,6 +2317,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2328,6 +2334,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2344,6 +2351,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2360,6 +2368,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2376,6 +2385,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2392,6 +2402,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2408,6 +2419,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2424,6 +2436,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2440,6 +2453,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2456,6 +2470,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2472,6 +2487,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2488,6 +2504,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2504,6 +2521,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2520,6 +2538,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2562,7 +2581,6 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", "license": "MIT", - "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -2806,7 +2824,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -2843,7 +2860,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2856,7 +2872,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2881,7 +2896,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -2925,7 +2939,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3049,7 +3062,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3062,7 +3074,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3078,7 +3089,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3213,6 +3223,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3232,7 +3243,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3941,6 +3952,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3954,6 +3966,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3967,6 +3980,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3980,6 +3994,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3993,6 +4008,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4006,6 +4022,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4019,6 +4036,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4032,6 +4050,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4045,6 +4064,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4058,6 +4078,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4071,6 +4092,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4084,6 +4106,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4097,6 +4120,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4110,6 +4134,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4123,6 +4148,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4136,6 +4162,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4149,6 +4176,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4162,6 +4190,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4175,6 +4204,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4188,6 +4218,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4201,6 +4232,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4214,6 +4246,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4227,6 +4260,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4240,6 +4274,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4368,6 +4403,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/event-calendar__core": { @@ -5339,9 +5375,8 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5376,7 +5411,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5470,8 +5504,7 @@ "version": "5.10.4", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz", "integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==", - "license": "SEE LICENSE IN LICENSE", - "peer": true + "license": "SEE LICENSE IN LICENSE" }, "node_modules/aria-query": { "version": "5.3.1", @@ -5595,7 +5628,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -5810,7 +5842,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5895,8 +5926,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz", "integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/bulma-block-list": { "version": "1.1.0", @@ -6154,7 +6184,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6343,7 +6372,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/colors": { @@ -6417,6 +6446,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/copy-anything": { @@ -6568,7 +6598,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6757,7 +6786,6 @@ "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-sfc": "2.7.16", "csstype": "^3.1.0" @@ -7195,6 +7223,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7239,6 +7268,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7516,6 +7546,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7770,6 +7801,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7863,6 +7895,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -8259,6 +8292,22 @@ "node": ">=18.18.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -8286,8 +8335,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/ieee754": { "version": "1.2.1", @@ -9192,6 +9240,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9552,16 +9601,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -10126,6 +10165,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10354,7 +10394,6 @@ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "license": "MIT", - "peer": true, "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", @@ -10708,8 +10747,8 @@ "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10756,6 +10795,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10893,7 +10933,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10913,9 +10952,8 @@ "version": "1.98.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -11262,7 +11300,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -11555,7 +11593,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -11566,7 +11604,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11976,7 +12014,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "sync-message-port": "^1.0.0" @@ -11989,7 +12027,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -12086,7 +12124,7 @@ "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -12105,7 +12143,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tesseract.js": { @@ -12197,6 +12235,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -12655,15 +12694,15 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vite": { "version": "7.1.11", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13485,6 +13524,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14056,7 +14096,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -14116,7 +14155,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", @@ -14804,7 +14842,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/package.json b/package.json index f44dbafa..b7487739 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "vite build", "build:dev": "vite build --mode development", "format:tests": "prettier --write \"tests/**/*.{js,ts}\"", + "format:tests:commit": "node scripts/pre-commit-format-tests.mjs", "format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"", + "prepare": "node scripts/prepare-husky.mjs", "postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs", "preview": "vite preview", "preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173", @@ -90,6 +92,7 @@ "@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/test-utils": "^2.4.6", + "husky": "^9.1.7", "jsdom": "^29.0.0", "otpauth": "^9.5.0", "prettier": "2.8.8", diff --git a/scripts/pre-commit-format-tests.mjs b/scripts/pre-commit-format-tests.mjs new file mode 100644 index 00000000..7efe18d5 --- /dev/null +++ b/scripts/pre-commit-format-tests.mjs @@ -0,0 +1,111 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const gitPathspecs = [":(glob)tests/**/*.js", ":(glob)tests/**/*.ts"]; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + ...options, + }); + + if (result.error) { + throw result.error; + } + + return result; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function runNpmScript(scriptName) { + if (process.env.npm_execpath) { + return run(process.execPath, [process.env.npm_execpath, "run", scriptName], { stdio: "inherit" }); + } + + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + return run(npmCommand, ["run", scriptName], { + shell: process.platform === "win32", + stdio: "inherit", + }); +} + +function parseLines(output) { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]); + +if (worktreeResult.status !== 0) { + fail("Pre-commit formatting must run inside the front-end-vue Git worktree."); +} + +const gitRoot = path.resolve(worktreeResult.stdout.trim()); + +if (gitRoot !== repoRoot) { + fail(`Expected front-end-vue to be the Git root, but git reported: ${gitRoot}`); +} + +const unstagedTrackedResult = run("git", ["diff", "--name-only", "--", ...gitPathspecs]); + +if (unstagedTrackedResult.status !== 0) { + fail("Unable to inspect unstaged test-file changes before running Prettier."); +} + +const untrackedResult = run("git", ["ls-files", "--others", "--exclude-standard", "--", ...gitPathspecs]); + +if (untrackedResult.status !== 0) { + fail("Unable to inspect untracked test files before running Prettier."); +} + +const unsafePaths = [ + ...parseLines(unstagedTrackedResult.stdout), + ...parseLines(untrackedResult.stdout), +]; + +if (unsafePaths.length > 0) { + const formattedPaths = unsafePaths.map((filePath) => ` - ${filePath}`).join("\n"); + fail( + [ + "Pre-commit formatting aborted because tests contain unstaged changes.", + "Stage or discard these files before committing so the hook does not add unrelated work:", + formattedPaths, + ].join("\n") + ); +} + +console.log("Running Prettier for frontend test files..."); +const formatResult = runNpmScript("format:tests"); + +if (formatResult.status !== 0) { + process.exit(formatResult.status ?? 1); +} + +console.log("Restaging formatted frontend test files..."); +const knownFilesResult = run("git", ["ls-files", "--cached", "--others", "--exclude-standard", "--", ...gitPathspecs]); + +if (knownFilesResult.status !== 0) { + fail("Prettier completed, but the hook could not resolve test files for restaging."); +} + +const filesToStage = parseLines(knownFilesResult.stdout); + +if (filesToStage.length === 0) { + process.exit(0); +} + +const addResult = run("git", ["add", "--", ...filesToStage], { stdio: "inherit" }); + +if (addResult.status !== 0) { + fail("Prettier completed, but git add failed while restaging formatted test files."); +} diff --git a/scripts/prepare-husky.mjs b/scripts/prepare-husky.mjs new file mode 100644 index 00000000..0b9d1963 --- /dev/null +++ b/scripts/prepare-husky.mjs @@ -0,0 +1,38 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const huskyBin = path.join(repoRoot, "node_modules", "husky", "bin.js"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + ...options, + }); + + if (result.error) { + throw result.error; + } + + return result; +} + +const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]); + +if (worktreeResult.status !== 0) { + console.log("Skipping Husky install because front-end-vue is not attached to a Git worktree."); + process.exit(0); +} + +const gitRoot = path.resolve(worktreeResult.stdout.trim()); + +if (gitRoot !== repoRoot) { + console.log(`Skipping Husky install because Git root is ${gitRoot}, not ${repoRoot}.`); + process.exit(0); +} + +const huskyResult = run(process.execPath, [huskyBin], { stdio: "inherit" }); +process.exit(huskyResult.status ?? 1); diff --git a/scripts/run-playwright-ci-parallel.mjs b/scripts/run-playwright-ci-parallel.mjs index 088e479e..2c750346 100644 --- a/scripts/run-playwright-ci-parallel.mjs +++ b/scripts/run-playwright-ci-parallel.mjs @@ -8,7 +8,7 @@ const forwardedArgs = process.argv.slice(2); const workingDirectory = process.cwd(); const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); const basePort = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_BASE_PORT, 5191); -const perProcessWorkers = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS, 1); +const legacyPerProcessWorkers = parsePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS); const reportIndexDirectory = path.join(workingDirectory, "output", "playwright", "ci-parallel-report"); const execFileAsync = promisify(execFile); const activeChildren = new Set(); @@ -18,20 +18,63 @@ const groups = [ { name: "chromium", projects: ["chromium-desktop", "chromium-mobile"], + defaultWorkers: 2, }, { name: "firefox", projects: ["firefox-desktop"], + defaultWorkers: 1, }, { name: "webkit", projects: ["webkit-desktop", "webkit-mobile"], + defaultWorkers: 2, }, ]; -function normalizePositiveInt(value, fallback) { +function parsePositiveInt(value) { const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function normalizePositiveInt(value, fallback) { + const parsed = parsePositiveInt(value); + return parsed ?? fallback; +} + +function resolveGroupWorkers(group) { + const envName = `PLAYWRIGHT_PARALLEL_WORKERS_${group.name.toUpperCase()}`; + const groupWorkers = parsePositiveInt(process.env[envName]); + + if (groupWorkers !== null) { + return groupWorkers; + } + + if (legacyPerProcessWorkers !== null) { + return legacyPerProcessWorkers; + } + + return group.defaultWorkers; +} + +function resolveConfiguredGroups() { + return groups.map((group) => ({ + ...group, + workers: resolveGroupWorkers(group), + })); +} + +function validateWorkerCap(configuredGroups) { + const totalWorkers = configuredGroups.reduce((sum, group) => sum + group.workers, 0); + + if (totalWorkers > 5) { + const groupSummary = configuredGroups.map((group) => `${group.name}=${group.workers}`).join(", "); + throw new Error( + `Parallel Playwright worker cap exceeded (${totalWorkers} > 5). Configure the group workers so their sum stays at or below 5. Current values: ${groupSummary}` + ); + } + + return totalWorkers; } function getArtifactNamespace(group) { @@ -89,8 +132,8 @@ function spawnGroup(group, index) { const artifactNamespace = getArtifactNamespace(group); const args = [ "test", - ...group.projects.flatMap((project) => ["--project", project]), ...forwardedArgs, + ...group.projects.flatMap((project) => ["--project", project]), ]; const child = spawn(process.execPath, [playwrightCliPath, ...args], { @@ -99,7 +142,7 @@ function spawnGroup(group, index) { ...process.env, PLAYWRIGHT_BASE_URL: "", PLAYWRIGHT_DEV_PORT: String(devPort), - PLAYWRIGHT_WORKERS: String(perProcessWorkers), + PLAYWRIGHT_WORKERS: String(group.workers), PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace, PLAYWRIGHT_REPORTER_MODE: "line-html", PLAYWRIGHT: "1", @@ -121,6 +164,7 @@ function spawnGroup(group, index) { artifactNamespace, projects: group.projects, port: devPort, + workers: group.workers, }); }); }); @@ -141,6 +185,7 @@ async function writeCombinedReportIndex(results) { ${result.name} ${result.projects.join(", ")} ${result.port} + ${result.workers} ${status} Open report `; @@ -172,6 +217,7 @@ async function writeCombinedReportIndex(results) { Group Projects Port + Workers Status Report @@ -207,14 +253,17 @@ for (const signal of ["SIGINT", "SIGTERM"]) { } async function main() { + const configuredGroups = resolveConfiguredGroups(); + const totalWorkers = validateWorkerCap(configuredGroups); + console.log( - `Starting Playwright CI in parallel with ${groups.length} processes and ${perProcessWorkers} worker(s) per process.` + `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(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); - const results = await Promise.all(groups.map((group, index) => spawnGroup(group, index))); + await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); + const results = await Promise.all(configuredGroups.map((group, index) => spawnGroup(group, index))); await writeCombinedReportIndex(results); - await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); + await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); const failedRuns = results.filter((result) => result.code !== 0); if (failedRuns.length > 0) { diff --git a/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue b/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue index 3d0e8f01..dbb7c10b 100644 --- a/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue +++ b/src/components/displays/edgeGateway/EdgeGatewayTerminal.vue @@ -245,7 +245,7 @@ watch(terminalHost, (element) => { diff --git a/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue b/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue index 796655d5..3347556d 100644 --- a/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue +++ b/src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue @@ -18,8 +18,14 @@ const props = defineProps({ type: Number, default: null, }, + selectedGatewayId: { + type: [Number, String], + default: null, + }, }); +const emit = defineEmits(["open-gateway-page", "select-gateway"]); + const STATUS_META = { ONLINE: { label: "Online", @@ -56,10 +62,23 @@ const DISCOVERY_STATUS_LABELS = { UNKNOWN: "Ukendt", }; +const UPDATE_STATUS_LABELS = { + PENDING: "Afventer", + DISPATCHING: "Udsendt", + VERIFYING: "Verificerer genstart", + COMPLETED: "Fuldført", + FAILED: "Fejlet", + ROLLED_BACK: "Rullet tilbage", + UNKNOWN: "Ukendt", +}; + +const ACTIVE_UPDATE_JOB_STATUSES = ["PENDING", "DISPATCHING", "VERIFYING"]; + const gateways = ref([]); const departments = ref([]); -const selectedGatewayId = ref(null); +const activeGatewayId = ref(null); const selectedGateway = ref(null); +const unavailableGatewayId = ref(null); const fleetQuery = ref(""); const fleetFilter = ref("ALL"); @@ -69,6 +88,8 @@ const installerDepartmentId = ref(props.departmentId ? String(props.departmentId const installerLabel = ref(""); const installerCommand = ref(""); const installerMeta = ref(null); +const rotatedAgentToken = ref(""); +const rotationConfirmationOpen = ref(false); const editableBindings = ref([]); const targetVersion = ref(""); @@ -90,13 +111,27 @@ const heartbeatNow = ref(Date.now()); let heartbeatTimer = null; let gatewayRefreshTimer = null; let gatewayRefreshStopTimer = null; +let backgroundGatewayRefreshTimer = null; +let gatewayPollingInFlight = false; const GATEWAY_REFRESH_INTERVAL_MS = 2_000; const GATEWAY_REFRESH_WINDOW_MS = 25_000; +const BACKGROUND_GATEWAY_REFRESH_INTERVAL_MS = 5_000; const unwrapData = (response, fallback = null) => response?.data?.data ?? fallback; const departmentScoped = computed(() => props.departmentId !== null && props.departmentId !== undefined); +const normalizeGatewayId = (value) => { + if (value === null || value === undefined) { + return null; + } + + const normalized = String(value).trim(); + return normalized === "" ? null : normalized; +}; + +const routeSelectedGatewayId = computed(() => normalizeGatewayId(props.selectedGatewayId)); +const routeSelectionActive = computed(() => routeSelectedGatewayId.value !== null); const departmentsById = computed(() => departments.value.reduce((accumulator, department) => { @@ -316,7 +351,22 @@ const buildGatewayView = (gateway) => { const statusMeta = getStatusMeta(gateway.status); const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : []; const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : []; - const recentUpdates = Array.isArray(gateway.recent_updates) ? gateway.recent_updates : []; + const recentUpdates = (Array.isArray(gateway.recent_updates) ? gateway.recent_updates : []).map((job) => { + const result = job?.result && typeof job.result === "object" ? job.result : {}; + const status = job?.status ?? "UNKNOWN"; + return { + ...job, + result, + statusLabel: UPDATE_STATUS_LABELS[status] ?? status ?? UPDATE_STATUS_LABELS.UNKNOWN, + helper: + result?.error || + (status === "VERIFYING" + ? "Den nye agent er installeret og venter på at bekræfte genstarten." + : status === "ROLLED_BACK" + ? "Agenten blev rullet tilbage efter en fejl under opdateringen." + : ""), + }; + }); const recentShellSessions = Array.isArray(gateway.recent_shell_sessions) ? gateway.recent_shell_sessions : []; const recentCommands = Array.isArray(gateway.recent_commands) ? gateway.recent_commands : []; const auditLogs = Array.isArray(gateway.audit_logs) ? gateway.audit_logs : []; @@ -412,14 +462,25 @@ const selectedGatewayView = computed(() => { return buildGatewayView(selectedGateway.value); } - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return null; } - const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(selectedGatewayId.value)); + const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(activeGatewayId.value)); return buildGatewayView(gateway); }); +const unavailableSelectionState = computed(() => { + if (!routeSelectionActive.value || unavailableGatewayId.value === null) { + return null; + } + + return { + title: "Gatewayen er ikke tilgængelig", + description: `Gateway ${unavailableGatewayId.value} findes ikke i din tilgængelige flåde eller kunne ikke indlæses.`, + }; +}); + const selectedDepartmentId = computed(() => { if (departmentScoped.value) { return props.departmentId; @@ -512,6 +573,11 @@ const syncBindingsFromSelection = (gateway) => { editableBindings.value = (gateway?.bindings ?? []).map((binding) => normalizeBinding(binding)); }; +const resetRotationState = () => { + rotatedAgentToken.value = ""; + rotationConfirmationOpen.value = false; +}; + const syncMutableStateFromSelection = (gateway) => { if (!gateway) { editableBindings.value = []; @@ -543,6 +609,46 @@ const mergeGatewayIntoList = (gateway) => { ); }; +const applyGatewaySelection = (gateway, { syncState = true } = {}) => { + unavailableGatewayId.value = null; + selectedGateway.value = gateway; + + if (gateway) { + mergeGatewayIntoList(gateway); + } + + if (syncState) { + syncMutableStateFromSelection(gateway); + } +}; + +const setUnavailableGatewayState = (gatewayId, error) => { + unavailableGatewayId.value = gatewayId; + selectedGateway.value = null; + syncMutableStateFromSelection(null); + errorMessage.value = resolveEdgeGatewayErrorMessage( + error, + "Den valgte gateway er ikke tilgængelig eller kunne ikke indlæses." + ); +}; + +const showGatewayRefreshFallback = (gatewayId, { syncState = true, background = false } = {}) => { + const fallbackGateway = gateways.value.find((candidate) => Number(candidate.id) === Number(gatewayId)) ?? null; + + unavailableGatewayId.value = null; + if (!selectedGateway.value || Number(selectedGateway.value?.id) !== Number(gatewayId)) { + selectedGateway.value = fallbackGateway; + + if (syncState) { + syncMutableStateFromSelection(fallbackGateway); + } + } + + errorMessage.value = background + ? "Kunne ikke opdatere gateway-data. Viser seneste kendte data." + : "Kunne ikke hente gateway-detaljer. Viser seneste kendte data."; +}; + const handleShellSessionApproved = (session) => { if (!session) { return; @@ -565,13 +671,34 @@ const clearMessages = () => { errorMessage.value = ""; }; +const copyTextToClipboard = async (value) => { + if (!value) { + return; + } + + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const clipboard = document.createElement("textarea"); + clipboard.value = value; + clipboard.setAttribute("readonly", "readonly"); + clipboard.style.position = "absolute"; + clipboard.style.left = "-9999px"; + document.body.appendChild(clipboard); + clipboard.select(); + document.execCommand("copy"); + document.body.removeChild(clipboard); +}; + const gatewayHasQueuedWork = (gateway) => { if (!gateway) { return false; } const pendingUpdate = (gateway.recent_updates ?? []).some((job) => - ["PENDING", "DISPATCHING"].includes(job?.status) + ACTIVE_UPDATE_JOB_STATUSES.includes(job?.status) ); const pendingCommand = (gateway.recent_commands ?? []).some((job) => ["PENDING", "DISPATCHING"].includes(job?.status) @@ -593,19 +720,22 @@ const stopGatewayRefreshLoop = () => { }; const startGatewayRefreshLoop = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } stopGatewayRefreshLoop(); const refresh = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { stopGatewayRefreshLoop(); return; } - await loadGateway(selectedGatewayId.value); + await refreshWorkspaceData({ + syncState: false, + showDetailLoading: false, + }); if (!gatewayHasQueuedWork(selectedGateway.value)) { stopGatewayRefreshLoop(); @@ -676,11 +806,34 @@ const resolveEdgeGatewayErrorMessage = (error, fallbackMessage) => { return message; }; -const selectGateway = async (gatewayId) => { +const openGatewayPage = () => { + if (!selectedGatewayView.value?.id) { + return; + } + + emit("open-gateway-page", selectedGatewayView.value.id); +}; + +const selectGatewayLocally = async (gatewayId) => { + clearMessages(); stopGatewayRefreshLoop(); - selectedGatewayId.value = gatewayId; + activeGatewayId.value = normalizeGatewayId(gatewayId); + unavailableGatewayId.value = null; + resetRotationState(); fleetDrawerOpen.value = false; - await loadGateway(gatewayId); + await loadGateway(activeGatewayId.value); +}; + +const selectGateway = async (gatewayId) => { + const normalizedGatewayId = normalizeGatewayId(gatewayId); + emit("select-gateway", normalizedGatewayId); + + if (routeSelectionActive.value) { + fleetDrawerOpen.value = false; + return; + } + + await selectGatewayLocally(normalizedGatewayId); }; const loadDepartments = async () => { @@ -688,31 +841,49 @@ const loadDepartments = async () => { departments.value = unwrapData(response, []) ?? []; }; -const loadGateway = async (gatewayId) => { +const loadGateway = async (gatewayId, { syncState = true, showLoading = true } = {}) => { if (!gatewayId) { + unavailableGatewayId.value = null; selectedGateway.value = null; syncMutableStateFromSelection(null); return; } - detailLoading.value = true; + if (showLoading) { + detailLoading.value = true; + } try { const response = await getEdgeGateway(gatewayId); const gateway = unwrapData(response, null); - selectedGateway.value = gateway; - mergeGatewayIntoList(gateway); - syncMutableStateFromSelection(gateway); + if (!gateway) { + throw new Error("Gateway unavailable"); + } + + applyGatewaySelection(gateway, { + syncState: syncState || String(selectedGateway.value?.id ?? "") !== String(gateway.id ?? ""), + }); } catch (error) { - const fallbackGateway = gateways.value.find((candidate) => Number(candidate.id) === Number(gatewayId)); - selectedGateway.value = fallbackGateway ?? null; - syncMutableStateFromSelection(fallbackGateway); - errorMessage.value = "Kunne ikke hente gateway-detaljer. Viser seneste kendte data."; + const isRouteSelectedGateway = + routeSelectionActive.value && String(routeSelectedGatewayId.value) === String(gatewayId); + const isNotFoundError = Number(error?.response?.status ?? 0) === 404; + + if (isRouteSelectedGateway && (isNotFoundError || !selectedGateway.value)) { + setUnavailableGatewayState(gatewayId, error); + } else { + showGatewayRefreshFallback(gatewayId, { + syncState, + background: !showLoading, + }); + } } finally { - detailLoading.value = false; + if (showLoading) { + detailLoading.value = false; + } } }; -const loadFleet = async () => { +const loadFleet = async ({ syncState = true, showDetailLoading = true } = {}) => { + const previousActiveGatewayId = activeGatewayId.value; const response = await listEdgeGateways({ departmentId: props.departmentId ?? null, }); @@ -721,18 +892,77 @@ const loadFleet = async () => { if (!gateways.value.length) { stopGatewayRefreshLoop(); - selectedGatewayId.value = null; selectedGateway.value = null; + activeGatewayId.value = routeSelectionActive.value ? routeSelectedGatewayId.value : null; + unavailableGatewayId.value = routeSelectionActive.value ? routeSelectedGatewayId.value : null; syncMutableStateFromSelection(null); return; } - const candidateId = - gateways.value.find((gateway) => Number(gateway.id) === Number(selectedGatewayId.value))?.id ?? - gateways.value[0]?.id; + if (routeSelectionActive.value) { + activeGatewayId.value = routeSelectedGatewayId.value; + await loadGateway(routeSelectedGatewayId.value, { + syncState, + showLoading: showDetailLoading, + }); + return; + } - selectedGatewayId.value = candidateId; - await loadGateway(candidateId); + const candidateId = gateways.value.find((gateway) => Number(gateway.id) === Number(activeGatewayId.value))?.id ?? gateways.value[0]?.id; + activeGatewayId.value = candidateId; + unavailableGatewayId.value = null; + await loadGateway(candidateId, { + syncState: syncState || String(candidateId ?? "") !== String(previousActiveGatewayId ?? ""), + showLoading: showDetailLoading, + }); +}; + +const refreshWorkspaceData = async ({ syncState = false, showDetailLoading = false } = {}) => { + if (gatewayPollingInFlight || initializing.value) { + return; + } + + gatewayPollingInFlight = true; + + try { + await loadFleet({ + syncState, + showDetailLoading, + }); + } finally { + gatewayPollingInFlight = false; + } +}; + +const stopBackgroundGatewayRefreshLoop = () => { + if (backgroundGatewayRefreshTimer) { + window.clearInterval(backgroundGatewayRefreshTimer); + backgroundGatewayRefreshTimer = null; + } +}; + +const refreshWorkspaceDataInBackground = async () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + + await refreshWorkspaceData({ + syncState: false, + showDetailLoading: false, + }); +}; + +const startBackgroundGatewayRefreshLoop = () => { + stopBackgroundGatewayRefreshLoop(); + backgroundGatewayRefreshTimer = window.setInterval(() => { + refreshWorkspaceDataInBackground().catch(() => {}); + }, BACKGROUND_GATEWAY_REFRESH_INTERVAL_MS); +}; + +const handleGatewayWorkspaceVisibilityChange = () => { + if (typeof document !== "undefined" && document.visibilityState === "visible") { + refreshWorkspaceDataInBackground().catch(() => {}); + } }; const initializeWorkspace = async () => { @@ -886,7 +1116,7 @@ const generateInstaller = async () => { installerCommand.value = installerMeta.value?.install_command ?? ""; flashMessage.value = `Installationskommando oprettet for ${installerDepartmentName.value}.`; } catch (error) { - errorMessage.value = "Kunne ikke generere installationskommando."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke generere installationskommando."); } finally { installerLoading.value = false; } @@ -898,20 +1128,7 @@ const copyInstallerCommand = async () => { } try { - if (navigator?.clipboard?.writeText) { - await navigator.clipboard.writeText(installerCommand.value); - } else { - const clipboard = document.createElement("textarea"); - clipboard.value = installerCommand.value; - clipboard.setAttribute("readonly", "readonly"); - clipboard.style.position = "absolute"; - clipboard.style.left = "-9999px"; - document.body.appendChild(clipboard); - clipboard.select(); - document.execCommand("copy"); - document.body.removeChild(clipboard); - } - + await copyTextToClipboard(installerCommand.value); flashMessage.value = "Installationskommando kopieret."; } catch (error) { errorMessage.value = "Kunne ikke kopiere installationskommandoen."; @@ -919,7 +1136,7 @@ const copyInstallerCommand = async () => { }; const queueDiscovery = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -927,7 +1144,7 @@ const queueDiscovery = async () => { discoveryLoading.value = true; try { - const response = await triggerEdgeGatewayDiscovery(selectedGatewayId.value); + const response = await triggerEdgeGatewayDiscovery(activeGatewayId.value); const gateway = unwrapData(response, null); if (gateway) { selectedGateway.value = gateway; @@ -944,7 +1161,7 @@ const queueDiscovery = async () => { }; const rotateCredentials = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -952,23 +1169,40 @@ const rotateCredentials = async () => { rotationLoading.value = true; try { - const response = await rotateEdgeGatewayCredentials(selectedGatewayId.value); + const response = await rotateEdgeGatewayCredentials(activeGatewayId.value); const payload = unwrapData(response, {}); if (payload?.gateway) { selectedGateway.value = payload.gateway; mergeGatewayIntoList(payload.gateway); syncMutableStateFromSelection(payload.gateway); } - flashMessage.value = "Gateway-legitimationsoplysninger er roteret."; + rotatedAgentToken.value = payload?.agent_token ?? ""; + rotationConfirmationOpen.value = false; + flashMessage.value = rotatedAgentToken.value + ? "Gateway-legitimationsoplysninger er roteret. Gem den nye agent-token nu." + : "Gateway-legitimationsoplysninger er roteret."; } catch (error) { - errorMessage.value = "Kunne ikke rotere legitimationsoplysninger."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke rotere legitimationsoplysninger."); } finally { rotationLoading.value = false; } }; +const copyRotatedCredential = async () => { + if (!rotatedAgentToken.value) { + return; + } + + try { + await copyTextToClipboard(rotatedAgentToken.value); + flashMessage.value = "Den nye agent-token er kopieret."; + } catch (error) { + errorMessage.value = "Kunne ikke kopiere den nye agent-token."; + } +}; + const saveBindings = async () => { - if (!selectedGatewayId.value) { + if (!activeGatewayId.value) { return; } @@ -984,7 +1218,7 @@ const saveBindings = async () => { binding_source: binding.binding_source ?? "MANUAL", })); - const response = await saveEdgeGatewayBindings(selectedGatewayId.value, payload); + const response = await saveEdgeGatewayBindings(activeGatewayId.value, payload); const bindings = unwrapData(response, []) ?? []; selectedGateway.value = { @@ -996,14 +1230,14 @@ const saveBindings = async () => { syncBindingsFromSelection(selectedGateway.value); flashMessage.value = "Bindinger gemt."; } catch (error) { - errorMessage.value = "Kunne ikke gemme bindingerne."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke gemme bindingerne."); } finally { bindingSaveLoading.value = false; } }; const queueUpdate = async () => { - if (!selectedGatewayId.value || !targetVersion.value.trim()) { + if (!activeGatewayId.value || !targetVersion.value.trim()) { return; } @@ -1011,7 +1245,7 @@ const queueUpdate = async () => { updateLoading.value = true; try { - const response = await createEdgeGatewayUpdateJob(selectedGatewayId.value, { + const response = await createEdgeGatewayUpdateJob(activeGatewayId.value, { target_version: targetVersion.value.trim(), }); const updateJob = unwrapData(response, null); @@ -1063,7 +1297,7 @@ const applyCutoverMode = async () => { flashMessage.value = "Transporttilstand opdateret for afdelingen."; } catch (error) { - errorMessage.value = "Kunne ikke opdatere transporttilstanden."; + errorMessage.value = resolveEdgeGatewayErrorMessage(error, "Kunne ikke opdatere transporttilstanden."); } finally { cutoverLoading.value = false; } @@ -1073,10 +1307,32 @@ watch( () => props.departmentId, async (departmentId) => { installerDepartmentId.value = departmentId ? String(departmentId) : ""; + resetRotationState(); await initializeWorkspace(); } ); +watch( + () => routeSelectedGatewayId.value, + async (gatewayId, previousGatewayId) => { + if (String(gatewayId ?? "") === String(previousGatewayId ?? "")) { + return; + } + + if (!gatewayId) { + unavailableGatewayId.value = null; + return; + } + + clearMessages(); + stopGatewayRefreshLoop(); + activeGatewayId.value = gatewayId; + unavailableGatewayId.value = null; + resetRotationState(); + await loadGateway(gatewayId); + } +); + watch( () => selectedGatewayView.value?.department_id, (departmentId) => { @@ -1090,8 +1346,10 @@ onMounted(async () => { heartbeatTimer = window.setInterval(() => { heartbeatNow.value = Date.now(); }, 60_000); + document.addEventListener("visibilitychange", handleGatewayWorkspaceVisibilityChange); await initializeWorkspace(); + startBackgroundGatewayRefreshLoop(); }); onBeforeUnmount(() => { @@ -1099,25 +1357,28 @@ onBeforeUnmount(() => { window.clearInterval(heartbeatTimer); } stopGatewayRefreshLoop(); + stopBackgroundGatewayRefreshLoop(); + document.removeEventListener("visibilitychange", handleGatewayWorkspaceVisibilityChange); });