From df2fc5d934391fcc5dee6f459ebf345988514d17 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 08:20:13 +0100 Subject: [PATCH 01/17] Add e2e tests for server-sent events Add tests for the three SSE features: notification count updates, stopwatch events, and logout propagation across tabs. Also extract user management helpers (apiCreateUser, apiDeleteUser, loginUser) into the shared e2e utils and use them in register tests. Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/events.test.ts | 123 +++++++++++++++++++++++++++++++++++++ tests/e2e/register.test.ts | 7 +-- tests/e2e/utils.ts | 29 ++++++++- tools/test-e2e.sh | 3 + 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/events.test.ts diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts new file mode 100644 index 0000000000000..0e881bf8b97d5 --- /dev/null +++ b/tests/e2e/events.test.ts @@ -0,0 +1,123 @@ +import {test, expect} from '@playwright/test'; +import {loginUser, apiBaseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser} from './utils.ts'; + +// These tests rely on EVENT_SOURCE_UPDATE_TIME=2s in the e2e server config. +test.describe('Events', () => { + test.describe.configure({timeout: 30000}); + + 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 a repo owned by the dedicated user + await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { + headers: apiUserHeaders(owner), + data: {name: repoName, auto_init: true}, + }); + + // Login as the owner so the event connection starts + await loginUser(page, owner); + + // Verify notification badge is initially hidden (use desktop variant) + const badge = page.locator('a.not-mobile .notification_count'); + await expect(badge).toBeHidden(); + + // Create an issue as the commenter to generate a notification for the owner + await request.post(`${apiBaseUrl()}/api/v1/repos/${owner}/${repoName}/issues`, { + headers: apiUserHeaders(commenter), + data: {title: 'events notification test'}, + }); + + // Wait for the notification badge to appear via server event + await expect(badge).toBeVisible({timeout: 20000}); + + // Cleanup + await apiDeleteUser(request, commenter); + await 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 a repo and issue owned by the dedicated user + await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { + headers, + data: {name, auto_init: true}, + }); + await request.post(`${apiBaseUrl()}/api/v1/repos/${name}/${name}/issues`, { + headers, + data: {title: 'events stopwatch test'}, + }); + await request.post(`${apiBaseUrl()}/api/v1/repos/${name}/${name}/issues/1/stopwatch/start`, { + headers, + }); + + // Login as the dedicated user + await loginUser(page, name); + + // Listen for a stopwatch event via a direct EventSource connection + const stopwatchData = await page.evaluate(() => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('stopwatch event timeout')), 15000); + const es = new EventSource('/user/events'); + es.addEventListener('stopwatches', (e: MessageEvent) => { + clearTimeout(timeout); + es.close(); + resolve(JSON.parse(e.data)); + }); + es.addEventListener('error', () => { + clearTimeout(timeout); + es.close(); + reject(new Error('EventSource connection error')); + }); + }); + }); + + expect(stopwatchData).toHaveLength(1); + expect(stopwatchData[0].repo_owner_name).toBe(name); + expect(stopwatchData[0].repo_name).toBe(name); + expect(stopwatchData[0].issue_index).toBe(1); + + // 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(); + const page1 = await context.newPage(); + const page2 = await context.newPage(); + + await loginUser(page1, name); + + // Navigate page2 so the SharedWorker connects on both pages + await page2.goto('/'); + await page2.waitForTimeout(1000); + + // Verify page2 is logged in + await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden(); + + // Logout from page1 — this sends a logout event + await page1.goto('/user/logout'); + + // page2 should be redirected via logout event + // (logoutFromWorker waits 5s before redirecting) + await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible({timeout: 15000}); + + await context.close(); + + // Cleanup + await apiDeleteUser(request, name); + }); +}); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index 425fc7e40c247..5c70541747f38 100644 --- a/tests/e2e/register.test.ts +++ b/tests/e2e/register.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {test, expect} from '@playwright/test'; -import {login, logout} from './utils.ts'; +import {login, logout, apiDeleteUser} from './utils.ts'; test.beforeEach(async ({page}) => { await page.goto('/user/sign_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}) => { diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 6ee16b32f861c..790c1608a6341 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -6,8 +6,12 @@ export function apiBaseUrl() { 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}>, label: string) { @@ -43,11 +47,34 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri }), 'apiDeleteOrg'); } +const testUserPassword = 'password123!AA'; + +export function apiUserHeaders(username: string) { + return apiAuthHeader(username, testUserPassword); +} + +export async function apiCreateUser(requestContext: APIRequestContext, username: string) { + await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/admin/users`, { + headers: apiHeaders(), + data: {username, password: testUserPassword, email: `${username}@e2e.gitea.com`, must_change_password: false}, + }), 'apiCreateUser'); +} + +export async function apiDeleteUser(requestContext: APIRequestContext, username: string) { + await apiRetry(() => requestContext.delete(`${apiBaseUrl()}/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); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index d8608a85bbbd4..aafd8b63c7d64 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -34,6 +34,9 @@ INSTALL_LOCK = true [service] ENABLE_CAPTCHA = false +[ui.notification] +EVENT_SOURCE_UPDATE_TIME = 2s + [log] MODE = console LEVEL = Warn From 39aa8a07d0026e527943724a5502ce1a602394d9 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 08:42:53 +0100 Subject: [PATCH 02/17] Make event tests transport-agnostic and faster Remove SSE-specific code (raw EventSource connections) from e2e tests so they work with any transport (EventSource, WebSocket, etc.). - Stopwatch test: verify DOM element instead of raw EventSource data - Logout test: remove EventSource open probe and waitForTimeout - Remove unnecessary 5s sleep from logoutFromWorker (session is already destroyed server-side before the event reaches the client) - Reduce EVENT_SOURCE_UPDATE_TIME from 2s to 1s for faster tests - Raise timeouts for CI (60s assertions, 120s describe) Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/events.test.ts | 60 ++++++++++-------------------------- tools/test-e2e.sh | 2 +- web_src/js/modules/worker.ts | 6 +--- 3 files changed, 19 insertions(+), 49 deletions(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 0e881bf8b97d5..e144490950174 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -1,9 +1,9 @@ import {test, expect} from '@playwright/test'; import {loginUser, apiBaseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser} from './utils.ts'; -// These tests rely on EVENT_SOURCE_UPDATE_TIME=2s in the e2e server config. +// These tests rely on EVENT_SOURCE_UPDATE_TIME=1s in the e2e server config. test.describe('Events', () => { - test.describe.configure({timeout: 30000}); + test.describe.configure({timeout: 120000}); test('notification count', async ({page, request}) => { const id = `ev-notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; @@ -13,27 +13,22 @@ test.describe('Events', () => { await Promise.all([apiCreateUser(request, owner), apiCreateUser(request, commenter)]); - // Create a repo owned by the dedicated user + // Create repo and issue before login so the notification exists when event stream connects await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { headers: apiUserHeaders(owner), data: {name: repoName, auto_init: true}, }); - - // Login as the owner so the event connection starts - await loginUser(page, owner); - - // Verify notification badge is initially hidden (use desktop variant) - const badge = page.locator('a.not-mobile .notification_count'); - await expect(badge).toBeHidden(); - - // Create an issue as the commenter to generate a notification for the owner await request.post(`${apiBaseUrl()}/api/v1/repos/${owner}/${repoName}/issues`, { headers: apiUserHeaders(commenter), data: {title: 'events notification test'}, }); + // Login as the owner — the first server event poll picks up the notification + await loginUser(page, owner); + // Wait for the notification badge to appear via server event - await expect(badge).toBeVisible({timeout: 20000}); + const badge = page.locator('a.not-mobile .notification_count'); + await expect(badge).toBeVisible({timeout: 60000}); // Cleanup await apiDeleteUser(request, commenter); @@ -46,7 +41,7 @@ test.describe('Events', () => { await apiCreateUser(request, name); - // Create a repo and issue owned by the dedicated user + // Create repo, issue, and start stopwatch before login await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { headers, data: {name, auto_init: true}, @@ -59,31 +54,12 @@ test.describe('Events', () => { headers, }); - // Login as the dedicated user + // Login — page renders with the active stopwatch element await loginUser(page, name); - // Listen for a stopwatch event via a direct EventSource connection - const stopwatchData = await page.evaluate(() => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('stopwatch event timeout')), 15000); - const es = new EventSource('/user/events'); - es.addEventListener('stopwatches', (e: MessageEvent) => { - clearTimeout(timeout); - es.close(); - resolve(JSON.parse(e.data)); - }); - es.addEventListener('error', () => { - clearTimeout(timeout); - es.close(); - reject(new Error('EventSource connection error')); - }); - }); - }); - - expect(stopwatchData).toHaveLength(1); - expect(stopwatchData[0].repo_owner_name).toBe(name); - expect(stopwatchData[0].repo_name).toBe(name); - expect(stopwatchData[0].issue_index).toBe(1); + // 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); @@ -101,19 +77,17 @@ test.describe('Events', () => { await loginUser(page1, name); - // Navigate page2 so the SharedWorker connects on both pages + // Navigate page2 so it connects to the shared event stream await page2.goto('/'); - await page2.waitForTimeout(1000); // Verify page2 is logged in await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden(); - // Logout from page1 — this sends a logout event + // Logout from page1 — this sends a logout event to all tabs await page1.goto('/user/logout'); - // page2 should be redirected via logout event - // (logoutFromWorker waits 5s before redirecting) - await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible({timeout: 15000}); + // page2 should be redirected via the logout event + await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible({timeout: 60000}); await context.close(); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index aafd8b63c7d64..91804b27b55d5 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -35,7 +35,7 @@ INSTALL_LOCK = true ENABLE_CAPTCHA = false [ui.notification] -EVENT_SOURCE_UPDATE_TIME = 2s +EVENT_SOURCE_UPDATE_TIME = 1s [log] MODE = console diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index af2e52f411e2d..3da303c4eef60 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -1,9 +1,5 @@ -import {sleep} from '../utils.ts'; - const {appSubUrl} = window.config; -export async function logoutFromWorker(): Promise { - // wait for a while because other requests (eg: logout) may be in the flight - await sleep(5000); +export function logoutFromWorker(): void { window.location.href = `${appSubUrl}/`; } From fbbb46f9226424dbd36666811b2be95fb8cdcda5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 08:43:49 +0100 Subject: [PATCH 03/17] Rename test describe from "Events" to "events" Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/events.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index e144490950174..3e2a6dd8b152a 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -2,7 +2,7 @@ import {test, expect} from '@playwright/test'; import {loginUser, apiBaseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser} from './utils.ts'; // These tests rely on EVENT_SOURCE_UPDATE_TIME=1s in the e2e server config. -test.describe('Events', () => { +test.describe('events', () => { test.describe.configure({timeout: 120000}); test('notification count', async ({page, request}) => { From 0a8a248df8b4efc59535b5f9128e69115b553d1d Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 08:53:34 +0100 Subject: [PATCH 04/17] Reduce test timeout from 120s to 90s Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/events.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 3e2a6dd8b152a..618bc03a78a04 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -3,7 +3,7 @@ import {loginUser, apiBaseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser} fro // These tests rely on EVENT_SOURCE_UPDATE_TIME=1s in the e2e server config. test.describe('events', () => { - test.describe.configure({timeout: 120000}); + test.describe.configure({timeout: 90000}); test('notification count', async ({page, request}) => { const id = `ev-notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; From ec929484b1b9fe54724f01e02d22e61ea1124f0d Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 09:29:29 +0100 Subject: [PATCH 05/17] Improve e2e event tests and utilities - Reorder notification test to login first, then create issue, so the badge appearance is actually driven by server push, not SSR - Pass baseURL to browser.newContext() so relative URLs work in the logout propagation test - Add apiCreateIssue and apiStartStopwatch helpers with retry logic - Add optional headers param to apiCreateRepo for per-user auth - Use helpers instead of raw request.post() calls in event tests - Rename apiBaseUrl to baseUrl as it is the general server URL - Use env.GITEA_TEST_E2E_DOMAIN instead of hardcoded email domain Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/events.test.ts | 39 ++++++++++++++------------------------- tests/e2e/utils.ts | 31 ++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 618bc03a78a04..b0e726cc0f9a9 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {loginUser, apiBaseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser} from './utils.ts'; +import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch} from './utils.ts'; // These tests rely on EVENT_SOURCE_UPDATE_TIME=1s in the e2e server config. test.describe('events', () => { @@ -13,21 +13,18 @@ test.describe('events', () => { await Promise.all([apiCreateUser(request, owner), apiCreateUser(request, commenter)]); - // Create repo and issue before login so the notification exists when event stream connects - await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { - headers: apiUserHeaders(owner), - data: {name: repoName, auto_init: true}, - }); - await request.post(`${apiBaseUrl()}/api/v1/repos/${owner}/${repoName}/issues`, { - headers: apiUserHeaders(commenter), - data: {title: 'events notification test'}, - }); - - // Login as the owner — the first server event poll picks up the notification + // Create repo before login + await apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}); + + // Login as the owner first — event stream connects with no unread notifications await 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 - const badge = page.locator('a.not-mobile .notification_count'); await expect(badge).toBeVisible({timeout: 60000}); // Cleanup @@ -42,17 +39,9 @@ test.describe('events', () => { await apiCreateUser(request, name); // Create repo, issue, and start stopwatch before login - await request.post(`${apiBaseUrl()}/api/v1/user/repos`, { - headers, - data: {name, auto_init: true}, - }); - await request.post(`${apiBaseUrl()}/api/v1/repos/${name}/${name}/issues`, { - headers, - data: {title: 'events stopwatch test'}, - }); - await request.post(`${apiBaseUrl()}/api/v1/repos/${name}/${name}/issues/1/stopwatch/start`, { - headers, - }); + 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); @@ -71,7 +60,7 @@ test.describe('events', () => { await apiCreateUser(request, name); // Use a single context so both pages share the same session and SharedWorker - const context = await browser.newContext(); + const context = await browser.newContext({baseURL: baseUrl()}); const page1 = await context.newPage(); const page2 = await context.newPage(); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 790c1608a6341..be8503e271ee2 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -2,7 +2,7 @@ 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, ''); } @@ -28,21 +28,34 @@ 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}) { + 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}) { + 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} = {}) { + 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'); } @@ -54,14 +67,14 @@ export function apiUserHeaders(username: string) { } export async function apiCreateUser(requestContext: APIRequestContext, username: string) { - await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/admin/users`, { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/admin/users`, { headers: apiHeaders(), - data: {username, password: testUserPassword, email: `${username}@e2e.gitea.com`, must_change_password: false}, + 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(`${apiBaseUrl()}/api/v1/admin/users/${username}?purge=true`, { + await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/admin/users/${username}?purge=true`, { headers: apiHeaders(), }), 'apiDeleteUser'); } From 5d5322cd170f0e0384bbe12bb4e1df98dce862fd Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 20:01:25 +0100 Subject: [PATCH 06/17] Generate random test user password instead of hardcoding Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index be8503e271ee2..d790003f7201e 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,3 +1,4 @@ +import {randomBytes} from 'node:crypto'; import {env} from 'node:process'; import {expect} from '@playwright/test'; import type {APIRequestContext, Locator, Page} from '@playwright/test'; @@ -60,7 +61,14 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri }), 'apiDeleteOrg'); } -const testUserPassword = 'password123!AA'; +/** 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); From 0f8db2a3c9721908b4259ed2233b6c23b29b55f4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 20:02:17 +0100 Subject: [PATCH 07/17] Use JSDoc comment for testUserPassword Co-Authored-By: Claude (Opus 4.6) --- tests/e2e/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index d790003f7201e..aded858600253 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -67,7 +67,7 @@ function generatePassword() { 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. +/** Random password shared by all test users — used for both API user creation and browser login. */ const testUserPassword = generatePassword(); export function apiUserHeaders(username: string) { From 5faae40f7a5f0166051861ff82101ea479b0c6e6 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 25 Mar 2026 21:48:58 +0100 Subject: [PATCH 08/17] Improve e2e event tests: reduce timeouts, parallelize cleanup - Remove 90s describe timeout, reduce assertion timeouts from 60s to 15s - Lower EVENT_SOURCE_UPDATE_TIME from 1s to 500ms for faster event delivery - Parallelize cleanup apiDeleteUser calls - Remove unnecessary timeout on logout propagation assertion Co-Authored-By: Claude (claude-opus-4-6) --- tests/e2e/events.test.ts | 11 ++++------- tools/test-e2e.sh | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index b0e726cc0f9a9..b731d32cb54f3 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -1,10 +1,8 @@ import {test, expect} from '@playwright/test'; import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch} from './utils.ts'; -// These tests rely on EVENT_SOURCE_UPDATE_TIME=1s in the e2e server config. +// These tests rely on a short EVENT_SOURCE_UPDATE_TIME in the e2e server config. test.describe('events', () => { - test.describe.configure({timeout: 90000}); - test('notification count', async ({page, request}) => { const id = `ev-notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const owner = `${id}-owner`; @@ -25,11 +23,10 @@ test.describe('events', () => { 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: 60000}); + await expect(badge).toBeVisible({timeout: 15000}); // Cleanup - await apiDeleteUser(request, commenter); - await apiDeleteUser(request, owner); + await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]); }); test('stopwatch', async ({page, request}) => { @@ -76,7 +73,7 @@ test.describe('events', () => { await page1.goto('/user/logout'); // page2 should be redirected via the logout event - await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible({timeout: 60000}); + await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible(); await context.close(); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index 91804b27b55d5..1ee513c1093b9 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -35,7 +35,7 @@ INSTALL_LOCK = true ENABLE_CAPTCHA = false [ui.notification] -EVENT_SOURCE_UPDATE_TIME = 1s +EVENT_SOURCE_UPDATE_TIME = 500ms [log] MODE = console From a16e119de178728d125a39b1074af8190bee1e35 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 25 Mar 2026 22:11:51 +0100 Subject: [PATCH 09/17] Add frontend dep to test-e2e, parallelize repo creation and login - Add $(WEBPACK_DEST) dependency to test-e2e target so frontend assets are rebuilt when sources change - Parallelize apiCreateRepo and loginUser in notification count test Co-Authored-By: Claude (claude-opus-4-6) --- Makefile | 2 +- tests/e2e/events.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 4d1bd96ea51c3..37aee534cb6e5 100644 --- a/Makefile +++ b/Makefile @@ -537,7 +537,7 @@ playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright $(EXECUTABLE_E2E) +test-e2e: playwright $(EXECUTABLE_E2E) $(WEBPACK_DEST) @EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index b731d32cb54f3..61f1a3c881796 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -11,11 +11,11 @@ test.describe('events', () => { await Promise.all([apiCreateUser(request, owner), apiCreateUser(request, commenter)]); - // Create repo before login - await apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}); - - // Login as the owner first — event stream connects with no unread notifications - await loginUser(page, owner); + // 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(); From f79c8ea75dc634d56b4ba4a24c73712035ed9c50 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 25 Mar 2026 22:15:08 +0100 Subject: [PATCH 10/17] Move frontend asset dependency to EXECUTABLE_E2E target Co-Authored-By: Claude (claude-opus-4-6) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 37aee534cb6e5..8b435ffe4932c 100644 --- a/Makefile +++ b/Makefile @@ -537,7 +537,7 @@ playwright: deps-frontend @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright $(EXECUTABLE_E2E) $(WEBPACK_DEST) +test-e2e: playwright $(EXECUTABLE_E2E) @EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite @@ -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 From 1d950facc7ea54e1addd245b1d0ad8ed033506d7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 14:56:52 +0800 Subject: [PATCH 11/17] refactor legacy shared worker code --- web_src/js/features/notification.ts | 57 ++++------------------------ web_src/js/features/stopwatch.ts | 56 ++++------------------------ web_src/js/modules/worker.ts | 58 +++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 100 deletions(-) diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index 915f65f88d8b2..556bd172da601 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -1,8 +1,8 @@ import {GET} from '../modules/fetch.ts'; import {toggleElem, createElementFromHTML} from '../utils/dom.ts'; -import {logoutFromWorker} from '../modules/worker.ts'; +import {initUserEventsSharedWorker} 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}>) { @@ -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 = initUserEventsSharedWorker('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.start(); return; } diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 34e985332b3a6..3fd12a5c753a1 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -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 {initUserEventsSharedWorker} from '../modules/worker.ts'; -const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config; +const {appSubUrl, notificationSettings, enableTimeTracking} = window.config; export function initStopwatch() { if (!enableTimeTracking) { @@ -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 = initUserEventsSharedWorker('notification-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.start(); return; } diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index 3da303c4eef60..3f33a5f4872ca 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -1,5 +1,57 @@ -const {appSubUrl} = window.config; +const {appSubUrl, assetVersionEncoded} = window.config; -export function logoutFromWorker(): void { - window.location.href = `${appSubUrl}/`; +class UserEventsSharedWorker { + sharedWorker: SharedWorker; + + constructor(worker: SharedWorker) { + this.sharedWorker = 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('error', (e) => { + console.error('worker port error', e); + }); + window.addEventListener('beforeunload', () => { + worker.port.postMessage({type: 'close'}); + worker.port.close(); + }); + } + + addMessageEventListener(listener: (event: MessageEvent) => void) { + this.sharedWorker.port.addEventListener('message', (event: MessageEvent) => { + if (!event.data || !event.data.type) { + console.error('unknown worker message event', event); + return; + } + + 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; + this.sharedWorker.port.postMessage({type: 'close'}); + this.sharedWorker.port.close(); + window.location.href = `${appSubUrl}/`; + } else if (event.data.type === 'close') { + this.sharedWorker.port.postMessage({type: 'close'}); + this.sharedWorker.port.close(); + } + listener(event); + }); + } + + start() { + this.sharedWorker.port.start(); + } +} + +export function initUserEventsSharedWorker(options: string) : UserEventsSharedWorker { + const sharedWorker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); + return new UserEventsSharedWorker(sharedWorker); } From a5c9ee24d88a45f1fcdfa25c2bdc05db27159a53 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 15:26:33 +0800 Subject: [PATCH 12/17] rename --- web_src/js/features/notification.ts | 2 +- web_src/js/features/stopwatch.ts | 2 +- web_src/js/modules/worker.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index 556bd172da601..d267f2caaa748 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -41,7 +41,7 @@ export function initNotificationCount() { receiveUpdateCount(event); // no await } }); - worker.start(); + worker.startPort(); return; } diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 3fd12a5c753a1..9a09288fe1143 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -56,7 +56,7 @@ export function initStopwatch() { updateStopwatchData(JSON.parse(event.data.data)); } }); - worker.start(); + worker.startPort(); return; } diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index 3f33a5f4872ca..f32b25504e75c 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -46,7 +46,7 @@ class UserEventsSharedWorker { }); } - start() { + startPort() { this.sharedWorker.port.start(); } } From e60338039bbfda8578a2ce09be46f02db8d34d70 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 15:28:57 +0800 Subject: [PATCH 13/17] use UserEventsSharedWorker class directly --- web_src/js/features/notification.ts | 4 ++-- web_src/js/features/stopwatch.ts | 4 ++-- web_src/js/modules/worker.ts | 10 +++------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index d267f2caaa748..acb1b68f28a70 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -1,6 +1,6 @@ import {GET} from '../modules/fetch.ts'; import {toggleElem, createElementFromHTML} from '../utils/dom.ts'; -import {initUserEventsSharedWorker} from '../modules/worker.ts'; +import {UserEventsSharedWorker} from '../modules/worker.ts'; const {appSubUrl, notificationSettings} = window.config; let notificationSequenceNumber = 0; @@ -33,7 +33,7 @@ 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 = initUserEventsSharedWorker('notification-worker'); + const worker = new UserEventsSharedWorker('notification-worker'); worker.addMessageEventListener((event: MessageEvent) => { if (event.data.type === 'no-event-source') { if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 9a09288fe1143..e622e1be1ed6f 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -1,7 +1,7 @@ import {createTippy} from '../modules/tippy.ts'; import {GET} from '../modules/fetch.ts'; import {hideElem, queryElems, showElem} from '../utils/dom.ts'; -import {initUserEventsSharedWorker} from '../modules/worker.ts'; +import {UserEventsSharedWorker} from '../modules/worker.ts'; const {appSubUrl, notificationSettings, enableTimeTracking} = window.config; @@ -47,7 +47,7 @@ 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 = initUserEventsSharedWorker('notification-worker'); + const worker = new UserEventsSharedWorker('notification-worker'); worker.addMessageEventListener((event) => { if (event.data.type === 'no-event-source') { // browser doesn't support EventSource, falling back to periodic poller diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index f32b25504e75c..8e3677661b1b4 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -1,9 +1,10 @@ const {appSubUrl, assetVersionEncoded} = window.config; -class UserEventsSharedWorker { +export class UserEventsSharedWorker { sharedWorker: SharedWorker; - constructor(worker: SharedWorker) { + constructor(options: string) { + const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); this.sharedWorker = worker; worker.addEventListener('error', (event) => { console.error('worker error', event); @@ -50,8 +51,3 @@ class UserEventsSharedWorker { this.sharedWorker.port.start(); } } - -export function initUserEventsSharedWorker(options: string) : UserEventsSharedWorker { - const sharedWorker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); - return new UserEventsSharedWorker(sharedWorker); -} From 9b6a7386c11a2d01d45dd8c583d82f9d66ddc836 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 15:38:43 +0800 Subject: [PATCH 14/17] add comment for the delayed logout redirecting behavior --- web_src/js/modules/worker.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index 8e3677661b1b4..db9692ff0fc96 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -38,7 +38,13 @@ export class UserEventsSharedWorker { if (event.data.data !== 'here') return; this.sharedWorker.port.postMessage({type: 'close'}); this.sharedWorker.port.close(); - window.location.href = `${appSubUrl}/`; + // slightly delay our "logout" for a short while, in case there are other logout requests in-flight. + // * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout") + // * "beforeunload" event is triggered, this branch won't execute + // * if the logout is triggered by a fetch call + // * "beforeunload" event is not triggered before the fetch call is completed + // * there can be a data-race between the fetch call's redirecting and the "logout" message from the worker + setTimeout(() => { window.location.href = `${appSubUrl}/` }, 500); } else if (event.data.type === 'close') { this.sharedWorker.port.postMessage({type: 'close'}); this.sharedWorker.port.close(); From a1b6af6dd750d47294780eee0408bb4f61e2c178 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 18:01:54 +0800 Subject: [PATCH 15/17] fine tune comment --- web_src/js/modules/worker.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index db9692ff0fc96..aef324f6c2187 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -40,10 +40,12 @@ export class UserEventsSharedWorker { this.sharedWorker.port.close(); // slightly delay our "logout" for a short while, in case there are other logout requests in-flight. // * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout") - // * "beforeunload" event is triggered, this branch won't execute + // * "beforeunload" event is triggered, this code path won't execute // * if the logout is triggered by a fetch call - // * "beforeunload" event is not triggered before the fetch call is completed - // * there can be a data-race between the fetch call's redirecting and the "logout" message from the worker + // * "beforeunload" event is not triggered until JS does the redirection. + // * in this case, the logout fetch call already completes and has sent the "logout" message to the worker + // * there can be a data-race between the fetch call's redirection and the "logout" message from the worker + // * the fetch call's logout redirection should always win over the worker message, because it might have a custom location setTimeout(() => { window.location.href = `${appSubUrl}/` }, 500); } else if (event.data.type === 'close') { this.sharedWorker.port.postMessage({type: 'close'}); From 732961d9b9d3d90e0c57dc30d7ba4ccd7760d204 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 18:08:34 +0800 Subject: [PATCH 16/17] fix shared worker debug name --- web_src/js/features/stopwatch.ts | 2 +- web_src/js/modules/worker.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index e622e1be1ed6f..6fa8fbbdf36ea 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -47,7 +47,7 @@ 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 UserEventsSharedWorker('notification-worker'); + 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 diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index aef324f6c2187..c2cd63489481d 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -3,7 +3,8 @@ const {appSubUrl, assetVersionEncoded} = window.config; export class UserEventsSharedWorker { sharedWorker: SharedWorker; - constructor(options: string) { + // options can be either a string (the debug name of the worker) or an object of type WorkerOptions + constructor(options?: string | WorkerOptions) { const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); this.sharedWorker = worker; worker.addEventListener('error', (event) => { From b6b1cb631dd1a32389e44e933d30e6ba955af70f Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 27 Mar 2026 18:14:29 +0800 Subject: [PATCH 17/17] fix comment --- web_src/js/modules/worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index c2cd63489481d..b730e30bb2e4e 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -21,6 +21,9 @@ export class UserEventsSharedWorker { console.error('worker port error', e); }); window.addEventListener('beforeunload', () => { + // FIXME: this logic is not quite right. + // "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled. + // In this case: the worker port is incorrectly closed while the page is still there. worker.port.postMessage({type: 'close'}); worker.port.close(); }); @@ -47,7 +50,7 @@ export class UserEventsSharedWorker { // * in this case, the logout fetch call already completes and has sent the "logout" message to the worker // * there can be a data-race between the fetch call's redirection and the "logout" message from the worker // * the fetch call's logout redirection should always win over the worker message, because it might have a custom location - setTimeout(() => { window.location.href = `${appSubUrl}/` }, 500); + setTimeout(() => { window.location.href = `${appSubUrl}/` }, 1000); } else if (event.data.type === 'close') { this.sharedWorker.port.postMessage({type: 'close'}); this.sharedWorker.port.close();