diff --git a/.github/actions/e2e-boot/action.yml b/.github/actions/e2e-boot/action.yml index e6d49b1a..1cd395b0 100644 --- a/.github/actions/e2e-boot/action.yml +++ b/.github/actions/e2e-boot/action.yml @@ -41,3 +41,27 @@ runs: echo "$K3S_INSTALL_SHA256 /tmp/k3s-install.sh" | sha256sum -c - chmod +x /tmp/k3s-install.sh INSTALL_K3S_VERSION="$K3S_VERSION" INSTALL_K3S_EXEC="--disable=traefik --bind-address 0.0.0.0 --tls-san host.docker.internal" /tmp/k3s-install.sh + + - name: Pre-pull executor images and Kueue manifest (background) + shell: bash + run: | + KUEUE_VERSION="${KUEUE_VERSION:-v0.16.1}" + + nohup bash -c ' + curl -fsSL -o /tmp/kueue-manifests.yaml \ + "https://github.com/kubernetes-sigs/kueue/releases/download/'"$KUEUE_VERSION"'/manifests.yaml" + echo $? > /tmp/kueue-download.exit + ' > /tmp/kueue-download.log 2>&1 & + echo $! > /tmp/kueue-download.pid + + nohup bash -c ' + sudo k3s crictl pull docker.io/library/python:3.11-slim + echo $? > /tmp/crictl-python.exit + ' > /tmp/crictl-python.log 2>&1 & + echo $! > /tmp/crictl-python.pid + + nohup bash -c ' + sudo k3s crictl pull docker.io/library/busybox:1.36 + echo $? > /tmp/crictl-busybox.exit + ' > /tmp/crictl-busybox.log 2>&1 & + echo $! > /tmp/crictl-busybox.pid diff --git a/.github/actions/e2e-ready/action.yml b/.github/actions/e2e-ready/action.yml index 3d2361a6..d2b1bc6e 100644 --- a/.github/actions/e2e-ready/action.yml +++ b/.github/actions/e2e-ready/action.yml @@ -9,6 +9,10 @@ inputs: description: 'Also wait for frontend health check (default: false)' required: false default: 'false' + install-kueue: + description: 'Install Kueue CRDs for executor pods (default: true)' + required: false + default: 'true' runs: using: 'composite' @@ -26,18 +30,26 @@ runs: /home/runner/.kube/config > backend/kubeconfig.yaml chmod 644 backend/kubeconfig.yaml - - name: Install Kueue + - name: Install Kueue and wait for executor image pulls + if: inputs.install-kueue == 'true' shell: bash run: | + set -e + KUEUE_VERSION="${KUEUE_VERSION:-v0.16.1}" KUEUE_MANIFEST_SHA256="${KUEUE_MANIFEST_SHA256:-3201a66ff731be440ecfcf3c0fa5979d001b834f68389208fe7ee18017fbcfe8}" KUEUE_MANIFEST="/tmp/kueue-manifests.yaml" - curl -fsSL -o "$KUEUE_MANIFEST" "https://github.com/kubernetes-sigs/kueue/releases/download/${KUEUE_VERSION}/manifests.yaml" + + timeout 120 tail --pid="$(cat /tmp/kueue-download.pid)" -f /dev/null 2>/dev/null || true + cat /tmp/kueue-download.log 2>/dev/null || true + [ "$(cat /tmp/kueue-download.exit)" = "0" ] + echo "${KUEUE_MANIFEST_SHA256} ${KUEUE_MANIFEST}" | sha256sum -c - kubectl apply --server-side -f "$KUEUE_MANIFEST" rm -f "$KUEUE_MANIFEST" kubectl wait --for=condition=Available --timeout=120s \ deployment/kueue-controller-manager -n kueue-system + kubectl apply --server-side -f - <<'EOF' apiVersion: kueue.x-k8s.io/v1beta1 kind: ResourceFlavor @@ -69,34 +81,23 @@ runs: clusterQueue: executor-queue EOF - - name: Use test environment config - shell: bash - run: cp backend/config.test.toml backend/config.toml + timeout 120 tail --pid="$(cat /tmp/crictl-python.pid)" -f /dev/null 2>/dev/null || true + cat /tmp/crictl-python.log 2>/dev/null || true + [ "$(cat /tmp/crictl-python.exit)" = "0" ] - - name: Pre-pull test runtime images into K3s - shell: bash - run: | - sudo k3s crictl pull docker.io/library/python:3.11-slim - sudo k3s crictl pull docker.io/library/busybox:1.36 + timeout 120 tail --pid="$(cat /tmp/crictl-busybox.pid)" -f /dev/null 2>/dev/null || true + cat /tmp/crictl-busybox.log 2>/dev/null || true + [ "$(cat /tmp/crictl-busybox.exit)" = "0" ] - - name: Wait for image pull and infra + - name: Prepare config, wait for infra shell: bash run: | - if [ -f /tmp/infra-pull.pid ]; then - PID=$(cat /tmp/infra-pull.pid) - if kill -0 "$PID" 2>/dev/null; then - echo "Waiting for image pull + infra startup..." - tail --pid="$PID" -f /dev/null 2>/dev/null || true - fi - fi + set -e + cp backend/config.test.toml backend/config.toml + + timeout 120 tail --pid="$(cat /tmp/infra-pull.pid)" -f /dev/null 2>/dev/null || true cat /tmp/infra-pull.log 2>/dev/null || true - if [ -f /tmp/infra-pull.exit ]; then - EXIT_CODE=$(cat /tmp/infra-pull.exit) - if [ "$EXIT_CODE" != "0" ]; then - echo "::error::Background image pull / infra pre-warm failed (exit $EXIT_CODE)" - exit 1 - fi - fi + [ "$(cat /tmp/infra-pull.exit)" = "0" ] - name: Start stack shell: bash @@ -110,11 +111,10 @@ runs: WAIT_FOR_FRONTEND: ${{ inputs.wait-for-frontend }} run: | echo "Waiting for backend health..." - timeout 120 bash -c 'until curl -ksf https://localhost/api/v1/health/live 2>/dev/null; do sleep 2; done' + timeout 120 bash -c 'until curl -ksf https://localhost/api/v1/health/live 2>/dev/null; do sleep 1; done' echo "Backend ready" if [ "$WAIT_FOR_FRONTEND" = "true" ]; then echo "Waiting for frontend health..." - timeout 60 bash -c 'until curl -ksf https://localhost:5001 2>/dev/null; do sleep 2; done' + timeout 60 bash -c 'until curl -ksf https://localhost:5001 2>/dev/null; do sleep 1; done' echo "Frontend ready" fi - diff --git a/.github/workflows/grimp.yml b/.github/workflows/grimp.yml index 0d0fc584..87f399ea 100644 --- a/.github/workflows/grimp.yml +++ b/.github/workflows/grimp.yml @@ -29,4 +29,4 @@ jobs: - name: Run grimp orphan module check run: | cd backend - uv run python scripts/check_orphan_modules.py + uv run --no-sync python scripts/check_orphan_modules.py diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 35820dc5..30bd9f71 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -24,11 +24,11 @@ jobs: run: | cd backend uv python install 3.12 - uv sync --frozen --group lint --no-dev + uv sync --frozen --group lint --group test --no-dev - name: Run mypy env: SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} run: | cd backend - uv run mypy --config-file pyproject.toml --strict . + uv run --no-sync mypy --config-file pyproject.toml --strict . diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index c81bfec0..2470a40a 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -29,4 +29,4 @@ jobs: - name: Run ruff run: | cd backend - uv run ruff check . --config pyproject.toml + uv run --no-sync ruff check . --config pyproject.toml diff --git a/.github/workflows/stack-tests.yml b/.github/workflows/stack-tests.yml index eee041c3..de9cef02 100644 --- a/.github/workflows/stack-tests.yml +++ b/.github/workflows/stack-tests.yml @@ -58,7 +58,7 @@ jobs: timeout-minutes: 5 run: | cd backend - uv run pytest tests/unit -v -rs \ + uv run --no-sync pytest tests/unit -v -rs \ --durations=0 \ --cov=app \ --cov-report=xml --cov-report=term @@ -236,12 +236,14 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: ./.github/actions/e2e-boot + - name: Boot E2E environment + uses: ./.github/actions/e2e-boot with: image-tag: ${{ needs.build-images.outputs.sha-tag }} github-token: ${{ secrets.GITHUB_TOKEN }} - - uses: ./.github/actions/e2e-ready + - name: Finalize k3s & start stack + uses: ./.github/actions/e2e-ready with: image-tag: ${{ needs.build-images.outputs.sha-tag }} @@ -305,7 +307,8 @@ jobs: - uses: actions/checkout@v6 # Phase 1: kick off image pull + infra + k3s in background - - uses: ./.github/actions/e2e-boot + - name: Boot E2E environment + uses: ./.github/actions/e2e-boot with: image-tag: ${{ needs.build-images.outputs.sha-tag }} github-token: ${{ secrets.GITHUB_TOKEN }} @@ -339,10 +342,12 @@ jobs: run: npx playwright install chromium # Phase 3: finalize k3s + start stack (k3s has been booting since e2e-boot) - - uses: ./.github/actions/e2e-ready + - name: Finalize k3s & start stack + uses: ./.github/actions/e2e-ready with: image-tag: ${{ needs.build-images.outputs.sha-tag }} wait-for-frontend: 'true' + install-kueue: 'false' - name: Run Playwright tests timeout-minutes: 10 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 381e22bd..c45a7909 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,8 +59,8 @@ "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.8.3", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", @@ -88,7 +88,9 @@ "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@adobe/css-tools": { "version": "4.4.4", @@ -113,6 +115,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", @@ -126,6 +130,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", @@ -138,7 +144,9 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -223,6 +231,8 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "css-tree": "^3.0.0" }, @@ -432,6 +442,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -474,6 +486,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" @@ -1130,6 +1144,8 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -2550,8 +2566,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2571,17 +2585,13 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@types/node": "*" } @@ -3104,6 +3114,8 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -3277,6 +3289,8 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -4020,6 +4034,8 @@ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^4.1.2", "@csstools/css-syntax-patches-for-csstree": "^1.0.26", @@ -4035,6 +4051,8 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -4073,7 +4091,9 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/deep-equal": { "version": "1.0.1", @@ -5299,12 +5319,10 @@ "dev": true }, "node_modules/happy-dom": { - "version": "20.7.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", - "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz", + "integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -5322,8 +5340,6 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -5336,8 +5352,6 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -5347,8 +5361,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5421,6 +5433,8 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -5532,6 +5546,8 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -5545,6 +5561,8 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -5842,7 +5860,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/is-promise": { "version": "4.0.0", @@ -5948,6 +5968,8 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -6526,6 +6548,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -7138,6 +7162,8 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -7150,6 +7176,8 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -8538,6 +8566,8 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -9490,7 +9520,9 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/table": { "version": "6.9.0", @@ -9627,6 +9659,8 @@ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.0.19" }, @@ -9638,7 +9672,9 @@ "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -9675,6 +9711,8 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -9687,6 +9725,8 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -9765,6 +9805,8 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20.18.1" } @@ -9773,9 +9815,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/unicorn-magic": { "version": "0.4.0", @@ -10027,6 +10067,8 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -10039,6 +10081,8 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -10048,6 +10092,8 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -10057,6 +10103,8 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -10166,6 +10214,8 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -10174,7 +10224,9 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/yocto-queue": { "version": "0.1.0", diff --git a/frontend/package.json b/frontend/package.json index 88cd8215..b23307d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -71,8 +71,8 @@ "eslint-plugin-svelte": "^3.15.0", "express": "^5.2.1", "globals": "^17.3.0", + "happy-dom": "^20.8.3", "http-proxy": "^1.18.1", - "jsdom": "^28.1.0", "monocart-reporter": "^2.10.0", "postcss": "^8.4.47", "postcss-lightningcss": "^1.0.2", diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index ae049433..232fc4a0 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -26,6 +26,26 @@ import type { export type UserEventInstance = ReturnType; +const _r = new Request('http://test'); +const _s = new Response(); + +/** + * Fluent mock wrapper for hey-api SDK functions. + * Hides the `request`/`response` fields that `RequestResult` requires. + */ +export function mockApi(fn: (...args: any[]) => any) { + const mock = vi.mocked(fn) as ReturnType; + return { + ok(data: unknown) { + mock.mockResolvedValue({ data, error: undefined, request: _r, response: _s }); + }, + err(error: unknown) { + mock.mockResolvedValue({ data: undefined, error, request: _r, response: _s }); + }, + mock, + }; +} + export const user: UserEventInstance = userEvent.setup({ delay: null, pointerEventsCheck: 0, @@ -352,3 +372,19 @@ export const createMockUsers = (count: number): UserResponse[] => is_active: i % 3 !== 0, }), ); + +/** + * Selects an option in a onPageSizeChange?.(Number(e.currentTarget.value))} + bind:value={pageSize} + onchange={() => onPageSizeChange?.()} aria-label="Items per page" > {#each pageSizeOptions as size} diff --git a/frontend/src/components/__tests__/ErrorDisplay.test.ts b/frontend/src/components/__tests__/ErrorDisplay.test.ts index 60e4c61c..d92c15d8 100644 --- a/frontend/src/components/__tests__/ErrorDisplay.test.ts +++ b/frontend/src/components/__tests__/ErrorDisplay.test.ts @@ -7,7 +7,6 @@ describe('ErrorDisplay', () => { let originalLocation: Location; beforeEach(() => { - // Mock window.location originalLocation = window.location; Object.defineProperty(window, 'location', { value: { diff --git a/frontend/src/components/__tests__/Footer.test.ts b/frontend/src/components/__tests__/Footer.test.ts index bf1d545d..cb83c838 100644 --- a/frontend/src/components/__tests__/Footer.test.ts +++ b/frontend/src/components/__tests__/Footer.test.ts @@ -6,7 +6,6 @@ describe('Footer', () => { let originalDate: DateConstructor; beforeEach(() => { - // Mock Date to have consistent year in tests originalDate = globalThis.Date; const mockDate = class extends Date { constructor() { @@ -110,7 +109,6 @@ describe('Footer', () => { it('has screen reader text for social icons', () => { render(Footer); - // Check for sr-only spans expect(screen.getByText('Telegram', { selector: '.sr-only' })).toBeInTheDocument(); expect(screen.getByText('GitHub', { selector: '.sr-only' })).toBeInTheDocument(); }); diff --git a/frontend/src/components/__tests__/Header.test.ts b/frontend/src/components/__tests__/Header.test.ts index e74cd8b2..dcdd6c1d 100644 --- a/frontend/src/components/__tests__/Header.test.ts +++ b/frontend/src/components/__tests__/Header.test.ts @@ -22,12 +22,12 @@ const mocks = vi.hoisted(() => ({ mockToggleTheme: vi.fn(), })); -vi.mock('../../stores/auth.svelte', () => ({ +vi.mock('$stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); -vi.mock('../../stores/theme.svelte', () => ({ +vi.mock('$stores/theme.svelte', () => ({ get themeStore() { return mocks.mockThemeStore; }, @@ -35,7 +35,7 @@ vi.mock('../../stores/theme.svelte', () => ({ return mocks.mockToggleTheme; }, })); -vi.mock('../NotificationCenter.svelte', () => { +vi.mock('$components/NotificationCenter.svelte', () => { const M = function () { return {}; } as any; @@ -49,7 +49,6 @@ vi.mock('../NotificationCenter.svelte', () => { import Header from '$components/Header.svelte'; -// Test helpers const setAuth = ( isAuth: boolean, username: string | null = null, @@ -270,7 +269,6 @@ describe('Header', () => { expect(screen.getByRole('link', { name: /Settings/i })).toBeInTheDocument(); }); - // Click outside the dropdown document.body.click(); await waitFor(() => { @@ -281,7 +279,6 @@ describe('Header', () => { describe('resize behavior', () => { it('closes mobile menu when resizing to desktop width', async () => { - // Start in mobile mode Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 800 }); await openMobileMenu(); @@ -289,7 +286,6 @@ describe('Header', () => { expect(screen.getByTestId('mobile-menu')).toBeInTheDocument(); }); - // Resize to desktop width Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); window.dispatchEvent(new Event('resize')); @@ -302,7 +298,6 @@ describe('Header', () => { Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 500 }); render(Header); - // Mobile menu toggle should be visible expect(screen.getByTestId('mobile-menu-toggle')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/__tests__/NotificationCenter.test.ts b/frontend/src/components/__tests__/NotificationCenter.test.ts index 52931f3e..972d8605 100644 --- a/frontend/src/components/__tests__/NotificationCenter.test.ts +++ b/frontend/src/components/__tests__/NotificationCenter.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import { user, type UserEventInstance } from '$test/test-utils'; -// Types for mock notification state + interface MockNotification { notification_id: string; subject: string; @@ -40,25 +40,24 @@ const mocks = vi.hoisted(() => ({ mockStreamDisconnect: vi.fn(), })); -vi.mock('@mateothegreat/svelte5-router', () => ({ goto: mocks.mockGoto })); -vi.mock('../../stores/auth.svelte', () => ({ +vi.mock('@mateothegreat/svelte5-router', () => ({ route: () => {}, goto: mocks.mockGoto })); +vi.mock('$stores/auth.svelte', () => ({ get authStore() { return mocks.mockAuthStore; }, })); -vi.mock('../../stores/notificationStore.svelte', () => ({ +vi.mock('$stores/notificationStore.svelte', () => ({ get notificationStore() { return mocks.mockNotificationStore; }, })); -vi.mock('../../lib/notifications/stream.svelte', () => ({ +vi.mock('$lib/notifications/stream.svelte', () => ({ notificationStream: { connect: mocks.mockStreamConnect, disconnect: mocks.mockStreamDisconnect, }, })); -// Configurable Notification mock const mockRequestPermission = vi.fn().mockResolvedValue('granted'); let mockNotificationPermission = 'default'; vi.stubGlobal('Notification', { @@ -70,7 +69,6 @@ vi.stubGlobal('Notification', { import NotificationCenter from '$components/NotificationCenter.svelte'; -// Test Helpers const createNotification = (overrides: Partial = {}): MockNotification => ({ notification_id: '1', subject: 'Test', @@ -115,7 +113,6 @@ const interactWithButton = async (user: UserEventInstance, button: HTMLElement, } }; -// Test Data (consolidated arrays for it.each) const iconTestCases = [ { tags: ['completed'], iconClass: 'lucide-circle-check', desc: 'check' }, { tags: ['success'], iconClass: 'lucide-circle-check', desc: 'check' }, @@ -154,7 +151,6 @@ const interactionTestCases = [ { method: 'keyboard' as const, hasUrl: false, url: undefined }, ]; -// Tests describe('NotificationCenter', () => { const openDropdown = async () => { render(NotificationCenter); diff --git a/frontend/src/components/__tests__/Pagination.test.ts b/frontend/src/components/__tests__/Pagination.test.ts index 59c16df6..cea97923 100644 --- a/frontend/src/components/__tests__/Pagination.test.ts +++ b/frontend/src/components/__tests__/Pagination.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/svelte'; -import { user } from '$test/test-utils'; +import { user, selectOption } from '$test/test-utils'; import Pagination from '$components/Pagination.svelte'; const defaultProps = { @@ -85,8 +85,8 @@ describe('Pagination', () => { it('fires onPageSizeChange on select change', async () => { const onPageSizeChange = vi.fn(); renderPagination({ onPageSizeChange } as Record); - await user.selectOptions(screen.getByRole('combobox'), '25'); - expect(onPageSizeChange).toHaveBeenCalledWith(25); + selectOption(screen.getByRole('combobox'), '25'); + expect(onPageSizeChange).toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/__tests__/Spinner.test.ts b/frontend/src/components/__tests__/Spinner.test.ts index d07a7156..5c7120a7 100644 --- a/frontend/src/components/__tests__/Spinner.test.ts +++ b/frontend/src/components/__tests__/Spinner.test.ts @@ -79,7 +79,6 @@ describe('Spinner', () => { // @ts-expect-error - intentionally passing invalid value to test fallback render(Spinner, { props: { size: 'invalid-size' } }); const svg = getSpinner(); - // Should fall back to medium (h-6 w-6) expect(svg.classList.contains('h-6')).toBe(true); expect(svg.classList.contains('w-6')).toBe(true); }); @@ -88,7 +87,6 @@ describe('Spinner', () => { // @ts-expect-error - intentionally passing invalid value to test fallback render(Spinner, { props: { color: 'invalid-color' } }); const svg = getSpinner(); - // Should fall back to primary expect(svg.classList.contains('text-primary')).toBe(true); }); @@ -96,7 +94,6 @@ describe('Spinner', () => { // @ts-expect-error - intentionally passing invalid values to test fallback render(Spinner, { props: { size: 'unknown', color: 'unknown' } }); const svg = getSpinner(); - // Should fall back to defaults expect(svg.classList.contains('h-6')).toBe(true); expect(svg.classList.contains('w-6')).toBe(true); expect(svg.classList.contains('text-primary')).toBe(true); diff --git a/frontend/src/components/admin/AutoRefreshControl.svelte b/frontend/src/components/admin/AutoRefreshControl.svelte index 2cff796d..31e7c2de 100644 --- a/frontend/src/components/admin/AutoRefreshControl.svelte +++ b/frontend/src/components/admin/AutoRefreshControl.svelte @@ -31,18 +31,6 @@ onEnabledChange, onRateChange, }: Props = $props(); - - function handleEnabledChange(e: Event): void { - const target = e.target as HTMLInputElement; - enabled = target.checked; - onEnabledChange?.(enabled); - } - - function handleRateChange(e: Event): void { - const target = e.target as HTMLSelectElement; - rate = parseInt(target.value, 10); - onRateChange?.(rate); - }
@@ -50,8 +38,8 @@