#!/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); });