From 26afd06e673628143e7afa34187f13e28bdc1679 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 21:10:23 +0200 Subject: [PATCH 1/6] Add e2e tests for PR merge, commits, diff and review flows Covers the highest-recurrence UI regressions from the last ~500 issues: - pr-merge: default / delete-branch toggle / squash / conflicted - pr-commits: new commit appears on reload - pr-review: top-level / inline reply (#35994) / approve / reject / self-review - diff viewer: file-box render + split/unified toggle Also rewrites `login` in `tests/e2e/utils.ts` to use a direct form POST instead of driving the browser UI, which speeds up every existing e2e test that logs in. `events.test.ts` gets an explicit `page.goto('/')` since `login` no longer navigates. New helpers in `utils.ts`: apiCreatePR, apiMergePR, apiCreateReview, branch+file consolidation via `{branch, newBranch}` option on apiCreateFile, optional `message` for deterministic commit summaries. Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/diff-viewer.test.ts | 27 +++++++++ tests/e2e/events.test.ts | 2 + tests/e2e/pr-commits.test.ts | 31 ++++++++++ tests/e2e/pr-merge.test.ts | 80 ++++++++++++++++++++++++++ tests/e2e/pr-review.test.ts | 104 ++++++++++++++++++++++++++++++++++ tests/e2e/utils.ts | 56 +++++++++++++++--- 6 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/diff-viewer.test.ts create mode 100644 tests/e2e/pr-commits.test.ts create mode 100644 tests/e2e/pr-merge.test.ts create mode 100644 tests/e2e/pr-review.test.ts diff --git a/tests/e2e/diff-viewer.test.ts b/tests/e2e/diff-viewer.test.ts new file mode 100644 index 0000000000000..5e1a08a8d7ca7 --- /dev/null +++ b/tests/e2e/diff-viewer.test.ts @@ -0,0 +1,27 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; + +const owner = env.GITEA_TEST_E2E_USER; + +test('diff viewer renders and toggles split/unified', async ({page, request}) => { + const repoName = `e2e-diff-${randomString(8)}`; + await apiCreateRepo(request, {name: repoName}); + await apiCreateFile(request, owner, repoName, 'added.txt', 'only on feat\n', {branch: 'main', newBranch: 'feat'}); + const [, prIndex] = await Promise.all([ + login(page), + apiCreatePR(request, owner, repoName, 'feat', 'main', 'diff test'), + ]); + + const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]'); + + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files?style=split`); + await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt'); + await expect(fileBox.locator('tr.add-code')).toHaveCount(1); + await expect(fileBox.locator('.code-diff-split')).toBeVisible(); + await expect(page.locator('.diff-file-box .code-diff-unified')).toHaveCount(0); + + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files?style=unified`); + await expect(fileBox.locator('.code-diff-unified')).toBeVisible(); + await expect(page.locator('.diff-file-box .code-diff-split')).toHaveCount(0); +}); diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index abf05b27e7be8..ce923b889ce08 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -15,6 +15,7 @@ test.describe('events', () => { apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}), loginUser(page, owner), ]); + await page.goto('/'); const badge = page.locator('a.not-mobile .notification_count'); await expect(badge).toBeHidden(); @@ -41,6 +42,7 @@ test.describe('events', () => { // Login — page renders with the active stopwatch element await loginUser(page, name); + await page.goto('/'); // Verify stopwatch is visible and links to the correct issue const stopwatch = page.locator('.active-stopwatch.not-mobile'); diff --git a/tests/e2e/pr-commits.test.ts b/tests/e2e/pr-commits.test.ts new file mode 100644 index 0000000000000..b4eef9951099c --- /dev/null +++ b/tests/e2e/pr-commits.test.ts @@ -0,0 +1,31 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import type {APIRequestContext} from '@playwright/test'; +import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; + +const owner = env.GITEA_TEST_E2E_USER; + +async function setupPRWithCommits(request: APIRequestContext, commitMessages: string[]) { + const repoName = `e2e-prcommits-${randomString(8)}`; + await apiCreateRepo(request, {name: repoName}); + // first commit creates the `feat` branch; subsequent commits must be sequential (branch-ref race) + await apiCreateFile(request, owner, repoName, `file-0.txt`, `content 0\n`, {branch: 'main', newBranch: 'feat', message: commitMessages[0]}); + for (let index = 1; index < commitMessages.length; index++) { + await apiCreateFile(request, owner, repoName, `file-${index}.txt`, `content ${index}\n`, {branch: 'feat', message: commitMessages[index]}); + } + const prIndex = await apiCreatePR(request, owner, repoName, 'feat', 'main', 'commits test'); + return {repoName, prIndex}; +} + +test.describe('pr commits tab', () => { + test('new commit appears', async ({page, request}) => { + const [, {repoName, prIndex}] = await Promise.all([login(page), setupPRWithCommits(request, ['initial'])]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/commits`); + await expect(page.locator('#commits-table tbody.commit-list tr')).toHaveCount(1); + + await apiCreateFile(request, owner, repoName, 'added-later.txt', 'x\n', {branch: 'feat', message: 'appended'}); + await page.reload(); + + await expect(page.locator('#commits-table tbody.commit-list .commit-summary')).toHaveText(['appended', 'initial']); + }); +}); diff --git a/tests/e2e/pr-merge.test.ts b/tests/e2e/pr-merge.test.ts new file mode 100644 index 0000000000000..11587441e193e --- /dev/null +++ b/tests/e2e/pr-merge.test.ts @@ -0,0 +1,80 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import type {APIRequestContext} from '@playwright/test'; +import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; + +const owner = env.GITEA_TEST_E2E_USER; + +async function setupMergeablePR(request: APIRequestContext, {mergeable = true}: {mergeable?: boolean} = {}) { + const repoName = `e2e-prmerge-${randomString(8)}`; + await apiCreateRepo(request, {name: repoName}); + await Promise.all([ + apiCreateFile(request, owner, repoName, 'feature.txt', mergeable ? 'hello\n' : 'conflicting\n', {branch: 'main', newBranch: 'feat'}), + ...(mergeable ? [] : [apiCreateFile(request, owner, repoName, 'feature.txt', 'main side\n', {branch: 'main'})]), + ]); + const prIndex = await apiCreatePR(request, owner, repoName, 'feat', 'main', 'add feature'); + return {repoName, prIndex}; +} + +test.describe('pr merge', () => { + test('default merge', async ({page, request}) => { + const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); + + await page.locator('.merge-button button.ui.button').first().click(); + // default repo config has delete-branch-after-merge OFF + await expect(page.getByLabel(/^Delete Branch/)).not.toBeChecked(); + await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); + + await expect(page.locator('.issue-state-label')).toContainText('Merged'); + await expect(page.getByText('Pull request successfully merged and closed')).toBeVisible(); + await expect(page.locator('.merge-section .delete-branch-after-merge')).toBeVisible(); + await expect(page.locator('.merge-button')).toBeHidden(); + + const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); + expect(branchResponse.status()).toBe(200); + }); + + test('merge with delete-branch', async ({page, request}) => { + const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); + + await page.locator('.merge-button button.ui.button').first().click(); + await page.getByLabel(/^Delete Branch/).check(); + await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); + + await expect(page.locator('.issue-state-label')).toContainText('Merged'); + + const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); + expect(branchResponse.status()).toBe(404); + }); + + test('squash merge', async ({page, request}) => { + const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); + + const mergeButton = page.locator('.merge-button'); + await mergeButton.locator('.dropdown').click(); + await mergeButton.locator('.menu .action-text', {hasText: 'Create squash commit'}).click(); + await expect(mergeButton.locator('.button-text')).toContainText('Create squash commit'); + + await mergeButton.locator('button.ui.button').first().click(); + await page.getByRole('button', {name: 'Create squash commit', exact: true}).click(); + + await expect(page.locator('.issue-state-label')).toContainText('Merged'); + + // squash => main must have exactly 2 commits (initial README + squashed), and the tip must have a single parent (not a merge) + const commitsResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/commits?sha=main&limit=10`); + const commits = await commitsResponse.json(); + expect(commits).toHaveLength(2); + expect(commits[0].parents).toHaveLength(1); + }); + + test('conflicted pr', async ({page, request}) => { + const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request, {mergeable: false})]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); + + await expect(page.locator('.merge-button')).toBeHidden(); + await expect(page.locator('.merge-section')).toContainText(/conflict|cannot/i); + }); +}); diff --git a/tests/e2e/pr-review.test.ts b/tests/e2e/pr-review.test.ts new file mode 100644 index 0000000000000..1953fa7e5d464 --- /dev/null +++ b/tests/e2e/pr-review.test.ts @@ -0,0 +1,104 @@ +import {test, expect} from '@playwright/test'; +import type {APIRequestContext} from '@playwright/test'; +import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts'; + +async function createReviewUsers(request: APIRequestContext) { + const poster = `rv-poster-${randomString(8)}`; + const reviewer = `rv-reviewer-${randomString(8)}`; + await Promise.all([apiCreateUser(request, poster), apiCreateUser(request, reviewer)]); + return {poster, reviewer}; +} + +/** Build a PR owned by `poster` — reviewer exists as a separate user to avoid self-review restrictions. */ +async function createReviewablePR(request: APIRequestContext, poster: string) { + const repoName = `e2e-prreview-${randomString(8)}`; + await apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(poster)}); + await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'}); + const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test'); + return {repoName, prIndex}; +} + +test.describe('pr review', () => { + test('top-level comment', async ({page, request}) => { + const {poster, reviewer} = await createReviewUsers(request); + const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + + await page.locator('#review-box .js-btn-review').click(); + const panel = page.locator('.review-box-panel'); + await panel.locator('textarea[name="content"]').fill('looks fine'); + await panel.getByRole('button', {name: 'Comment', exact: true}).click(); + + await expect(page.locator('.timeline-item.comment').filter({hasText: 'looks fine'})).toBeVisible(); + }); + + test('reply to inline comment', async ({page, request}) => { + // regression for #35994 "Replying a code review results in 500" + const {poster, reviewer} = await createReviewUsers(request); + const [, {repoName, prIndex}] = await Promise.all([loginUser(page, poster), createReviewablePR(request, poster)]); + await apiCreateReview(request, poster, repoName, prIndex, { + comments: [{path: 'added.txt', body: 'inline to reply to', new_position: 1}], + headers: apiUserHeaders(reviewer), + }); + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + + const conversation = page.locator('.diff-file-box[data-new-filename="added.txt"] .conversation-holder'); + await conversation.locator('.comment-form-reply').click(); + const replyForm = conversation.locator('form'); + await replyForm.locator('textarea[name="content"]').fill('my reply body'); + await replyForm.getByRole('button', {name: 'Reply', exact: true}).click(); + + await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']); + }); + + test('approve review', async ({page, request}) => { + const {poster, reviewer} = await createReviewUsers(request); + const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + + await page.locator('#review-box .js-btn-review').click(); + const panel = page.locator('.review-box-panel'); + await panel.locator('textarea[name="content"]').fill('LGTM'); + await panel.getByRole('button', {name: 'Approve', exact: true}).click(); + + await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible(); + await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible(); + }); + + test('self-review disabled', async ({page, request}) => { + const poster = `rv-self-${randomString(8)}`; + await apiCreateUser(request, poster); + const posterHeaders = apiUserHeaders(poster); + const repoName = `e2e-prreview-self-${randomString(8)}`; + // login can run in parallel with repo/PR setup once the poster user exists + const [, prIndex] = await Promise.all([ + loginUser(page, poster), + (async () => { + await apiCreateRepo(request, {name: repoName, headers: posterHeaders}); + await apiCreateFile(request, poster, repoName, 'added.txt', 'new\n', {branch: 'main', newBranch: 'feat'}); + // poster must be the PR author for self-review to trigger + return apiCreatePR(request, poster, repoName, 'feat', 'main', 'self-review', {headers: posterHeaders}); + })(), + ]); + + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + await page.locator('#review-box .js-btn-review').click(); + + await expect(page.locator('.review-box-panel button[name="type"][value="approve"]')).toBeDisabled(); + }); + + test('request changes review', async ({page, request}) => { + const {poster, reviewer} = await createReviewUsers(request); + const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + + await page.locator('#review-box .js-btn-review').click(); + const panel = page.locator('.review-box-panel'); + await panel.locator('textarea[name="content"]').fill('needs changes'); + await panel.getByRole('button', {name: 'Request changes', exact: true}).click(); + + await expect(page.locator('.timeline-item').filter({hasText: 'needs changes'})).toBeVisible(); + // ReviewTypeReject renders as octicon-diff on a red badge (see ReviewType.Icon()) + await expect(page.locator('.timeline-item-group .badge.tw-bg-red .octicon-diff')).toBeVisible(); + }); +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 0b00ab129a61f..960991937d56d 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -60,10 +60,10 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner }), 'apiStartStopwatch'); } -export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string) { +export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) { await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, { headers: apiHeaders(), - data: {content: globalThis.btoa(content)}, + data: {content: globalThis.btoa(content), branch, new_branch: newBranch, message}, }), 'apiCreateFile'); } @@ -74,6 +74,44 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner: }), 'apiCreateBranch'); } +/** Create a PR via API. Returns the PR index for subsequent operations. */ +export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record} = {}): Promise { + let prIndex = 0; + await apiRetry(async () => { + const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls`, { + headers: headers || apiHeaders(), + data: {head, base, title}, + }); + if (response.ok()) prIndex = (await response.json()).number; + return response; + }, 'apiCreatePR'); + return prIndex; +} + +export type MergeStyle = 'merge' | 'rebase' | 'rebase-merge' | 'squash' | 'fast-forward-only'; +export type ReviewEvent = 'COMMENT' | 'APPROVED' | 'REQUEST_CHANGES'; + +export async function apiMergePR(requestContext: APIRequestContext, owner: string, repo: string, index: number, {style = 'merge', deleteBranch}: {style?: MergeStyle; deleteBranch?: boolean} = {}) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/merge`, { + headers: apiHeaders(), + data: {Do: style, delete_branch_after_merge: deleteBranch}, + }), 'apiMergePR'); +} + +/** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */ +export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: ReviewEvent; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record}) { + let reviewID = 0; + await apiRetry(async () => { + const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, { + headers: headers || apiHeaders(), + data: {event, body, comments}, + }); + if (response.ok()) reviewID = (await response.json()).id; + return response; + }, 'apiCreateReview'); + return reviewID; +} + export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) { await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, { headers: apiHeaders(), @@ -117,12 +155,16 @@ export async function loginUser(page: Page, username: string) { return login(page, username, testUserPassword); } +/** Log `page` in via a direct form POST — ~10× faster than driving the login UI. Gitea's /user/login + * accepts a form POST without CSRF (see tests/integration/integration_test.go loginUserWithPassword). + * Cookies land in the page context; caller is responsible for navigating to a destination page. */ export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) { - await page.goto('/user/login'); - await page.getByLabel('Username or Email Address').fill(username); - await page.getByLabel('Password').fill(password); - await page.getByRole('button', {name: 'Sign In'}).click(); - await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); + const response = await page.request.post('/user/login', { + form: {user_name: username, password}, + maxRedirects: 0, + }); + const status = response.status(); + if (status !== 302 && status !== 303) throw new Error(`login as ${username} failed: HTTP ${status}`); } export async function assertNoJsError(page: Page) { From 9fc9d1cd2b9aa8acbc0396c3d88cdb9c27d67c60 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 21:23:31 +0200 Subject: [PATCH 2/6] Trim: drop delete-branch/conflicted/self-review/request-changes tests Keep `login.test.ts::login form and logout` as the sole test that exercises the browser login form (since `utils.ts::login` now uses a direct form POST). Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/login.test.ts | 11 ++++++++--- tests/e2e/pr-merge.test.ts | 29 ++--------------------------- tests/e2e/pr-review.test.ts | 37 ------------------------------------- tests/e2e/utils.ts | 3 --- 4 files changed, 10 insertions(+), 70 deletions(-) diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts index ecf80d24744c6..7f4b97f2ecc21 100644 --- a/tests/e2e/login.test.ts +++ b/tests/e2e/login.test.ts @@ -1,12 +1,17 @@ +import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, logout} from './utils.ts'; +import {logout} from './utils.ts'; test('homepage', async ({page}) => { await page.goto('/'); await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg'); }); -test('login and logout', async ({page}) => { - await login(page); +test('login form and logout', async ({page}) => { + await page.goto('/user/login'); + await page.getByLabel('Username or Email Address').fill(env.GITEA_TEST_E2E_USER); + await page.getByLabel('Password').fill(env.GITEA_TEST_E2E_PASSWORD); + await page.getByRole('button', {name: 'Sign In'}).click(); + await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden(); await logout(page); }); diff --git a/tests/e2e/pr-merge.test.ts b/tests/e2e/pr-merge.test.ts index 11587441e193e..3be041d5e1a9b 100644 --- a/tests/e2e/pr-merge.test.ts +++ b/tests/e2e/pr-merge.test.ts @@ -5,13 +5,10 @@ import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './ const owner = env.GITEA_TEST_E2E_USER; -async function setupMergeablePR(request: APIRequestContext, {mergeable = true}: {mergeable?: boolean} = {}) { +async function setupMergeablePR(request: APIRequestContext) { const repoName = `e2e-prmerge-${randomString(8)}`; await apiCreateRepo(request, {name: repoName}); - await Promise.all([ - apiCreateFile(request, owner, repoName, 'feature.txt', mergeable ? 'hello\n' : 'conflicting\n', {branch: 'main', newBranch: 'feat'}), - ...(mergeable ? [] : [apiCreateFile(request, owner, repoName, 'feature.txt', 'main side\n', {branch: 'main'})]), - ]); + await apiCreateFile(request, owner, repoName, 'feature.txt', 'hello\n', {branch: 'main', newBranch: 'feat'}); const prIndex = await apiCreatePR(request, owner, repoName, 'feat', 'main', 'add feature'); return {repoName, prIndex}; } @@ -35,20 +32,6 @@ test.describe('pr merge', () => { expect(branchResponse.status()).toBe(200); }); - test('merge with delete-branch', async ({page, request}) => { - const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); - - await page.locator('.merge-button button.ui.button').first().click(); - await page.getByLabel(/^Delete Branch/).check(); - await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); - - await expect(page.locator('.issue-state-label')).toContainText('Merged'); - - const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); - expect(branchResponse.status()).toBe(404); - }); - test('squash merge', async ({page, request}) => { const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); @@ -69,12 +52,4 @@ test.describe('pr merge', () => { expect(commits).toHaveLength(2); expect(commits[0].parents).toHaveLength(1); }); - - test('conflicted pr', async ({page, request}) => { - const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request, {mergeable: false})]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); - - await expect(page.locator('.merge-button')).toBeHidden(); - await expect(page.locator('.merge-section')).toContainText(/conflict|cannot/i); - }); }); diff --git a/tests/e2e/pr-review.test.ts b/tests/e2e/pr-review.test.ts index 1953fa7e5d464..885d01b19a99b 100644 --- a/tests/e2e/pr-review.test.ts +++ b/tests/e2e/pr-review.test.ts @@ -64,41 +64,4 @@ test.describe('pr review', () => { await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible(); await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible(); }); - - test('self-review disabled', async ({page, request}) => { - const poster = `rv-self-${randomString(8)}`; - await apiCreateUser(request, poster); - const posterHeaders = apiUserHeaders(poster); - const repoName = `e2e-prreview-self-${randomString(8)}`; - // login can run in parallel with repo/PR setup once the poster user exists - const [, prIndex] = await Promise.all([ - loginUser(page, poster), - (async () => { - await apiCreateRepo(request, {name: repoName, headers: posterHeaders}); - await apiCreateFile(request, poster, repoName, 'added.txt', 'new\n', {branch: 'main', newBranch: 'feat'}); - // poster must be the PR author for self-review to trigger - return apiCreatePR(request, poster, repoName, 'feat', 'main', 'self-review', {headers: posterHeaders}); - })(), - ]); - - await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - await page.locator('#review-box .js-btn-review').click(); - - await expect(page.locator('.review-box-panel button[name="type"][value="approve"]')).toBeDisabled(); - }); - - test('request changes review', async ({page, request}) => { - const {poster, reviewer} = await createReviewUsers(request); - const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); - await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - - await page.locator('#review-box .js-btn-review').click(); - const panel = page.locator('.review-box-panel'); - await panel.locator('textarea[name="content"]').fill('needs changes'); - await panel.getByRole('button', {name: 'Request changes', exact: true}).click(); - - await expect(page.locator('.timeline-item').filter({hasText: 'needs changes'})).toBeVisible(); - // ReviewTypeReject renders as octicon-diff on a red badge (see ReviewType.Icon()) - await expect(page.locator('.timeline-item-group .badge.tw-bg-red .octicon-diff')).toBeVisible(); - }); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 960991937d56d..b42f548b0311c 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -155,9 +155,6 @@ export async function loginUser(page: Page, username: string) { return login(page, username, testUserPassword); } -/** Log `page` in via a direct form POST — ~10× faster than driving the login UI. Gitea's /user/login - * accepts a form POST without CSRF (see tests/integration/integration_test.go loginUserWithPassword). - * Cookies land in the page context; caller is responsible for navigating to a destination page. */ export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) { const response = await page.request.post('/user/login', { form: {user_name: username, password}, From d24033003807c37c94568bb1adced59322483576 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 21:34:33 +0200 Subject: [PATCH 3/6] Collapse test suite, remove cleanup, tighten review flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse pr-merge/pr-commits/pr-review/diff-viewer to one test each; inline setup helpers. pr-review consolidates reply(#35994) + approve into one flow with a mid-test session swap. - Remove apiDeleteRepo/apiDeleteUser calls across 11 pre-existing tests — the e2e server is a per-run mktemp workdir killed on exit, and tests use randomString suffixes, so cleanup was pure overhead. - Repurpose login.test.ts "login form and logout" to drive the browser login UI (utils.ts::login is now a direct form POST, no UI coverage). - Drop MergeStyle/ReviewEvent type aliases and the now-unused apiMergePR helper; inline plain string on apiCreateReview's event param. Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/codeeditor.test.ts | 22 ++++---- tests/e2e/diff-viewer.test.ts | 11 +--- tests/e2e/events.test.ts | 11 +--- tests/e2e/external-render.test.ts | 74 ++++++++++++-------------- tests/e2e/file-view-render.test.ts | 76 +++++++++++--------------- tests/e2e/issue-project.test.ts | 3 +- tests/e2e/milestone.test.ts | 3 +- tests/e2e/pr-commits.test.ts | 30 ++++------- tests/e2e/pr-merge.test.ts | 64 +++++++--------------- tests/e2e/pr-review.test.ts | 85 +++++++++++------------------- tests/e2e/reactions.test.ts | 24 ++++----- tests/e2e/readme.test.ts | 3 +- tests/e2e/register.test.ts | 5 +- tests/e2e/repo.test.ts | 3 +- tests/e2e/user-settings.test.ts | 22 ++++---- tests/e2e/utils.ts | 12 +---- 16 files changed, 162 insertions(+), 286 deletions(-) diff --git a/tests/e2e/codeeditor.test.ts b/tests/e2e/codeeditor.test.ts index e2df08e757a23..21038cacd0295 100644 --- a/tests/e2e/codeeditor.test.ts +++ b/tests/e2e/codeeditor.test.ts @@ -1,20 +1,16 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts'; +import {login, apiCreateRepo, randomString} from './utils.ts'; test('codeeditor textarea updates correctly', async ({page, request}) => { const repoName = `e2e-codeeditor-${randomString(8)}`; await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); - try { - await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`); - await page.getByPlaceholder('Name your file…').fill('test.js'); - await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden(); - const editor = page.locator('.cm-content[role="textbox"]'); - await expect(editor).toBeVisible(); - await editor.click(); - await page.keyboard.type('const hello = "world";'); - await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";'); - } finally { - await apiDeleteRepo(request, env.GITEA_TEST_E2E_USER, repoName); - } + await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`); + await page.getByPlaceholder('Name your file…').fill('test.js'); + await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden(); + const editor = page.locator('.cm-content[role="textbox"]'); + await expect(editor).toBeVisible(); + await editor.click(); + await page.keyboard.type('const hello = "world";'); + await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";'); }); diff --git a/tests/e2e/diff-viewer.test.ts b/tests/e2e/diff-viewer.test.ts index 5e1a08a8d7ca7..0d6b4887238c0 100644 --- a/tests/e2e/diff-viewer.test.ts +++ b/tests/e2e/diff-viewer.test.ts @@ -4,7 +4,7 @@ import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './ const owner = env.GITEA_TEST_E2E_USER; -test('diff viewer renders and toggles split/unified', async ({page, request}) => { +test('diff viewer renders file box', async ({page, request}) => { const repoName = `e2e-diff-${randomString(8)}`; await apiCreateRepo(request, {name: repoName}); await apiCreateFile(request, owner, repoName, 'added.txt', 'only on feat\n', {branch: 'main', newBranch: 'feat'}); @@ -13,15 +13,8 @@ test('diff viewer renders and toggles split/unified', async ({page, request}) => apiCreatePR(request, owner, repoName, 'feat', 'main', 'diff test'), ]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files`); const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]'); - - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files?style=split`); await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt'); await expect(fileBox.locator('tr.add-code')).toHaveCount(1); - await expect(fileBox.locator('.code-diff-split')).toBeVisible(); - await expect(page.locator('.diff-file-box .code-diff-unified')).toHaveCount(0); - - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files?style=unified`); - await expect(fileBox.locator('.code-diff-unified')).toBeVisible(); - await expect(page.locator('.diff-file-box .code-diff-split')).toHaveCount(0); }); diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index ce923b889ce08..5640386f57489 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts'; +import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts'; // These tests rely on a short EVENT_SOURCE_UPDATE_TIME in the e2e server config. test.describe('events', () => { @@ -24,9 +24,6 @@ test.describe('events', () => { // Wait for the notification badge to appear via server event await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor}); - - // Cleanup - await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]); }); test('stopwatch', async ({page, request}) => { @@ -47,9 +44,6 @@ test.describe('events', () => { // Verify stopwatch is visible and links to the correct issue const stopwatch = page.locator('.active-stopwatch.not-mobile'); await expect(stopwatch).toBeVisible(); - - // Cleanup - await apiDeleteUser(request, name); }); test('logout propagation', async ({browser, request}) => { @@ -77,8 +71,5 @@ test.describe('events', () => { await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible(); await context.close(); - - // Cleanup - await apiDeleteUser(request, name); }); }); diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts index b989c354ff566..da02db4c1a7db 100644 --- a/tests/e2e/external-render.test.ts +++ b/tests/e2e/external-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts'; test('external file', async ({page, request}) => { const repoName = `e2e-external-render-${randomString(8)}`; @@ -9,19 +9,15 @@ test('external file', async ({page, request}) => { apiCreateRepo(request, {name: repoName}), login(page), ]); - try { - await apiCreateFile(request, owner, repoName, 'test.external', '

rendered content

'); - await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`); - const iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`)); - const frame = page.frameLocator('iframe.external-render-iframe'); - await expect(frame.locator('p')).toContainText('rendered content'); - await assertFlushWithParent(iframe, page.locator('.file-view')); - await assertNoJsError(page); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + await apiCreateFile(request, owner, repoName, 'test.external', '

rendered content

'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`)); + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('p')).toContainText('rendered content'); + await assertFlushWithParent(iframe, page.locator('.file-view')); + await assertNoJsError(page); }); test('openapi file', async ({page, request}) => { @@ -31,31 +27,27 @@ test('openapi file', async ({page, request}) => { apiCreateRepo(request, {name: repoName}), login(page), ]); - try { - const title = 'Test & "quoted"'; - const spec = JSON.stringify({ - openapi: '3.0.0', - info: {title, version: '1.0'}, - paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}}, - components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}}, - }); - await apiCreateFile(request, owner, repoName, 'openapi.json', spec); - await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`); - const iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer'); - await expect(viewer.locator('.swagger-ui')).toBeVisible(); - await expect(viewer.locator('.info .title')).toContainText(title); - // expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location - // (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference" - await viewer.locator('.opblock-tag').first().click(); - await viewer.locator('.opblock').first().click(); - await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0); - // poll: postMessage resize may not have settled yet when the visibility checks pass - await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); - await assertFlushWithParent(iframe, page.locator('.file-view')); - await assertNoJsError(page); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + const title = 'Test & "quoted"'; + const spec = JSON.stringify({ + openapi: '3.0.0', + info: {title, version: '1.0'}, + paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}}, + components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}}, + }); + await apiCreateFile(request, owner, repoName, 'openapi.json', spec); + await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer'); + await expect(viewer.locator('.swagger-ui')).toBeVisible(); + await expect(viewer.locator('.info .title')).toContainText(title); + // expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location + // (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference" + await viewer.locator('.opblock-tag').first().click(); + await viewer.locator('.opblock').first().click(); + await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0); + // poll: postMessage resize may not have settled yet when the visibility checks pass + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + await assertNoJsError(page); }); diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index a3afe85b2675a..a4cf5c58edbde 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -1,32 +1,28 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; +import {apiCreateBranch, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; test('3d model file', async ({page, request}) => { const repoName = `e2e-3d-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await apiCreateRepo(request, {name: repoName}); - try { - const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n'; - await apiCreateFile(request, owner, repoName, 'test.stl', stl); - await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`); - const iframe = page.locator('iframe.external-render-iframe'); - await expect(iframe).toBeVisible(); - const frame = page.frameLocator('iframe.external-render-iframe'); - const viewer = frame.locator('#frontend-render-viewer'); - await expect(viewer.locator('canvas')).toBeVisible(); - expect((await viewer.boundingBox())!.height).toBeGreaterThan(300); - await assertFlushWithParent(iframe, page.locator('.file-view')); - // bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent - const [parentBg, iframeBg] = await Promise.all([ - page.evaluate(() => getComputedStyle(document.body).backgroundColor), - frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor), - ]); - expect(iframeBg).toBe(parentBg); - await assertNoJsError(page); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n'; + await apiCreateFile(request, owner, repoName, 'test.stl', stl); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + const viewer = frame.locator('#frontend-render-viewer'); + await expect(viewer.locator('canvas')).toBeVisible(); + expect((await viewer.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + // bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent + const [parentBg, iframeBg] = await Promise.all([ + page.evaluate(() => getComputedStyle(document.body).backgroundColor), + frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor), + ]); + expect(iframeBg).toBe(parentBg); + await assertNoJsError(page); }); test('pdf file', async ({page, request}) => { @@ -34,16 +30,12 @@ test('pdf file', async ({page, request}) => { const repoName = `e2e-pdf-render-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; await apiCreateRepo(request, {name: repoName}); - try { - await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n'); - await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`); - const container = page.locator('.file-view-render-container'); - await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer'); - expect((await container.boundingBox())!.height).toBeGreaterThan(300); - await assertFlushWithParent(container, page.locator('.file-view')); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`); + const container = page.locator('.file-view-render-container'); + await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer'); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(container, page.locator('.file-view')); }); test('asciicast file', async ({page, request}) => { @@ -54,16 +46,12 @@ test('asciicast file', async ({page, request}) => { const branch = '日本語-branch'; const branchEnc = encodeURIComponent(branch); await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]); - try { - const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; - await apiCreateFile(request, owner, repoName, 'readme.cast', cast); - await apiCreateBranch(request, owner, repoName, branch); - await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); - const container = page.locator('.asciinema-player-container'); - await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); - await expect(container.locator('.ap-wrapper')).toBeVisible(); - expect((await container.boundingBox())!.height).toBeGreaterThan(300); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; + await apiCreateFile(request, owner, repoName, 'readme.cast', cast); + await apiCreateBranch(request, owner, repoName, branch); + await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); + const container = page.locator('.asciinema-player-container'); + await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); + await expect(container.locator('.ap-wrapper')).toBeVisible(); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); }); diff --git a/tests/e2e/issue-project.test.ts b/tests/e2e/issue-project.test.ts index c5249c0cdf7b5..42cf0bd2ac5ec 100644 --- a/tests/e2e/issue-project.test.ts +++ b/tests/e2e/issue-project.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProjectColumn, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateIssue, createProjectColumn, randomString} from './utils.ts'; test('assign issue to project and change column', async ({page}) => { const repoName = `e2e-issue-project-${randomString(8)}`; @@ -26,5 +26,4 @@ test('assign issue to project and change column', async ({page}) => { await columnCombo.locator('.ui.dropdown').click(); await columnCombo.locator('.menu a.item', {hasText: 'In Progress'}).click(); await expect(columnCombo.getByTestId('sidebar-project-column-text')).toHaveText('In Progress'); - await apiDeleteRepo(page.request, user, repoName); }); diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index b8b456563ef03..5a688fb128231 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts'; +import {login, apiCreateRepo, randomString} from './utils.ts'; test('create a milestone', async ({page}) => { const repoName = `e2e-milestone-${randomString(8)}`; @@ -9,5 +9,4 @@ test('create a milestone', async ({page}) => { await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); await expect(page.locator('.milestone-list')).toContainText('Test Milestone'); - await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/pr-commits.test.ts b/tests/e2e/pr-commits.test.ts index b4eef9951099c..a4c95d9ed1f33 100644 --- a/tests/e2e/pr-commits.test.ts +++ b/tests/e2e/pr-commits.test.ts @@ -1,31 +1,23 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import type {APIRequestContext} from '@playwright/test'; import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; const owner = env.GITEA_TEST_E2E_USER; -async function setupPRWithCommits(request: APIRequestContext, commitMessages: string[]) { +test('new commit appears on pr commits tab', async ({page, request}) => { const repoName = `e2e-prcommits-${randomString(8)}`; await apiCreateRepo(request, {name: repoName}); - // first commit creates the `feat` branch; subsequent commits must be sequential (branch-ref race) - await apiCreateFile(request, owner, repoName, `file-0.txt`, `content 0\n`, {branch: 'main', newBranch: 'feat', message: commitMessages[0]}); - for (let index = 1; index < commitMessages.length; index++) { - await apiCreateFile(request, owner, repoName, `file-${index}.txt`, `content ${index}\n`, {branch: 'feat', message: commitMessages[index]}); - } - const prIndex = await apiCreatePR(request, owner, repoName, 'feat', 'main', 'commits test'); - return {repoName, prIndex}; -} + await apiCreateFile(request, owner, repoName, 'file-0.txt', 'content 0\n', {branch: 'main', newBranch: 'feat', message: 'initial'}); + const [, prIndex] = await Promise.all([ + login(page), + apiCreatePR(request, owner, repoName, 'feat', 'main', 'commits test'), + ]); -test.describe('pr commits tab', () => { - test('new commit appears', async ({page, request}) => { - const [, {repoName, prIndex}] = await Promise.all([login(page), setupPRWithCommits(request, ['initial'])]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/commits`); - await expect(page.locator('#commits-table tbody.commit-list tr')).toHaveCount(1); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/commits`); + await expect(page.locator('#commits-table tbody.commit-list tr')).toHaveCount(1); - await apiCreateFile(request, owner, repoName, 'added-later.txt', 'x\n', {branch: 'feat', message: 'appended'}); - await page.reload(); + await apiCreateFile(request, owner, repoName, 'added-later.txt', 'x\n', {branch: 'feat', message: 'appended'}); + await page.reload(); - await expect(page.locator('#commits-table tbody.commit-list .commit-summary')).toHaveText(['appended', 'initial']); - }); + await expect(page.locator('#commits-table tbody.commit-list .commit-summary')).toHaveText(['appended', 'initial']); }); diff --git a/tests/e2e/pr-merge.test.ts b/tests/e2e/pr-merge.test.ts index 3be041d5e1a9b..24454bbb4699c 100644 --- a/tests/e2e/pr-merge.test.ts +++ b/tests/e2e/pr-merge.test.ts @@ -1,55 +1,29 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import type {APIRequestContext} from '@playwright/test'; import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; const owner = env.GITEA_TEST_E2E_USER; -async function setupMergeablePR(request: APIRequestContext) { +test('default merge', async ({page, request}) => { const repoName = `e2e-prmerge-${randomString(8)}`; await apiCreateRepo(request, {name: repoName}); await apiCreateFile(request, owner, repoName, 'feature.txt', 'hello\n', {branch: 'main', newBranch: 'feat'}); - const prIndex = await apiCreatePR(request, owner, repoName, 'feat', 'main', 'add feature'); - return {repoName, prIndex}; -} - -test.describe('pr merge', () => { - test('default merge', async ({page, request}) => { - const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); - - await page.locator('.merge-button button.ui.button').first().click(); - // default repo config has delete-branch-after-merge OFF - await expect(page.getByLabel(/^Delete Branch/)).not.toBeChecked(); - await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); - - await expect(page.locator('.issue-state-label')).toContainText('Merged'); - await expect(page.getByText('Pull request successfully merged and closed')).toBeVisible(); - await expect(page.locator('.merge-section .delete-branch-after-merge')).toBeVisible(); - await expect(page.locator('.merge-button')).toBeHidden(); - - const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); - expect(branchResponse.status()).toBe(200); - }); - - test('squash merge', async ({page, request}) => { - const [, {repoName, prIndex}] = await Promise.all([login(page), setupMergeablePR(request)]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); - - const mergeButton = page.locator('.merge-button'); - await mergeButton.locator('.dropdown').click(); - await mergeButton.locator('.menu .action-text', {hasText: 'Create squash commit'}).click(); - await expect(mergeButton.locator('.button-text')).toContainText('Create squash commit'); - - await mergeButton.locator('button.ui.button').first().click(); - await page.getByRole('button', {name: 'Create squash commit', exact: true}).click(); - - await expect(page.locator('.issue-state-label')).toContainText('Merged'); - - // squash => main must have exactly 2 commits (initial README + squashed), and the tip must have a single parent (not a merge) - const commitsResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/commits?sha=main&limit=10`); - const commits = await commitsResponse.json(); - expect(commits).toHaveLength(2); - expect(commits[0].parents).toHaveLength(1); - }); + const [, prIndex] = await Promise.all([ + login(page), + apiCreatePR(request, owner, repoName, 'feat', 'main', 'add feature'), + ]); + await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); + + await page.locator('.merge-button button.ui.button').first().click(); + // default repo config has delete-branch-after-merge OFF + await expect(page.getByLabel(/^Delete Branch/)).not.toBeChecked(); + await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); + + await expect(page.locator('.issue-state-label')).toContainText('Merged'); + await expect(page.getByText('Pull request successfully merged and closed')).toBeVisible(); + await expect(page.locator('.merge-section .delete-branch-after-merge')).toBeVisible(); + await expect(page.locator('.merge-button')).toBeHidden(); + + const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); + expect(branchResponse.status()).toBe(200); }); diff --git a/tests/e2e/pr-review.test.ts b/tests/e2e/pr-review.test.ts index 885d01b19a99b..2f3d60e606a64 100644 --- a/tests/e2e/pr-review.test.ts +++ b/tests/e2e/pr-review.test.ts @@ -1,67 +1,42 @@ import {test, expect} from '@playwright/test'; -import type {APIRequestContext} from '@playwright/test'; import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts'; -async function createReviewUsers(request: APIRequestContext) { +test('pr review flow', async ({page, request}) => { const poster = `rv-poster-${randomString(8)}`; const reviewer = `rv-reviewer-${randomString(8)}`; await Promise.all([apiCreateUser(request, poster), apiCreateUser(request, reviewer)]); - return {poster, reviewer}; -} - -/** Build a PR owned by `poster` — reviewer exists as a separate user to avoid self-review restrictions. */ -async function createReviewablePR(request: APIRequestContext, poster: string) { + const posterHeaders = apiUserHeaders(poster); const repoName = `e2e-prreview-${randomString(8)}`; - await apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(poster)}); + await apiCreateRepo(request, {name: repoName, headers: posterHeaders}); await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'}); - const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test'); - return {repoName, prIndex}; -} - -test.describe('pr review', () => { - test('top-level comment', async ({page, request}) => { - const {poster, reviewer} = await createReviewUsers(request); - const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); - await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - - await page.locator('#review-box .js-btn-review').click(); - const panel = page.locator('.review-box-panel'); - await panel.locator('textarea[name="content"]').fill('looks fine'); - await panel.getByRole('button', {name: 'Comment', exact: true}).click(); + const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders}); - await expect(page.locator('.timeline-item.comment').filter({hasText: 'looks fine'})).toBeVisible(); - }); - - test('reply to inline comment', async ({page, request}) => { - // regression for #35994 "Replying a code review results in 500" - const {poster, reviewer} = await createReviewUsers(request); - const [, {repoName, prIndex}] = await Promise.all([loginUser(page, poster), createReviewablePR(request, poster)]); - await apiCreateReview(request, poster, repoName, prIndex, { + // reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994) + await Promise.all([ + apiCreateReview(request, poster, repoName, prIndex, { comments: [{path: 'added.txt', body: 'inline to reply to', new_position: 1}], headers: apiUserHeaders(reviewer), - }); - await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - - const conversation = page.locator('.diff-file-box[data-new-filename="added.txt"] .conversation-holder'); - await conversation.locator('.comment-form-reply').click(); - const replyForm = conversation.locator('form'); - await replyForm.locator('textarea[name="content"]').fill('my reply body'); - await replyForm.getByRole('button', {name: 'Reply', exact: true}).click(); - - await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']); - }); - - test('approve review', async ({page, request}) => { - const {poster, reviewer} = await createReviewUsers(request); - const [, {repoName, prIndex}] = await Promise.all([loginUser(page, reviewer), createReviewablePR(request, poster)]); - await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - - await page.locator('#review-box .js-btn-review').click(); - const panel = page.locator('.review-box-panel'); - await panel.locator('textarea[name="content"]').fill('LGTM'); - await panel.getByRole('button', {name: 'Approve', exact: true}).click(); - - await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible(); - await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible(); - }); + }), + loginUser(page, poster), + ]); + + // poster replies to the reviewer's inline comment + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + const conversation = page.locator('.diff-file-box[data-new-filename="added.txt"] .conversation-holder'); + await conversation.locator('.comment-form-reply').click(); + const replyForm = conversation.locator('form'); + await replyForm.locator('textarea[name="content"]').fill('my reply body'); + await replyForm.getByRole('button', {name: 'Reply', exact: true}).click(); + await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']); + + // switch to reviewer and submit an approve review + await page.context().clearCookies(); + await loginUser(page, reviewer); + await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); + await page.locator('#review-box .js-btn-review').click(); + const panel = page.locator('.review-box-panel'); + await panel.locator('textarea[name="content"]').fill('LGTM'); + await panel.getByRole('button', {name: 'Approve', exact: true}).click(); + await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible(); + await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible(); }); diff --git a/tests/e2e/reactions.test.ts b/tests/e2e/reactions.test.ts index 92eed2a4e5754..fc32932e6511a 100644 --- a/tests/e2e/reactions.test.ts +++ b/tests/e2e/reactions.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts'; test('toggle issue reactions', async ({page, request}) => { const repoName = `e2e-reactions-${randomString(8)}`; @@ -10,21 +10,17 @@ test('toggle issue reactions', async ({page, request}) => { apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}), login(page), ]); - try { - await page.goto(`/${owner}/${repoName}/issues/1`); + await page.goto(`/${owner}/${repoName}/issues/1`); - const issueComment = page.locator('.timeline-item.comment.first'); + const issueComment = page.locator('.timeline-item.comment.first'); - const reactionPicker = issueComment.locator('.select-reaction'); - await reactionPicker.click(); - await reactionPicker.getByLabel('+1').click(); + const reactionPicker = issueComment.locator('.select-reaction'); + await reactionPicker.click(); + await reactionPicker.getByLabel('+1').click(); - const reactions = issueComment.getByRole('group', {name: 'Reactions'}); - await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1'); + const reactions = issueComment.getByRole('group', {name: 'Reactions'}); + await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1'); - await reactions.getByRole('button', {name: /^\+1:/}).click(); - await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0); - } finally { - await apiDeleteRepo(request, owner, repoName); - } + await reactions.getByRole('button', {name: /^\+1:/}).click(); + await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0); }); diff --git a/tests/e2e/readme.test.ts b/tests/e2e/readme.test.ts index 9767e092d36b3..5056523b7040d 100644 --- a/tests/e2e/readme.test.ts +++ b/tests/e2e/readme.test.ts @@ -1,11 +1,10 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts'; +import {apiCreateRepo, randomString} from './utils.ts'; test('repo readme', async ({page}) => { const repoName = `e2e-readme-${randomString(8)}`; await apiCreateRepo(page.request, {name: repoName}); await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}`); await expect(page.locator('#readme')).toContainText(repoName); - await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index 32e0e4ca9cf58..64bf72cc0a646 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, logout, apiDeleteUser, randomString} from './utils.ts'; +import {login, logout, randomString} from './utils.ts'; test.beforeEach(async ({page}) => { await page.goto('/user/sign_up'); @@ -48,9 +48,6 @@ test('register then login', async ({page}) => { // Logout then login with the newly created account await logout(page); await login(page, username, password); - - // delete via API because of issues related to form-fetch-action - await apiDeleteUser(page.request, username); }); test('register with existing username shows error', async ({page}) => { diff --git a/tests/e2e/repo.test.ts b/tests/e2e/repo.test.ts index 981d22739d081..996fdf40295ee 100644 --- a/tests/e2e/repo.test.ts +++ b/tests/e2e/repo.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test} from '@playwright/test'; -import {login, apiDeleteRepo, randomString} from './utils.ts'; +import {login, randomString} from './utils.ts'; test('create a repository', async ({page}) => { const repoName = `e2e-repo-${randomString(8)}`; @@ -9,5 +9,4 @@ test('create a repository', async ({page}) => { await page.locator('input[name="repo_name"]').fill(repoName); await page.getByRole('button', {name: 'Create Repository'}).click(); await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`)); - await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName); }); diff --git a/tests/e2e/user-settings.test.ts b/tests/e2e/user-settings.test.ts index 412c89886a175..2a2d964abaea3 100644 --- a/tests/e2e/user-settings.test.ts +++ b/tests/e2e/user-settings.test.ts @@ -1,20 +1,16 @@ import {test, expect} from '@playwright/test'; -import {loginUser, apiCreateUser, apiDeleteUser, randomString} from './utils.ts'; +import {loginUser, apiCreateUser, randomString} from './utils.ts'; test('update profile biography', async ({page, request}) => { const username = `e2e-settings-${randomString(8)}`; const bio = `e2e-bio-${randomString(8)}`; await apiCreateUser(request, username); - try { - await loginUser(page, username); - await page.goto('/user/settings'); - await page.getByLabel('Biography').fill(bio); - await page.getByRole('button', {name: 'Update Profile'}).click(); - await expect(page.getByLabel('Biography')).toHaveValue(bio); - await page.getByLabel('Biography').fill(''); - await page.getByRole('button', {name: 'Update Profile'}).click(); - await expect(page.getByLabel('Biography')).toHaveValue(''); - } finally { - await apiDeleteUser(request, username); - } + await loginUser(page, username); + await page.goto('/user/settings'); + await page.getByLabel('Biography').fill(bio); + await page.getByRole('button', {name: 'Update Profile'}).click(); + await expect(page.getByLabel('Biography')).toHaveValue(bio); + await page.getByLabel('Biography').fill(''); + await page.getByRole('button', {name: 'Update Profile'}).click(); + await expect(page.getByLabel('Biography')).toHaveValue(''); }); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index b42f548b0311c..449a5b7aced9c 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -88,18 +88,8 @@ export async function apiCreatePR(requestContext: APIRequestContext, owner: stri return prIndex; } -export type MergeStyle = 'merge' | 'rebase' | 'rebase-merge' | 'squash' | 'fast-forward-only'; -export type ReviewEvent = 'COMMENT' | 'APPROVED' | 'REQUEST_CHANGES'; - -export async function apiMergePR(requestContext: APIRequestContext, owner: string, repo: string, index: number, {style = 'merge', deleteBranch}: {style?: MergeStyle; deleteBranch?: boolean} = {}) { - await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/merge`, { - headers: apiHeaders(), - data: {Do: style, delete_branch_after_merge: deleteBranch}, - }), 'apiMergePR'); -} - /** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */ -export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: ReviewEvent; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record}) { +export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record}) { let reviewID = 0; await apiRetry(async () => { const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, { From 1efcf7a79273922ce40abaa2859d3a61ec6a1df8 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 21:38:53 +0200 Subject: [PATCH 4/6] Parallelize events stopwatch login, collapse asciicast setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events.test.ts::stopwatch: move loginUser into Promise.all with the repo/issue/stopwatch chain - file-view-render.test.ts::asciicast: replace apiCreateFile+apiCreateBranch with a single apiCreateFile({newBranch}) — Gitea's file API creates the first commit on the specified branch when the repo is empty Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/events.test.ts | 16 +++++++++------- tests/e2e/file-view-render.test.ts | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 5640386f57489..d033c7b94f320 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -32,13 +32,15 @@ test.describe('events', () => { await apiCreateUser(request, name); - // Create repo, issue, and start stopwatch before login - await apiCreateRepo(request, {name, headers}); - await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers}); - await apiStartStopwatch(request, name, name, 1, {headers}); - - // Login — page renders with the active stopwatch element - await loginUser(page, name); + // Login in parallel with repo+issue+stopwatch setup (all independent after user exists) + await Promise.all([ + loginUser(page, name), + (async () => { + await apiCreateRepo(request, {name, headers}); + await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers}); + await apiStartStopwatch(request, name, name, 1, {headers}); + })(), + ]); await page.goto('/'); // Verify stopwatch is visible and links to the correct issue diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts index a4cf5c58edbde..d8e0354acce4c 100644 --- a/tests/e2e/file-view-render.test.ts +++ b/tests/e2e/file-view-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {apiCreateBranch, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; +import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; test('3d model file', async ({page, request}) => { const repoName = `e2e-3d-render-${randomString(8)}`; @@ -47,8 +47,8 @@ test('asciicast file', async ({page, request}) => { const branchEnc = encodeURIComponent(branch); await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]); const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; - await apiCreateFile(request, owner, repoName, 'readme.cast', cast); - await apiCreateBranch(request, owner, repoName, branch); + // on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit + await apiCreateFile(request, owner, repoName, 'readme.cast', cast, {newBranch: branch}); await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); const container = page.locator('.asciinema-player-container'); await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); From 4a8ee82d898f9d9f5f837c7fe8392abc736686f1 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 21:56:59 +0200 Subject: [PATCH 5/6] Collapse e2e PR tests into one flow; add piggyback checks Drop diff-viewer, pr-commits, pr-merge test files. Fold their assertions into the single pr-review flow that already loads /files: - diff-file-box header + add-code row (was diff-viewer) - Commits tab badge count (was pr-commits) - .diff-detail-stats changed-file count (was diff-viewer) The reply-to-inline-comment (#35994) and approve paths were the highest-value; the dropped tests were cheap to fold in since /files already rendered. Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/diff-viewer.test.ts | 20 -------------------- tests/e2e/pr-commits.test.ts | 23 ----------------------- tests/e2e/pr-merge.test.ts | 29 ----------------------------- tests/e2e/pr-review.test.ts | 15 +++++++++++++-- 4 files changed, 13 insertions(+), 74 deletions(-) delete mode 100644 tests/e2e/diff-viewer.test.ts delete mode 100644 tests/e2e/pr-commits.test.ts delete mode 100644 tests/e2e/pr-merge.test.ts diff --git a/tests/e2e/diff-viewer.test.ts b/tests/e2e/diff-viewer.test.ts deleted file mode 100644 index 0d6b4887238c0..0000000000000 --- a/tests/e2e/diff-viewer.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {env} from 'node:process'; -import {test, expect} from '@playwright/test'; -import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; - -const owner = env.GITEA_TEST_E2E_USER; - -test('diff viewer renders file box', async ({page, request}) => { - const repoName = `e2e-diff-${randomString(8)}`; - await apiCreateRepo(request, {name: repoName}); - await apiCreateFile(request, owner, repoName, 'added.txt', 'only on feat\n', {branch: 'main', newBranch: 'feat'}); - const [, prIndex] = await Promise.all([ - login(page), - apiCreatePR(request, owner, repoName, 'feat', 'main', 'diff test'), - ]); - - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/files`); - const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]'); - await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt'); - await expect(fileBox.locator('tr.add-code')).toHaveCount(1); -}); diff --git a/tests/e2e/pr-commits.test.ts b/tests/e2e/pr-commits.test.ts deleted file mode 100644 index a4c95d9ed1f33..0000000000000 --- a/tests/e2e/pr-commits.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {env} from 'node:process'; -import {test, expect} from '@playwright/test'; -import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; - -const owner = env.GITEA_TEST_E2E_USER; - -test('new commit appears on pr commits tab', async ({page, request}) => { - const repoName = `e2e-prcommits-${randomString(8)}`; - await apiCreateRepo(request, {name: repoName}); - await apiCreateFile(request, owner, repoName, 'file-0.txt', 'content 0\n', {branch: 'main', newBranch: 'feat', message: 'initial'}); - const [, prIndex] = await Promise.all([ - login(page), - apiCreatePR(request, owner, repoName, 'feat', 'main', 'commits test'), - ]); - - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}/commits`); - await expect(page.locator('#commits-table tbody.commit-list tr')).toHaveCount(1); - - await apiCreateFile(request, owner, repoName, 'added-later.txt', 'x\n', {branch: 'feat', message: 'appended'}); - await page.reload(); - - await expect(page.locator('#commits-table tbody.commit-list .commit-summary')).toHaveText(['appended', 'initial']); -}); diff --git a/tests/e2e/pr-merge.test.ts b/tests/e2e/pr-merge.test.ts deleted file mode 100644 index 24454bbb4699c..0000000000000 --- a/tests/e2e/pr-merge.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {env} from 'node:process'; -import {test, expect} from '@playwright/test'; -import {apiCreateFile, apiCreatePR, apiCreateRepo, login, randomString} from './utils.ts'; - -const owner = env.GITEA_TEST_E2E_USER; - -test('default merge', async ({page, request}) => { - const repoName = `e2e-prmerge-${randomString(8)}`; - await apiCreateRepo(request, {name: repoName}); - await apiCreateFile(request, owner, repoName, 'feature.txt', 'hello\n', {branch: 'main', newBranch: 'feat'}); - const [, prIndex] = await Promise.all([ - login(page), - apiCreatePR(request, owner, repoName, 'feat', 'main', 'add feature'), - ]); - await page.goto(`/${owner}/${repoName}/pulls/${prIndex}`); - - await page.locator('.merge-button button.ui.button').first().click(); - // default repo config has delete-branch-after-merge OFF - await expect(page.getByLabel(/^Delete Branch/)).not.toBeChecked(); - await page.getByRole('button', {name: 'Create merge commit', exact: true}).click(); - - await expect(page.locator('.issue-state-label')).toContainText('Merged'); - await expect(page.getByText('Pull request successfully merged and closed')).toBeVisible(); - await expect(page.locator('.merge-section .delete-branch-after-merge')).toBeVisible(); - await expect(page.locator('.merge-button')).toBeHidden(); - - const branchResponse = await request.get(`/api/v1/repos/${owner}/${repoName}/branches/feat`); - expect(branchResponse.status()).toBe(200); -}); diff --git a/tests/e2e/pr-review.test.ts b/tests/e2e/pr-review.test.ts index 2f3d60e606a64..d1e7aaa4ca0ec 100644 --- a/tests/e2e/pr-review.test.ts +++ b/tests/e2e/pr-review.test.ts @@ -20,9 +20,20 @@ test('pr review flow', async ({page, request}) => { loginUser(page, poster), ]); - // poster replies to the reviewer's inline comment await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`); - const conversation = page.locator('.diff-file-box[data-new-filename="added.txt"] .conversation-holder'); + + // diff viewer renders the added file with its header and one added-line row + const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]'); + await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt'); + await expect(fileBox.locator('tr.add-code')).toHaveCount(1); + + // commits tab badge reflects the single PR commit, and the diff stats header counts one changed file + const commitsTab = page.locator('.ui.pull.tabular.menu a.item', {has: page.locator('.octicon-git-commit')}); + await expect(commitsTab.locator('.label')).toHaveText('1'); + await expect(page.locator('.diff-detail-stats')).toContainText(/1 changed file/); + + // poster replies to the reviewer's inline comment + const conversation = fileBox.locator('.conversation-holder'); await conversation.locator('.comment-form-reply').click(); const replyForm = conversation.locator('form'); await replyForm.locator('textarea[name="content"]').fill('my reply body'); From 0d99159a8a2997c42cf4f10f19b392e202ea1c44 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 22:05:42 +0200 Subject: [PATCH 6/6] Address Copilot review: utf-8 safe base64, optional review opts - apiCreateFile: base64-encode via Buffer UTF-8 instead of btoa to avoid Latin-1 range errors on non-ASCII content - apiCreateReview: make options object optional (= {}); drop unused return value Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/utils.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 449a5b7aced9c..3f7c8ed3d15ca 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -63,7 +63,7 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) { await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, { headers: apiHeaders(), - data: {content: globalThis.btoa(content), branch, new_branch: newBranch, message}, + data: {content: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message}, }), 'apiCreateFile'); } @@ -89,17 +89,11 @@ export async function apiCreatePR(requestContext: APIRequestContext, owner: stri } /** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */ -export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record}) { - let reviewID = 0; - await apiRetry(async () => { - const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, { - headers: headers || apiHeaders(), - data: {event, body, comments}, - }); - if (response.ok()) reviewID = (await response.json()).id; - return response; - }, 'apiCreateReview'); - return reviewID; +export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record} = {}) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, { + headers: headers || apiHeaders(), + data: {event, body, comments}, + }), 'apiCreateReview'); } export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {