-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcompositor-perf.spec.ts
More file actions
327 lines (278 loc) · 12.3 KB
/
compositor-perf.spec.ts
File metadata and controls
327 lines (278 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0
/**
* Layer 2 — Compositor slider interaction perf test.
*
* Creates a Webcam PiP pipeline session via the API, navigates to the monitor
* view where the full compositor node graph is rendered, then selects each
* layer and drags its opacity and rotation sliders while measuring re-renders
* via `window.__PERF_DATA__`.
*
* The test asserts that render counts stay within budget — specifically that
* slider interactions on one layer do NOT trigger expensive cascade
* re-renders in unrelated components (the same regression PR #89 fixed).
*
* NOTE: This test requires the Vite dev server (`just ui`) because the
* profiler store (`window.__PERF_DATA__`) is only exposed when
* `import.meta.env.DEV` is true. Point E2E_BASE_URL at
* http://localhost:3045 (or wherever the dev server runs).
*/
import { test, expect, request } from '@playwright/test';
import { ensureLoggedIn, getAuthHeaders } from './auth-helpers';
import {
type ConsoleErrorCollector,
MOQ_BENIGN_PATTERNS,
createConsoleErrorCollector,
} from './test-helpers';
import {
resetPerfData,
capturePerfData,
assertRenderBudget,
formatPerfSummary,
} from './perf-helpers';
import { WEBCAM_PIP_YAML } from './compositor-fixtures';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Simulate dragging a Radix slider thumb horizontally by `deltaX` pixels.
* The thumb is located via its `role="slider"` within the given container.
*/
async function dragSliderThumb(
page: import('@playwright/test').Page,
container: import('@playwright/test').Locator,
deltaX: number
) {
const thumb = container.getByRole('slider');
await thumb.waitFor({ state: 'visible', timeout: 5_000 });
const box = await thumb.boundingBox();
if (!box) throw new Error('Slider thumb has no bounding box');
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Move in small increments to simulate a realistic drag that fires
// multiple onValueChange events.
const steps = 20;
const stepSize = deltaX / steps;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(startX + stepSize * i, startY);
}
await page.mouse.up();
}
// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
test.describe('Compositor Slider Perf — Cascade Re-render Budget', () => {
let collector: ConsoleErrorCollector;
let sessionId: string | null = null;
let sessionName: string | null = null;
test.beforeEach(async ({ page }) => {
collector = createConsoleErrorCollector(page);
});
test('slider drags stay within render budget across all compositor components', async ({
page,
baseURL,
}) => {
// This test involves API session creation + multiple slider interactions.
test.setTimeout(120_000);
// ── 1. Create Webcam PiP session via API ────────────────────────────
//
// Using the API avoids the stream view flow and MoQ WebTransport
// connection, which is unreliable in headless CI environments.
const apiContext = await request.newContext({
baseURL: baseURL!,
extraHTTPHeaders: getAuthHeaders(),
});
sessionName = `perf-test-${Date.now()}`;
const createResponse = await apiContext.post('/api/v1/sessions', {
data: {
name: sessionName,
yaml: WEBCAM_PIP_YAML,
},
});
const responseText = await createResponse.text();
expect(createResponse.ok(), `Failed to create session: ${responseText}`).toBeTruthy();
const createData = JSON.parse(responseText) as { session_id: string };
sessionId = createData.session_id;
expect(sessionId).toBeTruthy();
await apiContext.dispose();
// ── 2. Navigate to monitor view ─────────────────────────────────────
await page.goto('/monitor');
await ensureLoggedIn(page);
if (!page.url().includes('/monitor')) {
await page.goto('/monitor');
}
await expect(page.getByTestId('monitor-view')).toBeVisible({
timeout: 15_000,
});
// Wait for sessions list and click our session by name.
await expect(page.getByTestId('sessions-list')).toBeVisible({
timeout: 10_000,
});
const sessionItem = page.getByTestId('session-item').filter({ hasText: sessionName! });
await expect(sessionItem).toBeVisible({ timeout: 10_000 });
await sessionItem.click();
// Wait for the React Flow canvas and compositor node to render.
await expect(page.locator('.react-flow__node').first()).toBeVisible({
timeout: 15_000,
});
// ── 3. Verify dev-mode profiler is available ────────────────────────
const hasPerfData = await page.evaluate(() => {
const w = window as Window & {
__PERF_DATA__?: unknown;
__PERF_RESET__?: unknown;
};
return !!w.__PERF_DATA__ && !!w.__PERF_RESET__;
});
if (!hasPerfData) {
test.skip(
true,
'window.__PERF_DATA__ not found — test requires the Vite dev server (just ui)'
);
}
// ── 4. Locate compositor node and its layer list ────────────────────
// The compositor node is the React Flow node containing "Compositor".
// Wait for graph to settle before measuring perf data.
const compositorNode = page.locator('.react-flow__node').filter({
hasText: 'Compositor',
});
await expect(compositorNode).toBeVisible({ timeout: 10_000 });
// Layer names in the PiP pipeline: "Text 0", "Input 1", "Input 0".
// These are plain <div> elements inside the layer list — no test IDs
// or <li> wrappers. We locate them by exact text content within the
// compositor node.
const layerNames = ['Text 0', 'Input 1', 'Input 0'];
const availableLayers: string[] = [];
for (const name of layerNames) {
const layerDiv = compositorNode.getByText(name, { exact: true });
if (
await layerDiv
.first()
.isVisible()
.catch(() => false)
) {
availableLayers.push(name);
}
}
if (availableLayers.length === 0) {
test.skip(true, 'No compositor layers found — pipeline may not have initialised');
}
console.log(`Found ${availableLayers.length} layer(s): ${availableLayers.join(', ')}`);
// ── 5. Measure slider interactions per layer ────────────────────────
// Reset perf data before our measurement window.
await resetPerfData(page);
for (const layerName of availableLayers) {
// Click the layer in the layer list to select it and open inspector.
const layerDiv = compositorNode.getByText(layerName, { exact: true });
await layerDiv.first().click();
// Wait for the inspector panel (with sliders) to become visible.
await expect(compositorNode.getByRole('slider').first()).toBeVisible({ timeout: 3_000 });
// --- Opacity slider ---
// The inspector shows an "Opacity" label followed by a Radix slider
// (role="slider"). We locate the innermost div containing "Opacity"
// that also holds a slider thumb.
const opacitySection = compositorNode
.locator('div')
.filter({ hasText: /^Opacity/ })
.filter({ has: page.getByRole('slider') })
.first();
const hasOpacity = await opacitySection
.getByRole('slider')
.isVisible()
.catch(() => false);
if (hasOpacity) {
console.log(` Dragging opacity slider for "${layerName}"`);
await dragSliderThumb(page, opacitySection, 40);
await page.waitForTimeout(100);
// Drag back to exercise more render cycles.
await dragSliderThumb(page, opacitySection, -40);
await page.waitForTimeout(100);
}
// --- Rotation slider ---
// Similar approach: find the section labelled "Rotation" that
// contains a slider. (Rotation also has preset buttons like
// 0°/90°/180°/270° but we specifically target the slider.)
const rotationSection = compositorNode
.locator('div')
.filter({ hasText: /^Rotation/ })
.filter({ has: page.getByRole('slider') })
.first();
const hasRotation = await rotationSection
.getByRole('slider')
.isVisible()
.catch(() => false);
if (hasRotation) {
console.log(` Dragging rotation slider for "${layerName}"`);
await dragSliderThumb(page, rotationSection, 60);
await page.waitForTimeout(100);
await dragSliderThumb(page, rotationSection, -60);
await page.waitForTimeout(100);
}
}
// ── 6. Capture and assert render budgets ────────────────────────────
const snapshot = await capturePerfData(page);
console.log('\n' + formatPerfSummary(snapshot));
// CompositorNode itself will re-render on each slider tick — but with
// proper memoization the total should stay bounded. The budget below
// is generous (accommodates CI jitter) while still catching the
// pre-PR-#89 regression where every slider tick caused 94+ fiber
// re-renders across the entire tree.
//
// Observed baseline: ~440 renders / ~5800ms for the full 3-layer
// scenario (after crop-shape state was added to video layers).
// Echo-backs are skipped during slider drags (fire-and-forget with
// reconciliation on commit), keeping the count well bounded.
// Budget of 550 renders / 7500ms gives ~25% headroom while still
// catching cascade regressions.
const compositorData = snapshot.components['CompositorNode'];
if (compositorData) {
assertRenderBudget(snapshot, 'CompositorNode', {
max: 550,
maxDuration: 7_500,
});
}
// The key cascade assertion: if there are OTHER profiled components
// (siblings rendered outside the active slider path), their render
// count should be dramatically lower than CompositorNode's. A cascade
// regression would show them rendering at a similar rate.
for (const [id, data] of Object.entries(snapshot.components)) {
if (id === 'CompositorNode') continue;
// Sibling components should not exceed a fraction of the compositor's
// render count — generous 60% ceiling to allow for legitimate
// re-renders while catching full-cascade regressions.
if (compositorData && data.renderCount > compositorData.renderCount * 0.6) {
throw new Error(
`Cascade regression detected: "${id}" rendered ${data.renderCount} times ` +
`(${((data.renderCount / compositorData.renderCount) * 100).toFixed(0)}% of CompositorNode's ` +
`${compositorData.renderCount}). This suggests slider interactions are causing ` +
`expensive re-renders in unrelated components.`
);
}
}
// ── 7. Console error check ──────────────────────────────────────────
const unexpected = collector.getUnexpected(MOQ_BENIGN_PATTERNS);
// Log but don't fail — monitor view may have transient warnings during
// session state transitions that aren't perf-related.
if (unexpected.length > 0) {
console.warn('Unexpected console errors (non-fatal):', unexpected);
}
});
// ── Cleanup ─────────────────────────────────────────────────────────────
test.afterEach(async ({ baseURL }) => {
if (sessionId) {
try {
const apiContext = await request.newContext({
baseURL: baseURL!,
extraHTTPHeaders: getAuthHeaders(),
});
await apiContext.delete(`/api/v1/sessions/${sessionId}`);
await apiContext.dispose();
} catch {
// Best-effort cleanup; ignore errors.
}
sessionId = null;
}
});
});