binary-integration.test.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. /**
  2. * @fileoverview Binary Integration Tests for yt-dlp and ffmpeg
  3. * @author GrabZilla Development Team
  4. * @version 2.1.0
  5. * @since 2024-01-01
  6. */
  7. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  8. import { spawn } from 'child_process';
  9. import fs from 'fs';
  10. import path from 'path';
  11. import os from 'os';
  12. /**
  13. * BINARY INTEGRATION TESTS
  14. *
  15. * These tests verify direct integration with yt-dlp and ffmpeg binaries:
  16. * - Binary availability and version checking
  17. * - Command construction and execution
  18. * - Output parsing and progress tracking
  19. * - Error handling and recovery
  20. * - Platform-specific binary behavior
  21. * - Real download and conversion workflows (when binaries available)
  22. */
  23. describe('Binary Integration Tests', () => {
  24. let binaryPaths;
  25. let testOutputDir;
  26. beforeEach(() => {
  27. // Set up platform-specific binary paths
  28. const isWindows = process.platform === 'win32';
  29. const binariesDir = path.join(process.cwd(), 'binaries');
  30. binaryPaths = {
  31. ytDlp: path.join(binariesDir, `yt-dlp${isWindows ? '.exe' : ''}`),
  32. ffmpeg: path.join(binariesDir, `ffmpeg${isWindows ? '.exe' : ''}`)
  33. };
  34. // Create test output directory
  35. testOutputDir = path.join(os.tmpdir(), 'grabzilla-binary-test-' + Date.now());
  36. if (!fs.existsSync(testOutputDir)) {
  37. fs.mkdirSync(testOutputDir, { recursive: true });
  38. }
  39. });
  40. afterEach(() => {
  41. // Clean up test output directory
  42. if (fs.existsSync(testOutputDir)) {
  43. try {
  44. fs.rmSync(testOutputDir, { recursive: true, force: true });
  45. } catch (error) {
  46. console.warn('Failed to clean up test output directory:', error.message);
  47. }
  48. }
  49. });
  50. describe('yt-dlp Binary Integration', () => {
  51. it('should verify yt-dlp binary exists and get version', async () => {
  52. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  53. if (!binaryExists) {
  54. console.warn('yt-dlp binary not found at:', binaryPaths.ytDlp);
  55. expect(binaryExists).toBe(false);
  56. return;
  57. }
  58. const version = await new Promise((resolve, reject) => {
  59. const process = spawn(binaryPaths.ytDlp, ['--version'], {
  60. stdio: ['pipe', 'pipe', 'pipe']
  61. });
  62. let output = '';
  63. let errorOutput = '';
  64. process.stdout.on('data', (data) => {
  65. output += data.toString();
  66. });
  67. process.stderr.on('data', (data) => {
  68. errorOutput += data.toString();
  69. });
  70. process.on('close', (code) => {
  71. if (code === 0) {
  72. resolve(output.trim());
  73. } else {
  74. reject(new Error(`Version check failed: ${errorOutput}`));
  75. }
  76. });
  77. process.on('error', (error) => {
  78. reject(new Error(`Failed to spawn yt-dlp: ${error.message}`));
  79. });
  80. });
  81. expect(version).toMatch(/^\d{4}\.\d{2}\.\d{2}/);
  82. console.log('yt-dlp version:', version);
  83. });
  84. it('should list available formats for a video', async () => {
  85. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  86. if (!binaryExists) {
  87. console.warn('yt-dlp binary not found, skipping format list test');
  88. return;
  89. }
  90. // Use a known stable video for testing
  91. const testUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
  92. const formats = await new Promise((resolve, reject) => {
  93. const process = spawn(binaryPaths.ytDlp, [
  94. '--list-formats',
  95. '--no-download',
  96. testUrl
  97. ], {
  98. stdio: ['pipe', 'pipe', 'pipe']
  99. });
  100. let output = '';
  101. let errorOutput = '';
  102. process.stdout.on('data', (data) => {
  103. output += data.toString();
  104. });
  105. process.stderr.on('data', (data) => {
  106. errorOutput += data.toString();
  107. });
  108. process.on('close', (code) => {
  109. if (code === 0) {
  110. resolve(output);
  111. } else {
  112. reject(new Error(`Format listing failed: ${errorOutput}`));
  113. }
  114. });
  115. process.on('error', reject);
  116. });
  117. expect(formats).toContain('format code');
  118. expect(formats).toMatch(/\d+x\d+/); // Should contain resolution info
  119. }, 30000); // 30 second timeout
  120. it('should extract video metadata without downloading', async () => {
  121. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  122. if (!binaryExists) {
  123. console.warn('yt-dlp binary not found, skipping metadata test');
  124. return;
  125. }
  126. const testUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
  127. const metadata = await new Promise((resolve, reject) => {
  128. const process = spawn(binaryPaths.ytDlp, [
  129. '--dump-json',
  130. '--no-download',
  131. testUrl
  132. ], {
  133. stdio: ['pipe', 'pipe', 'pipe']
  134. });
  135. let output = '';
  136. let errorOutput = '';
  137. process.stdout.on('data', (data) => {
  138. output += data.toString();
  139. });
  140. process.stderr.on('data', (data) => {
  141. errorOutput += data.toString();
  142. });
  143. process.on('close', (code) => {
  144. if (code === 0 && output.trim()) {
  145. try {
  146. const json = JSON.parse(output.trim());
  147. resolve(json);
  148. } catch (parseError) {
  149. reject(new Error(`JSON parse error: ${parseError.message}`));
  150. }
  151. } else {
  152. reject(new Error(`Metadata extraction failed: ${errorOutput}`));
  153. }
  154. });
  155. process.on('error', reject);
  156. });
  157. expect(metadata).toHaveProperty('title');
  158. expect(metadata).toHaveProperty('duration');
  159. expect(metadata).toHaveProperty('thumbnail');
  160. expect(metadata).toHaveProperty('uploader');
  161. expect(metadata.title).toBeTruthy();
  162. console.log('Video title:', metadata.title);
  163. console.log('Duration:', metadata.duration);
  164. }, 30000);
  165. it('should handle invalid URLs gracefully', async () => {
  166. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  167. if (!binaryExists) {
  168. console.warn('yt-dlp binary not found, skipping invalid URL test');
  169. return;
  170. }
  171. const invalidUrl = 'https://example.com/not-a-video';
  172. await expect(async () => {
  173. await new Promise((resolve, reject) => {
  174. const process = spawn(binaryPaths.ytDlp, [
  175. '--dump-json',
  176. '--no-download',
  177. invalidUrl
  178. ], {
  179. stdio: ['pipe', 'pipe', 'pipe']
  180. });
  181. let errorOutput = '';
  182. process.stderr.on('data', (data) => {
  183. errorOutput += data.toString();
  184. });
  185. process.on('close', (code) => {
  186. if (code !== 0) {
  187. reject(new Error(`Invalid URL error: ${errorOutput}`));
  188. } else {
  189. resolve();
  190. }
  191. });
  192. process.on('error', reject);
  193. });
  194. }).rejects.toThrow();
  195. });
  196. it('should construct correct download commands', () => {
  197. const constructYtDlpCommand = (options) => {
  198. const args = [];
  199. if (options.format) {
  200. args.push('-f', options.format);
  201. }
  202. if (options.output) {
  203. args.push('-o', options.output);
  204. }
  205. if (options.cookieFile) {
  206. args.push('--cookies', options.cookieFile);
  207. }
  208. if (options.noPlaylist) {
  209. args.push('--no-playlist');
  210. }
  211. args.push(options.url);
  212. return args;
  213. };
  214. const options = {
  215. url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
  216. format: 'best[height<=720]',
  217. output: path.join(testOutputDir, '%(title)s.%(ext)s'),
  218. cookieFile: '/path/to/cookies.txt',
  219. noPlaylist: true
  220. };
  221. const command = constructYtDlpCommand(options);
  222. expect(command).toContain('-f');
  223. expect(command).toContain('best[height<=720]');
  224. expect(command).toContain('-o');
  225. expect(command).toContain('--cookies');
  226. expect(command).toContain('--no-playlist');
  227. expect(command).toContain(options.url);
  228. });
  229. it('should parse download progress output', () => {
  230. const parseProgress = (line) => {
  231. // Example yt-dlp progress line:
  232. // [download] 45.2% of 10.5MiB at 1.2MiB/s ETA 00:07
  233. const progressMatch = line.match(/\[download\]\s+(\d+\.?\d*)%/);
  234. const sizeMatch = line.match(/of\s+([\d.]+\w+)/);
  235. const speedMatch = line.match/at\s+([\d.]+\w+\/s)/);
  236. const etaMatch = line.match(/ETA\s+(\d{2}:\d{2})/);
  237. if (progressMatch) {
  238. return {
  239. progress: parseFloat(progressMatch[1]),
  240. size: sizeMatch ? sizeMatch[1] : null,
  241. speed: speedMatch ? speedMatch[1] : null,
  242. eta: etaMatch ? etaMatch[1] : null
  243. };
  244. }
  245. return null;
  246. };
  247. const testLines = [
  248. '[download] 45.2% of 10.5MiB at 1.2MiB/s ETA 00:07',
  249. '[download] 100% of 10.5MiB in 00:08',
  250. '[download] Destination: test_video.mp4'
  251. ];
  252. const progress1 = parseProgress(testLines[0]);
  253. const progress2 = parseProgress(testLines[1]);
  254. const progress3 = parseProgress(testLines[2]);
  255. expect(progress1).toEqual({
  256. progress: 45.2,
  257. size: '10.5MiB',
  258. speed: '1.2MiB/s',
  259. eta: '00:07'
  260. });
  261. expect(progress2.progress).toBe(100);
  262. expect(progress3).toBe(null); // No progress info in destination line
  263. });
  264. });
  265. describe('ffmpeg Binary Integration', () => {
  266. it('should verify ffmpeg binary exists and get version', async () => {
  267. const binaryExists = fs.existsSync(binaryPaths.ffmpeg);
  268. if (!binaryExists) {
  269. console.warn('ffmpeg binary not found at:', binaryPaths.ffmpeg);
  270. expect(binaryExists).toBe(false);
  271. return;
  272. }
  273. const version = await new Promise((resolve, reject) => {
  274. const process = spawn(binaryPaths.ffmpeg, ['-version'], {
  275. stdio: ['pipe', 'pipe', 'pipe']
  276. });
  277. let output = '';
  278. let errorOutput = '';
  279. process.stdout.on('data', (data) => {
  280. output += data.toString();
  281. });
  282. process.stderr.on('data', (data) => {
  283. errorOutput += data.toString();
  284. });
  285. process.on('close', (code) => {
  286. if (code === 0) {
  287. resolve(output);
  288. } else {
  289. reject(new Error(`Version check failed: ${errorOutput}`));
  290. }
  291. });
  292. process.on('error', (error) => {
  293. reject(new Error(`Failed to spawn ffmpeg: ${error.message}`));
  294. });
  295. });
  296. expect(version).toMatch(/ffmpeg version/i);
  297. console.log('ffmpeg version info:', version.split('\n')[0]);
  298. });
  299. it('should list available codecs', async () => {
  300. const binaryExists = fs.existsSync(binaryPaths.ffmpeg);
  301. if (!binaryExists) {
  302. console.warn('ffmpeg binary not found, skipping codec list test');
  303. return;
  304. }
  305. const codecs = await new Promise((resolve, reject) => {
  306. const process = spawn(binaryPaths.ffmpeg, ['-codecs'], {
  307. stdio: ['pipe', 'pipe', 'pipe']
  308. });
  309. let output = '';
  310. let errorOutput = '';
  311. process.stdout.on('data', (data) => {
  312. output += data.toString();
  313. });
  314. process.stderr.on('data', (data) => {
  315. errorOutput += data.toString();
  316. });
  317. process.on('close', (code) => {
  318. if (code === 0) {
  319. resolve(output);
  320. } else {
  321. reject(new Error(`Codec listing failed: ${errorOutput}`));
  322. }
  323. });
  324. process.on('error', reject);
  325. });
  326. expect(codecs).toContain('libx264');
  327. expect(codecs).toContain('aac');
  328. });
  329. it('should construct correct conversion commands', () => {
  330. const constructFFmpegCommand = (options) => {
  331. const args = ['-i', options.input];
  332. if (options.videoCodec) {
  333. args.push('-c:v', options.videoCodec);
  334. }
  335. if (options.audioCodec) {
  336. args.push('-c:a', options.audioCodec);
  337. }
  338. if (options.crf) {
  339. args.push('-crf', options.crf.toString());
  340. }
  341. if (options.preset) {
  342. args.push('-preset', options.preset);
  343. }
  344. if (options.audioOnly) {
  345. args.push('-vn');
  346. }
  347. if (options.videoOnly) {
  348. args.push('-an');
  349. }
  350. args.push('-y'); // Overwrite output file
  351. args.push(options.output);
  352. return args;
  353. };
  354. const h264Options = {
  355. input: 'input.mp4',
  356. output: 'output_h264.mp4',
  357. videoCodec: 'libx264',
  358. audioCodec: 'aac',
  359. crf: 23,
  360. preset: 'medium'
  361. };
  362. const audioOnlyOptions = {
  363. input: 'input.mp4',
  364. output: 'output.m4a',
  365. audioCodec: 'aac',
  366. audioOnly: true
  367. };
  368. const h264Command = constructFFmpegCommand(h264Options);
  369. const audioCommand = constructFFmpegCommand(audioOnlyOptions);
  370. expect(h264Command).toContain('-i');
  371. expect(h264Command).toContain('input.mp4');
  372. expect(h264Command).toContain('-c:v');
  373. expect(h264Command).toContain('libx264');
  374. expect(h264Command).toContain('-crf');
  375. expect(h264Command).toContain('23');
  376. expect(audioCommand).toContain('-vn');
  377. expect(audioCommand).toContain('-c:a');
  378. expect(audioCommand).toContain('aac');
  379. });
  380. it('should parse conversion progress output', () => {
  381. const parseFFmpegProgress = (line) => {
  382. // Example ffmpeg progress line:
  383. // frame= 123 fps= 25 q=28.0 size= 1024kB time=00:00:05.00 bitrate=1677.7kbits/s speed=1.02x
  384. const frameMatch = line.match(/frame=\s*(\d+)/);
  385. const fpsMatch = line.match(/fps=\s*([\d.]+)/);
  386. const timeMatch = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
  387. const sizeMatch = line.match(/size=\s*([\d.]+\w+)/);
  388. const speedMatch = line.match(/speed=\s*([\d.]+x)/);
  389. if (timeMatch) {
  390. const hours = parseInt(timeMatch[1]);
  391. const minutes = parseInt(timeMatch[2]);
  392. const seconds = parseFloat(timeMatch[3]);
  393. const totalSeconds = hours * 3600 + minutes * 60 + seconds;
  394. return {
  395. frame: frameMatch ? parseInt(frameMatch[1]) : null,
  396. fps: fpsMatch ? parseFloat(fpsMatch[1]) : null,
  397. timeSeconds: totalSeconds,
  398. size: sizeMatch ? sizeMatch[1] : null,
  399. speed: speedMatch ? speedMatch[1] : null
  400. };
  401. }
  402. return null;
  403. };
  404. const testLine = 'frame= 123 fps= 25 q=28.0 size= 1024kB time=00:00:05.00 bitrate=1677.7kbits/s speed=1.02x';
  405. const progress = parseFFmpegProgress(testLine);
  406. expect(progress).toEqual({
  407. frame: 123,
  408. fps: 25,
  409. timeSeconds: 5,
  410. size: '1024kB',
  411. speed: '1.02x'
  412. });
  413. });
  414. it('should create test input file for conversion testing', () => {
  415. // Create a minimal test video file (just for testing file operations)
  416. const testInputPath = path.join(testOutputDir, 'test_input.mp4');
  417. // Create a dummy file (in real scenario, this would be from yt-dlp)
  418. fs.writeFileSync(testInputPath, 'dummy video data for testing');
  419. expect(fs.existsSync(testInputPath)).toBe(true);
  420. const stats = fs.statSync(testInputPath);
  421. expect(stats.size).toBeGreaterThan(0);
  422. });
  423. });
  424. describe('Binary Process Management', () => {
  425. it('should handle process termination correctly', async () => {
  426. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  427. if (!binaryExists) {
  428. console.warn('yt-dlp binary not found, skipping process termination test');
  429. return;
  430. }
  431. // Start a long-running process (list formats for a video)
  432. const process = spawn(binaryPaths.ytDlp, [
  433. '--list-formats',
  434. 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
  435. ], {
  436. stdio: ['pipe', 'pipe', 'pipe']
  437. });
  438. // Wait a bit then terminate
  439. setTimeout(() => {
  440. process.kill('SIGTERM');
  441. }, 1000);
  442. const result = await new Promise((resolve) => {
  443. process.on('close', (code, signal) => {
  444. resolve({ code, signal });
  445. });
  446. process.on('error', (error) => {
  447. resolve({ error: error.message });
  448. });
  449. });
  450. // Process should be terminated by signal
  451. expect(result.signal || result.code !== 0 || result.error).toBeTruthy();
  452. });
  453. it('should handle multiple concurrent processes', async () => {
  454. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  455. if (!binaryExists) {
  456. console.warn('yt-dlp binary not found, skipping concurrent process test');
  457. return;
  458. }
  459. const processes = [];
  460. const maxConcurrent = 3;
  461. // Start multiple version check processes
  462. for (let i = 0; i < maxConcurrent; i++) {
  463. const process = spawn(binaryPaths.ytDlp, ['--version'], {
  464. stdio: ['pipe', 'pipe', 'pipe']
  465. });
  466. processes.push(process);
  467. }
  468. // Wait for all processes to complete
  469. const results = await Promise.all(
  470. processes.map(process => new Promise((resolve) => {
  471. let output = '';
  472. process.stdout.on('data', (data) => {
  473. output += data.toString();
  474. });
  475. process.on('close', (code) => {
  476. resolve({ code, output: output.trim() });
  477. });
  478. process.on('error', (error) => {
  479. resolve({ error: error.message });
  480. });
  481. }))
  482. );
  483. // All processes should complete successfully
  484. results.forEach(result => {
  485. expect(result.code === 0 || result.output || result.error).toBeTruthy();
  486. });
  487. });
  488. it('should monitor process resource usage', () => {
  489. const monitorProcess = (process) => {
  490. const startTime = Date.now();
  491. const startMemory = process.memoryUsage ? process.memoryUsage() : null;
  492. return {
  493. getStats: () => ({
  494. runtime: Date.now() - startTime,
  495. memory: process.memoryUsage ? process.memoryUsage() : null,
  496. pid: process.pid
  497. })
  498. };
  499. };
  500. // Test with current process
  501. const monitor = monitorProcess(process);
  502. // Wait a bit
  503. setTimeout(() => {
  504. const stats = monitor.getStats();
  505. expect(stats.runtime).toBeGreaterThan(0);
  506. expect(stats.pid).toBeTruthy();
  507. }, 100);
  508. });
  509. });
  510. describe('Error Handling and Recovery', () => {
  511. it('should handle binary not found errors', () => {
  512. const nonExistentBinary = path.join(process.cwd(), 'binaries', 'nonexistent-binary');
  513. expect(() => {
  514. spawn(nonExistentBinary, ['--version']);
  515. }).not.toThrow(); // spawn doesn't throw, but emits error event
  516. });
  517. it('should handle invalid command arguments', async () => {
  518. const binaryExists = fs.existsSync(binaryPaths.ytDlp);
  519. if (!binaryExists) {
  520. console.warn('yt-dlp binary not found, skipping invalid args test');
  521. return;
  522. }
  523. const result = await new Promise((resolve) => {
  524. const process = spawn(binaryPaths.ytDlp, ['--invalid-argument'], {
  525. stdio: ['pipe', 'pipe', 'pipe']
  526. });
  527. let errorOutput = '';
  528. process.stderr.on('data', (data) => {
  529. errorOutput += data.toString();
  530. });
  531. process.on('close', (code) => {
  532. resolve({ code, error: errorOutput });
  533. });
  534. process.on('error', (error) => {
  535. resolve({ error: error.message });
  536. });
  537. });
  538. // Should exit with non-zero code or error message
  539. expect(result.code !== 0 || result.error).toBeTruthy();
  540. });
  541. it('should implement retry logic for failed operations', async () => {
  542. const maxRetries = 3;
  543. let attemptCount = 0;
  544. const mockOperation = async () => {
  545. attemptCount++;
  546. if (attemptCount < maxRetries) {
  547. throw new Error('Simulated failure');
  548. }
  549. return 'success';
  550. };
  551. const retryOperation = async (operation, retries) => {
  552. for (let i = 0; i < retries; i++) {
  553. try {
  554. return await operation();
  555. } catch (error) {
  556. if (i === retries - 1) {
  557. throw error;
  558. }
  559. // Wait before retry
  560. await new Promise(resolve => setTimeout(resolve, 100));
  561. }
  562. }
  563. };
  564. const result = await retryOperation(mockOperation, maxRetries);
  565. expect(result).toBe('success');
  566. expect(attemptCount).toBe(maxRetries);
  567. });
  568. });
  569. });