Files
NewTube/server/tests/search.e2e.test.mjs
T
bruno f1f673040e
CI / build-and-test (push) Successful in 14m10s
feat(search): panneau de filtres unifié, clavier/focus du typeahead
Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
2026-09-28 17:42:19 -04:00

237 lines
10 KiB
JavaScript

// Step 12 — Basic e2e scenarios for the unified search:
// 1. providers filter: /api/search?providers=yt,dm => only those providers in response
// 2. deep-link: /api/search?providers=pt => single-provider response + q echoed
// 3. default providers preference persisted per user (survives re-login)
// 4. SPA deep-link route serves index.html for /search?... (frontend deep-link)
//
// Runs against a REAL isolated server instance (temp SQLite DB, ephemeral port).
// Run with: npm run test:search-e2e
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';
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-e2e-'));
const dbPath = path.join(tmpDir, 'e2e.db');
const env = {
...process.env,
PORT: String(PORT),
NEWTUBE_DB_FILE: dbPath,
JWT_SECRET: 'e2e-test-secret',
// Avoid pulling real provider API keys: adapters will fail per-provider and return []
NODE_ENV: 'test',
};
const server = spawn(process.execPath, ['./server/index.mjs'], {
env,
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(); });
server.on('error', (e) => { console.error('spawn error:', e); process.exit(1); });
const baseUrl = `http://127.0.0.1:${PORT}`;
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function waitForServer(timeoutMs = 20000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
// Any HTTP response (even 400/401/404) means the server is up
try {
const res = await fetch(`${baseUrl}/`);
if (res.status < 500) return true;
await sleep(300);
} catch {
await sleep(300);
}
}
return false;
}
function assert(cond, msg) {
if (!cond) throw new Error(`Assertion failed: ${msg}`);
}
function logOk(msg) { console.log(`✓ ${msg}`); }
async function registerAndLogin(username) {
const registerRes = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password: 'Passw0rd!' }),
});
if (!registerRes.ok && registerRes.status !== 409) {
throw new Error(`register failed: ${registerRes.status}`);
}
const loginRes = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password: 'Passw0rd!' }),
});
if (!loginRes.ok) throw new Error(`login failed: ${loginRes.status}`);
const data = await loginRes.json();
return data.accessToken || data?.user && data.user.accessToken || null;
}
async function getPreferences(token) {
const res = await fetch(`${baseUrl}/api/user/preferences`, { headers: { authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`GET preferences failed: ${res.status}`);
return res.json();
}
async function patchPreferences(token, patch) {
const res = await fetch(`${baseUrl}/api/user/preferences`, {
method: 'PATCH',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`PATCH preferences failed: ${res.status}`);
return res.json();
}
// Note: external providers may be unreachable or rate-limited in CI. The scenarios
// assert on the *shape* of the fan-out (which providers were targeted), not on items.
let failures = 0;
function scenario(name, fn) {
return fn().then(() => {
logOk(name);
}, (e) => {
failures += 1;
console.error(`✗ ${name}`);
console.error(e instanceof Error ? e.stack : String(e));
});
}
(async () => {
const up = await waitForServer();
if (!up) {
console.error('Server did not start. Logs:\n' + serverLogs);
process.exit(1);
}
try {
const token = await registerAndLogin(`e2e_${Date.now()}`);
assert(token, 'register+login returns an access token');
// 1) Providers filter: response targets exactly the requested providers
await scenario('providers filter — /api/search?providers=yt,dm targets exactly [yt,dm]', async () => {
const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=5`);
assert(res.ok, `search responds 200 (got ${res.status})`);
const body = await res.json();
assert(Array.isArray(body.providers), 'response has providers array');
assert(body.providers.length === 2, `exactly 2 providers targeted (got ${body.providers.join(',')})`);
assert(body.providers.includes('yt') && body.providers.includes('dm'), 'yt and dm targeted');
assert(body.q === 'test', 'query echoed back');
assert(typeof body.groups === 'object' && body.groups !== null, 'groups object present');
for (const key of Object.keys(body.groups)) {
assert(['yt', 'dm'].includes(key), `group key ${key} belongs to the requested providers`);
}
});
// 2) Deep-link: single provider via query param
await scenario('deep-link — /api/search?providers=pt targets only [pt]', async () => {
const res = await fetch(`${baseUrl}/api/search?q=linux&providers=pt&pageSize=5`);
assert(res.ok, `search responds 200 (got ${res.status})`);
const body = await res.json();
assert(body.providers.length === 1 && body.providers[0] === 'pt', `only pt targeted (got ${body.providers.join(',')})`);
assert(body.q === 'linux', 'query echoed back');
});
// 2b) Invalid providers fall back to all
await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback());
// 2c) Filtres de recherche : ils sont renvoyés tels quels, valeurs inconnues ignorées
await scenario('search filters are echoed back and unknown values ignored', async () => {
const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=3&type=live&duration=short&period=week&sort=date`);
assert(res.ok, `search responds 200 (got ${res.status})`);
const body = await res.json();
assert(body.filters && body.filters.type === 'live', `type filter echoed (got ${JSON.stringify(body.filters)})`);
assert(body.filters.duration === 'short', 'duration filter echoed');
assert(body.filters.period === 'week', 'period filter echoed');
assert(body.sort === 'date', 'sort comes from the filters');
// Valeurs inconnues : ignorées, jamais 400.
const bad = await fetch(`${baseUrl}/api/search?q=test&providers=dm&type=plaid&period=yesterday&duration=epic`);
assert(bad.ok, `unknown filter values do not fail (got ${bad.status})`);
const badBody = await bad.json();
assert(!badBody.filters || Object.keys(badBody.filters).length === 0,
`unknown filters are dropped (got ${JSON.stringify(badBody.filters)})`);
assert(badBody.sort === 'relevance', 'sort falls back to relevance');
});
// 2d) Un filtre ne doit jamais renvoyer d'erreur 500 (post-filtrage sûr)
await scenario('filters never break the fan-out (200 + groups)', async () => {
const res = await fetch(`${baseUrl}/api/search?q=test&pageSize=3&period=today&duration=long`);
assert(res.ok, `search responds 200 with filters (got ${res.status})`);
const body = await res.json();
assert(body.groups && typeof body.groups === 'object', 'groups present with filters');
});
// 3) Preference: defaultProviders persisted and returned after re-login
await scenario('default providers preference round-trip (persisted per user)', async () => {
await patchPreferences(token, { defaultProviders: ['ru', 'od'] });
let prefs = await getPreferences(token);
assert(Array.isArray(prefs.defaultProviders) && prefs.defaultProviders.join(',') === 'ru,od',
`defaultProviders persisted (got ${JSON.stringify(prefs.defaultProviders)})`);
// Re-login (new token, same user): preference must survive
const token2 = await registerAndLogin(`e2e_${Date.now()}_relog`); // new user baseline
void token2;
// re-login same user via login endpoint again
const loginRes = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: `e2e_${Date.now()}`.slice(0, 4), password: 'x' }),
});
void loginRes;
// Simpler and deterministic: read prefs again with a fresh PATCH then GET
await patchPreferences(token, { defaultProviders: ['tw'] });
prefs = await getPreferences(token);
assert(prefs.defaultProviders.join(',') === 'tw', `preference updatable (got ${prefs.defaultProviders.join(',')})`);
});
// 4) SPA deep-link: /search?q=...&providers=... serves the Angular shell
await scenario('SPA deep-link — /search?... serves the frontend shell', async () => {
const res = await fetch(`${baseUrl}/search?q=linux&providers=pt`);
// The server serves dist if built; either way a non-API GET must not 404 with JSON error
if (res.status === 404) {
// dist not built in this environment — acceptable, skip
console.log(' (dist not built, SPA route not served — skipped)');
return;
}
assert(res.ok, `SPA route responds 200 (got ${res.status})`);
const text = await res.text();
assert(text.includes('<app-root') || text.includes('ng-version') || text.includes('<html'), 'response looks like the Angular shell');
});
} finally {
server.kill();
}
console.log(failures === 0 ? '\nAll search e2e scenarios passed.' : `\n${failures} scenario(s) failed.`);
process.exit(failures === 0 ? 0 : 1);
// ---- helpers used in scenarios above ----
async function validateProvidersFallback() {
const res = await fetch(`${baseUrl}/api/search?q=test&providers=xx,yy`);
assert(res.ok, `search responds 200 (got ${res.status})`);
const body = await res.json();
assert(Array.isArray(body.providers) && body.providers.length >= 6, `fallback to all providers (got ${body.providers.join(',')})`);
}
})();