Skip to content

Commit 0aaf619

Browse files
authored
Replace WebSocket/STOMP push with Server-Sent Events (#316)
2 parents efefe47 + ecafccd commit 0aaf619

37 files changed

Lines changed: 2210 additions & 449 deletions

File tree

playwright/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ playwright-report/
55
test-results/
66
blob-report/
77
playwright/.cache/
8+
perf-results/

playwright/docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ services:
6969
networks: [wp]
7070

7171
webprotege-gwt-api-gateway:
72-
image: protegeproject/webprotege-gwt-api-gateway:3.0.2
72+
image: protegeproject/webprotege-gwt-api-gateway:3.0.3
7373
# Spring Security fetches the OIDC discovery doc at startup from
7474
# http://${SERVER_HOST}/keycloak/.../.well-known/openid-configuration,
7575
# which routes back through nginx. Nginx is downstream of this service
@@ -190,7 +190,7 @@ services:
190190
# real release of this service is amd64. Pinned back to the last
191191
# known-good amd64 build until an amd64/multi-arch :2.0.0 is published
192192
# (not yet filed upstream against webprotege-event-history-service).
193-
image: protegeproject/webprotege-event-history-service:2.0.25-WHO
193+
image: protegeproject/webprotege-event-history-service:2.0.1
194194
depends_on:
195195
mongo:
196196
condition: service_started
@@ -254,7 +254,7 @@ services:
254254
networks: [wp]
255255

256256
webprotege-backend-service:
257-
image: protegeproject/webprotege-backend-service:5.0.16
257+
image: protegeproject/webprotege-backend-service:5.0.17
258258
user: root
259259
depends_on:
260260
mongo:

playwright/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"test": "playwright test",
88
"test:headed": "playwright test --headed",
99
"test:smoke": "playwright test scenarios/regression-smoke",
10+
"test:perf": "playwright test -c playwright.perf.config.ts",
1011
"test:airplane": "playwright test scenarios/airplane-ontology",
1112
"report": "playwright show-report",
1213
"stack:up": "docker compose up -d --wait",
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { Page } from '@playwright/test';
4+
import { test, expect } from '../support/fixtures';
5+
import { CreateEntityDialog, Hierarchy, ProjectView } from '../support/selectors';
6+
7+
/**
8+
* UI performance baseline for epic #303 (WebSocket/STOMP -> SSE).
9+
*
10+
* Three user-perceived metrics, measured against whatever stack is running:
11+
* 1. Acting-user latency: create-class dialog submit -> new row in own tree.
12+
* 2. Propagation delay: user A submits -> the row appears in user B's tree.
13+
* 3. Project-open time, plus the size/duration of the project-events
14+
* history request that the 10s polling timer fires after open (#301).
15+
*
16+
* Results are printed, attached to the HTML report, and written as JSON to
17+
* test-results/perf/ so before/after runs can be diffed. Run via
18+
* `npm run test:perf` (workers=1, retries=0 — see playwright.perf.config.ts).
19+
*
20+
* Note for before/after comparisons: the project fixture installs a
21+
* MutationObserver error gate on the page. Its overhead is small and constant
22+
* and it is present in every run using this harness, so comparisons remain
23+
* valid — but do not compare against numbers gathered without the fixture.
24+
*/
25+
26+
const STORAGE_STATE = path.join(__dirname, '..', '.auth', 'storageState.json');
27+
// Not under test-results/ — Playwright wipes that directory at the start of
28+
// every run, which would destroy earlier metrics when re-running one test.
29+
const RESULTS_DIR = path.join(__dirname, '..', 'perf-results');
30+
31+
const LATENCY_ITERATIONS = 15;
32+
const PROPAGATION_ITERATIONS = 12;
33+
const OPEN_ITERATIONS = 5;
34+
const SEED_CLASS_COUNT = 25;
35+
// The first poll fires one full polling period (~10s) after project open, so
36+
// each open-iteration parks this long to catch the history request.
37+
const POLL_CAPTURE_WINDOW_MS = 13_000;
38+
39+
test.describe.configure({ mode: 'serial' });
40+
41+
interface MetricStats {
42+
n: number;
43+
medianMs: number;
44+
p90Ms: number;
45+
minMs: number;
46+
maxMs: number;
47+
samplesMs: number[];
48+
}
49+
50+
function statsOf(samples: number[]): MetricStats {
51+
const sorted = [...samples].sort((a, b) => a - b);
52+
const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
53+
return {
54+
n: sorted.length,
55+
medianMs: Math.round(at(0.5)),
56+
p90Ms: Math.round(at(0.9)),
57+
minMs: Math.round(sorted[0]),
58+
maxMs: Math.round(sorted[sorted.length - 1]),
59+
samplesMs: samples.map((s) => Math.round(s)),
60+
};
61+
}
62+
63+
function saveResults(name: string, data: unknown): void {
64+
fs.mkdirSync(RESULTS_DIR, { recursive: true });
65+
fs.writeFileSync(path.join(RESULTS_DIR, `${name}.json`), JSON.stringify(data, null, 2));
66+
}
67+
68+
function runMetadata() {
69+
return {
70+
baseUrl: process.env.WEBPROTEGE_BASE_URL ?? 'http://localhost',
71+
capturedAt: new Date().toISOString(),
72+
};
73+
}
74+
75+
/** Same 12 lines as tests/03-classes.spec.ts — duplicated per suite precedent. */
76+
async function createClassUnder(page: Page, parentLabel: string, newLabel: string): Promise<void> {
77+
await page.locator(Hierarchy.treeNode(parentLabel)).first().click();
78+
await page.locator(Hierarchy.toolbar.create).first().click();
79+
await expect(page.locator(CreateEntityDialog.root)).toBeVisible();
80+
await page.locator(CreateEntityDialog.name).fill(newLabel);
81+
await page.locator(CreateEntityDialog.submit).click();
82+
await expect(page.locator(Hierarchy.treeNode(newLabel))).toBeVisible({
83+
timeout: 15_000,
84+
});
85+
}
86+
87+
/**
88+
* Arm in-page timestamps: t0 on the dialog's primary-button click, t1 when a
89+
* tree row containing `label` first appears. Both are epoch-based
90+
* (performance.timeOrigin + performance.now()) so they can be compared across
91+
* pages on the same machine. In-page measurement avoids Playwright RPC/poll
92+
* jitter, which is comparable in size to sub-second latencies.
93+
*/
94+
async function armSubmitProbe(page: Page, label: string): Promise<void> {
95+
await page.evaluate((newLabel) => {
96+
const w = window as any;
97+
w.__perf = { t0: 0, t1: 0 };
98+
if (w.__perfClick) document.removeEventListener('click', w.__perfClick, true);
99+
w.__perfClick = (e: Event) => {
100+
const target = e.target as HTMLElement | null;
101+
if (w.__perf.t0 === 0 && target?.closest('.wp-modal button.wp-btn--dialog.wp-btn--primary')) {
102+
w.__perf.t0 = performance.timeOrigin + performance.now();
103+
}
104+
};
105+
document.addEventListener('click', w.__perfClick, true);
106+
if (w.__perfObs) w.__perfObs.disconnect();
107+
w.__perfObs = new MutationObserver(() => {
108+
for (const row of document.querySelectorAll('.gt-tree__row')) {
109+
if (row.textContent && row.textContent.includes(newLabel)) {
110+
w.__perf.t1 = performance.timeOrigin + performance.now();
111+
w.__perfObs.disconnect();
112+
return;
113+
}
114+
}
115+
});
116+
w.__perfObs.observe(document.body, { childList: true, subtree: true });
117+
}, label);
118+
}
119+
120+
/** Arm only the appearance half of the probe (for the observing page B). */
121+
async function armAppearanceProbe(page: Page, label: string): Promise<void> {
122+
await page.evaluate((newLabel) => {
123+
const w = window as any;
124+
w.__perf = { t0: 0, t1: 0 };
125+
if (w.__perfObs) w.__perfObs.disconnect();
126+
w.__perfObs = new MutationObserver(() => {
127+
for (const row of document.querySelectorAll('.gt-tree__row')) {
128+
if (row.textContent && row.textContent.includes(newLabel)) {
129+
w.__perf.t1 = performance.timeOrigin + performance.now();
130+
w.__perfObs.disconnect();
131+
return;
132+
}
133+
}
134+
});
135+
w.__perfObs.observe(document.body, { childList: true, subtree: true });
136+
}, label);
137+
}
138+
139+
const readPerf = (page: Page) => page.evaluate(() => (window as any).__perf as { t0: number; t1: number });
140+
141+
test('P1: acting-user latency — create-class submit to own tree update', async ({ page, project }, testInfo) => {
142+
const samples: number[] = [];
143+
for (let i = 0; i < LATENCY_ITERATIONS; i++) {
144+
const label = `PerfLat_${i}`;
145+
// Open and fill the dialog BEFORE arming, so the only primary-button
146+
// click after arming is the submit we want to time.
147+
await page.locator(Hierarchy.treeNode('owl:Thing')).first().click();
148+
await page.locator(Hierarchy.toolbar.create).first().click();
149+
await expect(page.locator(CreateEntityDialog.root)).toBeVisible();
150+
await page.locator(CreateEntityDialog.name).fill(label);
151+
await armSubmitProbe(page, label);
152+
await page.locator(CreateEntityDialog.submit).click();
153+
await page.waitForFunction(() => (window as any).__perf.t1 > 0, undefined, { timeout: 20_000 });
154+
const { t0, t1 } = await readPerf(page);
155+
expect(t0, 'submit click was not captured').toBeGreaterThan(0);
156+
samples.push(t1 - t0);
157+
}
158+
const result = { metric: 'acting-user-latency', ...runMetadata(), stats: statsOf(samples) };
159+
console.log(JSON.stringify(result, null, 2));
160+
await testInfo.attach('acting-user-latency', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
161+
saveResults('acting-user-latency', result);
162+
});
163+
164+
test('P2: propagation delay — user A edit to user B tree update', async ({ page, browser, project }, testInfo) => {
165+
// Second session as the same user, reusing the saved storage state — both
166+
// sessions receive project events; no sharing setup needed.
167+
const contextB = await browser.newContext({ storageState: STORAGE_STATE });
168+
const pageB = await contextB.newPage();
169+
170+
// Transport diagnostics on B: websocket frames on /wsapps, and completed
171+
// GetProjectEventsAction polls. Registered before goto so the socket opened
172+
// during bootstrap is captured. Node Date.now() is comparable to the pages'
173+
// epoch stamps on the same machine to within ms.
174+
const wsFrameTimes: number[] = [];
175+
pageB.on('websocket', (ws) => {
176+
if (!/wsapps/.test(ws.url())) return;
177+
ws.on('framereceived', () => wsFrameTimes.push(Date.now()));
178+
});
179+
// GWT-RPC bodies are obfuscated in production compiles, so individual
180+
// actions can't be identified; count every dispatch round-trip instead.
181+
// On the passive page B these are only the 10s poll ticks and the
182+
// TranslateEventListAction round-trip a websocket frame triggers.
183+
const pollDoneTimes: number[] = [];
184+
pageB.on('response', (response) => {
185+
if (!/dispatchservice/i.test(response.request().url())) return;
186+
pollDoneTimes.push(Date.now());
187+
});
188+
189+
await pageB.goto(project.url);
190+
await expect(pageB.locator(ProjectView.root)).toBeVisible({ timeout: 30_000 });
191+
await expect(pageB.locator(Hierarchy.treeNode('owl:Thing'))).toBeVisible({ timeout: 15_000 });
192+
193+
// Warmup edit: proves B's tree is live (and reveals owl:Thing's children)
194+
// before measurement starts. Falls back to selecting the root if the row
195+
// does not surface on its own.
196+
await armAppearanceProbe(pageB, 'PerfProp_warm');
197+
await createClassUnder(page, 'owl:Thing', 'PerfProp_warm');
198+
try {
199+
await pageB.waitForFunction(() => (window as any).__perf.t1 > 0, undefined, { timeout: 30_000 });
200+
} catch {
201+
await pageB.locator(Hierarchy.treeNode('owl:Thing')).first().click();
202+
await pageB.waitForFunction(() => (window as any).__perf.t1 > 0, undefined, { timeout: 15_000 });
203+
}
204+
205+
const samples: Array<{ deltaMs: number; wsFramesInWindow: number; dispatchRoundTripsInWindow: number }> = [];
206+
for (let i = 0; i < PROPAGATION_ITERATIONS; i++) {
207+
const label = `PerfProp_${i}`;
208+
await armAppearanceProbe(pageB, label);
209+
await page.locator(Hierarchy.treeNode('owl:Thing')).first().click();
210+
await page.locator(Hierarchy.toolbar.create).first().click();
211+
await expect(page.locator(CreateEntityDialog.root)).toBeVisible();
212+
await page.locator(CreateEntityDialog.name).fill(label);
213+
await armSubmitProbe(page, label);
214+
await page.locator(CreateEntityDialog.submit).click();
215+
await expect(page.locator(Hierarchy.treeNode(label))).toBeVisible({ timeout: 15_000 });
216+
// Worst case today is the 10s polling safety net; allow head-room.
217+
await pageB.waitForFunction(() => (window as any).__perf.t1 > 0, undefined, { timeout: 30_000 });
218+
const { t0 } = await readPerf(page);
219+
const { t1 } = await readPerf(pageB);
220+
expect(t0, 'submit click was not captured on A').toBeGreaterThan(0);
221+
samples.push({
222+
deltaMs: t1 - t0,
223+
wsFramesInWindow: wsFrameTimes.filter((t) => t >= t0 - 50 && t <= t1 + 50).length,
224+
dispatchRoundTripsInWindow: pollDoneTimes.filter((t) => t >= t0 - 50 && t <= t1 + 50).length,
225+
});
226+
}
227+
await contextB.close();
228+
229+
const result = {
230+
metric: 'propagation-delay',
231+
...runMetadata(),
232+
stats: statsOf(samples.map((s) => s.deltaMs)),
233+
// Propagation under STOMP+polling is expected to be bimodal; the raw
234+
// per-sample transport diagnostics make the split interpretable.
235+
samples,
236+
};
237+
console.log(JSON.stringify(result, null, 2));
238+
await testInfo.attach('propagation-delay', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
239+
saveResults('propagation-delay', result);
240+
});
241+
242+
test('P3: project-open time and event-history request', async ({ page, project }, testInfo) => {
243+
// Seed history with sequential creates — each is its own revision/event
244+
// batch (a single bulk create would collapse into one revision).
245+
for (let i = 0; i < SEED_CLASS_COUNT; i++) {
246+
await createClassUnder(page, 'owl:Thing', `PerfSeed_${String(i).padStart(2, '0')}`);
247+
}
248+
249+
// The production GWT compile obfuscates RPC type names (verified: request
250+
// bodies carry hash tokens like "8a", never "GetProjectEventsAction"), so
251+
// requests cannot be identified by body content. Instead, capture every
252+
// dispatchservice round-trip: the page is parked idle after ready, so any
253+
// request initiated during the park window is timer-driven — i.e. the
254+
// project-events poll. The full-history response is the largest of those.
255+
interface EventsRequestSample {
256+
sinceNavMs: number;
257+
requestMs: number;
258+
bytes: number;
259+
}
260+
let navStart = 0;
261+
let dispatchSamples: EventsRequestSample[] = [];
262+
page.on('response', async (response) => {
263+
const req = response.request();
264+
if (!/dispatchservice/i.test(req.url())) return;
265+
try {
266+
dispatchSamples.push({
267+
sinceNavMs: Date.now() - navStart,
268+
requestMs: Math.round(response.request().timing().responseEnd),
269+
bytes: (await response.body()).byteLength,
270+
});
271+
} catch {
272+
// Body unavailable after navigation — skip the sample.
273+
}
274+
});
275+
276+
const opens: Array<{ kind: 'warmup' | 'measured'; readyMs: number; historyRequest?: EventsRequestSample; parkedRequests?: EventsRequestSample[] }> = [];
277+
for (let i = 0; i <= OPEN_ITERATIONS; i++) {
278+
dispatchSamples = [];
279+
// about:blank first forces a genuine GWT re-bootstrap without a Keycloak
280+
// round-trip (a hard reload can bounce through Keycloak and drop the
281+
// deep-link hash).
282+
await page.goto('about:blank');
283+
navStart = Date.now();
284+
await page.goto(project.url);
285+
await expect(page.locator(ProjectView.root)).toBeVisible({ timeout: 30_000 });
286+
await expect(page.locator(Hierarchy.treeNode('owl:Thing'))).toBeVisible({ timeout: 15_000 });
287+
const readyMs = Date.now() - navStart;
288+
expect(page.url(), 'landed off the project (Keycloak bounce?)').toContain(project.url.split('#')[1].split('/')[1]);
289+
// Park to catch the polling timer's first (full-history) request, which
290+
// fires one full period after open — not at open.
291+
await page.waitForTimeout(POLL_CAPTURE_WINDOW_MS);
292+
// Poll ticks are the requests initiated while parked (well after ready).
293+
const parked = dispatchSamples.filter((s) => s.sinceNavMs > readyMs + 2_000);
294+
const history = [...parked].sort((a, b) => b.bytes - a.bytes)[0];
295+
opens.push({
296+
kind: i === 0 ? 'warmup' : 'measured',
297+
readyMs,
298+
historyRequest: history,
299+
parkedRequests: parked,
300+
});
301+
}
302+
303+
const measured = opens.filter((o) => o.kind === 'measured');
304+
expect(
305+
measured.some((o) => o.historyRequest),
306+
'no GetProjectEventsAction request captured — postData filter or park window is wrong',
307+
).toBeTruthy();
308+
const result = {
309+
metric: 'project-open',
310+
...runMetadata(),
311+
seedClassCount: SEED_CLASS_COUNT,
312+
readyStats: statsOf(measured.map((o) => o.readyMs)),
313+
historyRequestBytes: statsOf(measured.filter((o) => o.historyRequest).map((o) => o.historyRequest!.bytes)),
314+
historyRequestMs: statsOf(measured.filter((o) => o.historyRequest).map((o) => o.historyRequest!.requestMs)),
315+
opens,
316+
};
317+
console.log(JSON.stringify(result, null, 2));
318+
await testInfo.attach('project-open', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
319+
saveResults('project-open', result);
320+
});

0 commit comments

Comments
 (0)