diff options
Diffstat (limited to 'tests/e2e')
-rw-r--r-- | tests/e2e/README.md | 107 | ||||
-rw-r--r-- | tests/e2e/actions.test.e2e.js | 83 | ||||
-rw-r--r-- | tests/e2e/commit-graph-branch-selector.test.e2e.js | 25 | ||||
-rw-r--r-- | tests/e2e/dashboard-ci-status.test.e2e.js | 21 | ||||
-rw-r--r-- | tests/e2e/debugserver_test.go | 31 | ||||
-rw-r--r-- | tests/e2e/e2e_test.go | 128 | ||||
-rw-r--r-- | tests/e2e/edit-comment.test.e2e.js | 31 | ||||
-rw-r--r-- | tests/e2e/example.test.e2e.js | 57 | ||||
-rw-r--r-- | tests/e2e/explore.test.e2e.js | 39 | ||||
-rw-r--r-- | tests/e2e/issue-sidebar.test.e2e.js | 86 | ||||
-rw-r--r-- | tests/e2e/markdown-editor.test.e2e.js | 177 | ||||
-rw-r--r-- | tests/e2e/markup.test.e2e.js | 13 | ||||
-rw-r--r-- | tests/e2e/reaction-selectors.test.e2e.js | 65 | ||||
-rw-r--r-- | tests/e2e/repo-code.test.e2e.js | 53 | ||||
-rw-r--r-- | tests/e2e/right-settings-button.test.e2e.js | 128 | ||||
-rw-r--r-- | tests/e2e/utils_e2e.js | 60 | ||||
-rw-r--r-- | tests/e2e/utils_e2e_test.go | 56 |
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...) +} |