diff --git a/Makefile b/Makefile index a55493ab809..5ca1c0eda6d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts new file mode 100644 index 00000000000..61f1a3c8817 --- /dev/null +++ b/tests/e2e/events.test.ts @@ -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); + }); +}); diff --git a/tests/e2e/register.test.ts b/tests/e2e/register.test.ts index 425fc7e40c2..5c70541747f 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 6ee16b32f86..aded8586002 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -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}>, label: string) { @@ -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}) { + 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'); } +/** 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); diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index d8608a85bbb..1ee513c1093 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 = 500ms + [log] MODE = console LEVEL = Warn diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index 915f65f88d8..acb1b68f28a 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 {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}>) { @@ -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; } diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 34e985332b3..6fa8fbbdf36 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 {UserEventsSharedWorker} 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 = 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; } diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index af2e52f411e..b730e30bb2e 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -1,9 +1,65 @@ -import {sleep} from '../utils.ts'; +const {appSubUrl, assetVersionEncoded} = window.config; -const {appSubUrl} = window.config; +export class UserEventsSharedWorker { + sharedWorker: SharedWorker; -export async function logoutFromWorker(): Promise { - // wait for a while because other requests (eg: logout) may be in the flight - await sleep(5000); - window.location.href = `${appSubUrl}/`; + // 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) => { + 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', () => { + // 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(); + }); + } + + 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(); + // 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 code path won't execute + // * if the logout is triggered by a fetch call + // * "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}/` }, 1000); + } else if (event.data.type === 'close') { + this.sharedWorker.port.postMessage({type: 'close'}); + this.sharedWorker.port.close(); + } + listener(event); + }); + } + + startPort() { + this.sharedWorker.port.start(); + } }