mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
* perf(tooling): compositing probe, report command and route-wide clip-mask guard Make the instrument that found the dashboard's clip masks part of the repository: `src/performance/compositing-probe.ts` reads the layer tree over CDP (bounds in device pixels, compositing reasons and owner nodes, waiting for Blink's layer debug info), classifies synthesized clip masks (no owner node and no compositing reason) and reads the renderer's `cc/tile_memory` from a memory-infra dump. `pnpm run perf:compositing` seeds one profile against the Xtream mock on a dedicated port, serves the artwork from memory, fixes the window at 1600x1000 and records tile and image memory, layer counts, the largest layers, masks and tile warnings for Live TV, the movie and series details, the dashboard (idle, crossfade, scrolled) and settings into dist/performance/compositing/<timestamp>/summary.json and a console table. Both helpers have node:test coverage in the performance harness. The dashboard-only compositing E2E becomes `compositing.e2e.ts` and guards Live TV, a movie detail, a series detail and the dashboard in one launch, with the backdrop-filtered controls confirmed as composited layers on the dashboard and the movie detail. The rule now lives where it triggers: a Rendering Cost section in the theme/style skill, a validation line in the UI design skill, a context map row, and a Compositing budget section in the performance journeys contract (budget, mask mechanism, instruments, why megabytes are evidence rather than a ratchet). Validation map and README mention the command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(tooling): sample the dashboard idle reading with rotation paused The hero advances after 8 s, so a 9 s wait sampled the next slide one second into its zoom (backdrop scale 1.116 instead of 1.1, the previous slide's tiles still pooled): 224 MB read as idle, 122 MB with rotation paused and the zoom and tile pool settled. The warning delta and its checkpoint are now taken at one boundary after the sampling, so a "tile memory limits exceeded" line logged while the layers or the dump are read counts for that route instead of for none. The report's test artifacts go under dist/test-results like the other configs'. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(tooling): retry a memory dump that carries no renderer tile memory One reading of seven came back without `cc/tile_memory`: the dump's trace events reach the buffer shortly after the request resolves, and a process can skip a dump it is busy for. The capture now waits briefly before ending the trace and retries up to three times before a reading stays null. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(tooling): run the compositing report with motion enabled With the OS set to reduced motion the hero neither rotates nor renders its pause button, so the report's click timed out before the summary was written. The report now emulates `prefers-reduced-motion: no-preference`, which also keeps the idle and crossfade readings on the same animation set on every machine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(tooling): sample the crossfade frozen, count split stderr lines, drop stale layer samples Review findings on the pushed head, plus the CI failure: - A layer that vanished between the LayerTree snapshot and its reasons query was kept with empty reasons; without an owner node it would pass for a synthesized mask. Such a snapshot is now discarded and retaken. - The crossfade reading sampled layers, owners and memory across the 700 ms fade, so the row depended on how long the sampling took. The page's animations are frozen 300 ms into the fade while the reading is taken, resumed afterwards, and the incoming slide's zoom is allowed to finish before the scrolled reading. - A stderr chunk can end mid-line; the warning counter now keeps the unfinished line for the next chunk. - The mock server's launch spec lists the new Playwright config. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import type { CDPSession, Page } from '@playwright/test';
|
|
|
|
import {
|
|
addXtreamPortal,
|
|
clickCategoryByNameExact,
|
|
clickFirstGridListCard,
|
|
closeElectronApp,
|
|
defaultXtreamPassword,
|
|
defaultXtreamUsername,
|
|
expect,
|
|
goToDashboard,
|
|
launchElectronApp,
|
|
openSettings,
|
|
openWorkspaceSection,
|
|
resetMockServers,
|
|
test,
|
|
waitForXtreamWorkspaceReady,
|
|
workspaceRoot,
|
|
type LaunchedElectronApp,
|
|
} from '../electron-test-fixtures';
|
|
import {
|
|
addCurrentDetailToFavorites,
|
|
goBackFromDetail,
|
|
toggleFavoriteForChannel,
|
|
} from '../dashboard-e2e-flows';
|
|
import {
|
|
captureTileMemory,
|
|
collectCompositedLayers,
|
|
describeLayerOwner,
|
|
describeLayerSize,
|
|
findOversizedClipMasks,
|
|
layerDevicePixels,
|
|
layerScale,
|
|
measureContentArea,
|
|
routeDeterministicArtwork,
|
|
TILE_POOL_SETTLE_MS,
|
|
} from '../performance/compositing-probe';
|
|
import {
|
|
COMPOSITING_SUMMARY_SCHEMA_VERSION,
|
|
formatCompositingTable,
|
|
resolveCompositingSummaryPath,
|
|
writeCompositingSummary,
|
|
type CompositingRouteReading,
|
|
type CompositingSummary,
|
|
} from '../performance/compositing-report';
|
|
import {
|
|
fetchXtreamLiveFixture,
|
|
fetchXtreamSeriesFixture,
|
|
fetchXtreamVodFixture,
|
|
getXtreamTitle,
|
|
} from '../portal-mock-fixtures';
|
|
|
|
/**
|
|
* Compositing report: the renderer's tile memory and composited layers on
|
|
* the routes with the most artwork and effects, one fresh profile seeded
|
|
* through the app's dialogs against the Xtream mock, at a fixed window size.
|
|
* It measures, it does not assert; the deterministic guard is
|
|
* `compositing.e2e.ts`. Contract:
|
|
* docs/architecture/performance-journeys.md#compositing-budget.
|
|
*/
|
|
const WINDOW = { height: 1000, width: 1600 };
|
|
const LARGEST_LAYERS = 8;
|
|
/** The hero backdrop's zoom transition (`transform 8s` in its stylesheet). */
|
|
const HERO_ZOOM_MS = 8_000;
|
|
/** Into the 700 ms crossfade, where the animations are frozen for sampling. */
|
|
const CROSSFADE_SAMPLE_MS = 300;
|
|
const TILE_WARNING = /tile memory limits exceeded/;
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function setWindowContentSize(app: LaunchedElectronApp): Promise<void> {
|
|
await app.electronApp.evaluate(({ BrowserWindow }, size) => {
|
|
const [window] = BrowserWindow.getAllWindows();
|
|
window.setPosition(0, 0);
|
|
window.setContentSize(size.width, size.height);
|
|
}, WINDOW);
|
|
}
|
|
|
|
async function readGpuCompositing(app: LaunchedElectronApp): Promise<string> {
|
|
const status = await app.electronApp.evaluate(({ app: electronApp }) =>
|
|
electronApp.getGPUFeatureStatus()
|
|
);
|
|
return String(
|
|
(status as unknown as Record<string, unknown>)['gpu_compositing']
|
|
);
|
|
}
|
|
|
|
async function readRoute(
|
|
cdp: CDPSession,
|
|
page: Page,
|
|
route: string
|
|
): Promise<CompositingRouteReading> {
|
|
const area = await measureContentArea(page);
|
|
const layers = await collectCompositedLayers(cdp, page);
|
|
const largest = [...layers]
|
|
.filter((layer) => layer.drawsContent)
|
|
.sort((a, b) => layerDevicePixels(b) - layerDevicePixels(a))
|
|
.slice(0, LARGEST_LAYERS);
|
|
const largestLayers = [];
|
|
for (const layer of largest) {
|
|
largestLayers.push({
|
|
devicePixelsMB: Number(
|
|
((layerDevicePixels(layer) * 4) / 1_048_576).toFixed(1)
|
|
),
|
|
height: layer.height,
|
|
owner: await describeLayerOwner(cdp, layer),
|
|
reasons: layer.reasons,
|
|
scale: Number(layerScale(layer).x.toFixed(3)),
|
|
width: layer.width,
|
|
});
|
|
}
|
|
const memory = await captureTileMemory(cdp);
|
|
return {
|
|
backdropFilterLayers: layers.filter((layer) =>
|
|
layer.reasons.includes('BackdropFilter')
|
|
).length,
|
|
clipMasks: findOversizedClipMasks(layers, area).map(describeLayerSize),
|
|
drawingLayers: layers.filter((layer) => layer.drawsContent).length,
|
|
imageMB: memory.imageMB,
|
|
largestLayers,
|
|
route,
|
|
tileMB: memory.tileMB,
|
|
tileResources: memory.resourceCount,
|
|
tileWarnings: 0,
|
|
totalLayers: layers.length,
|
|
url: new URL(page.url()).pathname.replace(/^.*\/dist\/apps\/web/, ''),
|
|
};
|
|
}
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test('compositing report', async ({ dataDir, request }) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const credentials = {
|
|
password: defaultXtreamPassword,
|
|
username: defaultXtreamUsername,
|
|
};
|
|
const live = await fetchXtreamLiveFixture(request, credentials);
|
|
const vod = await fetchXtreamVodFixture(request, credentials);
|
|
const series = await fetchXtreamSeriesFixture(request, credentials);
|
|
|
|
const app = await launchElectronApp(dataDir, {
|
|
env: { ELECTRON_ENABLE_LOGGING: '1' },
|
|
});
|
|
const page = app.mainWindow;
|
|
let tileWarnings = 0;
|
|
// A chunk can end mid-line: only complete lines are counted, the rest
|
|
// waits for the next chunk.
|
|
let stderrRemainder = '';
|
|
app.electronApp.process().stderr?.on('data', (chunk: Buffer) => {
|
|
const lines = (stderrRemainder + chunk.toString()).split('\n');
|
|
stderrRemainder = lines.pop() ?? '';
|
|
tileWarnings += lines.filter((line) => TILE_WARNING.test(line)).length;
|
|
});
|
|
const routes: CompositingRouteReading[] = [];
|
|
|
|
try {
|
|
await routeDeterministicArtwork(page);
|
|
// The same motion on every machine: with the OS set to reduced
|
|
// motion the hero neither rotates nor renders its pause button, and
|
|
// the crossfade reading would measure a different animation set.
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
await setWindowContentSize(app);
|
|
await sleep(500);
|
|
const cdp = await app.electronApp.context().newCDPSession(page);
|
|
let warningsAtLastReading = 0;
|
|
const measure = async (route: string, settleMs: number) => {
|
|
await sleep(settleMs);
|
|
const reading = await readRoute(cdp, page, route);
|
|
// One boundary for the delta and the checkpoint, taken after the
|
|
// sampling: a warning logged while the layers or the dump were
|
|
// read belongs to this route, not to no route.
|
|
const warningsNow = tileWarnings;
|
|
reading.tileWarnings = warningsNow - warningsAtLastReading;
|
|
warningsAtLastReading = warningsNow;
|
|
routes.push(reading);
|
|
console.log(
|
|
`[compositing] ${route}: tile ${reading.tileMB ?? '-'} MB, ${reading.drawingLayers}/${reading.totalLayers} layers, ${reading.clipMasks.length} masks, ${reading.tileWarnings} warnings`
|
|
);
|
|
};
|
|
|
|
await addXtreamPortal(page);
|
|
await waitForXtreamWorkspaceReady(page);
|
|
await openWorkspaceSection(page, 'Live TV');
|
|
await clickCategoryByNameExact(page, live.categoryName);
|
|
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
|
|
await measure('live-tv', TILE_POOL_SETTLE_MS);
|
|
|
|
await page.getByRole('link', { exact: true, name: 'Movies' }).click();
|
|
await clickCategoryByNameExact(page, vod.categoryName);
|
|
await clickFirstGridListCard(page);
|
|
await expect(
|
|
page.locator('app-content-hero .hero__backdrop-image')
|
|
).toBeVisible({ timeout: 20_000 });
|
|
await measure('movie-detail', TILE_POOL_SETTLE_MS);
|
|
await addCurrentDetailToFavorites(page);
|
|
await goBackFromDetail(page);
|
|
|
|
await page.getByRole('link', { exact: true, name: 'Series' }).click();
|
|
await clickCategoryByNameExact(page, series.categoryName);
|
|
await clickFirstGridListCard(page);
|
|
await expect(page.locator('app-content-hero')).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
await measure('series-detail', TILE_POOL_SETTLE_MS);
|
|
await addCurrentDetailToFavorites(page);
|
|
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
await expect
|
|
.poll(() => page.getByTestId('dashboard-hero-dot').count(), {
|
|
timeout: 20_000,
|
|
})
|
|
.toBeGreaterThanOrEqual(2);
|
|
// Idle means no motion: rotation paused (it would advance after 8 s,
|
|
// into another slide's zoom), the current slide's zoom finished and
|
|
// the tile pool settled. The pointer stays off the hero.
|
|
const pause = page.getByTestId('dashboard-hero-pause');
|
|
await pause.click();
|
|
await expect(pause).toHaveAttribute('aria-pressed', 'true');
|
|
await page.mouse.move(1, 1);
|
|
await measure('dashboard', HERO_ZOOM_MS + TILE_POOL_SETTLE_MS);
|
|
|
|
const dots = page.getByTestId('dashboard-hero-dot');
|
|
const active = await dots.evaluateAll((all) =>
|
|
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
|
|
);
|
|
// Deliberately mid-motion: the outgoing and incoming slides are
|
|
// both composited while the crossfade and the zoom run. The page's
|
|
// animations are frozen at a known point of the 700 ms fade while
|
|
// the layers and the memory are read, so the reading does not
|
|
// depend on how long the sampling takes.
|
|
await dots.nth((active + 1) % (await dots.count())).click();
|
|
await page.mouse.move(1, 1);
|
|
await sleep(CROSSFADE_SAMPLE_MS);
|
|
await cdp.send('Animation.enable');
|
|
await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 });
|
|
try {
|
|
await measure('dashboard-crossfade', 0);
|
|
} finally {
|
|
await cdp.send('Animation.setPlaybackRate', { playbackRate: 1 });
|
|
await cdp.send('Animation.disable');
|
|
}
|
|
// The incoming slide's zoom runs on after the freeze; the scrolled
|
|
// reading is taken once it has finished.
|
|
await sleep(HERO_ZOOM_MS);
|
|
|
|
await page.mouse.move(WINDOW.width / 2, WINDOW.height / 2);
|
|
for (let step = 0; step < 8; step += 1) {
|
|
await page.mouse.wheel(0, 250);
|
|
await sleep(100);
|
|
}
|
|
await page.mouse.move(1, 1);
|
|
await measure('dashboard-scrolled', TILE_POOL_SETTLE_MS);
|
|
|
|
await openSettings(page);
|
|
await measure('settings', TILE_POOL_SETTLE_MS);
|
|
|
|
const summary: CompositingSummary = {
|
|
generatedAt: new Date().toISOString(),
|
|
harness: {
|
|
arch: process.arch,
|
|
electronVersion: await app.electronApp.evaluate(
|
|
() => process.versions['electron'] ?? 'unknown'
|
|
),
|
|
gpuCompositing: await readGpuCompositing(app),
|
|
platform: process.platform,
|
|
window: {
|
|
...WINDOW,
|
|
dpr: await page.evaluate(() => window.devicePixelRatio),
|
|
},
|
|
},
|
|
routes,
|
|
schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION,
|
|
};
|
|
const summaryPath = resolveCompositingSummaryPath(workspaceRoot);
|
|
await writeCompositingSummary(summaryPath, summary);
|
|
console.log(formatCompositingTable(summary));
|
|
console.log(`[compositing] summary: ${summaryPath}`);
|
|
expect(routes.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|