diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml new file mode 100644 index 00000000000..c77f7af3f08 --- /dev/null +++ b/.github/workflows/pull-e2e-tests.yml @@ -0,0 +1,43 @@ +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-frontend + - run: make frontend + - run: make deps-backend + - run: make gitea-e2e + - run: make playwright + - run: make test-e2e + timeout-minutes: 10 + env: + FORCE_COLOR: 1 + GITEA_TEST_E2E_DEBUG: 1 diff --git a/.gitignore b/.gitignore index cead4853cae..45e8e9295fd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ cpu.out *.log.*.gz /gitea +/gitea-e2e /gitea-vet /debug /integrations.test @@ -67,13 +68,9 @@ cpu.out /indexers /log /public/assets/img/avatar +/tests/e2e-output /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 c64d91a7ebb..abd853877f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,7 +178,14 @@ 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 | | + +- 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, for example ``--ui`` | ## Translation diff --git a/Makefile b/Makefile index 3c7582dd57b..cb7742c5c74 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) @@ -115,7 +117,7 @@ LDFLAGS := $(LDFLAGS) -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)" 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) @@ -153,10 +155,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 @@ -187,7 +185,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" @@ -204,9 +202,8 @@ 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) $(EXECUTABLE_E2E) $(DIST) $(BINDATA_DEST_WILDCARD) \ integrations*.test \ - e2e*.test \ tests/integration/gitea-integration-* \ tests/integration/indexers-* \ tests/sqlite.ini tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \ @@ -535,47 +532,12 @@ 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= + @# on GitHub Actions VMs, playwright's system deps are pre-installed + @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(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_E2E) + @EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite @@ -671,18 +633,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 @@ -721,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 diff --git a/eslint.config.ts b/eslint.config.ts index cd37c4321ef..28508c52a68 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -911,9 +911,10 @@ export default defineConfig([ }, { ...playwright.configs['flat/recommended'], - files: ['tests/e2e/**'], + 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 9e3396465a8..f566054f788 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,98 +1,35 @@ -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, - - expect: { - - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ - timeout: 2000, - }, - - /* Fail the build on CI if you accidentally left test.only in the source code. */ + outputDir: './tests/e2e-output/', + testMatch: /.*\.test\.ts/, 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 - - 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', + reporter: 'list', + timeout: env.CI ? 12000 : 6000, + expect: { + timeout: env.CI ? 6000 : 3000, + }, + use: { + baseURL: env.GITEA_TEST_E2E_URL?.replace?.(/\/$/g, ''), + locale: 'en-US', + actionTimeout: env.CI ? 6000 : 3000, + navigationTimeout: env.CI ? 12000 : 6000, }, - - /* Configure projects for major browsers */ projects: [ { name: 'chromium', - - /* Project-specific settings. */ use: { ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], }, }, - - // disabled because of https://github.com/go-gitea/gitea/issues/21355 - // { - // name: 'firefox', - // use: { - // ...devices['Desktop Firefox'], - // }, - // }, - - { - name: 'webkit', + ...env.CI ? [{ + name: 'firefox', use: { - ...devices['Desktop Safari'], + ...devices['Desktop Firefox'], }, - }, - - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { - ...devices['Pixel 5'], - }, - }, - { - name: 'Mobile Safari', - use: { - ...devices['iPhone 12'], - }, - }, + }] : [], ], - - /* 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 ea3805ab95c..00000000000 --- 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 6e7890105c8..00000000000 --- 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() - 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/env.d.ts b/tests/e2e/env.d.ts new file mode 100644 index 00000000000..71887f4048c --- /dev/null +++ b/tests/e2e/env.d.ts @@ -0,0 +1,9 @@ +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; + GITEA_TEST_E2E_URL: string; + } +} diff --git a/tests/e2e/example.test.e2e.ts b/tests/e2e/example.test.e2e.ts deleted file mode 100644 index 1689f1b8efc..00000000000 --- 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/explore.test.ts b/tests/e2e/explore.test.ts new file mode 100644 index 00000000000..49cd9bb87af --- /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/login.test.ts b/tests/e2e/login.test.ts new file mode 100644 index 00000000000..ecf80d24744 --- /dev/null +++ b/tests/e2e/login.test.ts @@ -0,0 +1,12 @@ +import {test, expect} from '@playwright/test'; +import {login, logout} from './utils.ts'; + +test('homepage', async ({page}) => { + await page.goto('/'); + await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); +}); + +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 new file mode 100644 index 00000000000..d63aee0cf28 --- /dev/null +++ b/tests/e2e/milestone.test.ts @@ -0,0 +1,14 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiDeleteRepo} from './utils.ts'; + +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.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.GITEA_TEST_E2E_USER, repoName); +}); diff --git a/tests/e2e/org.test.ts b/tests/e2e/org.test.ts new file mode 100644 index 00000000000..b4d4fc2e7d3 --- /dev/null +++ b/tests/e2e/org.test.ts @@ -0,0 +1,13 @@ +import {test, expect} from '@playwright/test'; +import {login, apiDeleteOrg} 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}`)); + // delete via API because of issues related to form-fetch-action + await apiDeleteOrg(page.request, orgName); +}); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts new file mode 100644 index 00000000000..94755a254fd --- /dev/null +++ b/tests/e2e/readme.test.ts @@ -0,0 +1,11 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {apiCreateRepo, apiDeleteRepo} from './utils.ts'; + +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}`); + await expect(page.locator('#readme')).toContainText(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 new file mode 100644 index 00000000000..425fc7e40c2 --- /dev/null +++ b/tests/e2e/register.test.ts @@ -0,0 +1,73 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, 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@${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(); + await expect(page.locator('.ui.negative.message')).toBeVisible(); +}); + +test('register then login', async ({page}) => { + const username = `e2e-register-${Date.now()}`; + const email = `${username}@${env.GITEA_TEST_E2E_DOMAIN}`; + 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 login(page, username, password); + + // 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.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(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(); + 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/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts new file mode 100644 index 00000000000..cca59d612d4 --- /dev/null +++ b/tests/e2e/repo.test.ts @@ -0,0 +1,13 @@ +import {env} from 'node:process'; +import {test} from '@playwright/test'; +import {login, apiDeleteRepo} 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.locator('input[name="repo_name"]').fill(repoName); + await page.getByRole('button', {name: 'Create Repository'}).click(); + 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/user-settings.test.ts b/tests/e2e/user-settings.test.ts new file mode 100644 index 00000000000..ee1c6c98eb2 --- /dev/null +++ b/tests/e2e/user-settings.test.ts @@ -0,0 +1,14 @@ +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); + 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 new file mode 100644 index 00000000000..6ee16b32f86 --- /dev/null +++ b/tests/e2e/utils.ts @@ -0,0 +1,63 @@ +import {env} from 'node:process'; +import {expect} from '@playwright/test'; +import type {APIRequestContext, Locator, Page} from '@playwright/test'; + +export function apiBaseUrl() { + return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, ''); +} + +export function apiHeaders() { + 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) { + 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 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 apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, { + headers: apiHeaders(), + }), 'apiDeleteRepo'); +} + +export async function apiDeleteOrg(requestContext: APIRequestContext, name: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, { + headers: apiHeaders(), + }), 'apiDeleteOrg'); +} + +export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) { + await trigger.click(); + await page.getByText(itemText).click(); +} + +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); + 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.context().clearCookies(); // workaround issues related to fomantic dropdown + await page.goto('/'); + await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible(); +} diff --git a/tests/e2e/utils_e2e.ts b/tests/e2e/utils_e2e.ts deleted file mode 100644 index 0973f0838c8..00000000000 --- 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 5ba05f3453c..00000000000 --- 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 00000000000..d8608a85bbb --- /dev/null +++ b/tools/test-e2e.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -euo pipefail + +# Create isolated work directory +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',()=>{process.stdout.write(String(s.address().port));s.close()})") + +cleanup() { + 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 + +# 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=$! + +# 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 ! 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 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 + +echo "Gitea server is ready at $E2E_URL" + +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" \ + --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_PASSWORD +export GITEA_TEST_E2E_EMAIL + +pnpm exec playwright test "$@"