0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 11:28:12 +01:00

Merge branch 'main' into aipolicy

This commit is contained in:
Giteabot 2026-02-21 08:26:59 +08:00 committed by GitHub
commit 1a2c01a4b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 419 additions and 524 deletions

43
.github/workflows/pull-e2e-tests.yml vendored Normal file
View File

@ -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

7
.gitignore vendored
View File

@ -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

View File

@ -192,7 +192,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

View File

@ -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

View File

@ -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],
},
},
{

View File

@ -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;
});

View File

@ -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.

View File

@ -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())
})
})
}
}

9
tests/e2e/env.d.ts vendored Normal file
View File

@ -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;
}
}

View File

@ -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);
});

17
tests/e2e/explore.test.ts Normal file
View File

@ -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();
});

12
tests/e2e/login.test.ts Normal file
View File

@ -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);
});

View File

@ -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);
});

13
tests/e2e/org.test.ts Normal file
View File

@ -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);
});

11
tests/e2e/readme.test.ts Normal file
View File

@ -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);
});

View File

@ -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$/);
});

13
tests/e2e/repo.test.ts Normal file
View File

@ -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);
});

View File

@ -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('');
});

63
tests/e2e/utils.ts Normal file
View File

@ -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<string>}>, 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();
}

View File

@ -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'),
],
});
}
}

View File

@ -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...)
}

93
tools/test-e2e.sh Executable file
View File

@ -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" <<EOF
[database]
DB_TYPE = sqlite3
PATH = $WORK_DIR/data/gitea.db
[server]
HTTP_PORT = $FREE_PORT
ROOT_URL = http://localhost:$FREE_PORT
STATIC_ROOT_PATH = $(pwd)
[security]
INSTALL_LOCK = true
[service]
ENABLE_CAPTCHA = false
[log]
MODE = console
LEVEL = Warn
EOF
export GITEA_WORK_DIR="$WORK_DIR"
# Start Gitea server
echo "Starting Gitea server on port $FREE_PORT (workdir: $WORK_DIR)..."
if [ -n "${GITEA_TEST_E2E_DEBUG:-}" ]; then
"./$EXECUTABLE" web &
else
"./$EXECUTABLE" web > "$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 "$@"