nextav/tests/streaming/test-transcode-lifecycle.mjs

177 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* End-to-end test for the live transcoding lifecycle (v2 rewrite).
*
* Covers the staged changes:
* - POST /api/transcode/start -> job + playlist URL
* - master.m3u8 serves a VOD playlist immediately
* - seg/ waits up to 5s for a segment to exist (waitForSegment)
* - ping keeps the job alive; status reports it
* - seek within the encoded window is reused (204)
* - DELETE is fast (markKilled) and segment/playlist 404 immediately after
* - the FFmpeg process actually exits afterwards
* - repeated DELETE is idempotent
*
* Usage:
* node tests/streaming/test-transcode-lifecycle.mjs [mediaId]
*
* Requires the dev server running with ENABLE_LIVE_TRANSCODE=true and a media
* row whose format decision is hls-remux or hls-transcode (e.g. a .mov).
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const MEDIA_ID = Number(process.argv[2] || process.env.TEST_MEDIA_ID || 127);
let failures = 0;
function ok(condition, label, extra = '') {
if (condition) {
console.log(`${label}${extra ? ` (${extra})` : ''}`);
} else {
failures += 1;
console.log(`${label}${extra ? ` (${extra})` : ''}`);
}
}
async function request(path, { method = 'GET', body, timeoutMs = 15_000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await response.text();
return {
status: response.status,
headers: response.headers,
body: text,
json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
elapsedMs: Date.now() - startedAt,
};
} finally {
clearTimeout(timer);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log(`🧪 Transcode lifecycle e2e — media #${MEDIA_ID} @ ${BASE_URL}`);
console.log('======================================================');
// ── 1. Start a transcode job ──────────────────────────────────────
console.log('\n1. Start transcode job');
const start = await request('/api/transcode/start', { method: 'POST', body: { mediaId: MEDIA_ID, startTime: 0 } });
ok(start.status === 200, 'POST /api/transcode/start -> 200', `elapsed ${start.elapsedMs}ms`);
if (start.status !== 200) {
console.log(` response: ${start.body.slice(0, 300)}`);
process.exit(1);
}
const jobId = start.json.jobId;
const playlistUrl = start.json.playlistUrl;
ok(typeof jobId === 'string' && jobId.length > 0, 'jobId returned');
ok(typeof playlistUrl === 'string' && playlistUrl.includes(jobId), 'playlistUrl returned');
ok(start.json.decision === 'hls-remux' || start.json.decision === 'hls-transcode', `decision ${start.json.decision}`);
// ── 2. Playlist serves a VOD playlist ─────────────────────────────
console.log('\n2. Master playlist');
const playlist = await request(playlistUrl);
ok(playlist.status === 200, `GET ${playlistUrl} -> 200`, `elapsed ${playlist.elapsedMs}ms`);
ok(playlist.body.includes('#EXTM3U'), 'contains #EXTM3U');
ok(playlist.body.includes('#EXT-X-PLAYLIST-TYPE:VOD'), 'playlist type VOD');
const segmentMatch = playlist.body.match(/seg\/seg_\d{5}\.ts/);
ok(!!segmentMatch, 'references a segment file');
// ── 3. Segment delivery (includes waitForSegment path) ────────────
console.log('\n3. Segment delivery');
const firstSegmentName = segmentMatch[0].replace('seg/', '');
const firstSeg = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
ok(firstSeg.status === 200, `GET ${firstSegmentName} -> 200`, `elapsed ${firstSeg.elapsedMs}ms`);
ok(firstSeg.headers.get('content-type') === 'video/mp2t', 'content-type video/mp2t');
ok(Number(firstSeg.headers.get('content-length') || 0) > 0, 'segment non-empty');
// A segment a bit further ahead: waitForSegment polls until ffmpeg writes it.
const aheadIndex = String(30).padStart(5, '0');
const aheadName = `seg_${aheadIndex}.ts`;
const ahead = await request(`/api/transcode/${jobId}/seg/${aheadName}`, { timeoutMs: 10_000 });
ok(ahead.status === 200, `GET ${aheadName} (waitForSegment) -> 200`, `elapsed ${ahead.elapsedMs}ms`);
// A segment that will never exist: should poll for ~5s then 404.
const neverSeg = await request(`/api/transcode/${jobId}/seg/seg_99999.ts`, { timeoutMs: 10_000 });
ok(neverSeg.status === 404, 'GET seg_99999.ts -> 404', `elapsed ${neverSeg.elapsedMs}ms (expected ~5000)`);
ok(neverSeg.elapsedMs >= 4500, 'waited for segment before 404ing (poll path exercised)');
// ── 4. Ping keeps the job alive ───────────────────────────────────
console.log('\n4. Heartbeat ping');
const ping = await request(`/api/transcode/${jobId}/ping`, { method: 'POST', body: { position: 0, isPaused: false } });
ok(ping.status === 200 && ping.json?.success === true, 'POST ping -> 200');
const statusBefore = await request('/api/transcode/status');
const listed = statusBefore.json?.jobs?.find(j => j.id === jobId);
ok(statusBefore.status === 200 && !!listed, 'job appears in /api/transcode/status', listed ? `status=${listed.status}` : '');
// ── 5. Seek within the encoded window is reused ───────────────────
console.log('\n5. Seek within encoded window');
const seek = await request(`/api/transcode/${jobId}/seek`, { method: 'POST', body: { position: 0 } });
ok(seek.status === 204, 'POST seek {position:0} -> 204 (job reused)');
// ── 6. DELETE fast-path + immediate 404s ─────────────────────────
console.log('\n6. DELETE (markKilled fast path)');
const del = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
ok(del.status === 200 && del.json?.success === true, 'DELETE -> 200 success', `elapsed ${del.elapsedMs}ms`);
ok(del.elapsedMs < 1000, 'DELETE responds in <1s (markKilled is synchronous)', `${del.elapsedMs}ms`);
const playlistAfter = await request(playlistUrl);
ok(playlistAfter.status === 404, 'playlist 404 immediately after DELETE');
const segAfter = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
ok(segAfter.status === 404, 'segment 404 immediately after DELETE');
const statusAfter = await request('/api/transcode/status');
ok(!statusAfter.json?.jobs?.some(j => j.id === jobId), 'job removed from status');
// ── 7. FFmpeg process actually exits ──────────────────────────────
console.log('\n7. FFmpeg process termination');
await sleep(5000);
const { execSync } = await import('node:child_process');
let ffmpegStillRunning = false;
try {
const out = execSync(`pgrep -fl "${jobId}" || true`, { encoding: 'utf8' }).trim();
ffmpegStillRunning = out.length > 0;
if (ffmpegStillRunning) console.log(` lingering: ${out}`);
} catch {
// pgrep unavailable — treat as pass-through, best-effort check
}
ok(!ffmpegStillRunning, 'no FFmpeg process remains for the job after grace period');
// ── 8. Idempotent DELETE ──────────────────────────────────────────
console.log('\n8. Idempotent DELETE');
const delAgain = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
ok(delAgain.status === 200, 'second DELETE -> 200 (no crash)');
// ── 9. Unknown job 404s ───────────────────────────────────────────
console.log('\n9. Unknown job handling');
const missing = await request('/api/transcode/00000000-0000-0000-0000-000000000000/master.m3u8');
ok(missing.status === 404, 'playlist for unknown job -> 404');
console.log('\n======================================================');
if (failures === 0) {
console.log(`🎉 ALL CHECKS PASSED (job ${jobId})`);
process.exit(0);
} else {
console.log(`💥 ${failures} check(s) FAILED (job ${jobId})`);
process.exit(1);
}
}
main().catch(error => {
console.error('❌ Test runner crashed:', error);
process.exit(1);
});