Skip to content

Commit 79ca39c

Browse files
committed
Merge remote-tracking branch 'origin/main' into vite
* origin/main: Add e2e tests for server push events (go-gitea#36879) # Conflicts: # web_src/js/features/notification.ts # web_src/js/features/stopwatch.ts
2 parents 8220760 + de478c4 commit 79ca39c

8 files changed

Lines changed: 222 additions & 116 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
671671
endif
672672
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
673673

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

677677
.PHONY: release

tests/e2e/events.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {test, expect} from '@playwright/test';
2+
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch} from './utils.ts';
3+
4+
// These tests rely on a short EVENT_SOURCE_UPDATE_TIME in the e2e server config.
5+
test.describe('events', () => {
6+
test('notification count', async ({page, request}) => {
7+
const id = `ev-notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
8+
const owner = `${id}-owner`;
9+
const commenter = `${id}-commenter`;
10+
const repoName = id;
11+
12+
await Promise.all([apiCreateUser(request, owner), apiCreateUser(request, commenter)]);
13+
14+
// Create repo and login in parallel — repo is needed for the issue, login for the event stream
15+
await Promise.all([
16+
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}),
17+
loginUser(page, owner),
18+
]);
19+
const badge = page.locator('a.not-mobile .notification_count');
20+
await expect(badge).toBeHidden();
21+
22+
// Create issue as another user — this generates a notification delivered via server push
23+
await apiCreateIssue(request, owner, repoName, {title: 'events notification test', headers: apiUserHeaders(commenter)});
24+
25+
// Wait for the notification badge to appear via server event
26+
await expect(badge).toBeVisible({timeout: 15000});
27+
28+
// Cleanup
29+
await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]);
30+
});
31+
32+
test('stopwatch', async ({page, request}) => {
33+
const name = `ev-sw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
34+
const headers = apiUserHeaders(name);
35+
36+
await apiCreateUser(request, name);
37+
38+
// Create repo, issue, and start stopwatch before login
39+
await apiCreateRepo(request, {name, headers});
40+
await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers});
41+
await apiStartStopwatch(request, name, name, 1, {headers});
42+
43+
// Login — page renders with the active stopwatch element
44+
await loginUser(page, name);
45+
46+
// Verify stopwatch is visible and links to the correct issue
47+
const stopwatch = page.locator('.active-stopwatch.not-mobile');
48+
await expect(stopwatch).toBeVisible();
49+
50+
// Cleanup
51+
await apiDeleteUser(request, name);
52+
});
53+
54+
test('logout propagation', async ({browser, request}) => {
55+
const name = `ev-logout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
56+
57+
await apiCreateUser(request, name);
58+
59+
// Use a single context so both pages share the same session and SharedWorker
60+
const context = await browser.newContext({baseURL: baseUrl()});
61+
const page1 = await context.newPage();
62+
const page2 = await context.newPage();
63+
64+
await loginUser(page1, name);
65+
66+
// Navigate page2 so it connects to the shared event stream
67+
await page2.goto('/');
68+
69+
// Verify page2 is logged in
70+
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden();
71+
72+
// Logout from page1 — this sends a logout event to all tabs
73+
await page1.goto('/user/logout');
74+
75+
// page2 should be redirected via the logout event
76+
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible();
77+
78+
await context.close();
79+
80+
// Cleanup
81+
await apiDeleteUser(request, name);
82+
});
83+
});

tests/e2e/register.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {env} from 'node:process';
22
import {test, expect} from '@playwright/test';
3-
import {login, logout} from './utils.ts';
3+
import {login, logout, apiDeleteUser} from './utils.ts';
44

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

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

5956
test('register with existing username shows error', async ({page}) => {

tests/e2e/utils.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import {randomBytes} from 'node:crypto';
12
import {env} from 'node:process';
23
import {expect} from '@playwright/test';
34
import type {APIRequestContext, Locator, Page} from '@playwright/test';
45

5-
export function apiBaseUrl() {
6+
export function baseUrl() {
67
return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, '');
78
}
89

10+
function apiAuthHeader(username: string, password: string) {
11+
return {Authorization: `Basic ${globalThis.btoa(`${username}:${password}`)}`};
12+
}
13+
914
export function apiHeaders() {
10-
return {Authorization: `Basic ${globalThis.btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`};
15+
return apiAuthHeader(env.GITEA_TEST_E2E_USER, env.GITEA_TEST_E2E_PASSWORD);
1116
}
1217

1318
async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise<string>}>, label: string) {
@@ -24,30 +29,73 @@ async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => numb
2429
}
2530
}
2631

27-
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) {
28-
await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, {
29-
headers: apiHeaders(),
32+
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true, headers}: {name: string; autoInit?: boolean; headers?: Record<string, string>}) {
33+
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/user/repos`, {
34+
headers: headers || apiHeaders(),
3035
data: {name, auto_init: autoInit},
3136
}), 'apiCreateRepo');
3237
}
3338

