From df71d5f5e2e01cecf45eab7559e5fc4840ba433f Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 18 Aug 2026 00:22:54 +0200 Subject: [PATCH] test: run frontend unit tests in browsers (#38860) Run them in headless [vitest browser mode](https://vitest.dev/guide/browser/) in chromium and firefox. Similar UX than current tests, it's about 5 times as slow (goes from 1s to 5s on my machine), but definitely worth it as it removes all happy-dom problems. --------- Signed-off-by: silverwind --- .github/workflows/files-changed.yml | 6 +- .github/workflows/pull-compliance.yml | 1 + Makefile | 9 +- docs/testing.md | 2 +- eslint.config.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 102 ++++++++++++++++-- tools/playwright.sh | 9 ++ tools/test-e2e.sh | 28 +---- types.d.ts | 2 +- vitest.config.ts | 41 +++++-- .../js/features/common-fetch-action.test.ts | 18 +--- .../features/repo-settings-branches.test.ts | 29 +++-- web_src/js/markup/render-iframe.test.ts | 38 +++---- web_src/js/modules/errors.test.ts | 9 +- web_src/js/render/ansi.test.ts | 4 +- web_src/js/utils/dom.test.ts | 11 +- web_src/js/utils/dom.ts | 9 +- web_src/js/utils/glob.test.ts | 12 +-- web_src/js/utils/match.test.ts | 4 +- web_src/js/utils/testhelper.ts | 17 ++- web_src/js/vitest.setup.ts | 2 +- 22 files changed, 211 insertions(+), 146 deletions(-) create mode 100755 tools/playwright.sh diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index 034291efb7..697ab16661 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -73,8 +73,9 @@ jobs: frontend: - "*.ts" - "web_src/**" - - "tools/generate-svg.ts" - - "tools/generate-svg-vscode-extensions.json" + - "tools/**/*.ts" + - "tools/**/*.json" + - "tools/playwright.sh" - "tsconfig.json" - "assets/emoji.json" - "package.json" @@ -134,6 +135,7 @@ jobs: e2e: - "tests/e2e/**" - "tools/test-e2e.sh" + - "tools/playwright.sh" - "playwright.config.ts" shell: diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index b23fcbfbaf..81589b22c5 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -77,6 +77,7 @@ jobs: - run: make deps-frontend - run: make lint-frontend - run: make checks-frontend + - run: make playwright - run: make test-frontend - run: make frontend diff --git a/Makefile b/Makefile index 04c3679caf..2c2b71e89e 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,9 @@ SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a CONTAINER_RUNTIME ?= $(shell hash docker >/dev/null 2>&1 && echo docker || echo podman) +PLAYWRIGHT_BROWSERS ?= chromium firefox +PLAYWRIGHT_FLAGS ?= + HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes) ifeq ($(HAS_GO), yes) CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766 @@ -389,7 +392,7 @@ test-backend: ## test backend files @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' $(GO_TEST_PACKAGES) .PHONY: test-frontend -test-frontend: node_modules ## test frontend files +test-frontend: playwright ## test frontend files pnpm exec vitest .PHONY: test-check @@ -484,11 +487,11 @@ migrations.individual.test\#%: .PHONY: playwright playwright: deps-frontend - @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/test-e2e.sh install + @./tools/playwright.sh $(PLAYWRIGHT_FLAGS) $(PLAYWRIGHT_BROWSERS) .PHONY: test-e2e test-e2e: playwright frontend backend - @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh run $(GITEA_TEST_E2E_FLAGS) + @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: build build: frontend backend ## build everything diff --git a/docs/testing.md b/docs/testing.md index ea719c18be..3184ecde7b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,7 @@ go test -run '^TestName$' ./modulepath/ make test-backend#TestName ``` -Frontend unit tests run with [Vitest](https://vitest.dev/): +Frontend unit tests run with [Vitest](https://vitest.dev/) browser mode: ```bash make test-frontend diff --git a/eslint.config.ts b/eslint.config.ts index fb5e80ba15..8422e38019 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1095,7 +1095,7 @@ export default defineConfig([ }, }, { - files: ['**/*.test.ts', 'web_src/js/test/setup.ts'], + files: ['**/*.test.ts', 'web_src/js/vitest.setup.ts'], plugins: {vitest}, languageOptions: {globals: globals.vitest}, rules: { diff --git a/package.json b/package.json index 9c5facb0ac..b4aaa1a84e 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/toastify-js": "1.12.4", "@typescript-eslint/parser": "8.67.0", "@vitejs/plugin-vue": "6.0.8", + "@vitest/browser-playwright": "4.1.10", "@vitest/eslint-plugin": "1.6.27", "eslint": "10.8.1", "eslint-import-resolver-typescript": "4.4.5", @@ -94,7 +95,6 @@ "eslint-plugin-vue-scoped-css": "3.1.3", "eslint-plugin-wc": "3.1.0", "globals": "17.11.0", - "happy-dom": "20.11.2", "jiti": "2.7.0", "linguist-languages": "9.4.0", "markdownlint-cli": "0.49.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d87b37d310..5d86360aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,9 +231,12 @@ importers: '@typescript-eslint/parser': specifier: 8.67.0 version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@vitest/browser-playwright': + specifier: 4.1.10 + version: 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/eslint-plugin': specifier: 1.6.27 - version: 1.6.27(@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.2.0)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))) + version: 1.6.27(@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10) eslint: specifier: 10.8.1 version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) @@ -264,9 +267,6 @@ importers: globals: specifier: 17.11.0 version: 17.11.0 - happy-dom: - specifier: 20.11.2 - version: 20.11.2 jiti: specifier: 2.7.0 version: 2.7.0 @@ -311,7 +311,7 @@ importers: version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.2.0)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) vue-tsc: specifier: 3.3.9 version: 3.3.9(typescript@6.0.3) @@ -350,6 +350,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -933,6 +936,9 @@ packages: engines: {node: '>=20'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1519,6 +1525,17 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + peerDependencies: + playwright: '*' + vitest: 4.1.10 + + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + '@vitest/eslint-plugin@1.6.27': resolution: {integrity: sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==} engines: {node: '>=18'} @@ -3347,6 +3364,10 @@ packages: moo@0.5.3: resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3547,6 +3568,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -3830,6 +3855,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4058,6 +4087,10 @@ packages: toastify-js@1.12.0: resolution: {integrity: sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -4422,6 +4455,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.2': {} '@cacheable/memory@2.2.0': @@ -5153,6 +5188,8 @@ snapshots: dependencies: playwright: 1.62.1 + '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} '@primer/octicons@19.33.0': @@ -5508,11 +5545,13 @@ snapshots: '@types/unist@2.0.11': {} - '@types/whatwg-mimetype@3.0.2': {} + '@types/whatwg-mimetype@3.0.2': + optional: true '@types/ws@8.18.1': dependencies: '@types/node': 26.2.0 + optional: true '@types/yargs-parser@21.0.3': {} @@ -5692,7 +5731,37 @@ snapshots: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) - '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.2.0)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)))': + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) @@ -5700,7 +5769,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.10(@types/node@26.2.0)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -5997,6 +6066,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: '@types/node': 26.2.0 + optional: true buffer@5.7.1: dependencies: @@ -7106,6 +7176,7 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate + optional: true has-bigints@1.1.0: optional: true @@ -7902,6 +7973,8 @@ snapshots: moo@0.5.3: {} + mrmime@2.0.1: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -8096,6 +8169,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@7.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -8420,6 +8495,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} slash@5.1.0: {} @@ -8713,6 +8794,8 @@ snapshots: toastify-js@1.12.0: {} + totalist@3.0.1: {} + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -8888,7 +8971,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.2.0)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.2.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.2)(jsdom@20.0.3(supports-color@10.2.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -8912,6 +8995,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.2.0 + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) happy-dom: 20.11.2 jsdom: 20.0.3(supports-color@10.2.2) transitivePeerDependencies: diff --git a/tools/playwright.sh b/tools/playwright.sh new file mode 100755 index 0000000000..daf361b845 --- /dev/null +++ b/tools/playwright.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail + +# playwright only supports ubuntu/debian officially, and on CI VMs its system deps are pre-installed +if [ -z "${GITHUB_ACTIONS:-}" ] && [ "$(uname -s)" = "Linux" ] && grep -qE '^ID(_LIKE)?=.*(ubuntu|debian)' /etc/os-release 2>/dev/null; then + pnpm exec playwright install --with-deps "$@" +else + pnpm exec playwright install "$@" +fi diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index be3e848ee1..cf7a39d2e1 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -44,13 +44,6 @@ wait_for_container() { echo "Container is ready." } -CMD="${1:-run}" -if [ "$CMD" = "install" ] || [ "$CMD" = "run" ]; then - [ $# -gt 0 ] && shift -else - CMD="run" -fi - detect_playwright_mode if [ "$PLAYWRIGHT_MODE" = "container" ]; then @@ -65,25 +58,10 @@ if [ "$PLAYWRIGHT_MODE" = "container" ]; then exit 1 fi PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" -fi - -if [ "$CMD" = "install" ]; then - if [ "$PLAYWRIGHT_MODE" = "local" ]; then - # on GitHub Actions VMs, playwright's system deps are pre-installed - if [ -z "${GITHUB_ACTIONS:-}" ]; then - # shellcheck disable=SC2086 # flag string - pnpm exec playwright install --with-deps chromium firefox ${PLAYWRIGHT_FLAGS:-} - else - # shellcheck disable=SC2086 # flag string - pnpm exec playwright install chromium firefox ${PLAYWRIGHT_FLAGS:-} - fi - else - echo "Running playwright in container as host distro is not supported by playwright directly" - if ! "$CONTAINER_RUNTIME" image inspect "$PLAYWRIGHT_IMAGE" >/dev/null 2>&1; then - "$CONTAINER_RUNTIME" pull "$PLAYWRIGHT_IMAGE" - fi + echo "Running playwright in container" + if ! "$CONTAINER_RUNTIME" image inspect "$PLAYWRIGHT_IMAGE" >/dev/null 2>&1; then + "$CONTAINER_RUNTIME" pull "$PLAYWRIGHT_IMAGE" fi - exit 0 fi # Create isolated work directory diff --git a/types.d.ts b/types.d.ts index 68f839924f..607d8d3b44 100644 --- a/types.d.ts +++ b/types.d.ts @@ -3,7 +3,7 @@ declare module '*.svg' { export default value; } -declare module '*.css' { +declare module '*.txt' { const value: string; export default value; } diff --git a/vitest.config.ts b/vitest.config.ts index ac58546dac..35129ac6a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,25 +1,44 @@ import {defineConfig} from 'vitest/config'; +import {playwright} from '@vitest/browser-playwright'; import {sharedPlugins, vueDefines} from './tools/shared.ts'; +import {env} from 'node:process'; export default defineConfig({ test: { - include: [ - 'web_src/**/*.test.ts', - 'tools/eslint-rules/**/*.test.ts', - ], - setupFiles: ['web_src/js/vitest.setup.ts'], - environment: 'happy-dom', testTimeout: 20000, - open: false, allowOnly: true, passWithNoTests: true, globals: true, watch: false, - isolate: false, - sequence: { - concurrent: true, - }, + projects: [ + { + extends: true, + test: { + name: 'browser', + include: ['web_src/**/*.test.ts'], + setupFiles: ['web_src/js/vitest.setup.ts'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + screenshotFailures: false, + instances: ((env.PLAYWRIGHT_BROWSERS || 'chromium firefox') + .split(' ') as Array<'chromium' | 'firefox' | 'webkit'>) + .map((browser) => ({browser, name: browser})), + }, + }, + }, + { + extends: true, + test: { + name: 'node', + include: ['tools/**/*.test.ts'], + environment: 'node', + }, + }, + ], }, + publicDir: false, define: vueDefines, plugins: sharedPlugins(), }); diff --git a/web_src/js/features/common-fetch-action.test.ts b/web_src/js/features/common-fetch-action.test.ts index 867017fa20..eb25511fdb 100644 --- a/web_src/js/features/common-fetch-action.test.ts +++ b/web_src/js/features/common-fetch-action.test.ts @@ -1,6 +1,6 @@ import {execPseudoSelectorCommands, handleFetchActionErrorFields, handleFetchActionSuccessJson} from './common-fetch-action.ts'; import {createElementFromHTML} from '../utils/dom.ts'; -import {normalizeTestHtml} from '../utils/testhelper.ts'; +import {captureNavigations, normalizeTestHtml} from '../utils/testhelper.ts'; test('execPseudoSelectorCommands', () => { window.document.body.innerHTML = ` @@ -41,23 +41,11 @@ test('execPseudoSelectorCommands', () => { }); test('handleFetchActionSuccessJson', async () => { - const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}); - const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {}); - + const navigations = captureNavigations(); await handleFetchActionSuccessJson(document.body, {redirect: '/'}); - expect(spyAssign).toHaveBeenCalledTimes(1); - expect(spyReload).toHaveBeenCalledTimes(0); - vi.resetAllMocks(); - await handleFetchActionSuccessJson(document.body, {redirect: ''}); - expect(spyAssign).toHaveBeenCalledTimes(0); - expect(spyReload).toHaveBeenCalledTimes(1); - vi.resetAllMocks(); - await handleFetchActionSuccessJson(document.body, {}); - expect(spyAssign).toHaveBeenCalledTimes(0); - expect(spyReload).toHaveBeenCalledTimes(1); - vi.resetAllMocks(); + expect(navigations.map((n) => n.type)).toEqual(['push', 'reload', 'reload']); }); test('handleFetchActionErrorFields', () => { diff --git a/web_src/js/features/repo-settings-branches.test.ts b/web_src/js/features/repo-settings-branches.test.ts index 55dbcd761b..5d317a01e9 100644 --- a/web_src/js/features/repo-settings-branches.test.ts +++ b/web_src/js/features/repo-settings-branches.test.ts @@ -3,13 +3,8 @@ import {POST} from '../modules/fetch.ts'; import {createSortable} from '../modules/sortable.ts'; import type {SortableEvent} from 'sortablejs'; -vi.mock('../modules/fetch.ts', () => ({ - POST: vi.fn(), -})); - -vi.mock('../modules/sortable.ts', () => ({ - createSortable: vi.fn(), -})); +vi.mock('../modules/fetch.ts', () => ({POST: vi.fn()})); +vi.mock('../modules/sortable.ts', () => ({createSortable: vi.fn()})); const branchesHTML = `
@@ -26,29 +21,29 @@ const branchesHTML = ` `; describe('Repository Branch Settings', () => { + beforeEach(() => { + vi.mocked(createSortable).mockClear(); + vi.mocked(POST).mockClear(); + }); + test('should initialize sortable for protected branches list', () => { document.body.innerHTML = branchesHTML; - const callsBefore = vi.mocked(createSortable).mock.calls.length; initRepoSettingsBranchesDrag(); - const newCalls = vi.mocked(createSortable).mock.calls.slice(callsBefore); - expect(newCalls).toHaveLength(1); - expect(newCalls[0][0]).toBe(document.querySelector('#protected-branches-list')); - expect(newCalls[0][1]).toMatchObject({handle: '.drag-handle', animation: 150}); + expect(createSortable).toHaveBeenCalledTimes(1); + expect(createSortable).toHaveBeenCalledWith(document.querySelector('#protected-branches-list'), expect.objectContaining({handle: '.drag-handle', animation: 150})); }); test('should not initialize if protected branches list is not present', () => { - document.querySelector('#protected-branches-list')?.remove(); - const callsBefore = vi.mocked(createSortable).mock.calls.length; + document.body.replaceChildren(); initRepoSettingsBranchesDrag(); - expect(vi.mocked(createSortable).mock.calls.length).toBe(callsBefore); + expect(createSortable).toHaveBeenCalledTimes(0); }); test('should post new order after sorting', () => { document.body.innerHTML = branchesHTML; vi.mocked(POST).mockResolvedValue({ok: true} as Response); - const callsBefore = vi.mocked(createSortable).mock.calls.length; initRepoSettingsBranchesDrag(); - const onEnd = vi.mocked(createSortable).mock.calls[callsBefore][1]!.onEnd!; + const onEnd = vi.mocked(createSortable).mock.calls[0][1]!.onEnd!; onEnd(new Event('SortableEvent') as SortableEvent); expect(POST).toHaveBeenCalledWith( 'some/repo/branches/priority', diff --git a/web_src/js/markup/render-iframe.test.ts b/web_src/js/markup/render-iframe.test.ts index def2a2a8e2..8022a5274d 100644 --- a/web_src/js/markup/render-iframe.test.ts +++ b/web_src/js/markup/render-iframe.test.ts @@ -1,47 +1,35 @@ import {navigateToIframeLink} from './render-iframe.ts'; +import {captureNavigations} from '../utils/testhelper.ts'; describe('navigateToIframeLink', () => { - const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); - test('safe links', () => { + const navigations = captureNavigations(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); navigateToIframeLink('http://example.com', '_blank'); expect(openSpy).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer'); - vi.clearAllMocks(); - navigateToIframeLink('https://example.com', '_self'); - expect(assignSpy).toHaveBeenCalledWith('https://example.com/'); - vi.clearAllMocks(); - + expect(navigations.at(-1)!.url).toEqual('https://example.com/'); navigateToIframeLink('https://example.com', null); - expect(assignSpy).toHaveBeenCalledWith('https://example.com/'); - vi.clearAllMocks(); - + expect(navigations.at(-1)!.url).toEqual('https://example.com/'); navigateToIframeLink('/path', ''); - expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/path'); - vi.clearAllMocks(); - + expect(navigations.at(-1)!.url).toEqual(`${window.location.origin}/path`); // input can be any type & any value, keep the same behavior as `window.location.href = 0` navigateToIframeLink(0, {}); - expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/0'); - vi.clearAllMocks(); + expect(navigations.at(-1)!.url).toEqual(`${window.location.origin}/0`); + expect(navigations).toHaveLength(4); + openSpy.mockRestore(); }); test('unsafe links', () => { + const navigations = captureNavigations(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - // eslint-disable-next-line no-script-url navigateToIframeLink('javascript:void(0);', '_blank'); - expect(openSpy).toHaveBeenCalledTimes(0); - expect(assignSpy).toHaveBeenCalledTimes(0); - expect(window.location.href).toBe('http://localhost:3000/'); - vi.clearAllMocks(); - navigateToIframeLink('data:image/svg+xml;utf8,', ''); expect(openSpy).toHaveBeenCalledTimes(0); - expect(assignSpy).toHaveBeenCalledTimes(0); - expect(window.location.href).toBe('http://localhost:3000/'); + expect(navigations).toEqual([]); + openSpy.mockRestore(); errorSpy.mockRestore(); - vi.clearAllMocks(); }); }); diff --git a/web_src/js/modules/errors.test.ts b/web_src/js/modules/errors.test.ts index df2431f968..53618b4569 100644 --- a/web_src/js/modules/errors.test.ts +++ b/web_src/js/modules/errors.test.ts @@ -5,18 +5,19 @@ beforeEach(() => { }); test('isGiteaError', () => { + const {origin} = window.location; expect(isGiteaError('', '')).toBe(true); expect(isGiteaError('moz-extension://abc/content.js', '')).toBe(false); expect(isGiteaError('safari-extension://abc/content.js', '')).toBe(false); expect(isGiteaError('safari-web-extension://abc/content.js', '')).toBe(false); expect(isGiteaError('chrome-extension://abc/content.js', '')).toBe(false); expect(isGiteaError('https://other-site.com/script.js', '')).toBe(false); - expect(isGiteaError('http://localhost:3000/some/page', '')).toBe(true); - expect(isGiteaError('http://localhost:3000/assets/js/index.abc123.js', '')).toBe(true); + expect(isGiteaError(`${origin}/some/page`, '')).toBe(true); + expect(isGiteaError(`${origin}/assets/js/index.abc123.js`, '')).toBe(true); expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false); - expect(isGiteaError('', `Error\n at http://localhost:3000/assets/js/index.abc123.js:1:1`)).toBe(true); - expect(isGiteaError('http://localhost:3000/assets/js/index.js', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); + expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true); + expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); }); test('showGlobalErrorMessage', () => { diff --git a/web_src/js/render/ansi.test.ts b/web_src/js/render/ansi.test.ts index 7a7713537d..e04391fa91 100644 --- a/web_src/js/render/ansi.test.ts +++ b/web_src/js/render/ansi.test.ts @@ -20,7 +20,7 @@ test('renderAnsi', () => { // treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally. expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc'); - expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`ab`); + expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`ab`); // URLs in ANSI output become clickable links const link = (url: string) => `${url}`; @@ -39,7 +39,7 @@ test('renderAnsi', () => { expect(renderAnsi('\x1b[4;58;5;9mx')).toEqual('x'); // a color as ":" sub-parameters, with and without a color space id, not consuming the codes after - expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('ab'); + expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('ab'); expect(renderAnsi('\x1b[1;38:5:9;4mx')).toEqual('x'); // a private CSI carries no style, even ending in "m", and does not split the run around it expect(renderAnsi('\x1b[31mred\x1b[>4;2m!')).toEqual('red!'); diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index de06708291..d047eac92b 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -26,15 +26,16 @@ test('createElementFromAttrs', () => { }); test('querySingleVisibleElem', () => { - let el = createElementFromHTML('
'); + const el = document.createElement('div'); + document.body.append(el); // layout, and thus visibility, is only computed in the document expect(querySingleVisibleElem(el, 'span')).toBeNull(); - el = createElementFromHTML('
foo
'); + el.innerHTML = 'foo'; expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('foo'); - el = createElementFromHTML('
foobar
'); + el.innerHTML = 'foobar'; expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar'); - el = createElementFromHTML('
foobar
'); + el.innerHTML = 'foobar'; expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar'); - el = createElementFromHTML('
foobar
'); + el.innerHTML = 'foobar'; expect(() => querySingleVisibleElem(el, 'span')).toThrow('Expected exactly one visible element'); }); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 71b6b7506f..43133e65f3 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -1,7 +1,6 @@ import {debounce} from './func.ts'; import type {Promisable} from '../types.ts'; import type $ from 'jquery'; -import {isInFrontendUnitTest} from './testhelper.ts'; type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array type ElementArg = Element | string | ArrayLikeIterable | ReturnType; @@ -73,11 +72,6 @@ export function queryElemSiblings(el: Element, selector = '*' /** it works like jQuery.children: only the direct children are selected */ export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable { - if (isInFrontendUnitTest()) { - // https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work - const selected = Array.from(parent.children as any).filter((child) => child.matches(selector)); - return applyElemsCallback(selected, fn); - } return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); } @@ -261,8 +255,7 @@ export function isElemVisible(el: HTMLElement): boolean { // Check if an element is visible, equivalent to jQuery's `:visible` pseudo. // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" if (!el) return false; - // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout - return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'); + return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); } export function createElementFromHTML(htmlString: string): T { diff --git a/web_src/js/utils/glob.test.ts b/web_src/js/utils/glob.test.ts index fbf83a6cfe..7b3427ffd4 100644 --- a/web_src/js/utils/glob.test.ts +++ b/web_src/js/utils/glob.test.ts @@ -1,10 +1,8 @@ -import {readFile} from 'node:fs/promises'; -import * as path from 'node:path'; +import globTestData from './glob.test.txt'; import {globCompile} from './glob.ts'; -async function loadGlobTestData(): Promise<{caseNames: string[], caseDataMap: Record}> { - const fileContent = await readFile(path.join(import.meta.dirname, 'glob.test.txt'), 'utf8'); - const fileLines = fileContent.split('\n'); +function loadGlobTestData(): {caseNames: string[], caseDataMap: Record} { + const fileLines = globTestData.split('\n'); const caseDataMap: Record = {}; const caseNameMap: Record = {}; for (let line of fileLines) { @@ -103,8 +101,8 @@ function loadGlobGolangCases() { ]; } -test('GlobCompiler', async () => { - const {caseNames, caseDataMap} = await loadGlobTestData(); +test('GlobCompiler', () => { + const {caseNames, caseDataMap} = loadGlobTestData(); expect(caseNames.length).toBe(10); // should have 10 test cases for (const caseName of caseNames) { const pattern = caseDataMap[`pattern_${caseName}`]; diff --git a/web_src/js/utils/match.test.ts b/web_src/js/utils/match.test.ts index 9e31d04f35..b4d1e1a69b 100644 --- a/web_src/js/utils/match.test.ts +++ b/web_src/js/utils/match.test.ts @@ -1,9 +1,7 @@ import {GET} from '../modules/fetch.ts'; import {matchEmoji, matchMention} from './match.ts'; -vi.mock('../modules/fetch.ts', () => ({ - GET: vi.fn(), -})); +vi.mock('../modules/fetch.ts', () => ({GET: vi.fn()})); const testMentions = [ {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'}, diff --git a/web_src/js/utils/testhelper.ts b/web_src/js/utils/testhelper.ts index 56ec639c33..9efb3aad42 100644 --- a/web_src/js/utils/testhelper.ts +++ b/web_src/js/utils/testhelper.ts @@ -1,8 +1,15 @@ -// there could be different "testing" concepts, for example: backend's "setting.IsInTesting" -// even if backend is in testing mode, frontend could be complied in production mode -// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files) -export function isInFrontendUnitTest() { - return import.meta.env.MODE === 'test'; +import {onTestFinished} from 'vitest'; + +/** Record and block navigations, as a real browser forbids stubbing "window.location" */ +export function captureNavigations() { + const navigations: Array<{url: string, type: NavigationType}> = []; + const onNavigate = (e: NavigateEvent) => { + navigations.push({url: e.destination.url, type: e.navigationType}); + e.preventDefault(); + }; + window.navigation.addEventListener('navigate', onNavigate); + onTestFinished(() => window.navigation.removeEventListener('navigate', onNavigate)); + return navigations; } /** strip common indentation from a string and trim it */ diff --git a/web_src/js/vitest.setup.ts b/web_src/js/vitest.setup.ts index 4c1c03a739..91e819f7a5 100644 --- a/web_src/js/vitest.setup.ts +++ b/web_src/js/vitest.setup.ts @@ -1,7 +1,7 @@ import './globals.ts'; window.config = { - appUrl: 'http://localhost:3000/', + appUrl: `${window.location.origin}/`, appSubUrl: '', assetUrlPrefix: '/assets', sharedWorkerUri: '',