youtubeDownloader/server/test/searchSession.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

251 lines
8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { FastifyBaseLogger } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
return { ...actual, runYtDlp: vi.fn() };
});
import {
SEARCH_DEPTH,
SearchSession,
type SearchSessionOptions,
} from '../src/services/SearchSession.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, entryLine, installFakeYtDlp } from './fakeYtDlp.js';
let runs: FakeYtDlpRun[];
let logger: { info: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
/**
* @param overrides - Réglages à surcharger.
* @returns Des réglages de session de test (plafond large, longue avance).
*/
function options(overrides: Partial<SearchSessionOptions> = {}): SearchSessionOptions {
return { query: 'speed climbing', max: 1000, readAhead: 20, lifetimeMs: 60_000, ...overrides };
}
/**
* @param overrides - Réglages à surcharger.
* @returns Une session ouverte avec le logger de test (son yt-dlp simulé est `runs.at(-1)`).
*/
function open(overrides: Partial<SearchSessionOptions> = {}): SearchSession {
return new SearchSession(logger as unknown as FastifyBaseLogger, options(overrides));
}
/** Laisse s'exécuter les micro-tâches et les callbacks en attente. */
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
logger = { info: vi.fn(), error: vi.fn() };
});
describe('SearchSession', () => {
it('lance yt-dlp en mode paresseux, avec la requête (préfixe ytsearch) et la durée de vie demandée', () => {
open({ query: 'escalade été', lifetimeMs: 12_345 });
expect(runs).toHaveLength(1);
expect(runs[0]?.args).toEqual([
'--flat-playlist',
'--lazy-playlist',
'-j',
'--',
`ytsearch${SEARCH_DEPTH}:escalade été`,
]);
expect(runs[0]?.options.timeoutMs).toBe(12_345);
});
it('attend d’avoir assez de résultats avant de répondre à une page', async () => {
const session = open();
let settled = false;
const pagePromise = session.page(0, 3).then((page) => {
settled = true;
return page;
});
runs[0]?.emitEntries(2);
await flush();
expect(settled).toBe(false);
runs[0]?.emitEntries(1);
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
expect(page.nextOffset).toBe(3);
});
it('sert les pages suivantes depuis le tampon, sans relancer yt-dlp', async () => {
const session = open();
const first = session.page(0, 2);
runs[0]?.emitEntries(6);
await first;
const second = await session.page(2, 2);
const third = await session.page(4, 2);
expect(second.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
expect(third.videos.map((video) => video.id)).toEqual(['id5', 'id6']);
expect(runs).toHaveLength(1);
});
it('ignore les doublons, les lignes illisibles et les entrées incomplètes', async () => {
const session = open();
const pagePromise = session.page(0, 10);
runs[0]?.emit(entryLine(1));
runs[0]?.emit(entryLine(1)); // doublon : YouTube répète parfois une vidéo
runs[0]?.emit('ceci n’est pas du JSON');
runs[0]?.emit(JSON.stringify({ id: 'sans-url', title: 'Incomplète' }));
runs[0]?.emit(entryLine(2));
runs[0]?.finish();
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2']);
});
it('signale la fin des résultats : page partielle et nextOffset null', async () => {
const session = open();
const pagePromise = session.page(0, 5);
runs[0]?.emitEntries(2);
runs[0]?.finish();
const page = await pagePromise;
expect(page.videos).toHaveLength(2);
expect(page.nextOffset).toBeNull();
});
it('signale la fin par une page vide quand la dernière page tombe pile sur le dernier résultat', async () => {
const session = open();
const first = session.page(0, 4);
runs[0]?.emitEntries(4);
const firstPage = await first;
// yt-dlp n'a pas encore signalé sa fin au moment où la page est pleine :
// une suite reste possible, la page suivante tranche.
expect(firstPage.videos).toHaveLength(4);
expect(firstPage.nextOffset).toBe(4);
runs[0]?.finish();
const next = await session.page(4, 4);
expect(next).toEqual({ videos: [], nextOffset: null });
});
it('renvoie une page vide et la fin quand on demande au-delà des résultats', async () => {
const session = open();
const first = session.page(0, 2);
runs[0]?.emitEntries(2);
runs[0]?.finish();
await first;
const beyond = await session.page(50, 10);
expect(beyond).toEqual({ videos: [], nextOffset: null });
});
it('s’arrête au plafond : annule yt-dlp, ignore la suite et signale la fin', async () => {
const session = open({ max: 3 });
const pagePromise = session.page(0, 10);
runs[0]?.emitEntries(5);
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
expect(page.nextOffset).toBeNull();
expect(runs[0]?.aborted).toBe(true);
expect(logger.error).not.toHaveBeenCalled();
});
it('suspend la lecture quand le tampon a assez d’avance, et la reprend à la demande', async () => {
const session = open({ readAhead: 2 });
const first = session.page(0, 2); // demande : 2 résultats
runs[0]?.emitEntries(3);
await first;
expect(runs[0]?.stdout.pause).not.toHaveBeenCalled(); // 3 < 2 + 2
runs[0]?.emitEntries(1); // 4 >= 2 + 2 : assez d'avance
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
expect(runs[0]?.stdout.resume).not.toHaveBeenCalled();
const next = await session.page(2, 2); // servi depuis le tampon...
expect(next.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1); // ...et le parcours repart
});
it('ne suspend qu’une fois tant qu’il n’a pas été relancé', async () => {
const session = open({ readAhead: 1 });
const first = session.page(0, 1);
runs[0]?.emitEntries(2); // suspend
await first;
runs[0]?.emitEntries(3); // encore des lignes déjà dans le tube au moment de la pause
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
});
it('lève l’erreur quand yt-dlp échoue avant le moindre résultat', async () => {
const session = open();
const pagePromise = session.page(0, 5);
runs[0]?.fail(new Error('boom'));
await expect(pagePromise).rejects.toThrow('boom');
expect(logger.error).toHaveBeenCalled();
});
it('sert ce qu’il a et signale la fin quand yt-dlp échoue après des résultats', async () => {
const session = open();
const pagePromise = session.page(0, 10);
runs[0]?.emitEntries(2);
runs[0]?.fail(new Error('réseau coupé'));
const page = await pagePromise;
expect(page.videos).toHaveLength(2);
expect(page.nextOffset).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
it('dispose : annule yt-dlp sans le compter comme un échec, et libère les appels en attente', async () => {
const session = open();
const pending = session.page(0, 5);
session.dispose('test');
const page = await pending;
await flush();
expect(runs[0]?.aborted).toBe(true);
expect(page).toEqual({ videos: [], nextOffset: null });
expect(logger.error).not.toHaveBeenCalled();
});
it('dispose relance un flux suspendu pour qu’il puisse se vider et se fermer', async () => {
const session = open({ readAhead: 1 });
const first = session.page(0, 1);
runs[0]?.emitEntries(2); // suspend
await first;
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
session.dispose('test');
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1);
});
it('dispose est idempotent', () => {
const session = open();
session.dispose('premier');
session.dispose('second');
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'premier' }),
expect.any(String),
);
expect(logger.info).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: 'second' }),
expect.any(String),
);
});
});