39+
export async function apiCreateIssue(requestContext: APIRequestContext, owner: string, repo: string, {title, headers}: {title: string; headers?: Record<string, string>}) {
40+
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
41+
headers: headers || apiHeaders(),
42+
data: {title},
43+
}), 'apiCreateIssue');
44+
}
45+
46+
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
47+
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/start`, {
48+
headers: headers || apiHeaders(),
49+
}), 'apiStartStopwatch');
50+
}
51+
3452
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
35-
await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/repos/${owner}/${name}`, {
53+
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
3654
headers: apiHeaders(),
3755
}), 'apiDeleteRepo');
3856
}
3957

4058
export async function apiDeleteOrg(requestContext: APIRequestContext, name: string) {
41-
await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/api/v1/orgs/${name}`, {
59+
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/orgs/${name}`, {
4260
headers: apiHeaders(),
4361
}), 'apiDeleteOrg');
4462
}
4563

64+
/** Generate a random password that satisfies the complexity requirements. */
65+
function generatePassword() {
66+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
67+
return `${Array.from(randomBytes(12), (b) => chars[b % chars.length]).join('')}!aA1`;
68+
}
69+
70+
/** Random password shared by all test users — used for both API user creation and browser login. */
71+
const testUserPassword = generatePassword();
72+
73+
export function apiUserHeaders(username: string) {
74+
return apiAuthHeader(username, testUserPassword);
75+
}
76+
77+
export async function apiCreateUser(requestContext: APIRequestContext, username: string) {
78+
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/admin/users`, {
79+
headers: apiHeaders(),
80+
data: {username, password: testUserPassword, email: `${username}@${env.GITEA_TEST_E2E_DOMAIN}`, must_change_password: false},
81+
}), 'apiCreateUser');
82+
}
83+
84+
export async function apiDeleteUser(requestContext: APIRequestContext, username: string) {
85+
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/admin/users/${username}?purge=true`, {
86+
headers: apiHeaders(),
87+
}), 'apiDeleteUser');
88+
}
89+
4690
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
4791
await trigger.click();
4892
await page.getByText(itemText).click();
4993
}
5094

