mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-28 17:25:17 +02:00
* Closes #36942 * Fixes #19265 Replaces the SSE-based push channel (`/user/events`) with a WebSocket endpoint (`/-/ws`). ### What changes - **New `/-/ws` endpoint** (authenticated). One WebSocket per origin, shared across tabs via a single `SharedWorker`. - **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a `Broker` interface. `MemoryBroker` is the default (single process); a Redis backend is available for multi-process setups, configured via `[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue was not usable here because it has FIFO/single-consumer semantics. - **Push-only event production.** Events are emitted by write-triggered notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and the logout publisher — wired into the existing `notify.Notifier` interface. No server-side pollers. - **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a singleton transport; features subscribe per event type via `onUserEvent('notification-count', cb)` instead of branching on `event.data.type`. - **Wire contract** (`UserEventType` union) is shared between the worker and consumers via `web_src/js/types.ts`, kept in sync with `services/websocket/events.go`. - **Client-side periodic polling fallback** kicks in only when the WebSocket cannot be established (e.g. proxy blocks WS, browser lacks module-SharedWorker support). ### What's removed - `modules/eventsource` (SSE manager, run loop, messenger). - `/user/events` route and `tests/integration/eventsource_test.go`. - All server-side polling for stopwatches and notification counts. ### Stopwatch multi-tab fix The navbar stopwatch icon was previously rendered conditionally on `{{if $activeStopwatch}}`, so tabs loaded before the timer started had no DOM element to update. The icon and popup are now always rendered (toggled with `tw-hidden`), and the start/stop/cancel handlers POST silently so all open tabs reflect the change in real time. ### Deployment note WebSocket needs the upgrade headers to pass through a reverse proxy, e.g. for nginx: ```nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Without them the WebSocket cannot be established, and after 3 consecutive failed opens the shared worker signals `push-unavailable`: the notification count and stopwatch fall back to periodic polling on the existing `[ui.notification]` timeouts. Real-time push is lost, the features keep working. The reverse-proxy docs need the same note (see the `docs-update-needed` label). --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Epid <rexmrj@gmail.com>
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
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 apiCancelStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
|
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/delete`, {
|
|
headers: headers || apiHeaders(),
|
|
}), 'apiCancelStopwatch');
|
|
}
|
|
|
|
export async function apiCloseIssue(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
|
await apiRetry(() => requestContext.patch(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}`, {
|
|
headers: headers || apiHeaders(),
|
|
data: {state: 'closed'},
|
|
}), 'apiCloseIssue');
|
|
}
|
|
|
|
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 > .item').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();
|
|
}
|