|
| 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