summaryrefslogtreecommitdiffstats
path: root/tests/e2e
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /tests/e2e
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'tests/e2e')
-rw-r--r--tests/e2e/.eslintrc.yaml24
-rw-r--r--tests/e2e/README.md220
-rw-r--r--tests/e2e/actions.test.e2e.js79
-rw-r--r--tests/e2e/commit-graph-branch-selector.test.e2e.js25
-rw-r--r--tests/e2e/dashboard-ci-status.test.e2e.js21
-rw-r--r--tests/e2e/debugserver_test.go32
-rw-r--r--tests/e2e/declare_repos_test.go87
-rw-r--r--tests/e2e/e2e_test.go129
-rw-r--r--tests/e2e/example.test.e2e.js50
-rw-r--r--tests/e2e/explore.test.e2e.js40
-rw-r--r--tests/e2e/issue-comment.test.e2e.js63
-rw-r--r--tests/e2e/issue-sidebar.test.e2e.js226
-rw-r--r--tests/e2e/markdown-editor.test.e2e.js177
-rw-r--r--tests/e2e/markup.test.e2e.js14
-rw-r--r--tests/e2e/org-settings.test.e2e.js24
-rw-r--r--tests/e2e/profile_actions.test.e2e.js41
-rw-r--r--tests/e2e/reaction-selectors.test.e2e.js65
-rw-r--r--tests/e2e/release.test.e2e.js80
-rw-r--r--tests/e2e/repo-code.test.e2e.js86
-rw-r--r--tests/e2e/repo-migrate.test.e2e.js32
-rw-r--r--tests/e2e/repo-settings.test.e2e.js48
-rw-r--r--tests/e2e/repo-wiki.test.e2e.js36
-rw-r--r--tests/e2e/right-settings-button.test.e2e.js128
-rw-r--r--tests/e2e/shared/forms.js44
-rw-r--r--tests/e2e/utils_e2e.js82
-rw-r--r--tests/e2e/utils_e2e_test.go56
-rw-r--r--tests/e2e/webauthn.test.e2e.js60
27 files changed, 1969 insertions, 0 deletions
diff --git a/tests/e2e/.eslintrc.yaml b/tests/e2e/.eslintrc.yaml
new file mode 100644
index 0000000..1486431
--- /dev/null
+++ b/tests/e2e/.eslintrc.yaml
@@ -0,0 +1,24 @@
+plugins:
+ - eslint-plugin-playwright
+
+extends:
+ - ../../.eslintrc.yaml
+ - plugin:playwright/recommended
+
+parserOptions:
+ sourceType: module
+ ecmaVersion: latest
+
+env:
+ browser: true
+
+rules:
+ playwright/no-conditional-in-test: [0]
+ playwright/no-conditional-expect: [0]
+ playwright/no-networkidle: [0]
+ playwright/no-skipped-test: [2, {allowConditional: true}]
+ playwright/prefer-comparison-matcher: [2]
+ playwright/prefer-equality-matcher: [2]
+ playwright/prefer-to-contain: [2]
+ playwright/prefer-to-have-length: [2]
+ playwright/require-to-throw-message: [2]
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..6552011
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,220 @@
+# End to end tests
+
+Thank you for your effort to provide good software tests for Forgejo.
+Please also read the general testing instructions in the
+[Forgejo contributor documentation](https://forgejo.org/docs/next/contributor/testing/)
+and make sure to also check the
+[Playwright documentation](https://playwright.dev/docs/intro)
+for further information.
+
+This file is meant to provide specific information for the integration tests
+as well as some tips and tricks you should know.
+
+Feel free to extend this file with more instructions if you feel like you have something to share!
+
+
+## How to run the tests?
+
+Before running any tests, please ensure you perform a clean frontend build:
+
+```
+make clean frontend
+```
+
+Whenever you modify frontend code (i.e. JavaScript and CSS files),
+you need to create a new frontend build.
+
+For tests that require interactive Git repos,
+you also need to ensure a Forgejo binary is ready to be used by Git hooks.
+For this, you additionally need to run
+
+~~~
+make TAGS="sqlite sqlite_unlock_notify" backend
+~~~
+
+### Install dependencies
+
+Browsertesting is performed by playwright.
+You need certain system libraries and playwright will download required browsers.
+Playwright takes care of this when you run:
+
+```
+npx playwright install-deps
+```
+
+> **Note**
+> On some operating systems, the installation of missing libraries can complicate testing certain browsers.
+> It is often not necessary to test with all browsers locally.
+> Choosing either Firefox or Chromium is fine.
+
+
+### Run all tests
+
+If you want to run the full test suite, you can use
+
+```
+make test-e2e-sqlite
+```
+
+### Interactive testing
+
+We recommend that you use interactive testing for the development.
+After you performed the required builds,
+you should use one shell to start the debugserver (and leave it running):
+
+```
+make test-e2e-debugserver
+```
+
+It allows you to explore the test data in your local browser,
+and playwright to perform tests on it.
+
+> **Note**
+> The modifications persist while the debugserver is running.
+> If you modified things, it might be useful to restart it to get back to a fresh state.
+> While writing playwright tests, you either
+> need to ensure they are resilient against repeated runs
+> (e.g. when only creating new content),
+> or that they restore the initial state for the next browser run.
+
+#### With the playwright UI:
+
+Playwright ships with an integrated UI mode which allows you to
+run individual tests and to debug them by seeing detailed traces of what playwright does.
+Launch it with:
+
+```
+npx playwright test --ui
+```
+
+#### Running individual tests
+
+```
+npx playwright test actions.test.e2e.js:9
+```
+
+First, specify the complete test filename,
+and after the colon you can put the linenumber where the test is defined.
+
+
+#### With VSCodium or VSCode
+
+To debug a test, you can also use "Playwright Test" for
+[VScodium](https://open-vsx.org/extension/ms-playwright/playwright)
+or [VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright).
+
+
+### Run all tests via local act_runner
+
+If you have a [forgejo runner](https://code.forgejo.org/forgejo/runner/),
+you can use it to run the test jobs:
+
+```
+forgejo-runner exec -W .forgejo/workflows/e2e.yml --event=pull_request
+```
+
+### Run e2e tests with another database
+
+This approach is not currently used,
+neither in the CI/CD nor by core contributors on their lcoal machines.
+It is still documented for the sake of completeness:
+You can also perform e2e tests using MariaDB/MySQL or PostgreSQL if you want.
+
+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
+```
+
+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
+```
+
+### Running individual tests
+
+Example command to run `example.test.e2e.js` test file:
+
+> **Note**
+> Unlike integration tests, this filtering is at the file level, not function
+
+For SQLite:
+
+```
+make test-e2e-sqlite#example
+```
+
+### Visual testing
+
+> **Warning**
+> This is not currently used by most Forgejo contributors.
+> Your help to improve the situation and allow for visual testing is appreciated.
+
+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.
+
+
+## Tips and tricks
+
+If you know noteworthy tests that can act as an inspiration for new tests,
+please add some details here.
+
+### Run tests very selectively
+
+Browser testing can take some time.
+If you want to iterate fast,
+save your time and only run very selected tests.
+Use only one browser.
+
+### Skip Safari if it doesn't work
+
+Many contributors have issues getting Safari (webkit)
+and especially Safari Mobile to work.
+
+At the top of your test function, you can use:
+
+~~~javascript
+test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile.');
+~~~
+
+### Don't forget the formatting.
+
+When writing tests without modifying other frontend code,
+it is easy to forget that the JavaScript test files also need formatting.
+
+Run `make lint-frontend-fix`.
+
+### Define new repos
+
+Take a look at `declare_repos_test.go` to see how to add your repositories.
+Feel free to improve the logic used there if you need more advanced functionality
+(it is a simplified version of the code used in the integration tests).
+
+### Accessibility testing
+
+If you can, perform automated accessibility testing using
+[AxeCore](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/README.md).
+
+Take a look at `shared/forms.js` and some other places for inspiration.
+
+### List related files coverage
+
+If you think your playwright tests covers an important aspect of some template, CSS or backend files,
+consider adding the paths to `.forgejo/workflows/e2e.yml` in the path filter.
+
+It ensures that future modifications to this file will be tested as well.
+
+Currently, we do not run the e2e tests on all changes.
diff --git a/tests/e2e/actions.test.e2e.js b/tests/e2e/actions.test.e2e.js
new file mode 100644
index 0000000..b049a93
--- /dev/null
+++ b/tests/e2e/actions.test.e2e.js
@@ -0,0 +1,79 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+const workflow_trigger_notification_text = 'This workflow has a workflow_dispatch event trigger.';
+
+test('workflow dispatch present', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+
+ await expect(page.getByText(workflow_trigger_notification_text)).toBeVisible();
+
+ const run_workflow_btn = page.locator('#workflow_dispatch_dropdown>button');
+ await expect(run_workflow_btn).toBeVisible();
+
+ const menu = page.locator('#workflow_dispatch_dropdown>.menu');
+ await expect(menu).toBeHidden();
+ await run_workflow_btn.click();
+ await expect(menu).toBeVisible();
+});
+
+test('workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await page.locator('#workflow_dispatch_dropdown>button').click();
+
+ // Remove the required attribute so we can trigger the error message!
+ await page.evaluate(() => {
+ const elem = document.querySelector('input[name="inputs[string2]"]');
+ elem?.removeAttribute('required');
+ });
+
+ await page.locator('#workflow-dispatch-submit').click();
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible();
+});
+
+test('workflow dispatch success', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await page.locator('#workflow_dispatch_dropdown>button').click();
+
+ await page.type('input[name="inputs[string2]"]', 'abc');
+ await page.locator('#workflow-dispatch-submit').click();
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible();
+
+ await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible();
+});
+
+test('workflow dispatch box not available for unauthenticated users', async ({page}) => {
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.locator('body')).not.toContainText(workflow_trigger_notification_text);
+});
diff --git a/tests/e2e/commit-graph-branch-selector.test.e2e.js b/tests/e2e/commit-graph-branch-selector.test.e2e.js
new file mode 100644
index 0000000..db84932
--- /dev/null
+++ b/tests/e2e/commit-graph-branch-selector.test.e2e.js
@@ -0,0 +1,25 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Switch branch', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/graph');
+ await expect(response?.status()).toBe(200);
+
+ await page.click('#flow-select-refs-dropdown');
+ const input = page.locator('#flow-select-refs-dropdown');
+ await input.pressSequentially('develop', {delay: 50});
+ await input.press('Enter');
+
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.locator('#loading-indicator')).toBeHidden();
+ await expect(page.locator('#rel-container')).toBeVisible();
+ await expect(page.locator('#rev-container')).toBeVisible();
+});
diff --git a/tests/e2e/dashboard-ci-status.test.e2e.js b/tests/e2e/dashboard-ci-status.test.e2e.js
new file mode 100644
index 0000000..1ff68b6
--- /dev/null
+++ b/tests/e2e/dashboard-ci-status.test.e2e.js
@@ -0,0 +1,21 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Correct link and tooltip', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+ const response = await page.goto('/?repo-search-query=test_workflows');
+ await expect(response?.status()).toBe(200);
+
+ await page.waitForLoadState('networkidle');
+
+ const repoStatus = page.locator('.dashboard-repos .repo-owner-name-list > li:nth-child(1) > a:nth-child(2)');
+
+ await expect(repoStatus).toHaveAttribute('href', '/user2/test_workflows/actions', {timeout: 10000});
+ await expect(repoStatus).toHaveAttribute('data-tooltip-content', 'Failure');
+});
diff --git a/tests/e2e/debugserver_test.go b/tests/e2e/debugserver_test.go
new file mode 100644
index 0000000..49461fa
--- /dev/null
+++ b/tests/e2e/debugserver_test.go
@@ -0,0 +1,32 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This "test" is meant to be run with `make test-e2e-debugserver` and will just
+// keep open a gitea instance in a test environment (with the data from
+// `models/fixtures`) on port 3000. This is useful for debugging e2e tests, for
+// example with the playwright vscode extension.
+
+//nolint:forbidigo
+package e2e
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "os/signal"
+ "syscall"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func TestDebugserver(t *testing.T) {
+ done := make(chan os.Signal, 1)
+ signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
+
+ onForgejoRun(t, func(*testing.T, *url.URL) {
+ defer DeclareGitRepos(t)()
+ fmt.Println(setting.AppURL)
+ <-done
+ })
+}
diff --git a/tests/e2e/declare_repos_test.go b/tests/e2e/declare_repos_test.go
new file mode 100644
index 0000000..7057b26
--- /dev/null
+++ b/tests/e2e/declare_repos_test.go
@@ -0,0 +1,87 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package e2e
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// first entry represents filename
+// the following entries define the full file content over time
+type FileChanges [][]string
+
+// put your Git repo declarations in here
+// feel free to amend the helper function below or use the raw variant directly
+func DeclareGitRepos(t *testing.T) func() {
+ cleanupFunctions := []func(){
+ newRepo(t, 2, "diff-test", FileChanges{
+ {"testfile", "hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"},
+ }),
+ // add your repo declarations here
+ }
+
+ return func() {
+ for _, cleanup := range cleanupFunctions {
+ cleanup()
+ }
+ }
+}
+
+func newRepo(t *testing.T, userID int64, repoName string, fileChanges FileChanges) func() {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+ somerepo, _, cleanupFunc := tests.CreateDeclarativeRepo(t, user, repoName,
+ []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil,
+ nil,
+ )
+
+ for _, file := range fileChanges {
+ changeLen := len(file)
+ for i := 1; i < changeLen; i++ {
+ operation := "create"
+ if i != 1 {
+ operation = "update"
+ }
+ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, somerepo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{{
+ Operation: operation,
+ TreePath: file[0],
+ ContentReader: strings.NewReader(file[i]),
+ }},
+ Message: fmt.Sprintf("Patch: %s-%s", file[0], strconv.Itoa(i)),
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp)
+ }
+ }
+
+ return cleanupFunc
+}
diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go
new file mode 100644
index 0000000..44a6897
--- /dev/null
+++ b/tests/e2e/e2e_test.go
@@ -0,0 +1,129 @@
+// 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
+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.Route
+
+func TestMain(m *testing.M) {
+ defer log.GetManager().Close()
+
+ managerCtx, cancel := context.WithCancel(context.Background())
+ graceful.InitManager(managerCtx)
+ defer cancel()
+
+ tests.InitTest(true)
+ testE2eWebRoutes = routers.NormalRoutes()
+
+ os.Unsetenv("GIT_AUTHOR_NAME")
+ os.Unsetenv("GIT_AUTHOR_EMAIL")
+ os.Unsetenv("GIT_AUTHOR_DATE")
+ os.Unsetenv("GIT_COMMITTER_NAME")
+ os.Unsetenv("GIT_COMMITTER_EMAIL")
+ os.Unsetenv("GIT_COMMITTER_DATE")
+
+ 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()
+
+ if err := testlogger.WriterCloser.Reset(); err != nil {
+ fmt.Printf("testlogger.WriterCloser.Reset: error ignored: %v\n", err)
+ }
+ 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.js" 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.js")
+ 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")
+ }
+ if project := os.Getenv("PLAYWRIGHT_PROJECT"); project != "" {
+ runArgs = append(runArgs, "--project="+project)
+ }
+
+ // 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
+ onForgejoRun(t, func(*testing.T, *url.URL) {
+ defer DeclareGitRepos(t)()
+ thisTest := runArgs
+ thisTest = append(thisTest, path)
+ cmd := exec.Command(runArgs[0], thisTest...)
+ cmd.Env = os.Environ()
+ cmd.Env = append(cmd.Env, fmt.Sprintf("GITEA_URL=%s", 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.js b/tests/e2e/example.test.e2e.js
new file mode 100644
index 0000000..86abdf6
--- /dev/null
+++ b/tests/e2e/example.test.e2e.js
@@ -0,0 +1,50 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, save_visual} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Load Homepage', async ({page}) => {
+ const response = await page.goto('/');
+ await expect(response?.status()).toBe(200); // Status OK
+ await expect(page).toHaveTitle(/^Forgejo: Beyond coding. We Forge.\s*$/);
+ await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg');
+});
+
+test('Register Form', async ({page}, workerInfo) => {
+ const response = await page.goto('/user/sign_up');
+ await expect(response?.status()).toBe(200); // Status OK
+ await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`);
+ await page.type('input[name=email]', `e2e-test-${workerInfo.workerIndex}@test.com`);
+ await page.type('input[name=password]', 'test123test123');
+ await page.type('input[name=retype]', 'test123test123');
+ await page.click('form button.ui.primary.button:visible');
+ // Make sure we routed to the home page. Else login failed.
+ await 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);
+});
+
+// eslint-disable-next-line playwright/no-skipped-test
+test.describe.skip('example with different viewports (not actually run)', () => {
+ // only necessary when the default web / mobile devices are not enough.
+ // If you need to use a single fixed viewport, you can also use:
+ // test.use({viewport: {width: 400, height: 800}});
+ // also see https://playwright.dev/docs/test-parameterize
+ for (const width of [400, 1000]) {
+ // do not actually run (skip) this test
+ test(`Do x on width: ${width}px`, async ({page}) => {
+ await page.setViewportSize({
+ width,
+ height: 800,
+ });
+ // do something, then check that an element is fully in viewport
+ // (i.e. not overflowing)
+ await expect(page.locator('#my-element')).toBeInViewport({ratio: 1});
+ });
+ }
+});
diff --git a/tests/e2e/explore.test.e2e.js b/tests/e2e/explore.test.e2e.js
new file mode 100644
index 0000000..9603443
--- /dev/null
+++ b/tests/e2e/explore.test.e2e.js
@@ -0,0 +1,40 @@
+// @ts-check
+// document is a global in evaluate, so it's safe to ignore here
+// eslint playwright/no-conditional-in-test: 0
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+test('Explore view taborder', async ({page}) => {
+ await page.goto('/explore/repos');
+
+ const l1 = page.locator('[href="https://forgejo.org"]');
+ const l2 = page.locator('[href="/assets/licenses.txt"]');
+ const l3 = page.locator('[href*="/stars"]').first();
+ const l4 = page.locator('[href*="/forks"]').first();
+ let res = 0;
+ const exp = 15; // 0b1111 = four passing tests
+
+ for (let i = 0; i < 150; i++) {
+ await page.keyboard.press('Tab');
+ if (await l1.evaluate((node) => document.activeElement === node)) {
+ res |= 1;
+ continue;
+ }
+ if (await l2.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 1;
+ continue;
+ }
+ if (await l3.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 2;
+ continue;
+ }
+ if (await l4.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 3;
+ continue;
+ }
+ if (res === exp) {
+ break;
+ }
+ }
+ await expect(res).toBe(exp);
+});
diff --git a/tests/e2e/issue-comment.test.e2e.js b/tests/e2e/issue-comment.test.e2e.js
new file mode 100644
index 0000000..ee2e3a4
--- /dev/null
+++ b/tests/e2e/issue-comment.test.e2e.js
@@ -0,0 +1,63 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Hyperlink paste behaviour', async ({browser}, workerInfo) => {
+ test.skip(['Mobile Safari', 'Mobile Chrome', 'webkit'].includes(workerInfo.project.name), 'Mobile clients seem to have very weird behaviour with this test, which I cannot confirm with real usage');
+ const page = await login({browser}, workerInfo);
+ await page.goto('/user2/repo1/issues/new');
+ await page.locator('textarea').click();
+ // same URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some-anchor');
+ await page.locator('textarea').press('Shift+Home');
+ await page.locator('textarea').press('ControlOrMeta+c');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // other text
+ await page.locator('textarea').fill('Some other text');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('[Some other text](https://codeberg.org/forgejo/forgejo#some-anchor)');
+ // subset of URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // superset of URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some-anchor-on-the-page');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // completely separate URL
+ await page.locator('textarea').fill('http://example.com');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+});
+
+test('Always focus edit tab first on edit', async ({browser}, workerInfo) => {
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ // Switch to preview tab and save
+ await page.click('#issue-1 .comment-container .context-menu');
+ await page.click('#issue-1 .comment-container .menu>.edit-content');
+ await page.locator('#issue-1 .comment-container a[data-tab-for=markdown-previewer]').click();
+ await page.click('#issue-1 .comment-container .save');
+
+ await page.waitForLoadState('networkidle');
+
+ // Edit again and assert that edit tab should be active (and not preview tab)
+ await page.click('#issue-1 .comment-container .context-menu');
+ await page.click('#issue-1 .comment-container .menu>.edit-content');
+ const editTab = page.locator('#issue-1 .comment-container a[data-tab-for=markdown-writer]');
+ const previewTab = page.locator('#issue-1 .comment-container a[data-tab-for=markdown-previewer]');
+
+ await expect(editTab).toHaveClass(/active/);
+ await expect(previewTab).not.toHaveClass(/active/);
+});
diff --git a/tests/e2e/issue-sidebar.test.e2e.js b/tests/e2e/issue-sidebar.test.e2e.js
new file mode 100644
index 0000000..61d3281
--- /dev/null
+++ b/tests/e2e/issue-sidebar.test.e2e.js
@@ -0,0 +1,226 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+// belongs to test: Pull: Toggle WIP
+const prTitle = 'pull5';
+
+async function click_toggle_wip({page}) {
+ await page.locator('.toggle-wip>a').click();
+ await page.waitForLoadState('networkidle');
+}
+
+async function check_wip({page}, is) {
+ const elemTitle = '#issue-title-display';
+ const stateLabel = '.issue-state-label';
+ await expect(page.locator(elemTitle)).toContainText(prTitle);
+ await expect(page.locator(elemTitle)).toContainText('#5');
+ if (is) {
+ await expect(page.locator(elemTitle)).toContainText('WIP');
+ await expect(page.locator(stateLabel)).toContainText('Draft');
+ } else {
+ await expect(page.locator(elemTitle)).not.toContainText('WIP');
+ await expect(page.locator(stateLabel)).toContainText('Open');
+ }
+}
+
+test('Pull: Toggle WIP', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/pulls/5');
+ await expect(response?.status()).toBe(200); // Status OK
+ // initial state
+ await check_wip({page}, false);
+ // toggle to WIP
+ await click_toggle_wip({page});
+ await check_wip({page}, true);
+ // remove WIP
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+
+ // manually edit title to another prefix
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(`[WIP] ${prTitle}`);
+ await page.getByText('Save').click();
+ await page.waitForLoadState('networkidle');
+ await check_wip({page}, true);
+ // remove again
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+ // check maximum title length is handled gracefully
+ const maxLenStr = prTitle + 'a'.repeat(240);
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(maxLenStr);
+ await page.getByText('Save').click();
+ await page.waitForLoadState('networkidle');
+ await click_toggle_wip({page});
+ await check_wip({page}, true);
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+ await expect(page.locator('h1')).toContainText(maxLenStr);
+ // restore original title
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(prTitle);
+ await page.getByText('Save').click();
+ await check_wip({page}, false);
+});
+
+test('Issue: Labels', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const labelList = page.locator('.issue-content-right .labels-list a');
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
+ await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
+ // add label2
+ await page.locator('.select-label').click();
+ // label search could be tested this way:
+ // await page.locator('.select-label input').fill('label2');
+ await page.locator('.select-label .item').filter({hasText: 'label2'}).click();
+ await page.locator('.select-label').click();
+ await page.waitForLoadState('networkidle');
+ await expect(labelList.filter({hasText: 'label2'})).toBeVisible();
+ // test removing label again
+ await page.locator('.select-label').click();
+ await page.locator('.select-label .item').filter({hasText: 'label2'}).click();
+ await page.locator('.select-label').click();
+ await page.waitForLoadState('networkidle');
+ await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
+ await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
+});
+
+test('Issue: Assignees', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item a');
+
+ const response = await page.goto('/org3/repo3/issues/1');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+
+ // Clear all assignees
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.locator('.select-assignees-modify.dropdown .no-select.item').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(page.locator('.select-assign-me')).toBeVisible();
+
+ // Assign other user (with searchbox)
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.type('.select-assignees-modify .menu .search input', 'user4');
+ await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user2'})).toBeHidden();
+ await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'})).toBeVisible();
+ await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
+
+ // remove user4
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Test assign me
+ await page.locator('.ui.assignees .select-assign-me').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+});
+
+test('New Issue: Assignees', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item');
+
+ const response = await page.goto('/org3/repo3/issues/new');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Assign other user (with searchbox)
+ await page.locator('.select-assignees.dropdown').click();
+ await page.type('.select-assignees .menu .search input', 'user4');
+ await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user2'})).toBeHidden();
+ await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user4'})).toBeVisible();
+ await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees.dropdown').click();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
+
+ // remove user4
+ await page.locator('.select-assignees.dropdown').click();
+ await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees.dropdown').click();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Test assign me
+ await page.locator('.ui.assignees .select-assign-me').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+
+ await page.locator('.select-assignees.dropdown').click();
+ await page.fill('.select-assignees .menu .search input', '');
+ await page.locator('.select-assignees.dropdown .no-select.item').click();
+ await expect(page.locator('.select-assign-me')).toBeVisible();
+});
+
+test('Issue: Milestone', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ const selectedMilestone = page.locator('.issue-content-right .select-milestone.list');
+ const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown');
+ await expect(selectedMilestone).toContainText('No milestone');
+
+ // Add milestone.
+ await milestoneDropdown.click();
+ await page.getByRole('option', {name: 'milestone1'}).click();
+ await expect(selectedMilestone).toContainText('milestone1');
+ await expect(page.locator('.timeline-item.event').last()).toContainText('user2 added this to the milestone1 milestone');
+
+ // Clear milestone.
+ await milestoneDropdown.click();
+ await page.getByText('Clear milestone', {exact: true}).click();
+ await expect(selectedMilestone).toContainText('No milestone');
+ await expect(page.locator('.timeline-item.event').last()).toContainText('user2 removed this from the milestone1 milestone');
+});
+
+test('New Issue: Milestone', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const selectedMilestone = page.locator('.issue-content-right .select-milestone.list');
+ const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown');
+ await expect(selectedMilestone).toContainText('No milestone');
+
+ // Add milestone.
+ await milestoneDropdown.click();
+ await page.getByRole('option', {name: 'milestone1'}).click();
+ await expect(selectedMilestone).toContainText('milestone1');
+
+ // Clear milestone.
+ await milestoneDropdown.click();
+ await page.getByText('Clear milestone', {exact: true}).click();
+ await expect(selectedMilestone).toContainText('No milestone');
+});
diff --git a/tests/e2e/markdown-editor.test.e2e.js b/tests/e2e/markdown-editor.test.e2e.js
new file mode 100644
index 0000000..4a3b414
--- /dev/null
+++ b/tests/e2e/markdown-editor.test.e2e.js
@@ -0,0 +1,177 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, load_logged_in_context, login_user} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('markdown indentation', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+
+ const initText = `* first\n* second\n* third\n* last`;
+
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const textarea = page.locator('textarea[name=content]');
+ const tab = ' ';
+ const indent = page.locator('button[data-md-action="indent"]');
+ const unindent = page.locator('button[data-md-action="unindent"]');
+ await textarea.fill(initText);
+ await textarea.click(); // Tab handling is disabled until pointer event or input.
+
+ // Indent, then unindent first line
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(0, 0));
+ await indent.click();
+ await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Indent second line while somewhere inside of it
+ await textarea.focus();
+ await textarea.press('ArrowDown');
+ await textarea.press('ArrowRight');
+ await textarea.press('ArrowRight');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+
+ // Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird')));
+ await indent.click();
+ const lines23 = `* first\n${tab}${tab}* second\n${tab}* third\n* last`;
+ await expect(textarea).toHaveValue(lines23);
+ await expect(textarea).toHaveJSProperty('selectionStart', lines23.indexOf('cond'));
+ await expect(textarea).toHaveJSProperty('selectionEnd', lines23.indexOf('hird'));
+
+ // Then unindent twice, erasing all indents.
+ await unindent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Indent and unindent with cursor at the end of the line
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
+ await textarea.press('End');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Check that Tab does work after input
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Shift+Enter'); // Avoid triggering the prefix continuation feature
+ await textarea.pressSequentially('* least');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n${tab}* least`);
+
+ // Check that partial indents are cleared
+ await textarea.focus();
+ await textarea.fill(initText);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second')));
+ await textarea.pressSequentially(' ');
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+});
+
+test('markdown list continuation', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+
+ const initText = `* first\n* second\n* third\n* last`;
+
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const textarea = page.locator('textarea[name=content]');
+ const tab = ' ';
+ const indent = page.locator('button[data-md-action="indent"]');
+ await textarea.fill(initText);
+
+ // Test continuation of '* ' prefix
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
+ await textarea.press('End');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('middle');
+ await expect(textarea).toHaveValue(`* first\n* second\n* middle\n* third\n* last`);
+
+ // Test continuation of ' * ' prefix
+ await indent.click();
+ await textarea.press('Enter');
+ await textarea.pressSequentially('muddle');
+ await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* muddle\n* third\n* last`);
+
+ // Test breaking in the middle of a line
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.lastIndexOf('ddle'), it.value.lastIndexOf('ddle')));
+ await textarea.pressSequentially('tate');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('me');
+ await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* mutate\n${tab}* meddle\n* third\n* last`);
+
+ // Test not triggering when Shift held
+ await textarea.fill(initText);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Shift+Enter');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('...but not least');
+ await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n\n...but not least`);
+
+ // Test continuation of ordered list
+ await textarea.fill(`1. one\n2. two`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('three');
+ await expect(textarea).toHaveValue(`1. one\n2. two\n3. three`);
+
+ // Test continuation of alternative ordered list syntax
+ await textarea.fill(`1) one\n2) two`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('three');
+ await expect(textarea).toHaveValue(`1) one\n2) two\n3) three`);
+
+ // Test continuation of blockquote
+ await textarea.fill(`> knowledge is power`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('france is bacon');
+ await expect(textarea).toHaveValue(`> knowledge is power\n> france is bacon`);
+
+ // Test continuation of checklists
+ await textarea.fill(`- [ ] have a problem\n- [x] create a solution`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('write a test');
+ await expect(textarea).toHaveValue(`- [ ] have a problem\n- [x] create a solution\n- [ ] write a test`);
+
+ // Test all conceivable syntax (except ordered lists)
+ const prefixes = [
+ '- ', // A space between the bullet and the content is required.
+ ' - ', // I have seen single space in front of -/* being used and even recommended, I think.
+ '* ',
+ '+ ',
+ ' ',
+ ' ',
+ ' - ',
+ '\t',
+ '\t\t* ',
+ '> ',
+ '> > ',
+ '- [ ] ',
+ '- [ ]', // This does seem to render, so allow.
+ '* [ ] ',
+ '+ [ ] ',
+ ];
+ for (const prefix of prefixes) {
+ await textarea.fill(`${prefix}one`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('two');
+ await expect(textarea).toHaveValue(`${prefix}one\n${prefix}two`);
+ }
+});
diff --git a/tests/e2e/markup.test.e2e.js b/tests/e2e/markup.test.e2e.js
new file mode 100644
index 0000000..920537d
--- /dev/null
+++ b/tests/e2e/markup.test.e2e.js
@@ -0,0 +1,14 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+test('markup with #xyz-mode-only', async ({page}) => {
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+ await page.waitForLoadState('networkidle');
+
+ const comment = page.locator('.comment-body>.markup', {hasText: 'test markup light/dark-mode-only'});
+ await expect(comment).toBeVisible();
+ await expect(comment.locator('[src$="#gh-light-mode-only"]')).toBeVisible();
+ await expect(comment.locator('[src$="#gh-dark-mode-only"]')).toBeHidden();
+});
diff --git a/tests/e2e/org-settings.test.e2e.js b/tests/e2e/org-settings.test.e2e.js
new file mode 100644
index 0000000..5ff0975
--- /dev/null
+++ b/tests/e2e/org-settings.test.e2e.js
@@ -0,0 +1,24 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('org team settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/org/org3/teams/team1/edit');
+ await expect(response?.status()).toBe(200);
+
+ await page.locator('input[name="permission"][value="admin"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+
+ await page.locator('input[name="permission"][value="read"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeVisible();
+
+ // we are validating the form here to include the part that could be hidden
+ await validate_form({page});
+});
diff --git a/tests/e2e/profile_actions.test.e2e.js b/tests/e2e/profile_actions.test.e2e.js
new file mode 100644
index 0000000..dcec0cd
--- /dev/null
+++ b/tests/e2e/profile_actions.test.e2e.js
@@ -0,0 +1,41 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test('Follow actions', async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user1');
+ await page.waitForLoadState('networkidle');
+
+ // Check if following and then unfollowing works.
+ // This checks that the event listeners of
+ // the buttons aren't disappearing.
+ const followButton = page.locator('.follow');
+ await expect(followButton).toContainText('Follow');
+ await followButton.click();
+ await expect(followButton).toContainText('Unfollow');
+ await followButton.click();
+ await expect(followButton).toContainText('Follow');
+
+ // Simple block interaction.
+ await expect(page.locator('.block')).toContainText('Block');
+
+ await page.locator('.block').click();
+ await expect(page.locator('#block-user')).toBeVisible();
+ await page.locator('#block-user .ok').click();
+ await expect(page.locator('.block')).toContainText('Unblock');
+ await expect(page.locator('#block-user')).toBeHidden();
+
+ // Check that following the user yields in a error being shown.
+ await followButton.click();
+ const flashMessage = page.locator('#flash-message');
+ await expect(flashMessage).toBeVisible();
+ await expect(flashMessage).toContainText('You cannot follow this user because you have blocked this user or this user has blocked you.');
+
+ // Unblock interaction.
+ await page.locator('.block').click();
+ await expect(page.locator('.block')).toContainText('Block');
+});
diff --git a/tests/e2e/reaction-selectors.test.e2e.js b/tests/e2e/reaction-selectors.test.e2e.js
new file mode 100644
index 0000000..2a9c62b
--- /dev/null
+++ b/tests/e2e/reaction-selectors.test.e2e.js
@@ -0,0 +1,65 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+const assertReactionCounts = (comment, counts) =>
+ expect(async () => {
+ await expect(comment.locator('.reactions')).toBeVisible();
+
+ const reactions = Object.fromEntries(
+ await Promise.all(
+ (
+ await comment
+ .locator(`.reactions [role=button][data-reaction-content]`)
+ .all()
+ ).map(async (button) => [
+ await button.getAttribute('data-reaction-content'),
+ parseInt(await button.locator('.reaction-count').textContent()),
+ ]),
+ ),
+ );
+ return expect(reactions).toStrictEqual(counts);
+ }).toPass();
+
+async function toggleReaction(menu, reaction) {
+ await menu.evaluateAll((menus) => menus[0].focus());
+ await menu.locator('.add-reaction').click();
+ await menu.locator(`[role=menuitem][data-reaction-content="${reaction}"]`).click();
+}
+
+test('Reaction Selectors', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ const comment = page.locator('.comment#issuecomment-2').first();
+
+ const topPicker = comment.locator('.actions [role=menu].select-reaction');
+ const bottomPicker = comment.locator('.reactions').getByRole('menu');
+
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await toggleReaction(topPicker, '+1');
+ await assertReactionCounts(comment, {'laugh': 2, '+1': 1});
+
+ await toggleReaction(bottomPicker, '+1');
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await toggleReaction(bottomPicker, '-1');
+ await assertReactionCounts(comment, {'laugh': 2, '-1': 1});
+
+ await toggleReaction(topPicker, '-1');
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await comment.locator('.reactions [role=button][data-reaction-content=laugh]').click();
+ await assertReactionCounts(comment, {'laugh': 1});
+
+ await toggleReaction(topPicker, 'laugh');
+ await assertReactionCounts(comment, {'laugh': 2});
+});
diff --git a/tests/e2e/release.test.e2e.js b/tests/e2e/release.test.e2e.js
new file mode 100644
index 0000000..daa2a82
--- /dev/null
+++ b/tests/e2e/release.test.e2e.js
@@ -0,0 +1,80 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, save_visual, load_logged_in_context} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test.describe.configure({
+ timeout: 30000,
+});
+
+test('External Release Attachments', async ({browser, isMobile}, workerInfo) => {
+ test.skip(isMobile);
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ // Click "New Release"
+ await page.goto('/user2/repo2/releases');
+ await page.click('.button.small.primary');
+
+ // Fill out form and create new release
+ await expect(page).toHaveURL('/user2/repo2/releases/new');
+ await validate_form({page}, 'fieldset');
+ await page.fill('input[name=tag_name]', '2.0');
+ await page.fill('input[name=title]', '2.0');
+ await page.click('#add-external-link');
+ await page.click('#add-external-link');
+ await page.fill('input[name=attachment-new-name-2]', 'Test');
+ await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/');
+ await page.click('.remove-rel-attach');
+ save_visual(page);
+ await page.click('.button.small.primary');
+
+ // Validate release page and click edit
+ await expect(page).toHaveURL('/user2/repo2/releases');
+ await expect(page.locator('.download[open] li')).toHaveCount(3);
+ await expect(page.locator('.download[open] li:nth-of-type(1)')).toContainText('Source code (ZIP)');
+ await expect(page.locator('.download[open] li:nth-of-type(1) a')).toHaveAttribute('href', '/user2/repo2/archive/2.0.zip');
+ await expect(page.locator('.download[open] li:nth-of-type(2)')).toContainText('Source code (TAR.GZ)');
+ await expect(page.locator('.download[open] li:nth-of-type(2) a')).toHaveAttribute('href', '/user2/repo2/archive/2.0.tar.gz');
+ await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test');
+ await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/');
+ save_visual(page);
+ await page.locator('.octicon-pencil').first().click();
+
+ // Validate edit page and edit the release
+ await expect(page).toHaveURL('/user2/repo2/releases/edit/2.0');
+ await validate_form({page}, 'fieldset');
+ await expect(page.locator('.attachment_edit:visible')).toHaveCount(2);
+ await expect(page.locator('.attachment_edit:visible').nth(0)).toHaveValue('Test');
+ await expect(page.locator('.attachment_edit:visible').nth(1)).toHaveValue('https://forgejo.org/');
+ await page.locator('.attachment_edit:visible').nth(0).fill('Test2');
+ await page.locator('.attachment_edit:visible').nth(1).fill('https://gitea.io/');
+ await page.click('#add-external-link');
+ await expect(page.locator('.attachment_edit:visible')).toHaveCount(4);
+ await page.locator('.attachment_edit:visible').nth(2).fill('Test3');
+ await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/');
+ save_visual(page);
+ await page.click('.button.small.primary');
+
+ // Validate release page and click edit
+ await expect(page).toHaveURL('/user2/repo2/releases');
+ await expect(page.locator('.download[open] li')).toHaveCount(4);
+ await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test2');
+ await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/');
+ await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3');
+ await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/');
+ save_visual(page);
+ await page.locator('.octicon-pencil').first().click();
+
+ // Delete release
+ await expect(page).toHaveURL('/user2/repo2/releases/edit/2.0');
+ await page.click('.delete-button');
+ await page.click('.button.ok');
+ await expect(page).toHaveURL('/user2/repo2/releases');
+});
diff --git a/tests/e2e/repo-code.test.e2e.js b/tests/e2e/repo-code.test.e2e.js
new file mode 100644
index 0000000..62c4f55
--- /dev/null
+++ b/tests/e2e/repo-code.test.e2e.js
@@ -0,0 +1,86 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+async function assertSelectedLines(page, nums) {
+ const pageAssertions = async () => {
+ expect(
+ await Promise.all((await page.locator('tr.active [data-line-number]').all()).map((line) => line.getAttribute('data-line-number'))),
+ )
+ .toStrictEqual(nums);
+
+ // the first line selected has an action button
+ if (nums.length > 0) await expect(page.locator(`#L${nums[0]} .code-line-button`)).toBeVisible();
+ };
+
+ await pageAssertions();
+
+ // URL has the expected state
+ expect(new URL(page.url()).hash)
+ .toEqual(nums.length === 0 ? '' : nums.length === 1 ? `#L${nums[0]}` : `#L${nums[0]}-L${nums.at(-1)}`);
+
+ // test selection restored from URL hash
+ await page.reload();
+ return pageAssertions();
+}
+
+test('Line Range Selection', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ const filePath = '/user2/repo1/src/branch/master/README.md?display=source';
+
+ const response = await page.goto(filePath);
+ await expect(response?.status()).toBe(200);
+
+ await assertSelectedLines(page, []);
+ await page.locator('span#L1').click();
+ await assertSelectedLines(page, ['1']);
+ await page.locator('span#L3').click({modifiers: ['Shift']});
+ await assertSelectedLines(page, ['1', '2', '3']);
+ await page.locator('span#L2').click();
+ await assertSelectedLines(page, ['2']);
+ await page.locator('span#L1').click({modifiers: ['Shift']});
+ await assertSelectedLines(page, ['1', '2']);
+
+ // out-of-bounds end line
+ await page.goto(`${filePath}#L1-L100`);
+ await assertSelectedLines(page, ['1', '2', '3']);
+});
+
+test('Readable diff', async ({page}, workerInfo) => {
+ // remove this when the test covers more (e.g. accessibility scans or interactive behaviour)
+ test.skip(workerInfo.project.name !== 'firefox', 'This currently only tests the backend-generated HTML code and it is not necessary to test with multiple browsers.');
+ const expectedDiffs = [
+ {id: 'testfile-2', removed: 'e', added: 'a'},
+ {id: 'testfile-3', removed: 'allo', added: 'ola'},
+ {id: 'testfile-4', removed: 'hola', added: 'native'},
+ {id: 'testfile-5', removed: 'native', added: 'ubuntu-latest'},
+ {id: 'testfile-6', added: '- runs-on: '},
+ {id: 'testfile-7', removed: 'ubuntu', added: 'debian'},
+ ];
+ for (const thisDiff of expectedDiffs) {
+ const response = await page.goto('/user2/diff-test/commits/branch/main');
+ await expect(response?.status()).toBe(200); // Status OK
+ await page.getByText(`Patch: ${thisDiff.id}`).click();
+ if (thisDiff.removed) {
+ await expect(page.getByText(thisDiff.removed, {exact: true})).toHaveClass(/removed-code/);
+ await expect(page.getByText(thisDiff.removed, {exact: true})).toHaveCSS('background-color', 'rgb(252, 165, 165)');
+ }
+ if (thisDiff.added) {
+ await expect(page.getByText(thisDiff.added, {exact: true})).toHaveClass(/added-code/);
+ await expect(page.getByText(thisDiff.added, {exact: true})).toHaveCSS('background-color', 'rgb(134, 239, 172)');
+ }
+ }
+});
+
+test('Commit graph overflow', async ({page}) => {
+ await page.goto('/user2/diff-test/graph');
+ await expect(page.getByRole('button', {name: 'Mono'})).toBeInViewport({ratio: 1});
+ await expect(page.getByRole('button', {name: 'Color'})).toBeInViewport({ratio: 1});
+ await expect(page.locator('.selection.search.dropdown')).toBeInViewport({ratio: 1});
+});
diff --git a/tests/e2e/repo-migrate.test.e2e.js b/tests/e2e/repo-migrate.test.e2e.js
new file mode 100644
index 0000000..63328e0
--- /dev/null
+++ b/tests/e2e/repo-migrate.test.e2e.js
@@ -0,0 +1,32 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(({browser}, workerInfo) => login_user(browser, workerInfo, 'user2'));
+
+test('Migration Progress Page', async ({page: unauthedPage, browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky actionability checks on Mobile Safari');
+
+ const page = await (await load_logged_in_context(browser, workerInfo, 'user2')).newPage();
+
+ await expect((await page.goto('/user2/invalidrepo'))?.status(), 'repo should not exist yet').toBe(404);
+
+ await page.goto('/repo/migrate?service_type=1');
+
+ const form = page.locator('form');
+ await form.getByRole('textbox', {name: 'Repository Name'}).fill('invalidrepo');
+ await form.getByRole('textbox', {name: 'Migrate / Clone from URL'}).fill('https://codeberg.org/forgejo/invalidrepo');
+ await form.locator('button.primary').click({timeout: 5000});
+ await expect(page).toHaveURL('user2/invalidrepo');
+
+ await expect((await unauthedPage.goto('/user2/invalidrepo'))?.status(), 'public migration page should be accessible').toBe(200);
+ await expect(unauthedPage.locator('#repo_migrating_progress')).toBeVisible();
+
+ await page.reload();
+ await expect(page.locator('#repo_migrating_failed')).toBeVisible();
+ await page.getByRole('button', {name: 'Delete this repository'}).click();
+ const deleteModal = page.locator('#delete-repo-modal');
+ await deleteModal.getByRole('textbox', {name: 'Confirmation string'}).fill('user2/invalidrepo');
+ await deleteModal.getByRole('button', {name: 'Delete repository'}).click();
+ await expect(page).toHaveURL('/');
+});
diff --git a/tests/e2e/repo-settings.test.e2e.js b/tests/e2e/repo-settings.test.e2e.js
new file mode 100644
index 0000000..b7b0884
--- /dev/null
+++ b/tests/e2e/repo-settings.test.e2e.js
@@ -0,0 +1,48 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('repo webhook settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/settings/hooks/forgejo/new');
+ await expect(response?.status()).toBe(200);
+
+ await page.locator('input[name="events"][value="choose_events"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeVisible();
+
+ // check accessibility including the custom events (now visible) part
+ await validate_form({page}, 'fieldset');
+
+ await page.locator('input[name="events"][value="push_only"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+ await page.locator('input[name="events"][value="send_everything"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+});
+
+test('repo branch protection settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/settings/branches/edit');
+ await expect(response?.status()).toBe(200);
+
+ await validate_form({page}, 'fieldset');
+
+ // verify header is new
+ await expect(page.locator('h4')).toContainText('new');
+ await page.locator('input[name="rule_name"]').fill('testrule');
+ await page.getByText('Save rule').click();
+ // verify header is in edit mode
+ await page.waitForLoadState('networkidle');
+ await page.getByText('Edit').click();
+ await expect(page.locator('h4')).toContainText('Protection rules for branch');
+ // delete the rule for the next test
+ await page.goBack();
+ await page.getByText('Delete rule').click();
+ await page.getByText('Yes').click();
+});
diff --git a/tests/e2e/repo-wiki.test.e2e.js b/tests/e2e/repo-wiki.test.e2e.js
new file mode 100644
index 0000000..dc8a670
--- /dev/null
+++ b/tests/e2e/repo-wiki.test.e2e.js
@@ -0,0 +1,36 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+for (const searchTerm of ['space', 'consectetur']) {
+ for (const width of [null, 2560, 4000]) {
+ test(`Search for '${searchTerm}' and test for no overflow ${width && `on ${width}-wide viewport` || ''}`, async ({page, viewport}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Fails as always, see https://codeberg.org/forgejo/forgejo/pulls/5326#issuecomment-2313275');
+
+ await page.setViewportSize({
+ width: width ?? viewport.width,
+ height: 1440, // We're testing that we fit horizontally - vertical scrolling is fine.
+ });
+ await page.goto('/user2/repo1/wiki');
+ await page.waitForLoadState('networkidle');
+ await page.getByPlaceholder('Search wiki').fill(searchTerm);
+ await page.getByPlaceholder('Search wiki').click();
+ // workaround: HTMX listens on keyup events, playwright's fill only triggers the input event
+ // so we manually "type" the last letter
+ await page.getByPlaceholder('Search wiki').dispatchEvent('keyup');
+ // timeout is necessary because HTMX search could be slow
+ await expect(page.locator('#wiki-search a[href]')).toBeInViewport({ratio: 1});
+ });
+ }
+}
+
+test(`Search results show titles (and not file names)`, async ({page}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Fails as always, see https://codeberg.org/forgejo/forgejo/pulls/5326#issuecomment-2313275');
+ await page.goto('/user2/repo1/wiki');
+ await page.getByPlaceholder('Search wiki').fill('spaces');
+ await page.getByPlaceholder('Search wiki').click();
+ // workaround: HTMX listens on keyup events, playwright's fill only triggers the input event
+ // so we manually "type" the last letter
+ await page.getByPlaceholder('Search wiki').dispatchEvent('keyup');
+ await expect(page.locator('#wiki-search a[href] b')).toHaveText('Page With Spaced Name');
+});
diff --git a/tests/e2e/right-settings-button.test.e2e.js b/tests/e2e/right-settings-button.test.e2e.js
new file mode 100644
index 0000000..4f2b09b
--- /dev/null
+++ b/tests/e2e/right-settings-button.test.e2e.js
@@ -0,0 +1,128 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test.describe('desktop viewport', () => {
+ test.use({viewport: {width: 1920, height: 300}});
+
+ test('Settings button on right of repo header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user2/repo1');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('Settings button on right of repo header also when add more button is shown', async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user12');
+ const context = await load_logged_in_context(browser, workerInfo, 'user12');
+ const page = await context.newPage();
+
+ await page.goto('/user12/repo10');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('Settings button on right of org header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/org3');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('User overview overflow menu should not be influenced', async ({page}) => {
+ await page.goto('/user2');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+});
+
+test.describe('small viewport', () => {
+ test.use({viewport: {width: 800, height: 300}});
+
+ test('Settings button in overflow menu of repo header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user2/repo1');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toBeVisible();
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+
+ test('Settings button in overflow menu of org header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/org3');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toBeVisible();
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+
+ test('User overview overflow menu should not be influenced', async ({page}) => {
+ await page.goto('/user2');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toHaveCount(0);
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+});
diff --git a/tests/e2e/shared/forms.js b/tests/e2e/shared/forms.js
new file mode 100644
index 0000000..0ffd6ee
--- /dev/null
+++ b/tests/e2e/shared/forms.js
@@ -0,0 +1,44 @@
+import {expect} from '@playwright/test';
+import {AxeBuilder} from '@axe-core/playwright';
+
+export async function validate_form({page}, scope) {
+ scope ??= 'form';
+ const accessibilityScanResults = await new AxeBuilder({page})
+ // disable checking for link style - should be fixed, but not now
+ .disableRules('link-in-text-block')
+ .include(scope)
+ // exclude automated tooltips from accessibility scan, remove when fixed
+ .exclude('span[data-tooltip-content')
+ // exclude weird non-semantic HTML disabled content
+ .exclude('.disabled')
+ .analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+
+ // assert CSS properties that needed to be overriden for forms (ensure they remain active)
+ const boxes = page.getByRole('checkbox').or(page.getByRole('radio'));
+ for (const b of await boxes.all()) {
+ await expect(b).toHaveCSS('margin-left', '0px');
+ await expect(b).toHaveCSS('margin-top', '0px');
+ await expect(b).toHaveCSS('vertical-align', 'baseline');
+ }
+
+ // assert no (trailing) colon is used in labels
+ // might be necessary to adjust in case colons are strictly necessary in help text
+ for (const l of await page.locator('label').all()) {
+ const str = await l.textContent();
+ await expect(str.split('\n')[0]).not.toContain(':');
+ }
+
+ // check that multiple help text are correctly aligned to each other
+ // used for example to separate read/write permissions in team permission matrix
+ for (const l of await page.locator('label:has(.help + .help)').all()) {
+ const helpLabels = await l.locator('.help').all();
+ const boxes = await Promise.all(helpLabels.map((help) => help.boundingBox()));
+ for (let i = 1; i < boxes.length; i++) {
+ // help texts vertically aligned on top of each other
+ await expect(boxes[i].x).toBe(boxes[0].x);
+ // help texts don't horizontally intersect each other
+ await expect(boxes[i].y + boxes[i].height).toBeGreaterThanOrEqual(boxes[i - 1].y + boxes[i - 1].height);
+ }
+ }
+}
diff --git a/tests/e2e/utils_e2e.js b/tests/e2e/utils_e2e.js
new file mode 100644
index 0000000..98d762f
--- /dev/null
+++ b/tests/e2e/utils_e2e.js
@@ -0,0 +1,82 @@
+import {expect, test as baseTest} from '@playwright/test';
+
+export const test = baseTest.extend({
+ context: async ({browser}, use) => {
+ return use(await test_context(browser));
+ },
+});
+
+async function test_context(browser, options) {
+ const context = await browser.newContext(options);
+
+ context.on('page', (page) => {
+ page.on('pageerror', (err) => expect(err).toBeUndefined());
+ });
+
+ return context;
+}
+
+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, workerInfo, user) {
+ test.setTimeout(60000);
+ // Set up a new context
+ const context = await test_context(browser);
+ 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');
+ await expect(response?.status()).toBe(200); // Status OK
+
+ // Fill out form
+ await page.type('input[name=user_name]', user);
+ await page.type('input[name=password]', LOGIN_PASSWORD);
+ await page.click('form button.ui.primary.button:visible');
+
+ await page.waitForLoadState('networkidle');
+
+ await 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, workerInfo, user) {
+ let context;
+ try {
+ context = await test_context(browser, {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()?`);
+ }
+ }
+ return context;
+}
+
+export async function login({browser}, workerInfo) {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ return await context.newPage();
+}
+
+export async function save_visual(page) {
+ // Optionally include visual testing
+ if (process.env.VISUAL_TEST) {
+ await page.waitForLoadState('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
new file mode 100644
index 0000000..cfd3ff9
--- /dev/null
+++ b/tests/e2e/utils_e2e_test.go
@@ -0,0 +1,56 @@
+// 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/require"
+)
+
+func onForgejoRunTB(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)
+ require.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++
+ }
+ require.NoError(t, err)
+ u.Host = listener.Addr().String()
+
+ defer func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 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 onForgejoRun(t *testing.T, callback func(*testing.T, *url.URL), prepare ...bool) {
+ onForgejoRunTB(t, func(t testing.TB, u *url.URL) {
+ callback(t.(*testing.T), u)
+ }, prepare...)
+}
diff --git a/tests/e2e/webauthn.test.e2e.js b/tests/e2e/webauthn.test.e2e.js
new file mode 100644
index 0000000..e11c17c
--- /dev/null
+++ b/tests/e2e/webauthn.test.e2e.js
@@ -0,0 +1,60 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+// @ts-check
+
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user40');
+});
+
+test('WebAuthn register & login flow', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name !== 'chromium', 'Uses Chrome protocol');
+ const context = await load_logged_in_context(browser, workerInfo, 'user40');
+ const page = await context.newPage();
+
+ // Register a security key.
+ let response = await page.goto('/user/settings/security');
+ await expect(response?.status()).toBe(200);
+
+ // https://github.com/microsoft/playwright/issues/7276#issuecomment-1516768428
+ const cdpSession = await page.context().newCDPSession(page);
+ await cdpSession.send('WebAuthn.enable');
+ await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ ctap2Version: 'ctap2_1',
+ hasUserVerification: true,
+ transport: 'usb',
+ automaticPresenceSimulation: true,
+ isUserVerified: true,
+ backupEligibility: true,
+ },
+ });
+
+ await page.locator('input#nickname').fill('Testing Security Key');
+ await page.getByText('Add security key').click();
+
+ // Logout.
+ await page.locator('div[aria-label="Profile and settingsā€¦"]').click();
+ await page.getByText('Sign Out').click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
+
+ // Login.
+ response = await page.goto('/user/login');
+ await expect(response?.status()).toBe(200);
+
+ await page.getByLabel('Username or email address').fill('user40');
+ await page.getByLabel('Password').fill('password');
+ await page.getByRole('button', {name: 'Sign in'}).click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/user/webauthn`);
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
+
+ // Cleanup.
+ response = await page.goto('/user/settings/security');
+ await expect(response?.status()).toBe(200);
+ await page.getByRole('button', {name: 'Remove'}).click();
+ await page.getByRole('button', {name: 'Yes'}).click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/user/settings/security`);
+});