177 lines
8.5 KiB
JavaScript
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);
|
|
});
|