mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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 →
|
||||
|
||||
Reference in new issue
Block a user