Add e2e tests for server push events (#36879)
Add e2e tests for the three server push features: - **Notification count**: verifies badge appears when another user creates an issue - **Stopwatch**: verifies stopwatch element is rendered when a stopwatch is active - **Logout propagation**: verifies logout in one tab triggers redirect in another Tests are transport-agnostic in preparation for a future WebSocket migration. --------- Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -672,7 +672,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
|
|||||||
endif
|
endif
|
||||||
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
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 $@
|
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||||
|
|
||||||
.PHONY: release
|
.PHONY: release
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {env} from 'node:process';
|
import {env} from 'node:process';
|
||||||
import {test, expect} from '@playwright/test';
|
import {test, expect} from '@playwright/test';
|
||||||
import {login, logout} from './utils.ts';
|
import {login, logout, apiDeleteUser} from './utils.ts';
|
||||||
|
|
||||||
test.beforeEach(async ({page}) => {
|
test.beforeEach(async ({page}) => {
|
||||||
await page.goto('/user/sign_up');
|
await page.goto('/user/sign_up');
|
||||||
@@ -50,10 +50,7 @@ test('register then login', async ({page}) => {
|
|||||||
await login(page, username, password);
|
await login(page, username, password);
|
||||||
|
|
||||||
// delete via API because of issues related to form-fetch-action
|
// delete via API because of issues related to form-fetch-action
|
||||||
const response = await page.request.delete(`/api/v1/admin/users/${username}?purge=true`, {
|
await apiDeleteUser(page.request, username);
|
||||||
headers: {Authorization: `Basic ${btoa(`${env.GITEA_TEST_E2E_USER}:${env.GITEA_TEST_E2E_PASSWORD}`)}`},
|
|
||||||
});
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('register with existing username shows error', async ({page}) => {
|
test('register with existing username shows error', async ({page}) => {
|
||||||
|
|||||||
+55
-7
@@ -1,13 +1,18 @@
|
|||||||
|
import {randomBytes} from 'node:crypto';
|
||||||
import {env} from 'node:process';
|
import {env} from 'node:process';
|
||||||
import {expect} from '@playwright/test';
|
import {expect} from '@playwright/test';
|
||||||
import type {APIRequestContext, Locator, Page} 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, '');
|
return env.GITEA_TEST_E2E_URL?.replace(/\/$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiAuthHeader(username: string, password: string) {
|
||||||
|
return {Authorization: `Basic ${globalThis.btoa(`${username}:${password}`)}`};
|
||||||
|
}
|
||||||
|
|
||||||
export function apiHeaders() {
|
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) {
|
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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true}: {name: string; autoInit?: boolean}) {
|
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true, headers}: {name: string; autoInit?: boolean; headers?: Record<string, string>}) {
|
||||||
await apiRetry(() => requestContext.post(`${apiBaseUrl()}/api/v1/user/repos`, {
|
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/user/repos`, {
|
||||||
headers: apiHeaders(),
|
headers: headers || apiHeaders(),
|
||||||
data: {name, auto_init: autoInit},
|
data: {name, auto_init: autoInit},
|
||||||
}), 'apiCreateRepo');
|
}), '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) {
|
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(),
|
headers: apiHeaders(),
|
||||||
}), 'apiDeleteRepo');
|
}), 'apiDeleteRepo');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDeleteOrg(requestContext: APIRequestContext, name: string) {
|
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(),
|
headers: apiHeaders(),
|
||||||
}), 'apiDeleteOrg');
|
}), '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) {
|
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
await page.getByText(itemText).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) {
|
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.goto('/user/login');
|
||||||
await page.getByLabel('Username or Email Address').fill(username);
|
await page.getByLabel('Username or Email Address').fill(username);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ INSTALL_LOCK = true
|
|||||||
[service]
|
[service]
|
||||||
ENABLE_CAPTCHA = false
|
ENABLE_CAPTCHA = false
|
||||||
|
|
||||||
|
[ui.notification]
|
||||||
|
EVENT_SOURCE_UPDATE_TIME = 500ms
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
MODE = console
|
MODE = console
|
||||||
LEVEL = Warn
|
LEVEL = Warn
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {toggleElem, createElementFromHTML} from '../utils/dom.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;
|
let notificationSequenceNumber = 0;
|
||||||
|
|
||||||
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
|
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
|
||||||
@@ -33,56 +33,15 @@ export function initNotificationCount() {
|
|||||||
|
|
||||||
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
|
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
|
||||||
// Try to connect to the event source via the shared worker first
|
// 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');
|
const worker = new UserEventsSharedWorker('notification-worker');
|
||||||
worker.addEventListener('error', (event) => {
|
worker.addMessageEventListener((event: MessageEvent) => {
|
||||||
console.error('worker error', event);
|
if (event.data.type === 'no-event-source') {
|
||||||
});
|
|
||||||
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
|
|
||||||
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
||||||
} else if (event.data.type === 'error') {
|
} else if (event.data.type === 'notification-count') {
|
||||||
console.error('worker port event error', event.data);
|
receiveUpdateCount(event); // no await
|
||||||
} 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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
worker.port.addEventListener('error', (e) => {
|
worker.startPort();
|
||||||
console.error('worker port error', e);
|
|
||||||
});
|
|
||||||
worker.port.start();
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
worker.port.postMessage({
|
|
||||||
type: 'close',
|
|
||||||
});
|
|
||||||
worker.port.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {createTippy} from '../modules/tippy.ts';
|
import {createTippy} from '../modules/tippy.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {hideElem, queryElems, showElem} from '../utils/dom.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() {
|
export function initStopwatch() {
|
||||||
if (!enableTimeTracking) {
|
if (!enableTimeTracking) {
|
||||||
@@ -47,56 +47,16 @@ export function initStopwatch() {
|
|||||||
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
|
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
|
||||||
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
|
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
|
||||||
// Try to connect to the event source via the shared worker first
|
// 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');
|
const worker = new UserEventsSharedWorker('stopwatch-worker');
|
||||||
worker.addEventListener('error', (event) => {
|
worker.addMessageEventListener((event) => {
|
||||||
console.error('worker error', event);
|
if (event.data.type === 'no-event-source') {
|
||||||
});
|
|
||||||
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') {
|
|
||||||
// browser doesn't support EventSource, falling back to periodic poller
|
// browser doesn't support EventSource, falling back to periodic poller
|
||||||
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
||||||
} else if (event.data.type === 'error') {
|
} else if (event.data.type === 'stopwatches') {
|
||||||
console.error('worker port event error', event.data);
|
updateStopwatchData(JSON.parse(event.data.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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
worker.port.addEventListener('error', (e) => {
|
worker.startPort();
|
||||||
console.error('worker port error', e);
|
|
||||||
});
|
|
||||||
worker.port.start();
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
worker.port.postMessage({
|
|
||||||
type: 'close',
|
|
||||||
});
|
|
||||||
worker.port.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> {
|
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
|
||||||
// wait for a while because other requests (eg: logout) may be in the flight
|
constructor(options?: string | WorkerOptions) {
|
||||||
await sleep(5000);
|
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options);
|
||||||
window.location.href = `${appSubUrl}/`;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user