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