Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
df2fc5d
Add e2e tests for server-sent events
silverwind Mar 10, 2026
39aa8a0
Make event tests transport-agnostic and faster
silverwind Mar 10, 2026
fbbb46f
Rename test describe from "Events" to "events"
silverwind Mar 10, 2026
0a8a248
Reduce test timeout from 120s to 90s
silverwind Mar 10, 2026
ec92948
Improve e2e event tests and utilities
silverwind Mar 10, 2026
5d5322c
Generate random test user password instead of hardcoding
silverwind Mar 10, 2026
0f8db2a
Use JSDoc comment for testUserPassword
silverwind Mar 10, 2026
6775064
Merge branch 'main' into e2eevents
silverwind Mar 11, 2026
d39768b
Merge branch 'main' into e2eevents
silverwind Mar 13, 2026
096b8a5
Merge branch 'main' into e2eevents
silverwind Mar 14, 2026
b55ed83
Merge branch 'main' into e2eevents
silverwind Mar 23, 2026
81df3c6
Merge branch 'main' into e2eevents
silverwind Mar 25, 2026
5faae40
Improve e2e event tests: reduce timeouts, parallelize cleanup
silverwind Mar 25, 2026
a16e119
Add frontend dep to test-e2e, parallelize repo creation and login
silverwind Mar 25, 2026
f79c8ea
Move frontend asset dependency to EXECUTABLE_E2E target
silverwind Mar 25, 2026
2b1a90b
Merge branch 'main' into e2eevents
silverwind Mar 27, 2026
1d950fa
refactor legacy shared worker code
wxiaoguang Mar 27, 2026
a5c9ee2
rename
wxiaoguang Mar 27, 2026
e603380
use UserEventsSharedWorker class directly
wxiaoguang Mar 27, 2026
9b6a738
add comment for the delayed logout redirecting behavior
wxiaoguang Mar 27, 2026
a1b6af6
fine tune comment
wxiaoguang Mar 27, 2026
732961d
fix shared worker debug name
wxiaoguang Mar 27, 2026
b6b1cb6
fix comment
wxiaoguang Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
endif
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@

$(EXECUTABLE_E2E): $(GO_SOURCES)
$(EXECUTABLE_E2E): $(GO_SOURCES) $(WEBPACK_DEST)
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@

.PHONY: release
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {test, expect} from '@playwright/test';
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch} 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 id = `ev-notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const owner = `${id}-owner`;
const commenter = `${id}-commenter`;
const repoName = id;

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, headers: apiUserHeaders(owner)}),
loginUser(page, owner),
]);
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, repoName, {title: 'events notification test', headers: apiUserHeaders(commenter)});

// Wait for the notification badge to appear via server event
await expect(badge).toBeVisible({timeout: 15000});

// Cleanup
await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]);
});

test('stopwatch', async ({page, request}) => {
const name = `ev-sw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const headers = apiUserHeaders(name);

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);

// 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}) => {
const name = `ev-logout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;

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();

// 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();

// Cleanup
await apiDeleteUser(request, name);
});
});
7 changes: 2 additions & 5 deletions tests/e2e/register.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, logout} from './utils.ts';
import {login, logout, apiDeleteUser} from './utils.ts';

test.beforeEach(async ({page}) => {
await page.goto('/user/sign_up');
Expand Down Expand Up @@ -50,10 +50,7 @@ test('register then login', async ({page}) => {
await login(page, username, password);

// delete via API because of issues related to form-fetch-action
const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, {
headers: {Authorization: `Basic ${btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`},
});
expect(response.ok()).toBeTruthy();
await apiDeleteUser(page.request, username);
});

test('register with existing username shows error', async ({page}) => {
Expand Down
62 changes: 55 additions & 7 deletions tests/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {randomBytes} from 'node:crypto';
import {env} from 'node:process';
import {expect} from '@playwright/test';
import type {APIRequestContext, Locator, Page} from '@playwright/test';

export function apiBaseUrl() {
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 {Authorization: `Basic ${globalThis.btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`};
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) {
Expand All @@ -24,30 +29,73 @@ async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => numb
}
}

export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) {
await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, {
headers: apiHeaders(),
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 apiCreateIssue(requestContext: APIRequestContext, owner: string, repo: string, {title, headers}: {title: string; headers?: Record<string, string>}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
headers: headers || apiHeaders(),
data: {title},
}), 'apiCreateIssue');
}

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 apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, {
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(`${apiBaseUrl()}/api/v1/orgs/${name}`, {
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/orgs/${name}`, {
headers: apiHeaders(),
}), 'apiDeleteOrg');
}

/** Generate a random password that satisfies the complexity requirements. */
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return `${Array.from(randomBytes(12), (b) => chars[b % chars.length]).join('')}!aA1`;
}

/** Random password shared by all test users — used for both API user creation and browser login. */
const testUserPassword = generatePassword();

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 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) {
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
Expand Down
3 changes: 3 additions & 0 deletions tools/test-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ INSTALL_LOCK = true
[service]
ENABLE_CAPTCHA = false

[ui.notification]
EVENT_SOURCE_UPDATE_TIME = 500ms

[log]
MODE = console
LEVEL = Warn
Expand Down
57 changes: 8 additions & 49 deletions web_src/js/features/notification.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {GET} from '../modules/fetch.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {logoutFromWorker} from '../modules/worker.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';

const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
const {appSubUrl, notificationSettings} = window.config;
let notificationSequenceNumber = 0;

async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
Expand Down Expand Up @@ -33,56 +33,15 @@ export function initNotificationCount() {

if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('message', (event: MessageEvent<{type: string, data: string}>) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
return;
}
if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
} else if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
const worker = new UserEventsSharedWorker('notification-worker');
worker.addMessageEventListener((event: MessageEvent) => {
if (event.data.type === 'no-event-source') {
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') {
return;
}
worker.port.postMessage({
type: 'close',
});
worker.port.close();
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
} else if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
}
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
worker.port.start();
window.addEventListener('beforeunload', () => {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
});

worker.startPort();
return;
}

Expand Down
56 changes: 8 additions & 48 deletions web_src/js/features/stopwatch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {createTippy} from '../modules/tippy.ts';
import {GET} from '../modules/fetch.ts';
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
import {logoutFromWorker} from '../modules/worker.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';

const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;

export function initStopwatch() {
if (!enableTimeTracking) {
Expand Down Expand Up @@ -47,56 +47,16 @@ export function initStopwatch() {
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('message', (event) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
return;
}
if (event.data.type === 'stopwatches') {
updateStopwatchData(JSON.parse(event.data.data));
} else if (event.data.type === 'no-event-source') {
const worker = new UserEventsSharedWorker('stopwatch-worker');
worker.addMessageEventListener((event) => {
if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') {
return;
}
worker.port.postMessage({
type: 'close',
});
worker.port.close();
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
} else if (event.data.type === 'stopwatches') {
updateStopwatchData(JSON.parse(event.data.data));
}
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
worker.port.start();
window.addEventListener('beforeunload', () => {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
});

worker.startPort();
return;
}

Expand Down
Loading