初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
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)]);
|
||||
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";');
|
||||
});
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
GITEA_TEST_E2E_DOMAIN: string;
|
||||
GITEA_TEST_E2E_USER: string;
|
||||
GITEA_TEST_E2E_EMAIL: string;
|
||||
GITEA_TEST_E2E_PASSWORD: string;
|
||||
GITEA_TEST_E2E_URL: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
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', () => {
|
||||
test('notification count', async ({page, request}) => {
|
||||
const owner = `ev-notif-owner-${randomString(8)}`;
|
||||
const commenter = `ev-notif-commenter-${randomString(8)}`;
|
||||
const repoName = `ev-notif-${randomString(8)}`;
|
||||
|
||||
await Promise.all([apiCreateUser(request, owner), apiCreateUser(request, commenter)]);
|
||||
|
||||
// Create repo and login in parallel — repo is needed for the issue, login for the event stream
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
|
||||
loginUser(page, owner),
|
||||
]);
|
||||
await page.goto('/');
|
||||
const badge = page.locator('a.not-mobile .notification_count');
|
||||
await expect(badge).toBeHidden();
|
||||
|
||||
// Create issue as another user — this generates a notification delivered via server push
|
||||
await apiCreateIssue(request, {owner, repo: repoName, title: 'events notification test', headers: apiUserHeaders(commenter)});
|
||||
|
||||
// Wait for the notification badge to appear via server event
|
||||
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
|
||||
});
|
||||
|
||||
test('stopwatch', async ({page, request}) => {
|
||||
const name = `ev-sw-${randomString(8)}`;
|
||||
const headers = apiUserHeaders(name);
|
||||
|
||||
await apiCreateUser(request, 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, autoInit: false, headers});
|
||||
await apiCreateIssue(request, {owner: name, repo: 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
|
||||
const stopwatch = page.locator('.active-stopwatch.not-mobile');
|
||||
await expect(stopwatch).toBeVisible();
|
||||
});
|
||||
|
||||
test('logout propagation', async ({browser, request}) => {
|
||||
const name = `ev-logout-${randomString(8)}`;
|
||||
|
||||
await apiCreateUser(request, name);
|
||||
|
||||
// Use a single context so both pages share the same session and SharedWorker
|
||||
const context = await browser.newContext({baseURL: baseUrl()});
|
||||
const page1 = await context.newPage();
|
||||
const page2 = await context.newPage();
|
||||
|
||||
await loginUser(page1, name);
|
||||
|
||||
// Navigate page2 so it connects to the shared event stream
|
||||
await page2.goto('/');
|
||||
|
||||
// Verify page2 is logged in
|
||||
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden();
|
||||
|
||||
// Give page2's SharedWorker time to register its SSE connection on the
|
||||
// server — otherwise the logout event can race the connection and be
|
||||
// silently dropped. See https://github.com/go-gitea/gitea/pull/37403
|
||||
// In the future, we can set an attribute to HTML page when the connection is established,
|
||||
// then here we can just wait for that attribute (it should also work for the planned WebSocket SharedWorker)
|
||||
await page2.waitForTimeout(500); // eslint-disable-line playwright/no-wait-for-timeout
|
||||
|
||||
// Logout from page1 — this sends a logout event to all tabs
|
||||
await page1.goto('/user/logout');
|
||||
|
||||
// page2 should be redirected via the logout event
|
||||
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
|
||||
test('explore repositories', async ({page}) => {
|
||||
await page.goto('/explore/repos');
|
||||
await expect(page.getByPlaceholder('Search repos…')).toBeVisible();
|
||||
await expect(page.getByRole('link', {name: 'Repositories'})).toBeVisible();
|
||||
});
|
||||
|
||||
test('explore users', async ({page}) => {
|
||||
await page.goto('/explore/users');
|
||||
await expect(page.getByPlaceholder('Search users…')).toBeVisible();
|
||||
});
|
||||
|
||||
test('explore organizations', async ({page}) => {
|
||||
await page.goto('/explore/organizations');
|
||||
await expect(page.getByPlaceholder('Search orgs…')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
|
||||
|
||||
test('external file', async ({page, request}) => {
|
||||
const repoName = `e2e-external-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName}),
|
||||
login(page),
|
||||
]);
|
||||
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
|
||||
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}) => {
|
||||
const repoName = `e2e-openapi-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName}),
|
||||
login(page),
|
||||
]);
|
||||
const title = 'Test <API> & "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);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {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});
|
||||
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}) => {
|
||||
// headless playwright cannot render PDFs (PDFObject.embed returns false), so this is a limited test
|
||||
const repoName = `e2e-pdf-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: 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}) => {
|
||||
// regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch
|
||||
// is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL
|
||||
const repoName = `e2e-asciicast-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
const branch = '日本語-branch';
|
||||
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';
|
||||
// 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`);
|
||||
await expect(container.locator('.ap-wrapper')).toBeVisible();
|
||||
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
|
||||
|
||||
test('fork a repository', async ({page, request}) => {
|
||||
const upstream = `fork-owner-${randomString(8)}`;
|
||||
const repoName = `e2e-fork-${randomString(8)}`;
|
||||
await apiCreateUser(request, upstream);
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(upstream)}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${upstream}/${repoName}/fork`);
|
||||
|
||||
await page.getByRole('button', {name: 'Fork Repository'}).click();
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`));
|
||||
await expect(page.getByRole('link', {name: `${upstream}/${repoName}`})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login} from './utils.ts';
|
||||
|
||||
test('heatmap tooltip shows on hover', async ({page}) => {
|
||||
await login(page);
|
||||
await page.goto('/');
|
||||
await page.locator('.heatmap-day').first().hover();
|
||||
await expect(page.locator('.tippy-box[data-state="visible"]')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
|
||||
|
||||
test('comment on and close an issue', async ({page, request}) => {
|
||||
const repoName = `e2e-issue-comment-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Comment test'}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
const body = `e2e-comment-${randomString(8)}`;
|
||||
await page.getByPlaceholder('Leave a comment').fill(body);
|
||||
// exact match: the status button reads "Close with Comment" while the box has content, which substring-matches "Comment"
|
||||
await page.getByRole('button', {name: 'Comment', exact: true}).click();
|
||||
await expect(page.locator('.comment-body').filter({hasText: body})).toBeVisible();
|
||||
|
||||
// wait for the form to re-initialize (the empty box disables the comment button); a close click
|
||||
// before that does a native submit which lands on a raw JSON page instead of reloading the issue
|
||||
await expect(page.getByRole('button', {name: 'Comment', exact: true})).toBeDisabled();
|
||||
await page.getByRole('button', {name: 'Close Issue'}).click();
|
||||
await expect(page.getByRole('button', {name: 'Reopen Issue'})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProject, createProjectColumn, randomString} from './utils.ts';
|
||||
|
||||
test('assign issue to project and change column', async ({page}) => {
|
||||
const repoName = `e2e-issue-project-${randomString(8)}`;
|
||||
const user = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
|
||||
await page.goto(`/${user}/${repoName}/projects/new`);
|
||||
await page.locator('input[name="title"]').fill('Kanban Board');
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first();
|
||||
await expect(projectLink).toBeVisible();
|
||||
const href = await projectLink.getAttribute('href');
|
||||
const projectID = href!.split('/').pop()!;
|
||||
// columns created via POST because the web UI uses modals that are hard to drive
|
||||
await Promise.all([
|
||||
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
|
||||
apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Column picker test'}),
|
||||
]);
|
||||
await page.goto(`/${user}/${repoName}/issues/1`);
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
|
||||
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
|
||||
await apiDeleteRepo(page.request, user, repoName);
|
||||
});
|
||||
|
||||
test('create a project', async ({page}) => {
|
||||
const repoName = `e2e-project-repo-${Date.now()}`;
|
||||
const projectTitle = 'Test Project';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Navigate to new project page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/new`);
|
||||
|
||||
// Fill in project details
|
||||
await page.getByLabel('Title').fill(projectTitle);
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
// Verify project was created by checking we're redirected to the projects list
|
||||
await expect(page).toHaveURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects$`));
|
||||
|
||||
// Verify the project appears in the list
|
||||
await expect(page.locator('.milestone-list')).toContainText(projectTitle);
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('assign issue to multiple projects via sidebar', async ({page}) => {
|
||||
const repoName = `e2e-multi-project-${Date.now()}`;
|
||||
const project1Title = 'Project Alpha';
|
||||
const project2Title = 'Project Beta';
|
||||
const issueTitle = 'Test issue for multiple projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create an issue without any project
|
||||
const issue = await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: issueTitle,
|
||||
});
|
||||
|
||||
// Navigate to the issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
||||
|
||||
// Open the projects dropdown in the sidebar
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown and trigger the update
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify both projects are shown in the sidebar
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('create issue with multiple projects pre-selected', async ({page}) => {
|
||||
const repoName = `e2e-issue-multi-proj-${Date.now()}`;
|
||||
const project1Title = 'Project One';
|
||||
const project2Title = 'Project Two';
|
||||
const issueTitle = 'Issue with multiple projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Navigate to new issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
||||
|
||||
// Fill in the issue title
|
||||
await page.locator('input[name="title"]').fill(issueTitle);
|
||||
|
||||
// Open the projects dropdown
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Issue'}).click();
|
||||
|
||||
// Wait for issue to be created and page to redirect
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/\\d+`));
|
||||
|
||||
// Verify both projects are shown in the sidebar
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('filter issues by multiple projects in issue list', async ({page}) => {
|
||||
const repoName = `e2e-filter-projects-${Date.now()}`;
|
||||
const project1Title = 'Filter Project A';
|
||||
const project2Title = 'Filter Project B';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create issues: one in project1, one in project2, one in both
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in Project A only',
|
||||
projects: [project1.id],
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in Project B only',
|
||||
projects: [project2.id],
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in both projects',
|
||||
projects: [project1.id, project2.id],
|
||||
});
|
||||
// Create an issue with no project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue with no project',
|
||||
});
|
||||
|
||||
// Verify only project1 issues are visible
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project1.id}`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in Project A only');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project B only');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
||||
|
||||
// Verify only project2 issues are visible
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project2.id}`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in Project B only');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project A only');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('remove issue from one project keeping others', async ({page}) => {
|
||||
const repoName = `e2e-remove-project-${Date.now()}`;
|
||||
const project1Title = 'Keep This Project';
|
||||
const project2Title = 'Remove This Project';
|
||||
const issueTitle = 'Issue to modify projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create an issue in both projects
|
||||
const issue = await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: issueTitle,
|
||||
projects: [project1.id, project2.id],
|
||||
});
|
||||
|
||||
// Navigate to the issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
||||
|
||||
// Verify both projects are initially shown
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeVisible();
|
||||
|
||||
// Open the projects dropdown
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Deselect project2 (click on the already selected item to deselect)
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown and trigger the update
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify project1 is still shown but project2 is removed
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeHidden();
|
||||
|
||||
// Reload the page to see the timeline comment
|
||||
await page.reload();
|
||||
|
||||
// Verify the timeline shows "removed this from the project" comment
|
||||
const timelineComments = page.locator('.timeline-item.event');
|
||||
await expect(timelineComments.filter({hasText: 'removed this from the'})).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('filter issues with no project using project=-1', async ({page}) => {
|
||||
const repoName = `e2e-no-project-filter-${Date.now()}`;
|
||||
const projectTitle = 'Some Project';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create a project via UI
|
||||
const project = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: projectTitle,
|
||||
});
|
||||
|
||||
// Create an issue with a project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue with project assigned',
|
||||
projects: [project.id],
|
||||
});
|
||||
|
||||
// Create issues with no project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue without any project',
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Another unassigned issue',
|
||||
});
|
||||
|
||||
// First verify we can see all issues without the filter
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue with project assigned');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
||||
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
||||
|
||||
// Navigate to issue list filtering for issues with no project (project=-1)
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open&project=-1`);
|
||||
|
||||
// Verify only issues with no project are visible
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
||||
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
||||
|
||||
// Verify the issue with a project is NOT visible
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with project assigned');
|
||||
|
||||
// Verify the last item in the list is NOT the issue with a project
|
||||
const issueItems = page.locator('#issue-list .item');
|
||||
const lastIssueItem = issueItems.last();
|
||||
await expect(lastIssueItem).not.toContainText('Issue with project assigned');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('close project and view in closed projects list', async ({page}) => {
|
||||
const repoName = `e2e-close-project-${Date.now()}`;
|
||||
const openProjectTitle = 'Open Project';
|
||||
const closedProjectTitle = 'Project To Close';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: openProjectTitle,
|
||||
});
|
||||
const projectToClose = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: closedProjectTitle,
|
||||
});
|
||||
|
||||
// Navigate to projects list
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
||||
|
||||
// Verify both projects are visible in open state
|
||||
await expect(page.locator('.milestone-list')).toContainText(openProjectTitle);
|
||||
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
||||
|
||||
// Close the second project by clicking the close link
|
||||
const projectCard = page.locator('.milestone-card').filter({hasText: closedProjectTitle});
|
||||
await projectCard.locator('a.link-action[data-url$="/close"]').click();
|
||||
|
||||
// Wait for redirect back to project view page
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/${projectToClose.id}`));
|
||||
|
||||
// Navigate to projects list
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
||||
|
||||
// Click on "Closed" tab to view closed projects
|
||||
await page.locator('.list-header-toggle a.item').filter({hasText: 'Closed'}).click();
|
||||
|
||||
// Wait for the page to load with closed projects
|
||||
await page.waitForURL(/state=closed/);
|
||||
|
||||
// Verify only the closed project is visible
|
||||
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
||||
await expect(page.locator('.milestone-list')).not.toContainText(openProjectTitle);
|
||||
|
||||
// Verify the "Closed" tab is active
|
||||
await expect(page.locator('.list-header-toggle a.item.active')).toContainText('Closed');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('select projects on new issue page shows in sidebar', async ({page}) => {
|
||||
const repoName = `e2e-new-issue-project-${Date.now()}`;
|
||||
const project1Title = 'Project One';
|
||||
const project2Title = 'Project Two';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
||||
|
||||
try {
|
||||
// Create two projects
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Navigate to new issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
||||
|
||||
// Open the projects dropdown in the sidebar
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close dropdown
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify both projects appear in the sidebar list below the dropdown
|
||||
// On new issue page, these are simple cloned items rendered in the list container
|
||||
const projectList = page.locator('.sidebar-project-combo > .ui.list');
|
||||
await expect(projectList.locator(`.item:has-text("${project1Title}")`).first()).toBeVisible();
|
||||
await expect(projectList.locator(`.item:has-text("${project2Title}")`).first()).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
|
||||
test('licenses.txt', async ({page}) => {
|
||||
const resp = await page.goto('/assets/licenses.txt');
|
||||
expect(resp?.status()).toBe(200);
|
||||
const content = await resp!.text();
|
||||
expect(content).toContain('@vue/');
|
||||
expect(content).toContain('gitea.dev/');
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
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 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);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {apiCreateRepo, apiCreateIssue, assertNoJsError, randomString} from './utils.ts';
|
||||
|
||||
test('mermaid diagram in issue', async ({page, request}) => {
|
||||
const repoName = `e2e-mermaid-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
const body = '```mermaid\nflowchart LR\n Alpha --> Beta\n Beta --> Gamma\n```\n';
|
||||
const {index} = await apiCreateIssue(request, {owner, repo: repoName, title: 'mermaid test', body});
|
||||
await page.goto(`/${owner}/${repoName}/issues/${index}`);
|
||||
|
||||
const svg = page.frameLocator('iframe.markup-content-iframe').locator('svg');
|
||||
await expect(svg).toContainText(/Alpha[\s\S]*Beta[\s\S]*Gamma/);
|
||||
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('create a milestone', async ({page}) => {
|
||||
const repoName = `e2e-milestone-${randomString(8)}`;
|
||||
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/milestones/new`);
|
||||
await page.getByPlaceholder('Title').fill('Test Milestone');
|
||||
await page.getByRole('button', {name: 'Create Milestone'}).click();
|
||||
await expect(page.locator('.milestone-list')).toContainText('Test Milestone');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateOrg, apiCreateTeam, apiCreateUser, apiDeleteOrg, randomString} from './utils.ts';
|
||||
|
||||
test('create an organization', async ({page}) => {
|
||||
const orgName = `e2e-org-${randomString(8)}`;
|
||||
await login(page);
|
||||
await page.goto('/org/create');
|
||||
await page.getByLabel('Organization Name').fill(orgName);
|
||||
await page.getByRole('button', {name: 'Create Organization'}).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/org/${orgName}`));
|
||||
// delete via API because of issues related to form-fetch-action
|
||||
await apiDeleteOrg(page.request, orgName);
|
||||
});
|
||||
|
||||
test('add team member search', async ({page, request}) => {
|
||||
const orgName = `team-add-${randomString(8)}`;
|
||||
const teamName = `team-add-${randomString(8)}`;
|
||||
const userName = `team-add-${randomString(8)}`;
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await apiCreateOrg(request, orgName);
|
||||
await apiCreateTeam(request, orgName, teamName);
|
||||
})(),
|
||||
apiCreateUser(request, userName),
|
||||
login(page),
|
||||
]);
|
||||
|
||||
await page.goto(`/org/${orgName}/teams/${teamName}`);
|
||||
const input = page.locator('#search-user-box input.prompt');
|
||||
await input.fill(userName.slice(-6));
|
||||
const result = page.locator('#search-user-box .results .result').first();
|
||||
await expect(result).toContainText(userName);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts';
|
||||
|
||||
test('create a pull request from the compare page', async ({page, request}) => {
|
||||
const repoName = `e2e-pr-create-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await Promise.all([
|
||||
apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}),
|
||||
login(page),
|
||||
]);
|
||||
// expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click
|
||||
await page.goto(`/${owner}/${repoName}/compare/main...feat?expand=1`);
|
||||
|
||||
const title = `e2e-pr-${randomString(8)}`;
|
||||
await page.getByPlaceholder('Title').fill(title);
|
||||
await page.getByRole('button', {name: 'Create Pull Request'}).click();
|
||||
|
||||
// commit, not full load: the PR title heading is server-rendered, so the assertion can resolve before the heavy diff/timeline finishes
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+$`), {waitUntil: 'commit'});
|
||||
await expect(page.getByRole('heading', {name: title})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
|
||||
|
||||
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)]);
|
||||
const posterHeaders = apiUserHeaders(poster);
|
||||
const repoName = `e2e-prreview-${randomString(8)}`;
|
||||
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', {headers: posterHeaders});
|
||||
|
||||
// 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),
|
||||
}),
|
||||
loginUser(page, poster),
|
||||
]);
|
||||
|
||||
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
|
||||
|
||||
// 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');
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
|
||||
|
||||
test('toggle issue reactions', async ({page, request}) => {
|
||||
const repoName = `e2e-reactions-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
const issueComment = page.locator('.timeline-item.comment.issue-content-comment');
|
||||
|
||||
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');
|
||||
|
||||
await reactions.getByRole('button', {name: /^\+1:/}).click();
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, logout, randomString} from './utils.ts';
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await page.goto('/user/sign_up');
|
||||
});
|
||||
|
||||
test('register page has form', async ({page}) => {
|
||||
await expect(page.getByLabel('Username')).toBeVisible();
|
||||
await expect(page.getByLabel('Email Address')).toBeVisible();
|
||||
await expect(page.getByLabel('Password', {exact: true})).toBeVisible();
|
||||
await expect(page.getByLabel('Confirm Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', {name: 'Register Account'})).toBeVisible();
|
||||
});
|
||||
|
||||
test('register with empty fields shows error', async ({page}) => {
|
||||
// HTML5 required attribute prevents submission, so verify the fields are required
|
||||
await expect(page.locator('input[name="user_name"][required]')).toBeVisible();
|
||||
await expect(page.locator('input[name="email"][required]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"][required]')).toBeVisible();
|
||||
await expect(page.locator('input[name="retype"][required]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('register with mismatched passwords shows error', async ({page}) => {
|
||||
await page.getByLabel('Username').fill('e2e-register-mismatch');
|
||||
await page.getByLabel('Email Address').fill(`e2e-register-mismatch@${env.GITEA_TEST_E2E_DOMAIN}`);
|
||||
await page.getByLabel('Password', {exact: true}).fill('password123!');
|
||||
await page.getByLabel('Confirm Password').fill('different123!');
|
||||
await page.getByRole('button', {name: 'Register Account'}).click();
|
||||
await expect(page.locator('.ui.negative.message')).toBeVisible();
|
||||
});
|
||||
|
||||
test('register then login', async ({page}) => {
|
||||
const username = `e2e-register-${randomString(8)}`;
|
||||
const email = `${username}@${env.GITEA_TEST_E2E_DOMAIN}`;
|
||||
const password = 'password123!';
|
||||
|
||||
await page.getByLabel('Username').fill(username);
|
||||
await page.getByLabel('Email Address').fill(email);
|
||||
await page.getByLabel('Password', {exact: true}).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByRole('button', {name: 'Register Account'}).click();
|
||||
|
||||
// After successful registration, should be redirected away from sign_up
|
||||
await expect(page).not.toHaveURL(/sign_up/);
|
||||
|
||||
// Logout then login with the newly created account
|
||||
await logout(page);
|
||||
await login(page, username, password);
|
||||
});
|
||||
|
||||
test('register with existing username shows error', async ({page}) => {
|
||||
await page.getByLabel('Username').fill(env.GITEA_TEST_E2E_USER);
|
||||
await page.getByLabel('Email Address').fill(`e2e-duplicate@${env.GITEA_TEST_E2E_DOMAIN}`);
|
||||
await page.getByLabel('Password', {exact: true}).fill('password123!');
|
||||
await page.getByLabel('Confirm Password').fill('password123!');
|
||||
await page.getByRole('button', {name: 'Register Account'}).click();
|
||||
await expect(page.locator('.ui.negative.message')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sign in link exists', async ({page}) => {
|
||||
const signInLink = page.getByText('Sign in now!');
|
||||
await expect(signInLink).toBeVisible();
|
||||
await signInLink.click();
|
||||
await expect(page).toHaveURL(/\/user\/login$/);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {assertNoJsError} from './utils.ts';
|
||||
|
||||
test('relative-time renders without errors', async ({page}) => {
|
||||
await page.goto('/devtest/relative-time');
|
||||
const relativeTime = page.getByTestId('relative-time-now');
|
||||
await expect(relativeTime).toHaveAttribute('data-tooltip-content', /.+/);
|
||||
await expect(relativeTime).toHaveText('now');
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('create a release', async ({page, request}) => {
|
||||
const repoName = `e2e-release-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);
|
||||
await page.goto(`/${owner}/${repoName}/releases/new`);
|
||||
|
||||
const tag = `v1.0.0-${randomString(8)}`;
|
||||
const title = `e2e-release-${randomString(8)}`;
|
||||
await page.getByLabel('Tag name').fill(tag);
|
||||
await page.getByLabel('Release title').fill(title);
|
||||
await page.getByRole('button', {name: 'Publish Release'}).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repoName}/releases$`));
|
||||
await expect(page.locator('.release-list-title')).toContainText(title);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateRepo, apiCreateUser, login, randomString} from './utils.ts';
|
||||
|
||||
test('add collaborator search', async ({page, request}) => {
|
||||
const userName = `repo-collab-${randomString(8)}`;
|
||||
const repoName = `repo-collab-${randomString(8)}`;
|
||||
|
||||
await Promise.all([
|
||||
apiCreateUser(request, userName),
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false}),
|
||||
login(page),
|
||||
]);
|
||||
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/settings/collaboration`);
|
||||
const input = page.locator('#search-user-box input.prompt');
|
||||
await input.fill(userName.slice(-6));
|
||||
const result = page.locator('#search-user-box .results .result').first();
|
||||
await expect(result).toContainText(userName);
|
||||
await result.click();
|
||||
await expect(input).toHaveValue(userName);
|
||||
await page.getByRole('button', {name: 'Add Collaborator'}).click();
|
||||
await expect(page.locator('body')).toContainText(userName);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
|
||||
|
||||
test('star and watch a repository', async ({page, request}) => {
|
||||
const owner = `sw-owner-${randomString(8)}`;
|
||||
const repoName = `e2e-star-watch-${randomString(8)}`;
|
||||
await apiCreateUser(request, owner);
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}`);
|
||||
|
||||
// exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch"
|
||||
await page.getByRole('button', {name: 'Star', exact: true}).click();
|
||||
await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible();
|
||||
|
||||
await page.getByRole('button', {name: 'Watch', exact: true}).click();
|
||||
await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import {env} from 'node:process';
|
||||
import {test} from '@playwright/test';
|
||||
import {login, randomString} from './utils.ts';
|
||||
|
||||
test('create a repository', async ({page}) => {
|
||||
const repoName = `e2e-repo-${randomString(8)}`;
|
||||
await login(page);
|
||||
await page.goto('/repo/create');
|
||||
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}$`));
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
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);
|
||||
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('');
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect} from '@playwright/test';
|
||||
import type {APIRequestContext, Locator, Page} from '@playwright/test';
|
||||
|
||||
/** Generate a random alphanumeric string. */
|
||||
export function randomString(length: number): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let index = 0; index < length; index++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const timeoutFactor = Number(env.GITEA_TEST_E2E_TIMEOUT_FACTOR) || 1;
|
||||
|
||||
export function baseUrl() {
|
||||
return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, '');
|
||||
}
|
||||
|
||||
function apiAuthHeader(username: string, password: string) {
|
||||
return {Authorization: `Basic ${globalThis.btoa(`${username}:${password}`)}`};
|
||||
}
|
||||
|
||||
export function apiHeaders() {
|
||||
return apiAuthHeader(env.GITEA_TEST_E2E_USER, env.GITEA_TEST_E2E_PASSWORD);
|
||||
}
|
||||
|
||||
async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise<string>}>, label: string) {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const response = await fn();
|
||||
if (response.ok()) return;
|
||||
if ([500, 502, 503].includes(response.status()) && attempt < maxAttempts - 1) {
|
||||
const jitter = Math.random() * 500;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1) + jitter));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${label} failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true, headers}: {name: string; autoInit?: boolean; headers?: Record<string, string>}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/user/repos`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {name, auto_init: autoInit},
|
||||
}), 'apiCreateRepo');
|
||||
}
|
||||
|
||||
export async function apiCreateOrg(requestContext: APIRequestContext, name: string, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {username: name},
|
||||
}), 'apiCreateOrg');
|
||||
}
|
||||
|
||||
export async function apiCreateTeam(requestContext: APIRequestContext, org: string, name: string, {permission = 'read', units = ['repo.code'], headers}: {permission?: string; units?: Array<string>; headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs/${org}/teams`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {name, permission, units},
|
||||
}), 'apiCreateTeam');
|
||||
}
|
||||
|
||||
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/start`, {
|
||||
headers: headers || apiHeaders(),
|
||||
}), 'apiStartStopwatch');
|
||||
}
|
||||
|
||||
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: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message},
|
||||
}), 'apiCreateFile');
|
||||
}
|
||||
|
||||
export async function apiCreateBranch(requestContext: APIRequestContext, owner: string, repo: string, newBranch: string) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches`, {
|
||||
headers: apiHeaders(),
|
||||
data: {new_branch_name: newBranch},
|
||||
}), '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<string, string>} = {}): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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<string, string>} = {}) {
|
||||
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) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, {
|
||||
headers: apiHeaders(),
|
||||
form: {title},
|
||||
}), 'createProjectColumn');
|
||||
}
|
||||
|
||||
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
|
||||
headers: apiHeaders(),
|
||||
}), 'apiDeleteRepo');
|
||||
}
|
||||
|
||||
export async function apiDeleteOrg(requestContext: APIRequestContext, name: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/orgs/${name}`, {
|
||||
headers: apiHeaders(),
|
||||
}), 'apiDeleteOrg');
|
||||
}
|
||||
|
||||
/** Password shared by all test users — used for both API user creation and browser login. */
|
||||
const testUserPassword = 'e2e-password!aA1';
|
||||
|
||||
export function apiUserHeaders(username: string) {
|
||||
return apiAuthHeader(username, testUserPassword);
|
||||
}
|
||||
|
||||
export async function apiCreateUser(requestContext: APIRequestContext, username: string) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/admin/users`, {
|
||||
headers: apiHeaders(),
|
||||
data: {username, password: testUserPassword, email: `${username}@${env.GITEA_TEST_E2E_DOMAIN}`, must_change_password: false},
|
||||
}), 'apiCreateUser');
|
||||
}
|
||||
|
||||
export async function apiDeleteUser(requestContext: APIRequestContext, username: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/admin/users/${username}?purge=true`, {
|
||||
headers: apiHeaders(),
|
||||
}), 'apiDeleteUser');
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
page: Page,
|
||||
{owner, repo, title}: {owner: string; repo: string; title: string},
|
||||
): Promise<{id: number}> {
|
||||
// Navigate to new project page
|
||||
await page.goto(`/${owner}/${repo}/projects/new`);
|
||||
|
||||
// Fill in project details
|
||||
await page.getByLabel('Title').fill(title);
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
// Wait for redirect to projects list
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repo}/projects$`));
|
||||
|
||||
// Extract the project ID from the project link in the list
|
||||
const projectLink = page.locator('.milestone-list .milestone-card').filter({hasText: title}).locator('a').first();
|
||||
const href = await projectLink.getAttribute('href');
|
||||
const match = /\/projects\/(\d+)/.exec(href || '');
|
||||
const id = match ? parseInt(match[1]) : 0;
|
||||
|
||||
return {id};
|
||||
}
|
||||
|
||||
export async function apiCreateIssue(
|
||||
requestContext: APIRequestContext,
|
||||
{owner, repo, title, body, projects, headers}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
projects?: number[];
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Promise<{index: number}> {
|
||||
let result: {index: number} = {index: 0};
|
||||
await apiRetry(async () => {
|
||||
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title, body: body || '', projects: projects || []},
|
||||
});
|
||||
if (response.ok()) {
|
||||
const json = await response.json();
|
||||
// API returns "number" field for the issue index
|
||||
result = {index: json.number};
|
||||
}
|
||||
return response;
|
||||
}, 'apiCreateIssue');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
|
||||
await trigger.click();
|
||||
await page.getByText(itemText).click();
|
||||
}
|
||||
|
||||
export async function loginUser(page: Page, username: string) {
|
||||
return login(page, username, testUserPassword);
|
||||
}
|
||||
|
||||
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},
|
||||
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) {
|
||||
await expect(page.locator('.js-global-error')).toHaveCount(0);
|
||||
}
|
||||
|
||||
/* asserts the child has no horizontal inset from its parent — catches padding/border anywhere
|
||||
* in between regardless of which element declares it */
|
||||
export async function assertFlushWithParent(child: Locator, parent: Locator) {
|
||||
const [childBox, parentBox] = await Promise.all([child.boundingBox(), parent.boundingBox()]);
|
||||
if (!childBox || !parentBox) throw new Error('boundingBox returned null');
|
||||
expect(childBox.x).toBe(parentBox.x);
|
||||
expect(childBox.width).toBe(parentBox.width);
|
||||
}
|
||||
|
||||
export async function logout(page: Page) {
|
||||
await page.context().clearCookies(); // workaround issues related to fomantic dropdown
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', {name: 'Sign In'})).toBeVisible();
|
||||
}
|
||||
Reference in New Issue
Block a user