CI / build-and-test (push) Successful in 14m10s
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.
237 lines
10 KiB
JavaScript
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(',')})`);
|
|
}
|
|
})();
|