feat(youtube): InnerTube-first search, transcripts and watch-next related (Steps 15-18)
- InnerTube layer via pinned youtubei.js 18.1.0 (no quota, no key): search with merged continuations (unlimited pages), watch-next related with LockupView mapping, caption-track discovery - 3-layer dispatcher (YT_SEARCH_MODE, default innertube-first): innertube -> yt-dlp scrape -> official API, graceful errors.yt - Robust yt-dlp binary resolution (YT_DLP_PATH > PATH > bundled) with systematic API fallback (fixes spawn ENOENT in UI) - Transcript: InnerTube caption discovery (YT_TRANSCRIPT_SOURCE), reusing pickTrack/orderedTracks/parseTrackText; yt-dlp fallback kept - Watch: sidebar uses real watch-next related[] (/api/details), title-search fallback for other providers - Cache: memory LRU + SQLite (youtube_search_cache, youtube_metrics), never persist empty pages; /healthz observability; /api/trending - Includes pending Step 15/16 leftovers in same files (suggest, test scripts); unrelated provider adapters left uncommitted
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Step 15 — Suggest tests: unit parsing/dedup (offline) + API contract (isolated server).
|
||||
// Run with: npm run test:suggest
|
||||
// Scenario coverage (todo Step 15):
|
||||
// - providers=yt returns only yt suggestions
|
||||
// - unknown provider falls back to the full registry
|
||||
// Assertions are on fan-out shape, never on external provider content.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
|
||||
function expect(cond, msg) {
|
||||
if (!cond) throw new Error(`Assertion failed: ${msg}`);
|
||||
}
|
||||
function logOk(msg) { console.log(`✓ ${msg}`); }
|
||||
|
||||
// ---------- 1) Offline unit tests: parsers + graceful degradation ----------
|
||||
const yt = (await import('../providers/youtube.mjs')).default;
|
||||
const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs');
|
||||
const dm = (await import('../providers/dailymotion.mjs')).default;
|
||||
|
||||
// Bare JSON payload
|
||||
expect(
|
||||
JSON.stringify(parseYoutubeSuggestResponse('["tut",["tutoriel angular","tutoriel android"],[],{}]')) ===
|
||||
JSON.stringify(['tutoriel angular', 'tutoriel android']),
|
||||
'bare JSON suggest payload parses',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse bare JSON');
|
||||
|
||||
// window.google.ac.h(...) wrapped payload
|
||||
expect(
|
||||
JSON.stringify(
|
||||
parseYoutubeSuggestResponse('window.google.ac.h(["tut",["tutoriel angular"],[],{}])'),
|
||||
) === JSON.stringify(['tutoriel angular']),
|
||||
'wrapped JSONP suggest payload parses',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse wrapped payload');
|
||||
|
||||
// Array-wrapped suggestions (newer format: [text, type, ...])
|
||||
expect(
|
||||
JSON.stringify(parseYoutubeSuggestResponse('["tut",[["tutoriel angular",0],["tutoriel android",0]]]')) ===
|
||||
JSON.stringify(['tutoriel angular', 'tutoriel android']),
|
||||
'array-wrapped suggestions keep text only',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse array-wrapped entries');
|
||||
|
||||
// Garbage / empty payloads never throw
|
||||
expect(parseYoutubeSuggestResponse('not json at all').length === 0, 'garbage payload yields []');
|
||||
expect(parseYoutubeSuggestResponse('').length === 0, 'empty payload yields []');
|
||||
logOk('parseYoutubeSuggestResponse graceful degradation');
|
||||
|
||||
// min-length guard: no network below 2 chars (would throw offline otherwise)
|
||||
expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query returns [] without network');
|
||||
expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network');
|
||||
logOk('suggest min-length guard (no network)');
|
||||
|
||||
// ---------- 2) API contract against a real isolated server ----------
|
||||
const PORT = await new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const p = srv.address().port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-suggest-test-'));
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: path.join(tmpDir, 'suggest.db'), JWT_SECRET: 'suggest-test-secret', NODE_ENV: 'test' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
});
|
||||
let serverLogs = '';
|
||||
server.stdout.on('data', (d) => { serverLogs += d.toString(); });
|
||||
server.stderr.on('data', (d) => { serverLogs += d.toString(); });
|
||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function waitForServer(timeoutMs = 20000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/`);
|
||||
if (res.status < 500) return true;
|
||||
} catch {}
|
||||
await sleep(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
async function scenario(name, fn) {
|
||||
try { await fn(); logOk(name); }
|
||||
catch (e) { failures += 1; console.error(`✗ ${name}`); console.error(e?.stack || String(e)); }
|
||||
}
|
||||
|
||||
try {
|
||||
const up = await waitForServer();
|
||||
expect(up, 'isolated server starts');
|
||||
|
||||
await scenario('q too short -> 400', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=a`);
|
||||
expect(res.status === 400, `expected 400, got ${res.status}`);
|
||||
});
|
||||
|
||||
await scenario('providers=yt returns only yt suggestions', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=yt&limit=5`);
|
||||
expect(res.status === 200, `expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
expect(body.q === 'tutoriel', 'q echoed');
|
||||
expect(JSON.stringify(Object.keys(body.groups)) === JSON.stringify(['yt']), `only yt group (got ${Object.keys(body.groups)})`);
|
||||
expect(Array.isArray(body.groups.yt), 'yt group is an array');
|
||||
expect(body.groups.yt.length <= 5, 'limit respected');
|
||||
});
|
||||
|
||||
await scenario('unknown provider falls back to the full registry', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=xx,yy&limit=3`);
|
||||
expect(res.status === 200, `expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
const keys = Object.keys(body.groups).sort();
|
||||
expect(JSON.stringify(keys) === JSON.stringify(['dm', 'od', 'pt', 'ru', 'tw', 'yt']), `full registry fallback (got ${keys})`);
|
||||
});
|
||||
} finally {
|
||||
server.kill();
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} suggest scenario(s) failed. Server logs:\n${serverLogs}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll suggest tests passed.');
|
||||
Reference in New Issue
Block a user