From f76c0acf597e57bbeb5a8a64d69e92093a3c8eb7 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 10:58:11 +0100 Subject: [PATCH 01/55] Rework e2e tests Remove the Go test harness that compiled test binaries and bootstrapped a full Gitea server with fixtures. Replace with a bash script that runs Playwright directly against an already-running Gitea instance. - Remove Go e2e test files (e2e_test.go, utils_e2e_test.go) - Add tools/test-e2e.sh that detects server URL, creates e2e user, runs Playwright - Simplify Makefile to single test-e2e target - Rewrite playwright.config.ts with chromium-only, no file outputs - Rewrite tests using semantic Playwright locators (getByLabel, getByRole, getByText) - Add login/logout utilities in tests/e2e/utils.ts - Add CI workflow for e2e tests (.github/workflows/pull-e2e-tests.yml) - Install only chromium in playwright install step Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 56 +++++++++++++ .gitignore | 5 -- CONTRIBUTING.md | 2 +- Makefile | 66 ++------------- eslint.config.ts | 2 +- playwright.config.ts | 95 +++------------------- tests/e2e/README.md | 86 -------------------- tests/e2e/e2e_test.go | 115 --------------------------- tests/e2e/example.test.e2e.ts | 56 ------------- tests/e2e/login.test.ts | 20 +++++ tests/e2e/utils.ts | 24 ++++++ tests/e2e/utils_e2e.ts | 62 --------------- tests/e2e/utils_e2e_test.go | 56 ------------- tools/test-e2e.sh | 47 +++++++++++ 14 files changed, 167 insertions(+), 525 deletions(-) create mode 100644 .github/workflows/pull-e2e-tests.yml delete mode 100644 tests/e2e/README.md delete mode 100644 tests/e2e/e2e_test.go delete mode 100644 tests/e2e/example.test.e2e.ts create mode 100644 tests/e2e/login.test.ts create mode 100644 tests/e2e/utils.ts delete mode 100644 tests/e2e/utils_e2e.ts delete mode 100644 tests/e2e/utils_e2e_test.go create mode 100755 tools/test-e2e.sh diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml new file mode 100644 index 0000000000000..1d255abb462a5 --- /dev/null +++ b/.github/workflows/pull-e2e-tests.yml @@ -0,0 +1,56 @@ +name: e2e-tests + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + files-changed: + uses: ./.github/workflows/files-changed.yml + permissions: + contents: read + + test-e2e: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' + needs: files-changed + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + check-latest: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-backend + - run: make backend + env: + TAGS: bindata sqlite sqlite_unlock_notify + - run: make deps-frontend + - run: make frontend + - run: make playwright + - run: | + mkdir -p custom/conf + cat <<'EOF' > custom/conf/app.ini + [database] + DB_TYPE = sqlite3 + + [server] + HTTP_PORT = 3000 + ROOT_URL = http://localhost:3000 + + [security] + INSTALL_LOCK = true + EOF + - run: ./gitea web & + - run: GITEA_URL=http://localhost:3000 make test-e2e + timeout-minutes: 10 diff --git a/.gitignore b/.gitignore index aa08e47aecd87..4da2d797ebbfa 100644 --- a/.gitignore +++ b/.gitignore @@ -69,11 +69,6 @@ cpu.out /public/assets/img/avatar /tests/integration/gitea-integration-* /tests/integration/indexers-* -/tests/e2e/gitea-e2e-* -/tests/e2e/indexers-* -/tests/e2e/reports -/tests/e2e/test-artifacts -/tests/e2e/test-snapshots /tests/*.ini /tests/**/*.git/**/*.sample /node_modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c64d91a7ebbed..91d9f53dd2fb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,7 +178,7 @@ Here's how to run the test suite: | :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- | |``make test[\#SpecificTestName]`` | run unit test(s) | | |``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | -|``make test-e2e-sqlite[\#SpecificTestName]`` | run [end-to-end](tests/e2e) test(s) for SQLite | [More details](tests/e2e/README.md) | +|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | | ## Translation diff --git a/Makefile b/Makefile index 93d87fc139e7d..1e34b8f2a92ea 100644 --- a/Makefile +++ b/Makefile @@ -134,7 +134,7 @@ LDFLAGS := $(LDFLAGS) -X "main.MakeVersion=$(MAKE_VERSION)" -X "main.Version=$(G LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/riscv64 -GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration code.gitea.io/gitea/tests/e2e,$(shell $(GO) list ./... | grep -v /vendor/)) +GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/)) MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) @@ -173,10 +173,6 @@ GO_SOURCES := $(wildcard *.go) GO_SOURCES += $(shell find $(GO_DIRS) -type f -name "*.go") GO_SOURCES += $(GENERATED_GO_DEST) -# Force installation of playwright dependencies by setting this flag -ifdef DEPS_PLAYWRIGHT - PLAYWRIGHT_FLAGS += --with-deps -endif SWAGGER_SPEC := templates/swagger/v1_json.tmpl SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json @@ -207,7 +203,7 @@ all: build .PHONY: help help: Makefile ## print Makefile help information. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST) - @printf " \033[36m%-46s\033[0m %s\n" "test-e2e[#TestSpecificName]" "test end to end using playwright" + @printf " \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright" @printf " \033[36m%-46s\033[0m %s\n" "test[#TestSpecificName]" "run unit test" @printf " \033[36m%-46s\033[0m %s\n" "test-sqlite[#TestSpecificName]" "run integration test for sqlite" @@ -226,13 +222,9 @@ clean-all: clean ## delete backend, frontend and integration files clean: ## delete backend and integration files rm -rf $(EXECUTABLE) $(DIST) $(BINDATA_DEST_WILDCARD) \ integrations*.test \ - e2e*.test \ tests/integration/gitea-integration-* \ tests/integration/indexers-* \ tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \ - tests/e2e/gitea-e2e-*/ \ - tests/e2e/indexers-*/ \ - tests/e2e/reports/ tests/e2e/test-artifacts/ tests/e2e/test-snapshots/ .PHONY: fmt fmt: ## format the Go and template code @@ -563,47 +555,11 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright playwright: deps-frontend - $(NODE_VARS) pnpm exec playwright install $(PLAYWRIGHT_FLAGS) - -.PHONY: test-e2e% -test-e2e%: TEST_TYPE ?= e2e - # Clear display env variable. Otherwise, chromium tests can fail. - DISPLAY= + $(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: test-e2e-sqlite - -.PHONY: test-e2e-sqlite -test-e2e-sqlite: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_TEST_CONF=tests/sqlite.ini ./e2e.sqlite.test - -.PHONY: test-e2e-sqlite\#% -test-e2e-sqlite\#%: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_TEST_CONF=tests/sqlite.ini ./e2e.sqlite.test -test.run TestE2e/$* - -.PHONY: test-e2e-mysql -test-e2e-mysql: playwright e2e.mysql.test generate-ini-mysql - GITEA_TEST_CONF=tests/mysql.ini ./e2e.mysql.test - -.PHONY: test-e2e-mysql\#% -test-e2e-mysql\#%: playwright e2e.mysql.test generate-ini-mysql - GITEA_TEST_CONF=tests/mysql.ini ./e2e.mysql.test -test.run TestE2e/$* - -.PHONY: test-e2e-pgsql -test-e2e-pgsql: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_TEST_CONF=tests/pgsql.ini ./e2e.pgsql.test - -.PHONY: test-e2e-pgsql\#% -test-e2e-pgsql\#%: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_TEST_CONF=tests/pgsql.ini ./e2e.pgsql.test -test.run TestE2e/$* - -.PHONY: test-e2e-mssql -test-e2e-mssql: playwright e2e.mssql.test generate-ini-mssql - GITEA_TEST_CONF=tests/mssql.ini ./e2e.mssql.test - -.PHONY: test-e2e-mssql\#% -test-e2e-mssql\#%: playwright e2e.mssql.test generate-ini-mssql - GITEA_TEST_CONF=tests/mssql.ini ./e2e.mssql.test -test.run TestE2e/$* +test-e2e: playwright + EXECUTABLE=$(EXECUTABLE) bash tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite @@ -699,18 +655,6 @@ migrations.individual.sqlite.test: $(GO_SOURCES) generate-ini-sqlite migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* -e2e.mysql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mysql.test - -e2e.pgsql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.pgsql.test - -e2e.mssql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mssql.test - -e2e.sqlite.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.sqlite.test -tags '$(TEST_TAGS)' - .PHONY: check check: test diff --git a/eslint.config.ts b/eslint.config.ts index 5815702c89083..3020c757ca811 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -910,7 +910,7 @@ export default defineConfig([ }, { ...playwright.configs['flat/recommended'], - files: ['tests/e2e/**'], + files: ['tests/e2e/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, }, diff --git a/playwright.config.ts b/playwright.config.ts index 9e3396465a82b..79979ad931f8a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,98 +1,29 @@ -import {devices} from '@playwright/test'; import {env} from 'node:process'; -import type {PlaywrightTestConfig} from '@playwright/test'; +import {defineConfig, devices} from '@playwright/test'; -const BASE_URL = env.GITEA_TEST_SERVER_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000'; - -export default { +export default defineConfig({ testDir: './tests/e2e/', - testMatch: /.*\.test\.e2e\.ts/, // Match any .test.e2e.ts files - - /* Maximum time one test can run for. */ - timeout: 30 * 1000, - + testMatch: /.*\.test\.ts/, + forbidOnly: Boolean(env.CI), + reporter: 'list', + timeout: env.CI ? 30000 : 10000, expect: { - - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ - timeout: 2000, + timeout: env.CI ? 15000 : 5000, }, - - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: Boolean(env.CI), - - /* Retry on CI only */ - retries: env.CI ? 2 : 0, - - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]], - - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - headless: true, // set to false to debug - + baseURL: env.GITEA_TEST_SERVER_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000', locale: 'en-US', - - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 1000, - - /* Maximum time allowed for navigation, such as `page.goto()`. */ - navigationTimeout: 5 * 1000, - - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: BASE_URL, - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - - screenshot: 'only-on-failure', + trace: 'off', + screenshot: 'off', + video: 'off', }, - - /* Configure projects for major browsers */ projects: [ { name: 'chromium', - - /* Project-specific settings. */ use: { ...devices['Desktop Chrome'], - }, - }, - - // disabled because of https://github.com/go-gitea/gitea/issues/21355 - // { - // name: 'firefox', - // use: { - // ...devices['Desktop Firefox'], - // }, - // }, - - { - name: 'webkit', - use: { - ...devices['Desktop Safari'], - }, - }, - - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { - ...devices['Pixel 5'], - }, - }, - { - name: 'Mobile Safari', - use: { - ...devices['iPhone 12'], + permissions: ['clipboard-read', 'clipboard-write'], }, }, ], - - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - outputDir: 'tests/e2e/test-artifacts/', - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - snapshotDir: 'tests/e2e/test-snapshots/', -} satisfies PlaywrightTestConfig; +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md deleted file mode 100644 index ea3805ab95c4f..0000000000000 --- a/tests/e2e/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# End to end tests - -E2e tests largely follow the same syntax as [integration tests](../integration). -Whereas integration tests are intended to mock and stress the back-end, server-side code, e2e tests the interface between front-end and back-end, as well as visual regressions with both assertions and visual comparisons. -They can be run with make commands for the appropriate backends, namely: -```shell -make test-sqlite -make test-pgsql -make test-mysql -make test-mssql -``` - -Make sure to perform a clean front-end build before running tests: -``` -make clean frontend -``` - -## Install playwright system dependencies -``` -pnpm exec playwright install-deps -``` - -## Run sqlite e2e tests -Start tests -``` -make test-e2e-sqlite -``` - -## Run MySQL e2e tests -Setup a MySQL database inside docker -``` -docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container) -docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-e2e-mysql -``` - -## Run pgsql e2e tests -Setup a pgsql database inside docker -``` -docker run -e "POSTGRES_DB=test" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-e2e-pgsql -``` - -## Run mssql e2e tests -Setup a mssql database inside docker -``` -docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-e2e-mssql -``` - -## Running individual tests - -Example command to run `example.test.e2e.ts` test file: - -_Note: unlike integration tests, this filtering is at the file level, not function_ - -For SQLite: - -``` -make test-e2e-sqlite#example -``` - -For other databases(replace `mssql` to `mysql` or `pgsql`): - -``` -TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-e2e-mssql#example -``` - -## Visual testing - -Although the main goal of e2e is assertion testing, we have added a framework for visual regress testing. If you are working on front-end features, please use the following: - - Check out `main`, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1` to generate outputs. This will initially fail, as no screenshots exist. You can run the e2e tests again to assert it passes. - - Check out your branch, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1`. You should be able to assert you front-end changes don't break any other tests unintentionally. - -VISUAL_TEST=1 will create screenshots in tests/e2e/test-snapshots. The test will fail the first time this is enabled (until we get visual test image persistence figured out), because it will be testing against an empty screenshot folder. - -ACCEPT_VISUAL=1 will overwrite the snapshot images with new images. diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go deleted file mode 100644 index 95093ffd2970a..0000000000000 --- a/tests/e2e/e2e_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -// This is primarily coped from /tests/integration/integration_test.go -// TODO: Move common functions to shared file - -//nolint:forbidigo // use of print functions is allowed in tests -package e2e - -import ( - "bytes" - "context" - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "testing" - - "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/graceful" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/testlogger" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers" - "code.gitea.io/gitea/tests" -) - -var testE2eWebRoutes *web.Router - -func TestMain(m *testing.M) { - defer log.GetManager().Close() - - managerCtx, cancel := context.WithCancel(context.Background()) - graceful.InitManager(managerCtx) - defer cancel() - - tests.InitTest(false) - testE2eWebRoutes = routers.NormalRoutes() - - err := unittest.InitFixtures( - unittest.FixturesOptions{ - Dir: filepath.Join(filepath.Dir(setting.AppPath), "models/fixtures/"), - }, - ) - if err != nil { - fmt.Printf("Error initializing test database: %v\n", err) - os.Exit(1) - } - - exitVal := m.Run() - - testlogger.WriterCloser.Reset() - - if err = util.RemoveAll(setting.Indexer.IssuePath); err != nil { - fmt.Printf("util.RemoveAll: %v\n", err) - os.Exit(1) - } - if err = util.RemoveAll(setting.Indexer.RepoPath); err != nil { - fmt.Printf("Unable to remove repo indexer: %v\n", err) - os.Exit(1) - } - - os.Exit(exitVal) -} - -// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.ts" files in this directory and build a test for each. -func TestE2e(t *testing.T) { - // Find the paths of all e2e test files in test directory. - searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.ts") - paths, err := filepath.Glob(searchGlob) - if err != nil { - t.Fatal(err) - } else if len(paths) == 0 { - t.Fatal(fmt.Errorf("No e2e tests found in %s", searchGlob)) - } - - runArgs := []string{"npx", "playwright", "test"} - - // To update snapshot outputs - if _, set := os.LookupEnv("ACCEPT_VISUAL"); set { - runArgs = append(runArgs, "--update-snapshots") - } - - // Create new test for each input file - for _, path := range paths { - _, filename := filepath.Split(path) - testname := filename[:len(filename)-len(filepath.Ext(path))] - - t.Run(testname, func(t *testing.T) { - // Default 2 minute timeout - onGiteaRun(t, func(*testing.T, *url.URL) { - cmd := exec.Command(runArgs[0], runArgs...) - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "GITEA_TEST_SERVER_URL="+setting.AppURL) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - if err != nil { - // Currently colored output is conflicting. Using Printf until that is resolved. - fmt.Printf("%v", stdout.String()) - fmt.Printf("%v", stderr.String()) - log.Fatal("Playwright Failed: %s", err) - } - - fmt.Printf("%v", stdout.String()) - }) - }) - } -} diff --git a/tests/e2e/example.test.e2e.ts b/tests/e2e/example.test.e2e.ts deleted file mode 100644 index 1689f1b8efc7e..0000000000000 --- a/tests/e2e/example.test.e2e.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {test, expect} from '@playwright/test'; -import {login_user, save_visual, load_logged_in_context} from './utils_e2e.ts'; - -test.beforeAll(async ({browser}, workerInfo) => { - await login_user(browser, workerInfo, 'user2'); -}); - -test('homepage', async ({page}) => { - const response = await page.goto('/'); - expect(response?.status()).toBe(200); // Status OK - await expect(page).toHaveTitle(/^Gitea: Git with a cup of tea\s*$/); - await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg'); -}); - -test('register', async ({page}, workerInfo) => { - const response = await page.goto('/user/sign_up'); - expect(response?.status()).toBe(200); // Status OK - await page.locator('input[name=user_name]').fill(`e2e-test-${workerInfo.workerIndex}`); - await page.locator('input[name=email]').fill(`e2e-test-${workerInfo.workerIndex}@test.com`); - await page.locator('input[name=password]').fill('test123test123'); - await page.locator('input[name=retype]').fill('test123test123'); - await page.click('form button.ui.primary.button:visible'); - // Make sure we routed to the home page. Else login failed. - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - await expect(page.locator('.secondary-nav span>img.ui.avatar')).toBeVisible(); - await expect(page.locator('.ui.positive.message.flash-success')).toHaveText('Account was successfully created. Welcome!'); - - save_visual(page); -}); - -test('login', async ({page}, workerInfo) => { - const response = await page.goto('/user/login'); - expect(response?.status()).toBe(200); // Status OK - - await page.locator('input[name=user_name]').fill(`user2`); - await page.locator('input[name=password]').fill(`password`); - await page.click('form button.ui.primary.button:visible'); - - await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle - - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - - save_visual(page); -}); - -test('logged in user', async ({browser}, workerInfo) => { - const context = await load_logged_in_context(browser, workerInfo, 'user2'); - const page = await context.newPage(); - - await page.goto('/'); - - // Make sure we routed to the home page. Else login failed. - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - - save_visual(page); -}); diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts new file mode 100644 index 0000000000000..307f64c52c6bf --- /dev/null +++ b/tests/e2e/login.test.ts @@ -0,0 +1,20 @@ +import {test, expect} from '@playwright/test'; +import {login, logout, login_user} from './utils.ts'; + +test('homepage', async ({page}) => { + const response = await page.goto('/'); + expect(response?.status()).toBe(200); + await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); +}); + +test('logged in user', async ({browser}) => { + const context = await login_user(browser, 'e2e'); + const page = await context.newPage(); + const response = await page.goto('/'); + expect(response?.status()).toBe(200); +}); + +test('login and logout', async ({page}) => { // eslint-disable-line playwright/expect-expect + await login(page); + await logout(page); +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts new file mode 100644 index 0000000000000..3262c3bf87e49 --- /dev/null +++ b/tests/e2e/utils.ts @@ -0,0 +1,24 @@ +import {expect} from '@playwright/test'; +import type {Browser, Page} from '@playwright/test'; + +const LOGIN_PASSWORD = 'password'; + +export async function login(page: Page, user: string = 'e2e') { + await page.goto('/user/login'); + await page.getByLabel('Username or Email Address').fill(user); + await page.getByLabel('Password').fill(LOGIN_PASSWORD); + await page.getByRole('button', {name: 'Sign In'}).click(); + await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); +} + +export async function logout(page: Page) { + await page.getByText('Sign Out').dispatchEvent('click'); + await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); +} + +export async function login_user(browser: Browser, user: string) { + const context = await browser.newContext(); + const page = await context.newPage(); + await login(page, user); + return context; +} diff --git a/tests/e2e/utils_e2e.ts b/tests/e2e/utils_e2e.ts deleted file mode 100644 index 0973f0838c8e3..0000000000000 --- a/tests/e2e/utils_e2e.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {expect} from '@playwright/test'; -import {env} from 'node:process'; -import type {Browser, Page, WorkerInfo} from '@playwright/test'; - -const ARTIFACTS_PATH = `tests/e2e/test-artifacts`; -const LOGIN_PASSWORD = 'password'; - -// log in user and store session info. This should generally be -// run in test.beforeAll(), then the session can be loaded in tests. -export async function login_user(browser: Browser, workerInfo: WorkerInfo, user: string) { - // Set up a new context - const context = await browser.newContext(); - const page = await context.newPage(); - - // Route to login page - // Note: this could probably be done more quickly with a POST - const response = await page.goto('/user/login'); - expect(response?.status()).toBe(200); // Status OK - - // Fill out form - await page.locator('input[name=user_name]').fill(user); - await page.locator('input[name=password]').fill(LOGIN_PASSWORD); - await page.click('form button.ui.primary.button:visible'); - - await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle - - expect(page.url(), {message: `Failed to login user ${user}`}).toBe(`${workerInfo.project.use.baseURL}/`); - - // Save state - await context.storageState({path: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`}); - - return context; -} - -export async function load_logged_in_context(browser: Browser, workerInfo: WorkerInfo, user: string) { - try { - return await browser.newContext({storageState: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`}); - } catch (err) { - if (err.code === 'ENOENT') { - throw new Error(`Could not find state for '${user}'. Did you call login_user(browser, workerInfo, '${user}') in test.beforeAll()?`); - } else { - throw err; - } - } -} - -export async function save_visual(page: Page) { - // Optionally include visual testing - if (env.VISUAL_TEST) { - await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle - // Mock page/version string - await page.locator('footer div.ui.left').evaluate((node) => node.innerHTML = 'MOCK'); - await expect(page).toHaveScreenshot({ - fullPage: true, - timeout: 20000, - mask: [ - page.locator('.secondary-nav span>img.ui.avatar'), - page.locator('.ui.dropdown.jump.item span>img.ui.avatar'), - ], - }); - } -} diff --git a/tests/e2e/utils_e2e_test.go b/tests/e2e/utils_e2e_test.go deleted file mode 100644 index 5ba05f3453c28..0000000000000 --- a/tests/e2e/utils_e2e_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package e2e - -import ( - "context" - "net" - "net/http" - "net/url" - "testing" - "time" - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/tests" - - "github.com/stretchr/testify/assert" -) - -func onGiteaRunTB(t testing.TB, callback func(testing.TB, *url.URL), prepare ...bool) { - if len(prepare) == 0 || prepare[0] { - defer tests.PrepareTestEnv(t, 1)() - } - s := http.Server{ - Handler: testE2eWebRoutes, - } - - u, err := url.Parse(setting.AppURL) - assert.NoError(t, err) - listener, err := net.Listen("tcp", u.Host) - i := 0 - for err != nil && i <= 10 { - time.Sleep(100 * time.Millisecond) - listener, err = net.Listen("tcp", u.Host) - i++ - } - assert.NoError(t, err) - u.Host = listener.Addr().String() - - defer func() { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) - s.Shutdown(ctx) - cancel() - }() - - go s.Serve(listener) - // Started by config go ssh.Listen(setting.SSH.ListenHost, setting.SSH.ListenPort, setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs) - - callback(t, u) -} - -func onGiteaRun(t *testing.T, callback func(*testing.T, *url.URL), prepare ...bool) { - onGiteaRunTB(t, func(t testing.TB, u *url.URL) { - callback(t.(*testing.T), u) - }, prepare...) -} diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh new file mode 100755 index 0000000000000..c59ecce4584c9 --- /dev/null +++ b/tools/test-e2e.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +# Determine the Gitea server URL, either from GITEA_URL env var or from custom/conf/app.ini +if [ -n "${GITEA_URL:-}" ]; then + GITEA_TEST_SERVER_URL="$GITEA_URL" +else + INI_FILE="custom/conf/app.ini" + if [ ! -f "$INI_FILE" ]; then + echo "error: $INI_FILE not found and GITEA_URL not set" >&2 + echo "Either start Gitea with a config or set GITEA_URL explicitly:" >&2 + echo " GITEA_URL=http://localhost:3000 make test-e2e" >&2 + exit 1 + fi + ROOT_URL=$(sed -n 's/^ROOT_URL\s*=\s*//p' "$INI_FILE" | tr -d '[:space:]') + if [ -z "$ROOT_URL" ]; then + echo "error: ROOT_URL not found in $INI_FILE" >&2 + exit 1 + fi + GITEA_TEST_SERVER_URL="$ROOT_URL" +fi + +echo "Using Gitea server: $GITEA_TEST_SERVER_URL" + +# Verify server is reachable +if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL" > /dev/null 2>&1; then + echo "error: Gitea server at $GITEA_TEST_SERVER_URL is not reachable" >&2 + echo "Start Gitea first: ${EXECUTABLE:-./gitea}" >&2 + exit 1 +fi + +# Create e2e test user if it does not already exist +E2E_USER="e2e" +E2E_EMAIL="e2e@test.gitea.io" +E2E_PASSWORD="password" +if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then + echo "Creating e2e test user..." + if ${EXECUTABLE:-./gitea} admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false 2>/dev/null; then + echo "User '$E2E_USER' created" + else + echo "error: failed to create user '$E2E_USER'" >&2 + exit 1 + fi +fi + +export GITEA_TEST_SERVER_URL +exec pnpm exec playwright test "$@" From 625e46d03358dafe6af3374b271588ce3b7a44d8 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:20:46 +0100 Subject: [PATCH 02/55] Pass e2e credentials via environment variables Remove login_user helper and LOGIN_PASSWORD constant, pass E2E_USER and E2E_PASSWORD from the test script to playwright via env vars. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/login.test.ts | 5 +++-- tests/e2e/utils.ts | 15 +++------------ tools/test-e2e.sh | 2 ++ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index 307f64c52c6bf..c6759f636ad7c 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {login, logout, login_user} from './utils.ts'; +import {login, logout} from './utils.ts'; test('homepage', async ({page}) => { const response = await page.goto('/'); @@ -8,8 +8,9 @@ test('homepage', async ({page}) => { }); test('logged in user', async ({browser}) => { - const context = await login_user(browser, 'e2e'); + const context = await browser.newContext(); const page = await context.newPage(); + await login(page, 'e2e'); const response = await page.goto('/'); expect(response?.status()).toBe(200); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 3262c3bf87e49..39d8218be643e 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,12 +1,10 @@ import {expect} from '@playwright/test'; -import type {Browser, Page} from '@playwright/test'; +import type {Page} from '@playwright/test'; -const LOGIN_PASSWORD = 'password'; - -export async function login(page: Page, user: string = 'e2e') { +export async function login(page: Page, user: string = process.env.E2E_USER) { await page.goto('/user/login'); await page.getByLabel('Username or Email Address').fill(user); - await page.getByLabel('Password').fill(LOGIN_PASSWORD); + await page.getByLabel('Password').fill(process.env.E2E_PASSWORD); await page.getByRole('button', {name: 'Sign In'}).click(); await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); } @@ -15,10 +13,3 @@ export async function logout(page: Page) { await page.getByText('Sign Out').dispatchEvent('click'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } - -export async function login_user(browser: Browser, user: string) { - const context = await browser.newContext(); - const page = await context.newPage(); - await login(page, user); - return context; -} diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index c59ecce4584c9..cdf80477753ef 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -44,4 +44,6 @@ if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL/api/v1/users/$E2E_USER" > /de fi export GITEA_TEST_SERVER_URL +export E2E_USER +export E2E_PASSWORD exec pnpm exec playwright test "$@" From 68acbd3ad49cfa47e4a6ec1653661996194f4b4e Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:25:17 +0100 Subject: [PATCH 03/55] Address review comments Trim trailing slash from server URL, add retry loop for server reachability, note in CONTRIBUTING.md that a running server is required. Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 2 +- tools/test-e2e.sh | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d9f53dd2fb7..1df5b36cce8ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,7 +178,7 @@ Here's how to run the test suite: | :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- | |``make test[\#SpecificTestName]`` | run unit test(s) | | |``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | -|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | | +|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | Requires a running Gitea server | ## Translation diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index cdf80477753ef..bde5d76561b30 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -20,14 +20,23 @@ else GITEA_TEST_SERVER_URL="$ROOT_URL" fi +# Normalize URL: trim trailing slash to avoid double slashes when appending paths +GITEA_TEST_SERVER_URL="${GITEA_TEST_SERVER_URL%/}" + echo "Using Gitea server: $GITEA_TEST_SERVER_URL" -# Verify server is reachable -if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL" > /dev/null 2>&1; then - echo "error: Gitea server at $GITEA_TEST_SERVER_URL is not reachable" >&2 - echo "Start Gitea first: ${EXECUTABLE:-./gitea}" >&2 - exit 1 -fi +# Verify server is reachable, retry for up to 2 minutes for slow startup +MAX_WAIT=120 +ELAPSED=0 +while ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL" > /dev/null 2>&1; do + if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then + echo "error: Gitea server at $GITEA_TEST_SERVER_URL is not reachable after ${MAX_WAIT}s" >&2 + echo "Start Gitea first: ${EXECUTABLE:-./gitea}" >&2 + exit 1 + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done # Create e2e test user if it does not already exist E2E_USER="e2e" From f604702a7779d2d302b35471cf9172c7a4fc60b4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:31:22 +0100 Subject: [PATCH 04/55] Use env vars directly in e2e login helper, remove user parameter Import env from node:process, remove the user argument from login() and read E2E_USER and E2E_PASSWORD directly from the environment. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/login.test.ts | 2 +- tests/e2e/utils.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index c6759f636ad7c..3cf235853cbee 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -10,7 +10,7 @@ test('homepage', async ({page}) => { test('logged in user', async ({browser}) => { const context = await browser.newContext(); const page = await context.newPage(); - await login(page, 'e2e'); + await login(page); const response = await page.goto('/'); expect(response?.status()).toBe(200); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 39d8218be643e..778db0ff54913 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,10 +1,11 @@ +import {env} from 'node:process'; import {expect} from '@playwright/test'; import type {Page} from '@playwright/test'; -export async function login(page: Page, user: string = process.env.E2E_USER) { +export async function login(page: Page) { await page.goto('/user/login'); - await page.getByLabel('Username or Email Address').fill(user); - await page.getByLabel('Password').fill(process.env.E2E_PASSWORD); + await page.getByLabel('Username or Email Address').fill(env.E2E_USER!); + await page.getByLabel('Password').fill(env.E2E_PASSWORD!); await page.getByRole('button', {name: 'Sign In'}).click(); await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); } From cbe336f466f9310343011eb2226fb78b192affc0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:36:55 +0100 Subject: [PATCH 05/55] cleanup --- Makefile | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 1e34b8f2a92ea..a01e00f090a7c 100644 --- a/Makefile +++ b/Makefile @@ -553,13 +553,10 @@ test-mssql\#%: integrations.mssql.test generate-ini-mssql .PHONY: test-mssql-migration test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test -.PHONY: playwright -playwright: deps-frontend - $(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) - .PHONY: test-e2e -test-e2e: playwright - EXECUTABLE=$(EXECUTABLE) bash tools/test-e2e.sh $(E2E_FLAGS) +test-e2e: deps-frontend + @$(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) + EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite From 3dd69ecfc019581b75127f0d1b3a160fc322052d Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:43:32 +0100 Subject: [PATCH 06/55] Add playwright/no-raw-locators eslint rule for e2e tests Forbid page.locator() in favor of semantic locators like getByRole, getByLabel, getByText. Co-Authored-By: Claude Opus 4.6 --- eslint.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.ts b/eslint.config.ts index 3020c757ca811..d3f5129fa5324 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -913,6 +913,7 @@ export default defineConfig([ files: ['tests/e2e/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, + 'playwright/no-raw-locators': [2], }, }, { From af481926f4cce69c621818473e5ae92406c67511 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:48:11 +0100 Subject: [PATCH 07/55] Remove make playwright step from e2e CI workflow The playwright target was inlined into test-e2e, so the separate step is no longer needed. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 1d255abb462a5..0cdf37cd2df32 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -37,7 +37,6 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify - run: make deps-frontend - run: make frontend - - run: make playwright - run: | mkdir -p custom/conf cat <<'EOF' > custom/conf/app.ini From 7075f3ad4a87a3bdf5a3f772bc1071dd8330da59 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:53:32 +0100 Subject: [PATCH 08/55] Remove redundant "logged in user" e2e test The login and logout test already covers this functionality. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/login.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index 3cf235853cbee..74a92aa5afdd8 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -7,14 +7,6 @@ test('homepage', async ({page}) => { await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); }); -test('logged in user', async ({browser}) => { - const context = await browser.newContext(); - const page = await context.newPage(); - await login(page); - const response = await page.goto('/'); - expect(response?.status()).toBe(200); -}); - test('login and logout', async ({page}) => { // eslint-disable-line playwright/expect-expect await login(page); await logout(page); From aa8b0ad87e1cf67f67e6b3b098b4ea7e8367e592 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:54:23 +0100 Subject: [PATCH 09/55] Remove response status check from e2e homepage test Playwright already fails on non-OK responses by default. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/login.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index 74a92aa5afdd8..8a8efabc21655 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -2,8 +2,7 @@ import {test, expect} from '@playwright/test'; import {login, logout} from './utils.ts'; test('homepage', async ({page}) => { - const response = await page.goto('/'); - expect(response?.status()).toBe(200); + await page.goto('/'); await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); }); From 83a448c498c08b77ea991e89c3cbe32dd7dbb56d Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 11:58:47 +0100 Subject: [PATCH 10/55] Rename e2e env vars to E2E_URL, simplify URL detection Rename GITEA_TEST_SERVER_URL and GITEA_URL to E2E_URL for consistency with E2E_USER and E2E_PASSWORD. Simplify the if/else in test-e2e.sh. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 2 +- playwright.config.ts | 2 +- tools/test-e2e.sh | 27 +++++++++++++-------------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 0cdf37cd2df32..2c850cbdf6940 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -51,5 +51,5 @@ jobs: INSTALL_LOCK = true EOF - run: ./gitea web & - - run: GITEA_URL=http://localhost:3000 make test-e2e + - run: E2E_URL=http://localhost:3000 make test-e2e timeout-minutes: 10 diff --git a/playwright.config.ts b/playwright.config.ts index 79979ad931f8a..a194960a50257 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ timeout: env.CI ? 15000 : 5000, }, use: { - baseURL: env.GITEA_TEST_SERVER_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000', + baseURL: env.E2E_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000', locale: 'en-US', trace: 'off', screenshot: 'off', diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index bde5d76561b30..edd42efc8e4b5 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -1,15 +1,13 @@ #!/bin/bash set -euo pipefail -# Determine the Gitea server URL, either from GITEA_URL env var or from custom/conf/app.ini -if [ -n "${GITEA_URL:-}" ]; then - GITEA_TEST_SERVER_URL="$GITEA_URL" -else +# Determine the Gitea server URL, either from E2E_URL env var or from custom/conf/app.ini +if [ -z "${E2E_URL:-}" ]; then INI_FILE="custom/conf/app.ini" if [ ! -f "$INI_FILE" ]; then - echo "error: $INI_FILE not found and GITEA_URL not set" >&2 - echo "Either start Gitea with a config or set GITEA_URL explicitly:" >&2 - echo " GITEA_URL=http://localhost:3000 make test-e2e" >&2 + echo "error: $INI_FILE not found and E2E_URL not set" >&2 + echo "Either start Gitea with a config or set E2E_URL explicitly:" >&2 + echo " E2E_URL=http://localhost:3000 make test-e2e" >&2 exit 1 fi ROOT_URL=$(sed -n 's/^ROOT_URL\s*=\s*//p' "$INI_FILE" | tr -d '[:space:]') @@ -17,20 +15,20 @@ else echo "error: ROOT_URL not found in $INI_FILE" >&2 exit 1 fi - GITEA_TEST_SERVER_URL="$ROOT_URL" + E2E_URL="$ROOT_URL" fi # Normalize URL: trim trailing slash to avoid double slashes when appending paths -GITEA_TEST_SERVER_URL="${GITEA_TEST_SERVER_URL%/}" +E2E_URL="${E2E_URL%/}" -echo "Using Gitea server: $GITEA_TEST_SERVER_URL" +echo "Using Gitea server: $E2E_URL" # Verify server is reachable, retry for up to 2 minutes for slow startup MAX_WAIT=120 ELAPSED=0 -while ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL" > /dev/null 2>&1; do +while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then - echo "error: Gitea server at $GITEA_TEST_SERVER_URL is not reachable after ${MAX_WAIT}s" >&2 + echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 echo "Start Gitea first: ${EXECUTABLE:-./gitea}" >&2 exit 1 fi @@ -42,7 +40,7 @@ done E2E_USER="e2e" E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" -if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then +if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." if ${EXECUTABLE:-./gitea} admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false 2>/dev/null; then echo "User '$E2E_USER' created" @@ -52,7 +50,8 @@ if ! curl -sf --max-time 5 "$GITEA_TEST_SERVER_URL/api/v1/users/$E2E_USER" > /de fi fi -export GITEA_TEST_SERVER_URL +export E2E_URL export E2E_USER export E2E_PASSWORD + exec pnpm exec playwright test "$@" From 734be7e3b58782cddfdb7c8eb581e680604fd429 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:08:42 +0100 Subject: [PATCH 11/55] Fix e2e CI: use ./gitea path for executable, show user creation errors The EXECUTABLE was passed as 'gitea' without ./ prefix, which fails in CI where the current directory is not in PATH. Also remove 2>/dev/null from user creation to surface errors. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- tools/test-e2e.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index a01e00f090a7c..b20287474a6db 100644 --- a/Makefile +++ b/Makefile @@ -556,7 +556,7 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: test-e2e test-e2e: deps-frontend @$(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) - EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) + EXECUTABLE=./$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index edd42efc8e4b5..0738a89b8bda5 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -42,7 +42,7 @@ E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." - if ${EXECUTABLE:-./gitea} admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false 2>/dev/null; then + if ${EXECUTABLE:-./gitea} admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then echo "User '$E2E_USER' created" else echo "error: failed to create user '$E2E_USER'" >&2 From bbfa948f51df98328849f264fac895e0a7815911 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:09:47 +0100 Subject: [PATCH 12/55] Remove EXECUTABLE fallback in test-e2e.sh The Makefile always passes EXECUTABLE, so the fallback is unnecessary. Co-Authored-By: Claude Opus 4.6 --- tools/test-e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 0738a89b8bda5..3d147f8a39892 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -29,7 +29,7 @@ ELAPSED=0 while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 - echo "Start Gitea first: ${EXECUTABLE:-./gitea}" >&2 + echo "Start Gitea first: $EXECUTABLE" >&2 exit 1 fi sleep 2 @@ -42,7 +42,7 @@ E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." - if ${EXECUTABLE:-./gitea} admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then + if $EXECUTABLE admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then echo "User '$E2E_USER' created" else echo "error: failed to create user '$E2E_USER'" >&2 From 61b75540a5092b59d88cd9dea77bea8a881609f5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:10:24 +0100 Subject: [PATCH 13/55] Quote shell variable expansions in test-e2e.sh Co-Authored-By: Claude Opus 4.6 --- tools/test-e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 3d147f8a39892..b6eecd08bd009 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -29,7 +29,7 @@ ELAPSED=0 while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 - echo "Start Gitea first: $EXECUTABLE" >&2 + echo "Start Gitea first: ${EXECUTABLE}" >&2 exit 1 fi sleep 2 @@ -42,7 +42,7 @@ E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." - if $EXECUTABLE admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then + if "$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then echo "User '$E2E_USER' created" else echo "error: failed to create user '$E2E_USER'" >&2 From d91a8d886b62cc53042ebfd8771548dda28e458e Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:11:39 +0100 Subject: [PATCH 14/55] Move ./ prefix for EXECUTABLE into test-e2e.sh Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- tools/test-e2e.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b20287474a6db..a01e00f090a7c 100644 --- a/Makefile +++ b/Makefile @@ -556,7 +556,7 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: test-e2e test-e2e: deps-frontend @$(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) - EXECUTABLE=./$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) + EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index b6eecd08bd009..24a9a65162241 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -1,6 +1,8 @@ #!/bin/bash set -euo pipefail +EXECUTABLE="./${EXECUTABLE}" + # Determine the Gitea server URL, either from E2E_URL env var or from custom/conf/app.ini if [ -z "${E2E_URL:-}" ]; then INI_FILE="custom/conf/app.ini" From cb1c0a952128fc2172f82ef81c4f4a14609cd2d0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:12:10 +0100 Subject: [PATCH 15/55] Use ./ prefix inline instead of rewriting EXECUTABLE Co-Authored-By: Claude Opus 4.6 --- tools/test-e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 24a9a65162241..3df7de995fdb7 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -31,7 +31,7 @@ ELAPSED=0 while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 - echo "Start Gitea first: ${EXECUTABLE}" >&2 + echo "Start Gitea first: ./${EXECUTABLE}" >&2 exit 1 fi sleep 2 @@ -44,7 +44,7 @@ E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." - if "$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then + if "./$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then echo "User '$E2E_USER' created" else echo "error: failed to create user '$E2E_USER'" >&2 From 03f05abb674d7c392bc7d0af17e29e61d4ec2bc0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:13:45 +0100 Subject: [PATCH 16/55] Remove leftover EXECUTABLE reassignment in test-e2e.sh Co-Authored-By: Claude Opus 4.6 --- tools/test-e2e.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 3df7de995fdb7..67b46698d9545 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -1,8 +1,6 @@ #!/bin/bash set -euo pipefail -EXECUTABLE="./${EXECUTABLE}" - # Determine the Gitea server URL, either from E2E_URL env var or from custom/conf/app.ini if [ -z "${E2E_URL:-}" ]; then INI_FILE="custom/conf/app.ini" From 4a0366d696887a9dc44425f880c0bd0db9ad13af Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:19:31 +0100 Subject: [PATCH 17/55] Split playwright install back into separate make target Restore make playwright as a separate target so CI can run it as its own step before test-e2e. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 + Makefile | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 2c850cbdf6940..3bedad29a8bb3 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -51,5 +51,6 @@ jobs: INSTALL_LOCK = true EOF - run: ./gitea web & + - run: make playwright - run: E2E_URL=http://localhost:3000 make test-e2e timeout-minutes: 10 diff --git a/Makefile b/Makefile index a01e00f090a7c..cf61bc89a2700 100644 --- a/Makefile +++ b/Makefile @@ -553,9 +553,12 @@ test-mssql\#%: integrations.mssql.test generate-ini-mssql .PHONY: test-mssql-migration test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test -.PHONY: test-e2e -test-e2e: deps-frontend +.PHONY: playwright +playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) + +.PHONY: test-e2e +test-e2e: playwright EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite From 30b9a0508240a40b89344091a6792c842cfc64f1 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:25:18 +0100 Subject: [PATCH 18/55] Fix flaky logout test by waiting for navigation The dispatchEvent('click') on Sign Out triggers a navigation. Wait for it to complete before checking for the Sign In link. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 778db0ff54913..7cf7a4c64cff4 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -12,5 +12,6 @@ export async function login(page: Page) { export async function logout(page: Page) { await page.getByText('Sign Out').dispatchEvent('click'); + await page.waitForURL('**/'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From 40446b8795ee730f46c51e08e5438f1f9c1b2608 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:37:43 +0100 Subject: [PATCH 19/55] Use semantic click for logout instead of dispatchEvent The dropdown already gets aria-label from data-tooltip-content via the ARIA dropdown patch, so we can open it with getByLabel and then click Sign Out normally. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 7cf7a4c64cff4..1b7b4992b95ee 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -11,7 +11,7 @@ export async function login(page: Page) { } export async function logout(page: Page) { - await page.getByText('Sign Out').dispatchEvent('click'); - await page.waitForURL('**/'); + await page.getByLabel('Profile and Settings…').click(); + await page.getByText('Sign Out').click(); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From 5354352bee1f8ef76f1f40ba3dce1e423610efc3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 12:58:16 +0100 Subject: [PATCH 20/55] Use server-rendered title attribute for dropdown locator Replace JS-dependent getByLabel (aria-label set by Fomantic init) with getByTitle targeting the avatar's server-rendered title attribute, scoped to the navigation bar. Extract reusable clickDropdownItem helper. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/utils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 1b7b4992b95ee..0e00316d1e47e 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,6 +1,11 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {Page} from '@playwright/test'; +import type {Locator, Page} from '@playwright/test'; + +export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { + await trigger.click(); + await page.getByText(itemText).click(); +} export async function login(page: Page) { await page.goto('/user/login'); @@ -11,7 +16,7 @@ export async function login(page: Page) { } export async function logout(page: Page) { - await page.getByLabel('Profile and Settings…').click(); - await page.getByText('Sign Out').click(); + const navbar = page.getByRole('navigation', {name: 'Navigation Bar'}); + await clickDropdownItem(page, navbar.getByTitle(env.E2E_USER!), 'Sign Out'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From 20f96f7c1b840c24b45a30401170086333d77082 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 13:09:09 +0100 Subject: [PATCH 21/55] Wait for logout response before verifying sign-out The link-action handler does an async fetch POST then a form-based redirect chain which can be slow on CI. Wait for the /user/logout response to confirm session destruction, then navigate to verify. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 0e00316d1e47e..bbe4ab44abcd0 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -17,6 +17,11 @@ export async function login(page: Page) { export async function logout(page: Page) { const navbar = page.getByRole('navigation', {name: 'Navigation Bar'}); - await clickDropdownItem(page, navbar.getByTitle(env.E2E_USER!), 'Sign Out'); + await navbar.getByTitle(env.E2E_USER!).click(); + await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/user/logout')), + page.getByText('Sign Out').click(), + ]); + await page.goto('/'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From 4517e8a5dc0c84c0b3a642e6c3aca4434bc7b2b7 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 13:17:50 +0100 Subject: [PATCH 22/55] Use clearCookies for logout to avoid fomantic JS dependency The fomantic dropdown JS does not reliably initialize on CI headless Chromium, making dropdown-based Sign Out impossible. Use clearCookies to destroy the session and verify logout state instead. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/utils.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index bbe4ab44abcd0..fa56881239ec1 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -16,12 +16,7 @@ export async function login(page: Page) { } export async function logout(page: Page) { - const navbar = page.getByRole('navigation', {name: 'Navigation Bar'}); - await navbar.getByTitle(env.E2E_USER!).click(); - await Promise.all([ - page.waitForResponse((resp) => resp.url().includes('/user/logout')), - page.getByText('Sign Out').click(), - ]); + await page.context().clearCookies(); await page.goto('/'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From a7af563050efd6ff077368b1f8d8b2ca43f82460 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 13:36:05 +0100 Subject: [PATCH 23/55] Apply suggestion from @silverwind Signed-off-by: silverwind --- tests/e2e/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index fa56881239ec1..1cfa3a6a037d3 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -16,7 +16,7 @@ export async function login(page: Page) { } export async function logout(page: Page) { - await page.context().clearCookies(); + await page.context().clearCookies(); // workarkound issues related to fomantic dropdown await page.goto('/'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } From 9a70946124b9dc2cec124079e6c741535306a055 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 14:34:17 +0100 Subject: [PATCH 24/55] add temp server for local development, misc tweaks --- .gitignore | 1 + CONTRIBUTING.md | 10 +++++++++- Makefile | 2 +- playwright.config.ts | 1 + tools/test-e2e.sh | 32 ++++++++++++++++++++++++++++++-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4da2d797ebbfa..d88c2597ceff8 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ cpu.out /indexers /log /public/assets/img/avatar +/tests/e2e-output /tests/integration/gitea-integration-* /tests/integration/indexers-* /tests/*.ini diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1df5b36cce8ca..baa288061fa4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,7 +178,15 @@ Here's how to run the test suite: | :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- | |``make test[\#SpecificTestName]`` | run unit test(s) | | |``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | -|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | Requires a running Gitea server | +|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | | + +- e2e test environment variables + +| Variable | Description | +| :-------------- | :-------------------------------------------------------------------------- | +|``E2E_URL`` | URL of the Gitea server to test against (default: read from ``app.ini``) | +|``E2E_DEBUG`` | When set, show Gitea server output (only for auto-started server) | +|``E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | ## Translation diff --git a/Makefile b/Makefile index cf61bc89a2700..e619e06096e40 100644 --- a/Makefile +++ b/Makefile @@ -559,7 +559,7 @@ playwright: deps-frontend .PHONY: test-e2e test-e2e: playwright - EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) + @EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite diff --git a/playwright.config.ts b/playwright.config.ts index a194960a50257..0bc844f6626da 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,7 @@ import {defineConfig, devices} from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e/', + outputDir: './tests/e2e-output/', testMatch: /.*\.test\.ts/, forbidOnly: Boolean(env.CI), reporter: 'list', diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 67b46698d9545..ac43bb82e47ea 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -23,13 +23,41 @@ E2E_URL="${E2E_URL%/}" echo "Using Gitea server: $E2E_URL" +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then + echo "Stopping temporary Gitea server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# For local development, if no gitea server is running, start a temporary one. +if [ -z "${CI:-}" ] && ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; then + if [ ! -x "./$EXECUTABLE" ]; then + echo "error: ./$EXECUTABLE not found or not executable, run 'make backend' first" >&2 + exit 1 + fi + echo "Starting temporary Gitea server..." + if [ -n "${E2E_DEBUG:-}" ]; then + "./$EXECUTABLE" web & + else + "./$EXECUTABLE" web > /dev/null 2>&1 & + fi + SERVER_PID=$! +fi + # Verify server is reachable, retry for up to 2 minutes for slow startup MAX_WAIT=120 ELAPSED=0 while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "error: Gitea server process exited unexpectedly" >&2 + exit 1 + fi if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 - echo "Start Gitea first: ./${EXECUTABLE}" >&2 exit 1 fi sleep 2 @@ -54,4 +82,4 @@ export E2E_URL export E2E_USER export E2E_PASSWORD -exec pnpm exec playwright test "$@" +pnpm exec playwright test "$@" From 764895407a5dfc0c959006f9187ccf884351fae2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 14:35:21 +0100 Subject: [PATCH 25/55] allow locators again --- eslint.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/eslint.config.ts b/eslint.config.ts index d3f5129fa5324..3020c757ca811 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -913,7 +913,6 @@ export default defineConfig([ files: ['tests/e2e/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, - 'playwright/no-raw-locators': [2], }, }, { From 5439c7ea2f2a76fe5f2311b9cd9e05314cb94e49 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 15:07:07 +0100 Subject: [PATCH 26/55] skip playwright --with-deps on actions --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e619e06096e40..05d0b1311deea 100644 --- a/Makefile +++ b/Makefile @@ -555,7 +555,8 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright playwright: deps-frontend - @$(NODE_VARS) pnpm exec playwright install --with-deps chromium $(PLAYWRIGHT_FLAGS) + @# on GitHub Actions CI, playwright system deps are pre-installed + @$(NODE_VARS) pnpm exec playwright install $(if $(CI),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e test-e2e: playwright From e5b5bdafa3833b66974ef628e301c585df3161c4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 15:08:41 +0100 Subject: [PATCH 27/55] skip installing playwright system deps on github actions --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 05d0b1311deea..91bb7b0bbeafc 100644 --- a/Makefile +++ b/Makefile @@ -555,8 +555,8 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright playwright: deps-frontend - @# on GitHub Actions CI, playwright system deps are pre-installed - @$(NODE_VARS) pnpm exec playwright install $(if $(CI),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) + @# on GitHub Actions VMs, playwright's system deps are pre-installed + @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e test-e2e: playwright From 4af15206cd6ec10da198dd361498fc437ac25070 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 15 Feb 2026 20:59:53 +0100 Subject: [PATCH 28/55] Add register e2e test and disable CAPTCHA for e2e Co-Authored-By: Claude Opus 4.6 --- tests/e2e/register.test.ts | 77 ++++++++++++++++++++++++++++++++++++++ tools/test-e2e.sh | 5 ++- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/register.test.ts diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts new file mode 100644 index 0000000000000..c8bc6c211f12d --- /dev/null +++ b/tests/e2e/register.test.ts @@ -0,0 +1,77 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {logout} from './utils.ts'; + +test.beforeEach(async ({page}) => { + await page.goto('/user/sign_up'); +}); + +test('register page has form', async ({page}) => { + await expect(page.getByLabel('Username')).toBeVisible(); + await expect(page.getByLabel('Email Address')).toBeVisible(); + await expect(page.getByLabel('Password', {exact: true})).toBeVisible(); + await expect(page.getByLabel('Confirm Password')).toBeVisible(); + await expect(page.getByRole('button', {name: 'Register Account'})).toBeVisible(); +}); + +test('register with empty fields shows error', async ({page}) => { + // HTML5 required attribute prevents submission, so verify the fields are required + await expect(page.locator('input[name="user_name"][required]')).toBeVisible(); + await expect(page.locator('input[name="email"][required]')).toBeVisible(); + await expect(page.locator('input[name="password"][required]')).toBeVisible(); + await expect(page.locator('input[name="retype"][required]')).toBeVisible(); +}); + +test('register with mismatched passwords shows error', async ({page}) => { + await page.getByLabel('Username').fill('e2e-register-mismatch'); + await page.getByLabel('Email Address').fill('e2e-register-mismatch@test.gitea.io'); + await page.getByLabel('Password', {exact: true}).fill('password123!'); + await page.getByLabel('Confirm Password').fill('different123!'); + await page.getByRole('button', {name: 'Register Account'}).click(); + await expect(page.locator('.ui.negative.message')).toBeVisible(); +}); + +test('register then login', async ({page}) => { + const username = `e2e-register-${Date.now()}`; + const email = `${username}@test.gitea.io`; + const password = 'password123!'; + + await page.getByLabel('Username').fill(username); + await page.getByLabel('Email Address').fill(email); + await page.getByLabel('Password', {exact: true}).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByRole('button', {name: 'Register Account'}).click(); + + // After successful registration, should be redirected away from sign_up + await expect(page).not.toHaveURL(/sign_up/); + + // Logout then login with the newly created account + await logout(page); + await page.goto('/user/login'); + await page.getByLabel('Username or Email Address').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', {name: 'Sign In'}).click(); + await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); + + // Clean up: delete the user via API using the main e2e admin account + const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, { + headers: {Authorization: `Basic ${btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}, + }); + expect(response.ok()).toBeTruthy(); +}); + +test('register with existing username shows error', async ({page}) => { + await page.getByLabel('Username').fill('e2e'); + await page.getByLabel('Email Address').fill('e2e-duplicate@test.gitea.io'); + await page.getByLabel('Password', {exact: true}).fill('password123!'); + await page.getByLabel('Confirm Password').fill('password123!'); + await page.getByRole('button', {name: 'Register Account'}).click(); + await expect(page.locator('.ui.negative.message')).toBeVisible(); +}); + +test('sign in link exists', async ({page}) => { + const signInLink = page.getByText('Sign in now!'); + await expect(signInLink).toBeVisible(); + await signInLink.click(); + await expect(page).toHaveURL(/\/user\/login$/); +}); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index ac43bb82e47ea..ed7d2233ef2e6 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -23,6 +23,9 @@ E2E_URL="${E2E_URL%/}" echo "Using Gitea server: $E2E_URL" +# Disable CAPTCHA for e2e tests +export GITEA__service__ENABLE_CAPTCHA=false + SERVER_PID="" cleanup() { if [ -n "$SERVER_PID" ]; then @@ -70,7 +73,7 @@ E2E_EMAIL="e2e@test.gitea.io" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." - if "./$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false; then + if "./$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false --admin; then echo "User '$E2E_USER' created" else echo "error: failed to create user '$E2E_USER'" >&2 From 2314ea319456788ff015361e1657e85696a98908 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 00:09:08 +0100 Subject: [PATCH 29/55] add 6 new e2e tests and tighten timeouts --- playwright.config.ts | 11 ++++----- tests/e2e/explore.test.ts | 17 +++++++++++++ tests/e2e/milestone.test.ts | 16 ++++++++++++ tests/e2e/org.test.ts | 14 +++++++++++ tests/e2e/readme.test.ts | 13 ++++++++++ tests/e2e/repo.test.ts | 15 ++++++++++++ tests/e2e/user-settings.test.ts | 16 ++++++++++++ tests/e2e/utils.ts | 43 ++++++++++++++++++++++++++++++++- 8 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/explore.test.ts create mode 100644 tests/e2e/milestone.test.ts create mode 100644 tests/e2e/org.test.ts create mode 100644 tests/e2e/readme.test.ts create mode 100644 tests/e2e/repo.test.ts create mode 100644 tests/e2e/user-settings.test.ts diff --git a/playwright.config.ts b/playwright.config.ts index 0bc844f6626da..613a3a7ad25a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,16 +7,15 @@ export default defineConfig({ testMatch: /.*\.test\.ts/, forbidOnly: Boolean(env.CI), reporter: 'list', - timeout: env.CI ? 30000 : 10000, + timeout: env.CI ? 6000 : 2000, expect: { - timeout: env.CI ? 15000 : 5000, + timeout: env.CI ? 3000 : 1000, }, use: { - baseURL: env.E2E_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000', + baseURL: env.E2E_URL?.replace?.(/\/$/g, ''), locale: 'en-US', - trace: 'off', - screenshot: 'off', - video: 'off', + actionTimeout: env.CI ? 3000 : 1000, + navigationTimeout: env.CI ? 6000 : 2000, }, projects: [ { diff --git a/tests/e2e/explore.test.ts b/tests/e2e/explore.test.ts new file mode 100644 index 0000000000000..49cd9bb87afe5 --- /dev/null +++ b/tests/e2e/explore.test.ts @@ -0,0 +1,17 @@ +import {test, expect} from '@playwright/test'; + +test('explore repositories', async ({page}) => { + await page.goto('/explore/repos'); + await expect(page.getByPlaceholder('Search repos…')).toBeVisible(); + await expect(page.getByRole('link', {name: 'Repositories'})).toBeVisible(); +}); + +test('explore users', async ({page}) => { + await page.goto('/explore/users'); + await expect(page.getByPlaceholder('Search users…')).toBeVisible(); +}); + +test('explore organizations', async ({page}) => { + await page.goto('/explore/organizations'); + await expect(page.getByPlaceholder('Search orgs…')).toBeVisible(); +}); diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts new file mode 100644 index 0000000000000..ffc0b50c307f7 --- /dev/null +++ b/tests/e2e/milestone.test.ts @@ -0,0 +1,16 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, createRepoApi, deleteRepoApi} from './utils.ts'; + +test('create a milestone', async ({page}) => { + const repoName = `e2e-milestone-${Date.now()}`; + await login(page); + await createRepoApi(page.request, {name: repoName}); + await page.goto(`/${env.E2E_USER}/${repoName}/milestones/new`); + await page.getByPlaceholder('Title').fill('Test Milestone'); + await page.getByRole('button', {name: 'Create Milestone'}).click(); + await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); + + // cleanup + await deleteRepoApi(page.request, env.E2E_USER!, repoName); +}); diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts new file mode 100644 index 0000000000000..d428832fb57a0 --- /dev/null +++ b/tests/e2e/org.test.ts @@ -0,0 +1,14 @@ +import {test, expect} from '@playwright/test'; +import {login, deleteOrgApi} from './utils.ts'; + +test('create an organization', async ({page}) => { + const orgName = `e2e-org-${Date.now()}`; + await login(page); + await page.goto('/org/create'); + await page.getByLabel('Organization Name').fill(orgName); + await page.getByRole('button', {name: 'Create Organization'}).click(); + await expect(page).toHaveURL(new RegExp(`/org/${orgName}`)); + + // cleanup + await deleteOrgApi(page.request, orgName); +}); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts new file mode 100644 index 0000000000000..1aba762ca2f3e --- /dev/null +++ b/tests/e2e/readme.test.ts @@ -0,0 +1,13 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {createRepoApi, deleteRepoApi} from './utils.ts'; + +test('README renders on repository page', async ({page}) => { + const repoName = `e2e-readme-${Date.now()}`; + await createRepoApi(page.request, {name: repoName}); + await page.goto(`/${env.E2E_USER}/${repoName}`); + await expect(page.locator('#readme')).toContainText(repoName); + + // cleanup + await deleteRepoApi(page.request, env.E2E_USER!, repoName); +}); diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts new file mode 100644 index 0000000000000..1f9904a893e9c --- /dev/null +++ b/tests/e2e/repo.test.ts @@ -0,0 +1,15 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, deleteRepoApi} from './utils.ts'; + +test('create a repository', async ({page}) => { + const repoName = `e2e-repo-${Date.now()}`; + await login(page); + await page.goto('/repo/create'); + await page.getByLabel('Repository Name').fill(repoName); + await page.getByRole('button', {name: 'Create Repository'}).click(); + await expect(page).toHaveURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); + + // cleanup + await deleteRepoApi(page.request, env.E2E_USER!, repoName); +}); diff --git a/tests/e2e/user-settings.test.ts b/tests/e2e/user-settings.test.ts new file mode 100644 index 0000000000000..3e339a2f26282 --- /dev/null +++ b/tests/e2e/user-settings.test.ts @@ -0,0 +1,16 @@ +import {test, expect} from '@playwright/test'; +import {login} from './utils.ts'; + +test('update profile biography', async ({page}) => { + const bio = `e2e-bio-${Date.now()}`; + await login(page); + await page.goto('/user/settings'); + await page.getByLabel('Biography').fill(bio); + await page.getByRole('button', {name: 'Update Profile'}).click(); + await expect(page.getByLabel('Biography')).toHaveValue(bio); + + // cleanup: clear the biography + await page.getByLabel('Biography').fill(''); + await page.getByRole('button', {name: 'Update Profile'}).click(); + await expect(page.getByLabel('Biography')).toHaveValue(''); +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 1cfa3a6a037d3..aa3bd48256187 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,6 +1,47 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {Locator, Page} from '@playwright/test'; +import type {APIRequestContext, Locator, Page} from '@playwright/test'; + +export function apiBaseUrl() { + return env.E2E_URL?.replace(/\/$/g, '') || 'http://localhost:3000'; +} + +export function apiHeaders() { + return {Authorization: `Basic ${globalThis.btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}; +} + +async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise}>, label: string) { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const response = await fn(); + if (response.ok()) return; + if (response.status() === 500 && attempt < maxAttempts - 1) { + const jitter = Math.random() * 500; + await new Promise((resolve) => globalThis.setTimeout(resolve, 1000 * (attempt + 1) + jitter)); + continue; + } + throw new Error(`${label} failed: ${response.status()} ${await response.text()}`); + } +} + +export async function createRepoApi(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) { + await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, { + headers: apiHeaders(), + data: {name, auto_init: autoInit}, + }), 'createRepoApi'); +} + +export async function deleteRepoApi(requestContext: APIRequestContext, owner: string, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, { + headers: apiHeaders(), + }), 'deleteRepoApi'); +} + +export async function deleteOrgApi(requestContext: APIRequestContext, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, { + headers: apiHeaders(), + }), 'deleteOrgApi'); +} export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { await trigger.click(); From 76c0fff889e2e80b8d99535d9b30c659cbf7d755 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 00:58:10 +0100 Subject: [PATCH 30/55] Rename test.gitea.io to e2e.gitea.com in e2e tests Co-Authored-By: Claude Opus 4.6 --- tests/e2e/register.test.ts | 6 +++--- tools/test-e2e.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index c8bc6c211f12d..70059355e4c61 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -24,7 +24,7 @@ test('register with empty fields shows error', async ({page}) => { test('register with mismatched passwords shows error', async ({page}) => { await page.getByLabel('Username').fill('e2e-register-mismatch'); - await page.getByLabel('Email Address').fill('e2e-register-mismatch@test.gitea.io'); + await page.getByLabel('Email Address').fill('e2e-register-mismatch@e2e.gitea.com'); await page.getByLabel('Password', {exact: true}).fill('password123!'); await page.getByLabel('Confirm Password').fill('different123!'); await page.getByRole('button', {name: 'Register Account'}).click(); @@ -33,7 +33,7 @@ test('register with mismatched passwords shows error', async ({page}) => { test('register then login', async ({page}) => { const username = `e2e-register-${Date.now()}`; - const email = `${username}@test.gitea.io`; + const email = `${username}@e2e.gitea.com`; const password = 'password123!'; await page.getByLabel('Username').fill(username); @@ -62,7 +62,7 @@ test('register then login', async ({page}) => { test('register with existing username shows error', async ({page}) => { await page.getByLabel('Username').fill('e2e'); - await page.getByLabel('Email Address').fill('e2e-duplicate@test.gitea.io'); + await page.getByLabel('Email Address').fill('e2e-duplicate@e2e.gitea.com'); await page.getByLabel('Password', {exact: true}).fill('password123!'); await page.getByLabel('Confirm Password').fill('password123!'); await page.getByRole('button', {name: 'Register Account'}).click(); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index ed7d2233ef2e6..648d5255b6be5 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -69,7 +69,7 @@ done # Create e2e test user if it does not already exist E2E_USER="e2e" -E2E_EMAIL="e2e@test.gitea.io" +E2E_EMAIL="e2e@e2e.gitea.com" E2E_PASSWORD="password" if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then echo "Creating e2e test user..." From 6ea6c37762531c8bf27849a0acfac321340a5c86 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 02:45:43 +0100 Subject: [PATCH 31/55] enable color on CI --- .github/workflows/pull-e2e-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 3bedad29a8bb3..c40d0b85e730e 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -54,3 +54,5 @@ jobs: - run: make playwright - run: E2E_URL=http://localhost:3000 make test-e2e timeout-minutes: 10 + env: + FORCE_COLOR: 1 From e9e676d55369f97c7fc72f185b49e844d2296983 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 03:02:38 +0100 Subject: [PATCH 32/55] Address e2e review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ENABLE_CAPTCHA=false to CI app.ini so the server starts with CAPTCHA disabled instead of relying on env var in test script - Retry on 502/503 in addition to 500 in apiRetry helper - Fix typo: workarkound → workaround - Add comment about section-unaware INI parsing in test-e2e.sh Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 3 +++ tests/e2e/utils.ts | 4 ++-- tools/test-e2e.sh | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index c40d0b85e730e..273a5e07fe031 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -47,6 +47,9 @@ jobs: HTTP_PORT = 3000 ROOT_URL = http://localhost:3000 + [service] + ENABLE_CAPTCHA = false + [security] INSTALL_LOCK = true EOF diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index aa3bd48256187..4204cf0b8499b 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -15,7 +15,7 @@ async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => numb for (let attempt = 0; attempt < maxAttempts; attempt++) { const response = await fn(); if (response.ok()) return; - if (response.status() === 500 && attempt < maxAttempts - 1) { + if ([500, 502, 503].includes(response.status()) && attempt < maxAttempts - 1) { const jitter = Math.random() * 500; await new Promise((resolve) => globalThis.setTimeout(resolve, 1000 * (attempt + 1) + jitter)); continue; @@ -57,7 +57,7 @@ export async function login(page: Page) { } export async function logout(page: Page) { - await page.context().clearCookies(); // workarkound issues related to fomantic dropdown + await page.context().clearCookies(); // workaround issues related to fomantic dropdown await page.goto('/'); await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); } diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 648d5255b6be5..b5463d7cf52e2 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -10,6 +10,7 @@ if [ -z "${E2E_URL:-}" ]; then echo " E2E_URL=http://localhost:3000 make test-e2e" >&2 exit 1 fi + # Note: this does not respect INI sections, assumes ROOT_URL only appears under [server] ROOT_URL=$(sed -n 's/^ROOT_URL\s*=\s*//p' "$INI_FILE" | tr -d '[:space:]') if [ -z "$ROOT_URL" ]; then echo "error: ROOT_URL not found in $INI_FILE" >&2 From 08a92e58591fa920e1a344e6b3482d43d98eead0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 04:19:37 +0100 Subject: [PATCH 33/55] add dependency on EXECUTABLE --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 91bb7b0bbeafc..43bacb2d66319 100644 --- a/Makefile +++ b/Makefile @@ -559,7 +559,7 @@ playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright +test-e2e: playwright $(EXECUTABLE) @EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) .PHONY: bench-sqlite From 5fcdd8589f213741b191de43ce7bddc18915e438 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 04:22:28 +0100 Subject: [PATCH 34/55] Apply suggestion from @silverwind Signed-off-by: silverwind --- tests/e2e/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 4204cf0b8499b..e4021fa6f84ba 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -3,7 +3,7 @@ import {expect} from '@playwright/test'; import type {APIRequestContext, Locator, Page} from '@playwright/test'; export function apiBaseUrl() { - return env.E2E_URL?.replace(/\/$/g, '') || 'http://localhost:3000'; + return env.E2E_URL?.replace(/\/$/g, ''); } export function apiHeaders() { From 1e010377f8d6f94c9ab93a88a5f88a5a34a580e1 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 16 Feb 2026 09:56:12 +0100 Subject: [PATCH 35/55] Pass TAGS to test-e2e to prevent binary rebuild without SQLite The `make test-e2e` target depends on $(EXECUTABLE), and the Makefile detects tag changes via TAGS_PREREQ. Without passing TAGS, the binary gets rebuilt without SQLite support, causing the e2e user creation to fail. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 273a5e07fe031..4bee5c4c267cc 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -59,3 +59,4 @@ jobs: timeout-minutes: 10 env: FORCE_COLOR: 1 + TAGS: bindata sqlite sqlite_unlock_notify From 6ce62bc545589ac6f1387627acb3b2281b35688d Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 20:23:42 +0100 Subject: [PATCH 36/55] Replace API calls in e2e tests with UI interactions Use browser-based user actions for test setup and cleanup instead of direct API/fetch calls, making tests exercise the same code paths as real users. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/milestone.test.ts | 6 +-- tests/e2e/org.test.ts | 4 +- tests/e2e/readme.test.ts | 7 ++-- tests/e2e/register.test.ts | 12 +++--- tests/e2e/repo.test.ts | 4 +- tests/e2e/utils.ts | 77 +++++++++++++++++-------------------- 6 files changed, 52 insertions(+), 58 deletions(-) diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index ffc0b50c307f7..56baddc14fb39 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -1,16 +1,16 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, createRepoApi, deleteRepoApi} from './utils.ts'; +import {login, createRepo, deleteRepo} from './utils.ts'; test('create a milestone', async ({page}) => { const repoName = `e2e-milestone-${Date.now()}`; await login(page); - await createRepoApi(page.request, {name: repoName}); + await createRepo(page, repoName); await page.goto(`/${env.E2E_USER}/${repoName}/milestones/new`); await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); // cleanup - await deleteRepoApi(page.request, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER!, repoName); }); diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts index d428832fb57a0..b790d929b6504 100644 --- a/tests/e2e/org.test.ts +++ b/tests/e2e/org.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {login, deleteOrgApi} from './utils.ts'; +import {login, deleteOrg} from './utils.ts'; test('create an organization', async ({page}) => { const orgName = `e2e-org-${Date.now()}`; @@ -10,5 +10,5 @@ test('create an organization', async ({page}) => { await expect(page).toHaveURL(new RegExp(`/org/${orgName}`)); // cleanup - await deleteOrgApi(page.request, orgName); + await deleteOrg(page, orgName); }); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index 1aba762ca2f3e..80d60d6cc6742 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -1,13 +1,14 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {createRepoApi, deleteRepoApi} from './utils.ts'; +import {login, createRepo, deleteRepo} from './utils.ts'; test('README renders on repository page', async ({page}) => { const repoName = `e2e-readme-${Date.now()}`; - await createRepoApi(page.request, {name: repoName}); + await login(page); + await createRepo(page, repoName); await page.goto(`/${env.E2E_USER}/${repoName}`); await expect(page.locator('#readme')).toContainText(repoName); // cleanup - await deleteRepoApi(page.request, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER!, repoName); }); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index 70059355e4c61..d7975ce081161 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -1,6 +1,5 @@ -import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {logout} from './utils.ts'; +import {login, logout, deleteUser} from './utils.ts'; test.beforeEach(async ({page}) => { await page.goto('/user/sign_up'); @@ -53,11 +52,10 @@ test('register then login', async ({page}) => { await page.getByRole('button', {name: 'Sign In'}).click(); await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); - // Clean up: delete the user via API using the main e2e admin account - const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, { - headers: {Authorization: `Basic ${btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}, - }); - expect(response.ok()).toBeTruthy(); + // Clean up: login as admin and delete the user via site administration + await logout(page); + await login(page); + await deleteUser(page, username); }); test('register with existing username shows error', async ({page}) => { diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index 1f9904a893e9c..2183f47dd4e1f 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, deleteRepoApi} from './utils.ts'; +import {login, deleteRepo} from './utils.ts'; test('create a repository', async ({page}) => { const repoName = `e2e-repo-${Date.now()}`; @@ -11,5 +11,5 @@ test('create a repository', async ({page}) => { await expect(page).toHaveURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); // cleanup - await deleteRepoApi(page.request, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER!, repoName); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index e4021fa6f84ba..06806f5043178 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,46 +1,41 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {APIRequestContext, Locator, Page} from '@playwright/test'; - -export function apiBaseUrl() { - return env.E2E_URL?.replace(/\/$/g, ''); -} - -export function apiHeaders() { - return {Authorization: `Basic ${globalThis.btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}; -} - -async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise}>, label: string) { - const maxAttempts = 5; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await fn(); - if (response.ok()) return; - if ([500, 502, 503].includes(response.status()) && attempt < maxAttempts - 1) { - const jitter = Math.random() * 500; - await new Promise((resolve) => globalThis.setTimeout(resolve, 1000 * (attempt + 1) + jitter)); - continue; - } - throw new Error(`${label} failed: ${response.status()} ${await response.text()}`); - } -} - -export async function createRepoApi(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) { - await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, { - headers: apiHeaders(), - data: {name, auto_init: autoInit}, - }), 'createRepoApi'); -} - -export async function deleteRepoApi(requestContext: APIRequestContext, owner: string, name: string) { - await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, { - headers: apiHeaders(), - }), 'deleteRepoApi'); -} - -export async function deleteOrgApi(requestContext: APIRequestContext, name: string) { - await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, { - headers: apiHeaders(), - }), 'deleteOrgApi'); +import type {Locator, Page} from '@playwright/test'; + +export async function createRepo(page: Page, name: string) { + await page.goto('/repo/create'); + await page.locator('input[name="repo_name"]').fill(name); + await page.locator('input[name="auto_init"]').check(); + await page.getByRole('button', {name: 'Create Repository'}).click(); +} + +export async function deleteRepo(page: Page, owner: string, name: string) { + await page.goto(`/${owner}/${name}/settings`); + await page.locator('button[data-modal="#delete-repo-modal"]').click(); + const modal = page.locator('#delete-repo-modal'); + await modal.locator('input[name="repo_name"]').fill(name); + await modal.getByRole('button', {name: 'Delete Repository'}).click(); + await page.waitForURL('**/'); +} + +export async function deleteOrg(page: Page, name: string) { + await page.goto(`/org/${name}/settings`); + await page.locator('button[data-modal="#delete-org-modal"]').click(); + const modal = page.locator('#delete-org-modal'); + await modal.locator('input[name="org_name"]').fill(name); + await modal.getByRole('button', {name: 'Delete This Organization'}).click(); + await page.waitForURL('**/'); +} + +export async function deleteUser(page: Page, username: string) { + await page.goto(`/-/admin/users?q=${username}`); + const userRow = page.locator('tr', {has: page.locator(`a[href="/${username}"]`)}); + await userRow.locator('a[data-tooltip-content="Edit"]').click(); + await page.locator('button[data-modal="#delete-user-modal"]').click(); + const modal = page.locator('#delete-user-modal'); + await modal.locator('input[name="purge"]').check(); + await modal.locator('.ok.button').click(); + await page.waitForURL('**/-/admin/users'); } export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { From 408b2596abe2ec5ed546b015c83dbac52c382ac9 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 20:26:50 +0100 Subject: [PATCH 37/55] Reuse login helper in register test Accept optional username/password in the login utility so the register test can use it for the newly-created account instead of duplicating the sign-in steps. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/register.test.ts | 6 +----- tests/e2e/utils.ts | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index d7975ce081161..7c1207ff6df84 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -46,11 +46,7 @@ test('register then login', async ({page}) => { // Logout then login with the newly created account await logout(page); - await page.goto('/user/login'); - await page.getByLabel('Username or Email Address').fill(username); - await page.getByLabel('Password').fill(password); - await page.getByRole('button', {name: 'Sign In'}).click(); - await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); + await login(page, username, password); // Clean up: login as admin and delete the user via site administration await logout(page); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 06806f5043178..c548a6b66352f 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -43,10 +43,10 @@ export async function clickDropdownItem(page: Page, trigger: Locator, itemText: await page.getByText(itemText).click(); } -export async function login(page: Page) { +export async function login(page: Page, username = env.E2E_USER!, password = env.E2E_PASSWORD!) { await page.goto('/user/login'); - await page.getByLabel('Username or Email Address').fill(env.E2E_USER!); - await page.getByLabel('Password').fill(env.E2E_PASSWORD!); + await page.getByLabel('Username or Email Address').fill(username); + await page.getByLabel('Password').fill(password); await page.getByRole('button', {name: 'Sign In'}).click(); await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); } From 799d525f6487251ca1f485c15ef43fc2b9d0cd7f Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 20:30:32 +0100 Subject: [PATCH 38/55] Fix repo creation test by using direct input selector Use locator('input[name="repo_name"]') instead of getByLabel which can fail when Fomantic UI interferes with label-input association. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/repo.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index 2183f47dd4e1f..dcf9a6e4f19ea 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -6,10 +6,8 @@ test('create a repository', async ({page}) => { const repoName = `e2e-repo-${Date.now()}`; await login(page); await page.goto('/repo/create'); - await page.getByLabel('Repository Name').fill(repoName); + await page.locator('input[name="repo_name"]').fill(repoName); await page.getByRole('button', {name: 'Create Repository'}).click(); await expect(page).toHaveURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); - - // cleanup await deleteRepo(page, env.E2E_USER!, repoName); }); From 530b86e244003d5ac4a0ea690dec2edb25ae3695 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 20:32:40 +0100 Subject: [PATCH 39/55] Add ambient type declarations for e2e env variables Declare E2E_USER, E2E_PASSWORD and E2E_URL as string in ProcessEnv so non-null assertions are no longer needed throughout the e2e tests. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/env.d.ts | 7 +++++++ tests/e2e/milestone.test.ts | 4 +--- tests/e2e/readme.test.ts | 4 +--- tests/e2e/repo.test.ts | 2 +- tests/e2e/utils.ts | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/env.d.ts diff --git a/tests/e2e/env.d.ts b/tests/e2e/env.d.ts new file mode 100644 index 0000000000000..d2cbaf0f6d69d --- /dev/null +++ b/tests/e2e/env.d.ts @@ -0,0 +1,7 @@ +declare namespace NodeJS { + interface ProcessEnv { + E2E_USER: string; + E2E_PASSWORD: string; + E2E_URL: string; + } +} diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index 56baddc14fb39..3cd9acfc6150c 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -10,7 +10,5 @@ test('create a milestone', async ({page}) => { await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); - - // cleanup - await deleteRepo(page, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER, repoName); }); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index 80d60d6cc6742..d5e010af5881b 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -8,7 +8,5 @@ test('README renders on repository page', async ({page}) => { await createRepo(page, repoName); await page.goto(`/${env.E2E_USER}/${repoName}`); await expect(page.locator('#readme')).toContainText(repoName); - - // cleanup - await deleteRepo(page, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER, repoName); }); diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index dcf9a6e4f19ea..db800da88efe0 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -9,5 +9,5 @@ test('create a repository', async ({page}) => { await page.locator('input[name="repo_name"]').fill(repoName); await page.getByRole('button', {name: 'Create Repository'}).click(); await expect(page).toHaveURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); - await deleteRepo(page, env.E2E_USER!, repoName); + await deleteRepo(page, env.E2E_USER, repoName); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index c548a6b66352f..fc5b4b73441c1 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -43,7 +43,7 @@ export async function clickDropdownItem(page: Page, trigger: Locator, itemText: await page.getByText(itemText).click(); } -export async function login(page: Page, username = env.E2E_USER!, password = env.E2E_PASSWORD!) { +export async function login(page: Page, username = env.E2E_USER, password = env.E2E_PASSWORD) { await page.goto('/user/login'); await page.getByLabel('Username or Email Address').fill(username); await page.getByLabel('Password').fill(password); From aaad6403976d98e29dba29d408821a8a21b9e13f Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 20:33:27 +0100 Subject: [PATCH 40/55] cleanup --- tests/e2e/org.test.ts | 2 -- tests/e2e/user-settings.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts index b790d929b6504..d2d99c3efb4ca 100644 --- a/tests/e2e/org.test.ts +++ b/tests/e2e/org.test.ts @@ -8,7 +8,5 @@ test('create an organization', async ({page}) => { await page.getByLabel('Organization Name').fill(orgName); await page.getByRole('button', {name: 'Create Organization'}).click(); await expect(page).toHaveURL(new RegExp(`/org/${orgName}`)); - - // cleanup await deleteOrg(page, orgName); }); diff --git a/tests/e2e/user-settings.test.ts b/tests/e2e/user-settings.test.ts index 3e339a2f26282..ee1c6c98eb2cc 100644 --- a/tests/e2e/user-settings.test.ts +++ b/tests/e2e/user-settings.test.ts @@ -8,8 +8,6 @@ test('update profile biography', async ({page}) => { await page.getByLabel('Biography').fill(bio); await page.getByRole('button', {name: 'Update Profile'}).click(); await expect(page.getByLabel('Biography')).toHaveValue(bio); - - // cleanup: clear the biography await page.getByLabel('Biography').fill(''); await page.getByRole('button', {name: 'Update Profile'}).click(); await expect(page.getByLabel('Biography')).toHaveValue(''); From c63da125bbfceb14b7a8df9ad8046eca1dd0f888 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 21:07:05 +0100 Subject: [PATCH 41/55] Apply suggestion from @silverwind Signed-off-by: silverwind --- eslint.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.ts b/eslint.config.ts index 3020c757ca811..84071312fcf8d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -910,7 +910,7 @@ export default defineConfig([ }, { ...playwright.configs['flat/recommended'], - files: ['tests/e2e/*.test.ts'], + files: ['tests/e2e/**/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, }, From 79b4b2e0d433c4dd7299df2b66cd5697c1659355 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 18 Feb 2026 03:01:11 +0100 Subject: [PATCH 42/55] Revert e2e org/user deletion to API calls The UI-based deleteOrg and deleteUser functions fail due to form-fetch-action issues. Revert these to API calls while keeping the working UI-based createRepo/deleteRepo functions. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/org.test.ts | 5 +++-- tests/e2e/register.test.ts | 12 +++++----- tests/e2e/utils.ts | 46 ++++++++++++++++++++++---------------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts index d2d99c3efb4ca..8160fdda10d07 100644 --- a/tests/e2e/org.test.ts +++ b/tests/e2e/org.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {login, deleteOrg} from './utils.ts'; +import {login, deleteOrgApi} from './utils.ts'; test('create an organization', async ({page}) => { const orgName = `e2e-org-${Date.now()}`; @@ -8,5 +8,6 @@ test('create an organization', async ({page}) => { await page.getByLabel('Organization Name').fill(orgName); await page.getByRole('button', {name: 'Create Organization'}).click(); await expect(page).toHaveURL(new RegExp(`/org/${orgName}`)); - await deleteOrg(page, orgName); + // delete via API because of issues related to form-fetch-action + await deleteOrgApi(page.request, orgName); }); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index 7c1207ff6df84..d854b345d681a 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -1,5 +1,6 @@ +import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, logout, deleteUser} from './utils.ts'; +import {login, logout} from './utils.ts'; test.beforeEach(async ({page}) => { await page.goto('/user/sign_up'); @@ -48,10 +49,11 @@ test('register then login', async ({page}) => { await logout(page); await login(page, username, password); - // Clean up: login as admin and delete the user via site administration - await logout(page); - await login(page); - await deleteUser(page, username); + // delete via API because of issues related to form-fetch-action + const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, { + headers: {Authorization: `Basic ${btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}, + }); + expect(response.ok()).toBeTruthy(); }); test('register with existing username shows error', async ({page}) => { diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index fc5b4b73441c1..5603c7c9398e5 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,6 +1,28 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {Locator, Page} from '@playwright/test'; +import type {APIRequestContext, Locator, Page} from '@playwright/test'; + +export function apiBaseUrl() { + return env.E2E_URL?.replace(/\/$/g, ''); +} + +export function apiHeaders() { + return {Authorization: `Basic ${globalThis.btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}; +} + +async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise}>, label: string) { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const response = await fn(); + if (response.ok()) return; + if ([500, 502, 503].includes(response.status()) && attempt < maxAttempts - 1) { + const jitter = Math.random() * 500; + await new Promise((resolve) => globalThis.setTimeout(resolve, 1000 * (attempt + 1) + jitter)); + continue; + } + throw new Error(`${label} failed: ${response.status()} ${await response.text()}`); + } +} export async function createRepo(page: Page, name: string) { await page.goto('/repo/create'); @@ -18,24 +40,10 @@ export async function deleteRepo(page: Page, owner: string, name: string) { await page.waitForURL('**/'); } -export async function deleteOrg(page: Page, name: string) { - await page.goto(`/org/${name}/settings`); - await page.locator('button[data-modal="#delete-org-modal"]').click(); - const modal = page.locator('#delete-org-modal'); - await modal.locator('input[name="org_name"]').fill(name); - await modal.getByRole('button', {name: 'Delete This Organization'}).click(); - await page.waitForURL('**/'); -} - -export async function deleteUser(page: Page, username: string) { - await page.goto(`/-/admin/users?q=${username}`); - const userRow = page.locator('tr', {has: page.locator(`a[href="/${username}"]`)}); - await userRow.locator('a[data-tooltip-content="Edit"]').click(); - await page.locator('button[data-modal="#delete-user-modal"]').click(); - const modal = page.locator('#delete-user-modal'); - await modal.locator('input[name="purge"]').check(); - await modal.locator('.ok.button').click(); - await page.waitForURL('**/-/admin/users'); +export async function deleteOrgApi(requestContext: APIRequestContext, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, { + headers: apiHeaders(), + }), 'deleteOrgApi'); } export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { From 766ba0184aef4d7a6477a11b0561222ac69f999d Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 18 Feb 2026 03:21:28 +0100 Subject: [PATCH 43/55] Revert e2e repo create/delete to API calls, double timeouts Revert createRepo/deleteRepo to API-based functions for test reliability. The UI-based versions were flaky due to navigation timing. Also double all playwright timeouts (local and CI), rename API functions to apiX convention, and disable playwright/expect-expect lint rule. Co-Authored-By: Claude Opus 4.6 --- eslint.config.ts | 1 + playwright.config.ts | 8 ++++---- tests/e2e/login.test.ts | 2 +- tests/e2e/milestone.test.ts | 6 +++--- tests/e2e/org.test.ts | 4 ++-- tests/e2e/readme.test.ts | 7 +++---- tests/e2e/repo.test.ts | 8 ++++---- tests/e2e/utils.ts | 25 +++++++++++-------------- 8 files changed, 29 insertions(+), 32 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index 84071312fcf8d..49fcae22d6aaf 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -913,6 +913,7 @@ export default defineConfig([ files: ['tests/e2e/**/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, + 'playwright/expect-expect': [0], }, }, { diff --git a/playwright.config.ts b/playwright.config.ts index 613a3a7ad25a6..4df92134403a9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,15 +7,15 @@ export default defineConfig({ testMatch: /.*\.test\.ts/, forbidOnly: Boolean(env.CI), reporter: 'list', - timeout: env.CI ? 6000 : 2000, + timeout: env.CI ? 12000 : 6000, expect: { - timeout: env.CI ? 3000 : 1000, + timeout: env.CI ? 6000 : 3000, }, use: { baseURL: env.E2E_URL?.replace?.(/\/$/g, ''), locale: 'en-US', - actionTimeout: env.CI ? 3000 : 1000, - navigationTimeout: env.CI ? 6000 : 2000, + actionTimeout: env.CI ? 6000 : 3000, + navigationTimeout: env.CI ? 12000 : 6000, }, projects: [ { diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index 8a8efabc21655..ecf80d24744c6 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -6,7 +6,7 @@ test('homepage', async ({page}) => { await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); }); -test('login and logout', async ({page}) => { // eslint-disable-line playwright/expect-expect +test('login and logout', async ({page}) => { await login(page); await logout(page); }); diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index 3cd9acfc6150c..68d5e9b3d026e 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -1,14 +1,14 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, createRepo, deleteRepo} from './utils.ts'; +import {login, apiCreateRepo, apiDeleteRepo} from './utils.ts'; test('create a milestone', async ({page}) => { const repoName = `e2e-milestone-${Date.now()}`; await login(page); - await createRepo(page, repoName); + await apiCreateRepo(page.request, {name: repoName}); await page.goto(`/${env.E2E_USER}/${repoName}/milestones/new`); await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); - await deleteRepo(page, env.E2E_USER, repoName); + await apiDeleteRepo(page.request, env.E2E_USER, repoName); }); diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts index 8160fdda10d07..b4d4fc2e7d359 100644 --- a/tests/e2e/org.test.ts +++ b/tests/e2e/org.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {login, deleteOrgApi} from './utils.ts'; +import {login, apiDeleteOrg} from './utils.ts'; test('create an organization', async ({page}) => { const orgName = `e2e-org-${Date.now()}`; @@ -9,5 +9,5 @@ test('create an organization', async ({page}) => { await page.getByRole('button', {name: 'Create Organization'}).click(); await expect(page).toHaveURL(new RegExp(`/org/${orgName}`)); // delete via API because of issues related to form-fetch-action - await deleteOrgApi(page.request, orgName); + await apiDeleteOrg(page.request, orgName); }); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index d5e010af5881b..999a280f1ee08 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -1,12 +1,11 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, createRepo, deleteRepo} from './utils.ts'; +import {apiCreateRepo, apiDeleteRepo} from './utils.ts'; test('README renders on repository page', async ({page}) => { const repoName = `e2e-readme-${Date.now()}`; - await login(page); - await createRepo(page, repoName); + await apiCreateRepo(page.request, {name: repoName}); await page.goto(`/${env.E2E_USER}/${repoName}`); await expect(page.locator('#readme')).toContainText(repoName); - await deleteRepo(page, env.E2E_USER, repoName); + await apiDeleteRepo(page.request, env.E2E_USER, repoName); }); diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index db800da88efe0..1df024511c325 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; -import {test, expect} from '@playwright/test'; -import {login, deleteRepo} from './utils.ts'; +import {test} from '@playwright/test'; +import {login, apiDeleteRepo} from './utils.ts'; test('create a repository', async ({page}) => { const repoName = `e2e-repo-${Date.now()}`; @@ -8,6 +8,6 @@ test('create a repository', async ({page}) => { await page.goto('/repo/create'); await page.locator('input[name="repo_name"]').fill(repoName); await page.getByRole('button', {name: 'Create Repository'}).click(); - await expect(page).toHaveURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); - await deleteRepo(page, env.E2E_USER, repoName); + await page.waitForURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); + await apiDeleteRepo(page.request, env.E2E_USER, repoName); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 5603c7c9398e5..4ba8cc9d73e06 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -24,26 +24,23 @@ async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => numb } } -export async function createRepo(page: Page, name: string) { - await page.goto('/repo/create'); - await page.locator('input[name="repo_name"]').fill(name); - await page.locator('input[name="auto_init"]').check(); - await page.getByRole('button', {name: 'Create Repository'}).click(); +export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) { + await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, { + headers: apiHeaders(), + data: {name, auto_init: autoInit}, + }), 'apiCreateRepo'); } -export async function deleteRepo(page: Page, owner: string, name: string) { - await page.goto(`/${owner}/${name}/settings`); - await page.locator('button[data-modal="#delete-repo-modal"]').click(); - const modal = page.locator('#delete-repo-modal'); - await modal.locator('input[name="repo_name"]').fill(name); - await modal.getByRole('button', {name: 'Delete Repository'}).click(); - await page.waitForURL('**/'); +export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, { + headers: apiHeaders(), + }), 'apiDeleteRepo'); } -export async function deleteOrgApi(requestContext: APIRequestContext, name: string) { +export async function apiDeleteOrg(requestContext: APIRequestContext, name: string) { await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, { headers: apiHeaders(), - }), 'deleteOrgApi'); + }), 'apiDeleteOrg'); } export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { From f6037c90d3f7cf0ebe3742117e2bef9956f43986 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 03:59:33 +0100 Subject: [PATCH 44/55] Rework e2e test setup for full isolation Always start an isolated ephemeral Gitea instance with its own temp directory, SQLite database, and config file. This addresses review feedback that using the developer's existing instance is unreliable. - Rewrite test-e2e.sh to create a temp workdir, find a free port, write a minimal app.ini, start the server, and clean up on exit - Build a separate gitea-e2e binary using TEST_TAGS (includes sqlite) - Simplify CI workflow: remove manual app.ini, server start, and redundant build steps - Rename all env vars to use GITEA_TEST_E2E_* prefix - Rename test user from "e2e" to "e2e-user" Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 26 +----- .gitignore | 1 + CONTRIBUTING.md | 5 +- Makefile | 7 +- playwright.config.ts | 2 +- tests/e2e/env.d.ts | 7 +- tests/e2e/milestone.test.ts | 4 +- tests/e2e/readme.test.ts | 4 +- tests/e2e/register.test.ts | 4 +- tests/e2e/repo.test.ts | 4 +- tests/e2e/utils.ts | 6 +- tools/test-e2e.sh | 121 ++++++++++++++------------- 12 files changed, 86 insertions(+), 105 deletions(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 4bee5c4c267cc..aac5602e6a36c 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -31,32 +31,10 @@ jobs: node-version: 24 cache: pnpm cache-dependency-path: pnpm-lock.yaml - - run: make deps-backend - - run: make backend - env: - TAGS: bindata sqlite sqlite_unlock_notify - run: make deps-frontend - run: make frontend - - run: | - mkdir -p custom/conf - cat <<'EOF' > custom/conf/app.ini - [database] - DB_TYPE = sqlite3 - - [server] - HTTP_PORT = 3000 - ROOT_URL = http://localhost:3000 - - [service] - ENABLE_CAPTCHA = false - - [security] - INSTALL_LOCK = true - EOF - - run: ./gitea web & - - run: make playwright - - run: E2E_URL=http://localhost:3000 make test-e2e + - run: make deps-backend + - run: make test-e2e timeout-minutes: 10 env: FORCE_COLOR: 1 - TAGS: bindata sqlite sqlite_unlock_notify diff --git a/.gitignore b/.gitignore index 87051babc18e2..45e8e9295fd31 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ cpu.out *.log.*.gz /gitea +/gitea-e2e /gitea-vet /debug /integrations.test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baa288061fa4a..ad892d6adbc68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,9 +184,8 @@ Here's how to run the test suite: | Variable | Description | | :-------------- | :-------------------------------------------------------------------------- | -|``E2E_URL`` | URL of the Gitea server to test against (default: read from ``app.ini``) | -|``E2E_DEBUG`` | When set, show Gitea server output (only for auto-started server) | -|``E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | +|``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | +|``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | ## Translation diff --git a/Makefile b/Makefile index cf5a54f8aedca..2a597790fca92 100644 --- a/Makefile +++ b/Makefile @@ -200,7 +200,7 @@ clean-all: clean ## delete backend, frontend and integration files .PHONY: clean clean: ## delete backend and integration files - rm -rf $(EXECUTABLE) $(DIST) $(BINDATA_DEST_WILDCARD) \ + rm -rf $(EXECUTABLE) gitea-e2e $(DIST) $(BINDATA_DEST_WILDCARD) \ integrations*.test \ tests/integration/gitea-integration-* \ tests/integration/indexers-* \ @@ -534,8 +534,9 @@ playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright $(EXECUTABLE) - @EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(E2E_FLAGS) +test-e2e: playwright + $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e + @EXECUTABLE=gitea-e2e ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite diff --git a/playwright.config.ts b/playwright.config.ts index 4df92134403a9..68f1dbaa63472 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ timeout: env.CI ? 6000 : 3000, }, use: { - baseURL: env.E2E_URL?.replace?.(/\/$/g, ''), + baseURL: env.GITEA_TEST_E2E_URL?.replace?.(/\/$/g, ''), locale: 'en-US', actionTimeout: env.CI ? 6000 : 3000, navigationTimeout: env.CI ? 12000 : 6000, diff --git a/tests/e2e/env.d.ts b/tests/e2e/env.d.ts index d2cbaf0f6d69d..ff8898ef0e256 100644 --- a/tests/e2e/env.d.ts +++ b/tests/e2e/env.d.ts @@ -1,7 +1,8 @@ declare namespace NodeJS { interface ProcessEnv { - E2E_USER: string; - E2E_PASSWORD: string; - E2E_URL: string; + GITEA_TEST_E2E_USER: string; + GITEA_TEST_E2E_EMAIL: string; + GITEA_TEST_E2E_PASSWORD: string; + GITEA_TEST_E2E_URL: string; } } diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index 68d5e9b3d026e..d63aee0cf2865 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -6,9 +6,9 @@ test('create a milestone', async ({page}) => { const repoName = `e2e-milestone-${Date.now()}`; await login(page); await apiCreateRepo(page.request, {name: repoName}); - await page.goto(`/${env.E2E_USER}/${repoName}/milestones/new`); + await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/milestones/new`); await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); - await apiDeleteRepo(page.request, env.E2E_USER, repoName); + await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index 999a280f1ee08..9dc291d5c5539 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -5,7 +5,7 @@ import {apiCreateRepo, apiDeleteRepo} from './utils.ts'; test('README renders on repository page', async ({page}) => { const repoName = `e2e-readme-${Date.now()}`; await apiCreateRepo(page.request, {name: repoName}); - await page.goto(`/${env.E2E_USER}/${repoName}`); + await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}`); await expect(page.locator('#readme')).toContainText(repoName); - await apiDeleteRepo(page.request, env.E2E_USER, repoName); + await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index d854b345d681a..dd42d5113e0cf 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -51,13 +51,13 @@ test('register then login', async ({page}) => { // delete via API because of issues related to form-fetch-action const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, { - headers: {Authorization: `Basic ${btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}, + headers: {Authorization: `Basic ${btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`}, }); expect(response.ok()).toBeTruthy(); }); test('register with existing username shows error', async ({page}) => { - await page.getByLabel('Username').fill('e2e'); + await page.getByLabel('Username').fill('e2e-user'); await page.getByLabel('Email Address').fill('e2e-duplicate@e2e.gitea.com'); await page.getByLabel('Password', {exact: true}).fill('password123!'); await page.getByLabel('Confirm Password').fill('password123!'); diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index 1df024511c325..cca59d612d459 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -8,6 +8,6 @@ test('create a repository', async ({page}) => { await page.goto('/repo/create'); await page.locator('input[name="repo_name"]').fill(repoName); await page.getByRole('button', {name: 'Create Repository'}).click(); - await page.waitForURL(new RegExp(`/${env.E2E_USER}/${repoName}$`)); - await apiDeleteRepo(page.request, env.E2E_USER, repoName); + await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`)); + await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 4ba8cc9d73e06..6ee16b32f861c 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -3,11 +3,11 @@ import {expect} from '@playwright/test'; import type {APIRequestContext, Locator, Page} from '@playwright/test'; export function apiBaseUrl() { - return env.E2E_URL?.replace(/\/$/g, ''); + return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, ''); } export function apiHeaders() { - return {Authorization: `Basic ${globalThis.btoa(`${env.E2E_USER}:${env.E2E_PASSWORD}`)}`}; + return {Authorization: `Basic ${globalThis.btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`}; } async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise}>, label: string) { @@ -48,7 +48,7 @@ export async function clickDropdownItem(page: Page, trigger: Locator, itemText: await page.getByText(itemText).click(); } -export async function login(page: Page, username = env.E2E_USER, password = env.E2E_PASSWORD) { +export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) { await page.goto('/user/login'); await page.getByLabel('Username or Email Address').fill(username); await page.getByLabel('Password').fill(password); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index b5463d7cf52e2..616bd04d33e96 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -1,89 +1,90 @@ #!/bin/bash set -euo pipefail -# Determine the Gitea server URL, either from E2E_URL env var or from custom/conf/app.ini -if [ -z "${E2E_URL:-}" ]; then - INI_FILE="custom/conf/app.ini" - if [ ! -f "$INI_FILE" ]; then - echo "error: $INI_FILE not found and E2E_URL not set" >&2 - echo "Either start Gitea with a config or set E2E_URL explicitly:" >&2 - echo " E2E_URL=http://localhost:3000 make test-e2e" >&2 - exit 1 - fi - # Note: this does not respect INI sections, assumes ROOT_URL only appears under [server] - ROOT_URL=$(sed -n 's/^ROOT_URL\s*=\s*//p' "$INI_FILE" | tr -d '[:space:]') - if [ -z "$ROOT_URL" ]; then - echo "error: ROOT_URL not found in $INI_FILE" >&2 - exit 1 - fi - E2E_URL="$ROOT_URL" -fi +# Create isolated work directory +WORK_DIR=$(mktemp -d) -# Normalize URL: trim trailing slash to avoid double slashes when appending paths -E2E_URL="${E2E_URL%/}" +# Find a random free port +FREE_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})") -echo "Using Gitea server: $E2E_URL" - -# Disable CAPTCHA for e2e tests -export GITEA__service__ENABLE_CAPTCHA=false - -SERVER_PID="" cleanup() { - if [ -n "$SERVER_PID" ]; then - echo "Stopping temporary Gitea server (PID $SERVER_PID)..." + if [ -n "${SERVER_PID:-}" ]; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi + rm -rf "$WORK_DIR" } trap cleanup EXIT -# For local development, if no gitea server is running, start a temporary one. -if [ -z "${CI:-}" ] && ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; then - if [ ! -x "./$EXECUTABLE" ]; then - echo "error: ./$EXECUTABLE not found or not executable, run 'make backend' first" >&2 - exit 1 - fi - echo "Starting temporary Gitea server..." - if [ -n "${E2E_DEBUG:-}" ]; then - "./$EXECUTABLE" web & - else - "./$EXECUTABLE" web > /dev/null 2>&1 & - fi - SERVER_PID=$! +# Write config file for isolated instance +mkdir -p "$WORK_DIR/custom/conf" +cat > "$WORK_DIR/custom/conf/app.ini" < "$WORK_DIR/server.log" 2>&1 & fi +SERVER_PID=$! -# Verify server is reachable, retry for up to 2 minutes for slow startup +# Wait for server to be reachable +E2E_URL="http://localhost:$FREE_PORT" MAX_WAIT=120 ELAPSED=0 while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do - if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then - echo "error: Gitea server process exited unexpectedly" >&2 + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "error: Gitea server process exited unexpectedly. Server log:" >&2 + cat "$WORK_DIR/server.log" 2>/dev/null >&2 || true exit 1 fi if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then - echo "error: Gitea server at $E2E_URL is not reachable after ${MAX_WAIT}s" >&2 + echo "error: Gitea server not reachable after ${MAX_WAIT}s. Server log:" >&2 + cat "$WORK_DIR/server.log" 2>/dev/null >&2 || true exit 1 fi sleep 2 ELAPSED=$((ELAPSED + 2)) done -# Create e2e test user if it does not already exist -E2E_USER="e2e" -E2E_EMAIL="e2e@e2e.gitea.com" -E2E_PASSWORD="password" -if ! curl -sf --max-time 5 "$E2E_URL/api/v1/users/$E2E_USER" > /dev/null 2>&1; then - echo "Creating e2e test user..." - if "./$EXECUTABLE" admin user create --username "$E2E_USER" --email "$E2E_EMAIL" --password "$E2E_PASSWORD" --must-change-password=false --admin; then - echo "User '$E2E_USER' created" - else - echo "error: failed to create user '$E2E_USER'" >&2 - exit 1 - fi -fi +echo "Gitea server is ready at $E2E_URL" + +# Create admin test user +GITEA_TEST_E2E_USER="e2e-user" +GITEA_TEST_E2E_EMAIL="e2e-user@e2e.gitea.com" +GITEA_TEST_E2E_PASSWORD="password" +"./$EXECUTABLE" admin user create \ + --username "$GITEA_TEST_E2E_USER" \ + --email "$GITEA_TEST_E2E_EMAIL" \ + --password "$GITEA_TEST_E2E_PASSWORD" \ + --must-change-password=false \ + --admin -export E2E_URL -export E2E_USER -export E2E_PASSWORD +export GITEA_TEST_E2E_URL="$E2E_URL" +export GITEA_TEST_E2E_USER +export GITEA_TEST_E2E_EMAIL +export GITEA_TEST_E2E_PASSWORD pnpm exec playwright test "$@" From d1320302062967128129eed66b0871fe65eed77e Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:06:23 +0100 Subject: [PATCH 45/55] Fix markdown table alignment in CONTRIBUTING.md Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad892d6adbc68..8ade6e24ef44e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,10 +182,10 @@ Here's how to run the test suite: - e2e test environment variables -| Variable | Description | -| :-------------- | :-------------------------------------------------------------------------- | -|``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | -|``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | +| Variable | Description | +| :------------------------ | :---------------------------------------------------------------- | +| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | +| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | ## Translation From 6bc667d7d3975f9b817d2506f0adef0b2b518d04 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:11:30 +0100 Subject: [PATCH 46/55] rename test --- tests/e2e/readme.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index 9dc291d5c5539..94755a254fd23 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -2,7 +2,7 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; import {apiCreateRepo, apiDeleteRepo} from './utils.ts'; -test('README renders on repository page', async ({page}) => { +test('repo readme', async ({page}) => { const repoName = `e2e-readme-${Date.now()}`; await apiCreateRepo(page.request, {name: repoName}); await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}`); From a2d1138d5ca1c201a43e54e2591e736041972206 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:16:37 +0100 Subject: [PATCH 47/55] Add Firefox to e2e test matrix on CI Locally only Chromium runs for fast feedback. On CI, tests also run on Firefox for broader coverage. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- playwright.config.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2a597790fca92..ae71758a8abf1 100644 --- a/Makefile +++ b/Makefile @@ -531,7 +531,7 @@ test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright playwright: deps-frontend @# on GitHub Actions VMs, playwright's system deps are pre-installed - @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(PLAYWRIGHT_FLAGS) + @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e test-e2e: playwright diff --git a/playwright.config.ts b/playwright.config.ts index 68f1dbaa63472..f566054f788c0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,5 +25,11 @@ export default defineConfig({ permissions: ['clipboard-read', 'clipboard-write'], }, }, + ...env.CI ? [{ + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + }, + }] : [], ], }); From 6b5fa8407deb46f65f63ed126476909b8e02ea3a Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:24:04 +0100 Subject: [PATCH 48/55] Fix CGO_ENABLED for e2e binary build, enable debug on CI The test-e2e target uses TEST_TAGS (not TAGS) so the Makefile's automatic CGO_ENABLED=1 detection for sqlite didn't trigger. Set CGO_ENABLED=1 explicitly in the build command. Also enable GITEA_TEST_E2E_DEBUG on CI to see server output on failure. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 + Makefile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index aac5602e6a36c..8ea9057d2fba6 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -38,3 +38,4 @@ jobs: timeout-minutes: 10 env: FORCE_COLOR: 1 + GITEA_TEST_E2E_DEBUG: 1 diff --git a/Makefile b/Makefile index ae71758a8abf1..656ba8a3bfdb1 100644 --- a/Makefile +++ b/Makefile @@ -535,7 +535,7 @@ playwright: deps-frontend .PHONY: test-e2e test-e2e: playwright - $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e + CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e @EXECUTABLE=gitea-e2e ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite From 564b9f0aff9c79da432ca583d82f3141acc01b74 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:25:07 +0100 Subject: [PATCH 49/55] silence --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 656ba8a3bfdb1..69cc048297d0e 100644 --- a/Makefile +++ b/Makefile @@ -535,7 +535,7 @@ playwright: deps-frontend .PHONY: test-e2e test-e2e: playwright - CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e + @CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e @EXECUTABLE=gitea-e2e ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite From a49aecf623a8e6780cf650cfb671bd8ca0997063 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:34:41 +0100 Subject: [PATCH 50/55] Fix FORCE_COLOR corrupting port number in e2e script FORCE_COLOR=1 on CI caused console.log to wrap the port number in ANSI color codes, breaking ROOT_URL parsing. Use process.stdout.write which bypasses color formatting. Co-Authored-By: Claude Opus 4.6 --- tools/test-e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 616bd04d33e96..889d5a6f4437a 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -5,7 +5,7 @@ set -euo pipefail WORK_DIR=$(mktemp -d) # Find a random free port -FREE_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})") +FREE_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()})") cleanup() { if [ -n "${SERVER_PID:-}" ]; then From 50c30c8173a423059ed40d485ebc6b91b2aa852a Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:44:53 +0100 Subject: [PATCH 51/55] Add EXECUTABLE_E2E variable and proper Make target - Add EXECUTABLE_E2E variable alongside EXECUTABLE for the e2e binary - Make the e2e binary target non-phony, tracking $(GO_SOURCES) to avoid unnecessary rebuilds - Add separate make gitea-e2e step in CI for visibility Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 + Makefile | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 8ea9057d2fba6..271b3799af896 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -34,6 +34,7 @@ jobs: - run: make deps-frontend - run: make frontend - run: make deps-backend + - run: make gitea-e2e - run: make test-e2e timeout-minutes: 10 env: diff --git a/Makefile b/Makefile index 69cc048297d0e..230a562478c7b 100644 --- a/Makefile +++ b/Makefile @@ -53,9 +53,11 @@ endif ifeq ($(IS_WINDOWS),yes) GOFLAGS := -v -buildmode=exe EXECUTABLE ?= gitea.exe + EXECUTABLE_E2E ?= gitea-e2e.exe else GOFLAGS := -v EXECUTABLE ?= gitea + EXECUTABLE_E2E ?= gitea-e2e endif ifeq ($(shell sed --version 2>/dev/null | grep -q GNU && echo gnu),gnu) @@ -200,7 +202,7 @@ clean-all: clean ## delete backend, frontend and integration files .PHONY: clean clean: ## delete backend and integration files - rm -rf $(EXECUTABLE) gitea-e2e $(DIST) $(BINDATA_DEST_WILDCARD) \ + rm -rf $(EXECUTABLE) $(EXECUTABLE_E2E) $(DIST) $(BINDATA_DEST_WILDCARD) \ integrations*.test \ tests/integration/gitea-integration-* \ tests/integration/indexers-* \ @@ -534,9 +536,8 @@ playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright - @CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o gitea-e2e - @EXECUTABLE=gitea-e2e ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) +test-e2e: playwright $(EXECUTABLE_E2E) + @EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite @@ -670,6 +671,9 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),) endif CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ +$(EXECUTABLE_E2E): $(GO_SOURCES) + CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ + .PHONY: release release: frontend generate release-windows release-linux release-darwin release-freebsd release-copy release-compress vendor release-sources release-check From 69b5c0b0c9fc23a7c3bd7dcb233d0470df7f31a0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:53:51 +0100 Subject: [PATCH 52/55] update docs --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ade6e24ef44e..abd853877f191 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,12 +180,12 @@ Here's how to run the test suite: |``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | |``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | | -- e2e test environment variables +- E2E test environment variables | Variable | Description | | :------------------------ | :---------------------------------------------------------------- | | ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | -| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright (e.g. ``--headed --debug``) | +| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` | ## Translation From a83dd45c9a24ee0dddb9dce33a374c2bb22c2c59 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 04:56:52 +0100 Subject: [PATCH 53/55] Add separate playwright install step in CI Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 271b3799af896..b55b1e034cf22 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -33,6 +33,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make frontend + - run: make playwright - run: make deps-backend - run: make gitea-e2e - run: make test-e2e From 01567304ccb527d646799145c7b40ff6a0a1dc51 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 05:00:10 +0100 Subject: [PATCH 54/55] Reorder CI steps to match original order Move playwright install after backend build so all dependency/build steps run first, then browser install, then test execution. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index b55b1e034cf22..c77f7af3f089b 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -33,9 +33,9 @@ jobs: cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make frontend - - run: make playwright - run: make deps-backend - run: make gitea-e2e + - run: make playwright - run: make test-e2e timeout-minutes: 10 env: From 109ffab8c4ba21476693201dc721d6c036771ad8 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 20 Feb 2026 06:55:20 +0100 Subject: [PATCH 55/55] tweak vars --- tests/e2e/env.d.ts | 1 + tests/e2e/register.test.ts | 8 ++++---- tools/test-e2e.sh | 13 ++++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/e2e/env.d.ts b/tests/e2e/env.d.ts index ff8898ef0e256..71887f4048c5e 100644 --- a/tests/e2e/env.d.ts +++ b/tests/e2e/env.d.ts @@ -1,5 +1,6 @@ declare namespace NodeJS { interface ProcessEnv { + GITEA_TEST_E2E_DOMAIN: string; GITEA_TEST_E2E_USER: string; GITEA_TEST_E2E_EMAIL: string; GITEA_TEST_E2E_PASSWORD: string; diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index dd42d5113e0cf..425fc7e40c247 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -24,7 +24,7 @@ test('register with empty fields shows error', async ({page}) => { test('register with mismatched passwords shows error', async ({page}) => { await page.getByLabel('Username').fill('e2e-register-mismatch'); - await page.getByLabel('Email Address').fill('e2e-register-mismatch@e2e.gitea.com'); + await page.getByLabel('Email Address').fill(`e2e-register-mismatch@${env.GITEA_TEST_E2E_DOMAIN}`); await page.getByLabel('Password', {exact: true}).fill('password123!'); await page.getByLabel('Confirm Password').fill('different123!'); await page.getByRole('button', {name: 'Register Account'}).click(); @@ -33,7 +33,7 @@ test('register with mismatched passwords shows error', async ({page}) => { test('register then login', async ({page}) => { const username = `e2e-register-${Date.now()}`; - const email = `${username}@e2e.gitea.com`; + const email = `${username}@${env.GITEA_TEST_E2E_DOMAIN}`; const password = 'password123!'; await page.getByLabel('Username').fill(username); @@ -57,8 +57,8 @@ test('register then login', async ({page}) => { }); test('register with existing username shows error', async ({page}) => { - await page.getByLabel('Username').fill('e2e-user'); - await page.getByLabel('Email Address').fill('e2e-duplicate@e2e.gitea.com'); + await page.getByLabel('Username').fill(env.GITEA_TEST_E2E_USER); + await page.getByLabel('Email Address').fill(`e2e-duplicate@${env.GITEA_TEST_E2E_DOMAIN}`); await page.getByLabel('Password', {exact: true}).fill('password123!'); await page.getByLabel('Confirm Password').fill('password123!'); await page.getByRole('button', {name: 'Register Account'}).click(); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 889d5a6f4437a..d8608a85bbbd4 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -71,20 +71,23 @@ done echo "Gitea server is ready at $E2E_URL" -# Create admin test user -GITEA_TEST_E2E_USER="e2e-user" -GITEA_TEST_E2E_EMAIL="e2e-user@e2e.gitea.com" +GITEA_TEST_E2E_DOMAIN="e2e.gitea.com" +GITEA_TEST_E2E_USER="e2e-admin" GITEA_TEST_E2E_PASSWORD="password" +GITEA_TEST_E2E_EMAIL="$GITEA_TEST_E2E_USER@$GITEA_TEST_E2E_DOMAIN" + +# Create admin test user "./$EXECUTABLE" admin user create \ --username "$GITEA_TEST_E2E_USER" \ - --email "$GITEA_TEST_E2E_EMAIL" \ --password "$GITEA_TEST_E2E_PASSWORD" \ + --email "$GITEA_TEST_E2E_EMAIL" \ --must-change-password=false \ --admin export GITEA_TEST_E2E_URL="$E2E_URL" +export GITEA_TEST_E2E_DOMAIN export GITEA_TEST_E2E_USER -export GITEA_TEST_E2E_EMAIL export GITEA_TEST_E2E_PASSWORD +export GITEA_TEST_E2E_EMAIL pnpm exec playwright test "$@"