95+
export async function loginUser(page: Page, username: string) {
96+
return login(page, username, testUserPassword);
97+
}
98+
5199
export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) {
52100
await page.goto('/user/login');
53101
await page.getByLabel('Username or Email Address').fill(username);

tools/test-e2e.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ INSTALL_LOCK = true
3434
[service]
3535
ENABLE_CAPTCHA = false
3636
37+
[ui.notification]
38+
EVENT_SOURCE_UPDATE_TIME = 500ms
39+
3740
[log]
3841
MODE = console
3942
LEVEL = Warn

web_src/js/features/notification.ts

Lines changed: 8 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {GET} from '../modules/fetch.ts';
22
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
3-
import {logoutFromWorker} from '../modules/worker.ts';
3+
import {UserEventsSharedWorker} from '../modules/worker.ts';
44

5-
const {appSubUrl, notificationSettings, sharedWorkerPath} = window.config;
5+
const {appSubUrl, notificationSettings} = window.config;
66
let notificationSequenceNumber = 0;
77

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

3434
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
3535
// Try to connect to the event source via the shared worker first
36-
const worker = new SharedWorker(sharedWorkerPath, 'notification-worker');
37-
worker.addEventListener('error', (event) => {
38-
console.error('worker error', event);
39-
});
40-
worker.port.addEventListener('messageerror', () => {
41-
console.error('unable to deserialize message');
42-
});
43-
worker.port.postMessage({
44-
type: 'start',
45-
url: `${window.location.origin}${appSubUrl}/user/events`,
46-
});
47-
worker.port.addEventListener('message', (event: MessageEvent<{type: string, data: string}>) => {
48-
if (!event.data || !event.data.type) {
49-
console.error('unknown worker message event', event);
50-
return;
51-
}
52-
if (event.data.type === 'notification-count') {
53-
receiveUpdateCount(event); // no await
54-
} else if (event.data.type === 'no-event-source') {
55-
// browser doesn't support EventSource, falling back to periodic poller
36+
const worker = new UserEventsSharedWorker('notification-worker');
37+
worker.addMessageEventListener((event: MessageEvent) => {
38+
if (event.data.type === 'no-event-source') {
5639
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
57-
} else if (event.data.type === 'error') {
58-
console.error('worker port event error', event.data);
59-
} else if (event.data.type === 'logout') {
60-
if (event.data.data !== 'here') {
61-
return;
62-
}
63-
worker.port.postMessage({
64-
type: 'close',
65-
});
66-
worker.port.close();
67-
logoutFromWorker();
68-
} else if (event.data.type === 'close') {
69-
worker.port.postMessage({
70-
type: 'close',
71-
});
72-
worker.port.close();
40+
} else if (event.data.type === 'notification-count') {
41+
receiveUpdateCount(event); // no await
7342
}
7443
});
75-
worker.port.addEventListener('error', (e) => {
76-
console.error('worker port error', e);
77-
});
78-
worker.port.start();
79-
window.addEventListener('beforeunload', () => {
80-
worker.port.postMessage({
81-
type: 'close',
82-
});
83-
worker.port.close();
84-
});
85-
44+
worker.startPort();
8645
return;
8746
}
8847

web_src/js/features/stopwatch.ts

Lines changed: 8 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {createTippy} from '../modules/tippy.ts';
22
import {GET} from '../modules/fetch.ts';
33
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
4-
import {logoutFromWorker} from '../modules/worker.ts';
4+
import {UserEventsSharedWorker} from '../modules/worker.ts';
55

6-
const {appSubUrl, notificationSettings, enableTimeTracking, sharedWorkerPath} = window.config;
6+
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
77

88
export function initStopwatch() {
99
if (!enableTimeTracking) {
@@ -47,56 +47,16 @@ export function initStopwatch() {
4747
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
4848
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
4949
// Try to connect to the event source via the shared worker first
50-
const worker = new SharedWorker(sharedWorkerPath, 'notification-worker');
51-
worker.addEventListener('error', (event) => {
52-
console.error('worker error', event);
53-
});
54-
worker.port.addEventListener('messageerror', () => {
55-
console.error('unable to deserialize message');
56-
});
57-
worker.port.postMessage({
58-
type: 'start',
59-
url: `${window.location.origin}${appSubUrl}/user/events`,
60-
});
61-
worker.port.addEventListener('message', (event) => {
62-
if (!event.data || !event.data.type) {
63-
console.error('unknown worker message event', event);
64-
return;
65-
}
66-
if (event.data.type === 'stopwatches') {
67-
updateStopwatchData(JSON.parse(event.data.data));
68-
} else if (event.data.type === 'no-event-source') {
50+
const worker = new UserEventsSharedWorker('stopwatch-worker');
51+
worker.addMessageEventListener((event) => {
52+
if (event.data.type === 'no-event-source') {
6953
// browser doesn't support EventSource, falling back to periodic poller
7054
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
71-
} else if (event.data.type === 'error') {
72-
console.error('worker port event error', event.data);
73-
} else if (event.data.type === 'logout') {
74-
if (event.data.data !== 'here') {
75-
return;
76-
}
77-
worker.port.postMessage({
78-
type: 'close',
79-
});
80-
worker.port.close();
81-
logoutFromWorker();
82-
} else if (event.data.type === 'close') {
83-
worker.port.postMessage({
84-
type: 'close',
85-
});
86-
worker.port.close();
55+
} else if (event.data.type === 'stopwatches') {
56+
updateStopwatchData(JSON.parse(event.data.data));
8757
}
8858
});
89-
worker.port.addEventListener('error', (e) => {
90-
console.error('worker port error', e);
91-
});
92-
worker.port.start();
93-
window.addEventListener('beforeunload', () => {
94-
worker.port.postMessage({
95-
type: 'close',
96-
});
97-
worker.port.close();
98-
});
99-
59+
worker.startPort();
10060
return;
10161
}
10262

0 commit comments

Comments
 (0)