From 136c62915a66bccde1041095397976b5043a4df1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 11:22:03 +0200 Subject: [PATCH] test(e2e): explain black canvases in the packaged frame-copy smoke When the smoke's canvas stays black, print and attach the session snapshot (with mpv's drop counter), every slot of the helper's shared-memory frame rings read from /dev/shm, and the session's verbose mpv log (log-file). Together they separate these cases: the helper never published a frame, mpv rendered black, or the preload pump did not draw a real frame. Co-Authored-By: Claude Opus 5.5 --- ...ded-mpv-frame-copy-packaged-diagnostics.ts | 167 ++++++++++++++++++ ...d-mpv-frame-copy-packaged-fixtures.spec.ts | 72 ++++++++ .../embedded-mpv-frame-copy-packaged.e2e.ts | 24 +-- 3 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-diagnostics.ts diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-diagnostics.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-diagnostics.ts new file mode 100644 index 000000000..50a909b04 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-diagnostics.ts @@ -0,0 +1,167 @@ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + expect, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { + getLatestSession, + renderedFrameSignal, +} from './embedded-mpv-frame-copy-packaged-fixtures'; + +/** + * Failure diagnostics for the packaged frame-copy smoke. + * + * A black canvas alone cannot tell whether the helper never published a + * frame, published a black one, or published a real frame that the preload + * pump failed to draw. The helper's POSIX shared-memory rings live in + * /dev/shm on Linux, so the test runner can read them directly. + */ + +const SHM_DIRECTORY = '/dev/shm'; +const SHM_PREFIX = 'impv-fc-'; +const FRAME_SHM_MAGIC = 0x564d5046; +const FRAME_SHM_RING_SLOTS = 3; +const HEADER_BYTES = 56 + FRAME_SHM_RING_SLOTS * 16; + +export interface FrameRingDiagnostics { + name: string; + valid: boolean; + width?: number; + height?: number; + generation?: number; + latestSeq?: number; + slotSeqs?: number[]; + /** Per slot: pixels whose B+G+R exceeds 30 (null past the segment). */ + slotSignals?: Array; + heartbeatAgeMs?: number | null; + /** `slotSignals` entry of the newest published frame. */ + latestFrameSignal?: number | null; +} + +function countVisiblePixels( + segment: Buffer, + start: number, + bytes: number +): number | null { + if (start + bytes > segment.length) { + return null; + } + let signal = 0; + for (let pixel = start; pixel + 3 < start + bytes; pixel += 4) { + if (segment[pixel] + segment[pixel + 1] + segment[pixel + 2] > 30) { + signal += 1; + } + } + return signal; +} + +/** + * Parses one frame ring (layout: FrameShmHeader in native/helper/frame_shm.h). + * `nowNs` must come from CLOCK_MONOTONIC, as the helper's heartbeat does. + */ +export function describeFrameRing( + name: string, + segment: Buffer, + nowNs: bigint +): FrameRingDiagnostics { + if ( + segment.length < HEADER_BYTES || + segment.readUInt32LE(0) !== FRAME_SHM_MAGIC + ) { + return { name, valid: false }; + } + + const frameBytes = Number(segment.readBigUInt64LE(24)); + const dataOffset = Number(segment.readBigUInt64LE(32)); + const latestSeq = segment.readBigUInt64LE(40); + const heartbeatNs = segment.readBigUInt64LE(48); + const slotSeqs = Array.from({ length: FRAME_SHM_RING_SLOTS }, (_, slot) => + Number(segment.readBigUInt64LE(56 + slot * 16)) + ); + + const slotSignals = slotSeqs.map((_, slot) => + countVisiblePixels(segment, dataOffset + slot * frameBytes, frameBytes) + ); + + return { + name, + valid: true, + width: segment.readUInt32LE(8), + height: segment.readUInt32LE(12), + generation: segment.readUInt32LE(20), + latestSeq: Number(latestSeq), + slotSeqs, + slotSignals, + heartbeatAgeMs: + heartbeatNs > BigInt(0) ? Number(nowNs - heartbeatNs) / 1e6 : null, + latestFrameSignal: + latestSeq > BigInt(0) + ? slotSignals[Number(latestSeq % BigInt(FRAME_SHM_RING_SLOTS))] + : null, + }; +} + +export function describeFrameRings(): FrameRingDiagnostics[] | string { + try { + return readdirSync(SHM_DIRECTORY) + .filter((entry) => entry.startsWith(SHM_PREFIX)) + .map((entry) => + describeFrameRing( + entry, + readFileSync(join(SHM_DIRECTORY, entry)), + process.hrtime.bigint() + ) + ); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +/** + * Waits for a visible frame on the smoke canvas; on timeout, attaches the + * session snapshot, the helper's frame rings and mpv's own log (the session + * sets `log-file`) before rethrowing. + */ +export async function expectRenderedFrame( + app: LaunchedElectronApp, + sessionId: string, + timeout: number, + mpvLogPath?: string +): Promise { + try { + await expect + .poll(() => renderedFrameSignal(app), { timeout }) + .toBeGreaterThan(0); + } catch (error) { + const session = await getLatestSession(app, sessionId).catch( + () => null + ); + const diagnostics = { + session: session && { + status: session.status, + positionSeconds: session.positionSeconds, + videoWidth: session.videoWidth, + videoHeight: session.videoHeight, + stats: session.stats, + }, + frameRings: describeFrameRings(), + }; + const body = JSON.stringify(diagnostics, null, 2); + console.log(`[frame-copy smoke diagnostics] ${body}`); + await test.info().attach('frame-copy-diagnostics', { + body, + contentType: 'application/json', + }); + if (mpvLogPath && existsSync(mpvLogPath)) { + const mpvLog = readFileSync(mpvLogPath, 'utf8'); + console.log(`[frame-copy smoke mpv log]\n${mpvLog}`); + await test.info().attach('mpv-log', { + body: mpvLog, + contentType: 'text/plain', + }); + } + throw error; + } +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts index 9dc9c7f5f..4e174e52a 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts @@ -6,6 +6,7 @@ import { createWavFixture, isMeaningfulNativePlaybackSnapshot, } from './embedded-mpv-frame-copy-packaged-fixtures'; +import { describeFrameRing } from './embedded-mpv-frame-copy-packaged-diagnostics'; import './embedded-mpv-frame-copy-packaged-filesystem.tests'; import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures'; import packagedPlaywrightConfig from '../playwright.packaged.config'; @@ -206,3 +207,74 @@ describe('dedicated packaged smoke target', () => { assert.deepEqual(packagedPlaywrightConfig.webServer, []); }); }); + +describe('frame ring diagnostics', () => { + // FrameShmHeader (native/helper/frame_shm.h) for a 4x2 ring whose newest + // frame (seq 4, slot 1) has three non-black BGRA pixels. + function createRing(): Buffer { + const width = 4; + const height = 2; + const frameBytes = width * height * 4; + const dataOffset = 4096; + const ring = Buffer.alloc(dataOffset + 3 * frameBytes); + ring.writeUInt32LE(0x564d5046, 0); + ring.writeUInt32LE(1, 4); + ring.writeUInt32LE(width, 8); + ring.writeUInt32LE(height, 12); + ring.writeUInt32LE(width * 4, 16); + ring.writeUInt32LE(2, 20); + ring.writeBigUInt64LE(BigInt(frameBytes), 24); + ring.writeBigUInt64LE(BigInt(dataOffset), 32); + ring.writeBigUInt64LE(BigInt(4), 40); + ring.writeBigUInt64LE(BigInt(1000000000), 48); + [BigInt(3), BigInt(4), BigInt(2)].forEach((seq, slot) => + ring.writeBigUInt64LE(seq, 56 + slot * 16) + ); + const latest = dataOffset + frameBytes; + for (const pixel of [0, 3, 7]) { + ring[latest + pixel * 4 + 2] = 255; // red channel of BGRA + } + // Slot 0 holds a fully visible older frame; it must not leak into the + // newest frame's signal. + ring.fill(255, dataOffset, dataOffset + frameBytes); + return ring; + } + + it('reports the newest published frame and its visible signal', () => { + assert.deepEqual( + describeFrameRing( + 'impv-fc-test-g2', + createRing(), + BigInt(1250000000) + ), + { + name: 'impv-fc-test-g2', + valid: true, + width: 4, + height: 2, + generation: 2, + latestSeq: 4, + slotSeqs: [3, 4, 2], + slotSignals: [8, 3, 0], + heartbeatAgeMs: 250, + latestFrameSignal: 3, + } + ); + }); + + it('distinguishes an unpublished or uninitialised ring', () => { + const unpublished = createRing(); + unpublished.writeBigUInt64LE(BigInt(0), 40); + assert.equal( + describeFrameRing('ring', unpublished, BigInt(0)).latestFrameSignal, + null + ); + assert.deepEqual( + describeFrameRing('ring', Buffer.alloc(8), BigInt(0)), + { + name: 'ring', + valid: false, + } + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts index c9ba81236..ac13e51d4 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -18,9 +18,9 @@ import { installEmbeddedMpvSessionCapture, installFrameCanvasAndSessionCapture, isMeaningfulNativePlaybackSnapshot, - renderedFrameSignal, type LocalMediaServer, } from './embedded-mpv-frame-copy-packaged-fixtures'; +import { expectRenderedFrame } from './embedded-mpv-frame-copy-packaged-diagnostics'; import { createDisposablePackagedLinuxApp, createPackagedEntryGuard, @@ -120,18 +120,21 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { }); await installFrameCanvasAndSessionCapture(launchedFrameCopyApp); + const mpvLogPath = join(dataDir, 'mpv-frame-copy.log'); const created = await launchedFrameCopyApp.mainWindow.evaluate( - async () => { - // Decode audio without requiring a sound device on CI. + async (logFile) => { + // Decode audio without requiring a sound device on CI; + // keep mpv's verbose log for failure diagnostics. await window.electron.updateSettings({ - embeddedMpvExtraOptions: 'ao=null', + embeddedMpvExtraOptions: `ao=null\nlog-file=${logFile}`, }); return window.electron.createEmbeddedMpvSession( { x: 0, y: 0, width: 320, height: 180 }, 'Packaged frame-copy smoke', 0 ); - } + }, + mpvLogPath ); await launchedFrameCopyApp.mainWindow.evaluate( @@ -179,11 +182,12 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { 'packaged-embedded-mpv-frame' ) ).toHaveAttribute('height', '180'); - await expect - .poll(() => renderedFrameSignal(launchedFrameCopyApp), { - timeout: 15000, - }) - .toBeGreaterThan(0); + await expectRenderedFrame( + launchedFrameCopyApp, + created.id, + 15000, + mpvLogPath + ); // Relative seek steps (arrow keys, ±10 s buttons) must reach mpv // as `seek relative+exact` through the real preload →