summaryrefslogtreecommitdiffstats
path: root/tests/e2e
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-10-11 10:27:00 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-10-11 10:27:00 +0000
commit65aa53fc52ff15efe54df4147564828d535837f8 (patch)
tree31c51dad04fdcca80e6d3043c8bd49d2f1a51f83 /tests/e2e
parentInitial commit. (diff)
downloadforgejo-65aa53fc52ff15efe54df4147564828d535837f8.tar.xz
forgejo-65aa53fc52ff15efe54df4147564828d535837f8.zip
Adding upstream version 8.0.3.HEADupstream/8.0.3upstreamdebian
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'tests/e2e')
-rw-r--r--tests/e2e/README.md107
-rw-r--r--tests/e2e/actions.test.e2e.js83
-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.go31
-rw-r--r--tests/e2e/e2e_test.go128
-rw-r--r--tests/e2e/edit-comment.test.e2e.js31
-rw-r--r--tests/e2e/example.test.e2e.js57
-rw-r--r--tests/e2e/explore.test.e2e.js39
-rw-r--r--tests/e2e/issue-sidebar.test.e2e.js86
-rw-r--r--tests/e2e/markdown-editor.test.e2e.js177
-rw-r--r--tests/e2e/markup.test.e2e.js13
-rw-r--r--tests/e2e/reaction-selectors.test.e2e.js65
-rw-r--r--tests/e2e/repo-code.test.e2e.js53
-rw-r--r--tests/e2e/right-settings-button.test.e2e.js128
-rw-r--r--tests/e2e/utils_e2e.js60
-rw-r--r--tests/e2e/utils_e2e_test.go56
17 files changed, 1160 insertions, 0 deletions
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 00000000..7d63c387
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,107 @@
+# 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 sure to perform a clean front-end build before running tests:
+```
+make clean frontend
+```
+
+## Install playwright system dependencies
+```
+npx playwright install-deps
+```
+
+## Interactive testing
+
+You can make use of Playwright's integrated UI mode to run individual tests,
+get feedback and visually trace what your browser is doing.
+
+To do so, launch the debugserver using:
+
+```
+make test-e2e-debugserver
+```
+
+Then launch the Playwright UI:
+
+```
+npx playwright test --ui
+```
+
+You can also run individual tests while the debugserver using:
+
+```
+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.
+
+
+## Run all tests via local act_runner
+```
+act_runner exec -W ./.github/workflows/pull-e2e-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
+```
+
+## 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
+```
+
+## 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
+```
+
+For PostgreSQL databases(replace `mysql` to `pgsql`):
+
+```
+TEST_MYSQL_HOST=localhost:1433 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=sa TEST_MYSQL_PASSWORD=MwantsaSecurePassword1 make test-e2e-mysql#example
+```
+
+## Visual testing
+
+Although the main goal of e2e is assertion testing, we have added a framework for visual regress testing. If you are working on front-end features, please use the following:
+ - Check out `main`, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1` to generate outputs. This will initially fail, as no screenshots exist. You can run the e2e tests again to assert it passes.
+ - Check out your branch, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1`. You should be able to assert you front-end changes don't break any other tests unintentionally.
+
+VISUAL_TEST=1 will create screenshots in tests/e2e/test-snapshots. The test will fail the first time this is enabled (until we get visual test image persistence figured out), because it will be testing against an empty screenshot folder.
+
+ACCEPT_VISUAL=1 will overwrite the snapshot images with new images.
diff --git a/tests/e2e/actions.test.e2e.js b/tests/e2e/actions.test.e2e.js
new file mode 100644
index 00000000..dcbd123e
--- /dev/null
+++ b/tests/e2e/actions.test.e2e.js
@@ -0,0 +1,83 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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('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('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();
+
+ await page.waitForTimeout(1000);
+
+ // Remove the required attribute so we can trigger the error message!
+ await page.evaluate(() => {
+ // eslint-disable-next-line no-undef
+ 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('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.waitForTimeout(1000);
+
+ 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('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 00000000..b19277c1
--- /dev/null
+++ b/tests/e2e/commit-graph-branch-selector.test.e2e.js
@@ -0,0 +1,25 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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')).not.toBeVisible();
+ 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 00000000..fdf868f0
--- /dev/null
+++ b/tests/e2e/dashboard-ci-status.test.e2e.js
@@ -0,0 +1,21 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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');
+ 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 00000000..b1496b22
--- /dev/null
+++ b/tests/e2e/debugserver_test.go
@@ -0,0 +1,31 @@
+// 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)
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ fmt.Println(setting.AppURL)
+ <-done
+ })
+}
diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go
new file mode 100644
index 00000000..39974e00
--- /dev/null
+++ b/tests/e2e/e2e_test.go
@@ -0,0 +1,128 @@
+// 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(false)
+ 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
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ 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/edit-comment.test.e2e.js b/tests/e2e/edit-comment.test.e2e.js
new file mode 100644
index 00000000..c2a5a8fe
--- /dev/null
+++ b/tests/e2e/edit-comment.test.e2e.js
@@ -0,0 +1,31 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Always focus edit tab first on edit', 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);
+
+ // 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/example.test.e2e.js b/tests/e2e/example.test.e2e.js
new file mode 100644
index 00000000..c185d915
--- /dev/null
+++ b/tests/e2e/example.test.e2e.js
@@ -0,0 +1,57 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {login_user, save_visual, load_logged_in_context} 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('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);
+});
+
+test('Test Login Form', async ({page}, workerInfo) => {
+ const response = await page.goto('/user/login');
+ await expect(response?.status()).toBe(200); // Status OK
+
+ await page.type('input[name=user_name]', `user2`);
+ await page.type('input[name=password]', `password`);
+ await page.click('form button.ui.primary.button:visible');
+
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`);
+
+ save_visual(page);
+});
+
+test('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.
+ await expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`);
+
+ save_visual(page);
+});
diff --git a/tests/e2e/explore.test.e2e.js b/tests/e2e/explore.test.e2e.js
new file mode 100644
index 00000000..f486a3cb
--- /dev/null
+++ b/tests/e2e/explore.test.e2e.js
@@ -0,0 +1,39 @@
+// @ts-check
+// document is a global in evaluate, so it's safe to ignore here
+/* eslint no-undef: 0 */
+import {test, expect} from '@playwright/test';
+
+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-sidebar.test.e2e.js b/tests/e2e/issue-sidebar.test.e2e.js
new file mode 100644
index 00000000..41b1b206
--- /dev/null
+++ b/tests/e2e/issue-sidebar.test.e2e.js
@@ -0,0 +1,86 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+async function login({browser}, workerInfo) {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ return await context.newPage();
+}
+
+// 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);
+});
+
+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'})).not.toBeVisible();
+ // 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'})).not.toBeVisible();
+ await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
+});
diff --git a/tests/e2e/markdown-editor.test.e2e.js b/tests/e2e/markdown-editor.test.e2e.js
new file mode 100644
index 00000000..14451987
--- /dev/null
+++ b/tests/e2e/markdown-editor.test.e2e.js
@@ -0,0 +1,177 @@
+// @ts-check
+import {expect, test} from '@playwright/test';
+import {load_logged_in_context, login_user} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('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('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 00000000..7bc6d2b6
--- /dev/null
+++ b/tests/e2e/markup.test.e2e.js
@@ -0,0 +1,13 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+
+test('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"]')).not.toBeVisible();
+});
diff --git a/tests/e2e/reaction-selectors.test.e2e.js b/tests/e2e/reaction-selectors.test.e2e.js
new file mode 100644
index 00000000..91754b09
--- /dev/null
+++ b/tests/e2e/reaction-selectors.test.e2e.js
@@ -0,0 +1,65 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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/repo-code.test.e2e.js b/tests/e2e/repo-code.test.e2e.js
new file mode 100644
index 00000000..adbcc7b3
--- /dev/null
+++ b/tests/e2e/repo-code.test.e2e.js
@@ -0,0 +1,53 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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']);
+});
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 00000000..698aa031
--- /dev/null
+++ b/tests/e2e/right-settings-button.test.e2e.js
@@ -0,0 +1,128 @@
+// @ts-check
+import {test, expect} from '@playwright/test';
+import {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/utils_e2e.js b/tests/e2e/utils_e2e.js
new file mode 100644
index 00000000..d60c78b1
--- /dev/null
+++ b/tests/e2e/utils_e2e.js
@@ -0,0 +1,60 @@
+import {expect} 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, workerInfo, user) {
+ // 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');
+ 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 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()?`);
+ }
+ }
+ return context;
+}
+
+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 00000000..a0e940f1
--- /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 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)
+ 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 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...)
+}