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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 19:39:52 +02:00
1 parent f38c6f86d1
commit 136c62915a
3 files changed
+253 -10

No files matched your search

@@ -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<number | null>;
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<void> {
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;
}
}
@@ -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,
}
);
});
});
@@ -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 <delta> relative+exact` through the real preload →