youtubeDownloader/server/test/ytDlpRunner.test.ts
Nicolas 374cfbb192 feat: recherche YouTube globale, avec defilement infini
Nouveau formulaire "Recherche YouTube" (mots-cles + limite optionnelle),
branche sur la zone de resultats existante (miniatures, bouton "Ajouter"
par ligne ; pas de "Tout ajouter", comme demande).

- Avec une limite : tous les resultats d'un coup (une seule page).
- Sans limite : defilement infini. Un element sentinelle en fin de liste,
  observe par un IntersectionObserver (racine = la liste, marge 300 px),
  charge la page suivante ; il est rearme apres chaque page, affiche
  chargement / erreur avec "Reessayer" / "Fin des resultats", et une page
  en echec ne fait pas perdre les precedentes.

Serveur (GET/POST/DELETE /api/search) : une "session" = un seul yt-dlp
(--flat-playlist --lazy-playlist -j -- ytsearch1000:<requete>) servi page
par page depuis un tampon. Pourquoi pas un appel yt-dlp par page - mesure
avec le vrai yt-dlp : l'ordre des resultats de YouTube n'est pas
deterministe meme pour deux appels identiques (5/20 aux memes positions,
69 % de recouvrement sur 100), donc paginer en relancant la recherche
donnait 15 a 30 % de doublons des la 3e-4e page et des trous ; YouTube
s'arrete vers ~590 resultats ; ~7 % de doublons internes (dedoublonnes a
l'ingestion) ; sans --lazy-playlist yt-dlp n'imprime rien avant la fin
(~23 s), avec il emet au fil des pages (20 en ~2,3 s).

Le parcours n'avance qu'a la demande : quand le tampon a 20 resultats
d'avance, la lecture de stdout est suspendue (nouvelle option onSpawn de
runYtDlp) ; le tube se remplit et yt-dlp bloque (mesure : plus aucune
ligne pendant 10 s de pause). On n'aspire donc pas ~590 resultats, et ~30
requetes vers YouTube, quand l'utilisateur ne defile pas.

Cycle de vie : 4 sessions simultanees max (la moins recemment utilisee est
fermee), expiration a 10 min d'inactivite / 30 min de vie, fermeture des
que la derniere page est servie, sur DELETE (liste remplacee cote client),
au pagehide du client, et a l'arret du serveur. Requete validee (1 a 200
caracteres, sans caractere de controle) et transmise en un seul argument
ytsearchN:<requete>, jamais interpretable comme une option.

Mise en page : ce troisieme formulaire laissait a la liste ~104 px (une
ligne) a 1366x650. Une liste non vide garde min-height 13rem et la colonne
du formulaire defile au lieu de rogner (la page ne defile toujours pas).

Verifie : contre le vrai yt-dlp (page tamponnee en 4 ms, page 200 apres
12 s d'inactivite en ~4 s : la suspension tient ; 29 pages -> 576 videos,
576 uniques ; processus fermes a la fin / sur DELETE / au plafond) et dans
le navigateur (580 resultats sans doublon jusqu'a "Fin des resultats",
requetes de page uniques malgre deux signaux simultanes, echec + reprise,
session expiree, remplacement de la recherche, mode limite, ajout d'un
resultat a la file, pagehide, playlist et chaine en non-regression). Le
volet IntersectionObserver a ete pilote par un observateur factice : le
panneau de navigation n'affichait plus de frames (rAF/IO natifs muets).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:43:46 +02:00

87 lines
2.6 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
describe('runYtDlp', () => {
afterEach(() => {
// biome-ignore lint/performance/noDelete: une assignation à `undefined` stockerait la chaîne "undefined" dans process.env.
delete process.env.YTDLP_PATH;
});
it('retourne stdout quand le processus réussit', async () => {
process.env.YTDLP_PATH = process.execPath;
const stdout = await runYtDlp(['-e', "console.log('bonjour')"]);
expect(stdout.trim()).toBe('bonjour');
});
it('lève YtDlpError quand le processus se termine en échec', async () => {
process.env.YTDLP_PATH = process.execPath;
await expect(runYtDlp(['-e', "console.error('boom'); process.exit(3)"])).rejects.toBeInstanceOf(
YtDlpError,
);
});
it('appelle onStdoutLine pour chaque ligne, sans attendre la fin du processus', async () => {
process.env.YTDLP_PATH = process.execPath;
const lines: string[] = [];
await runYtDlp(['-e', "console.log('a'); console.log('b'); console.log('c')"], {
onStdoutLine: (line) => lines.push(line),
});
expect(lines).toEqual(['a', 'b', 'c']);
});
it('émet le fragment final même sans saut de ligne terminal', async () => {
process.env.YTDLP_PATH = process.execPath;
const lines: string[] = [];
await runYtDlp(['-e', "process.stdout.write('sans-newline')"], {
onStdoutLine: (line) => lines.push(line),
});
expect(lines).toEqual(['sans-newline']);
});
it('donne le processus lancé à onSpawn, avant toute sortie', async () => {
process.env.YTDLP_PATH = process.execPath;
const spawns: Array<{
pid: number | undefined;
canPause: boolean;
canResume: boolean;
linesSoFar: number;
}> = [];
let lines = 0;
await runYtDlp(['-e', "console.log('a')"], {
onSpawn: (child) => {
spawns.push({
pid: child.pid,
canPause: typeof child.stdout.pause === 'function',
canResume: typeof child.stdout.resume === 'function',
linesSoFar: lines,
});
},
onStdoutLine: () => {
lines += 1;
},
});
expect(spawns).toEqual([
{ pid: expect.any(Number), canPause: true, canResume: true, linesSoFar: 0 },
]);
expect(lines).toBe(1);
});
it("rejette avec une AbortError quand le signal d'annulation se déclenche", async () => {
process.env.YTDLP_PATH = process.execPath;
const controller = new AbortController();
const promise = runYtDlp(['-e', 'setTimeout(() => {}, 5000)'], { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
});
});