openvibers/openvibe.live Commits

Search page (WS-O task 10: product search boxes use the query API): /search finds Live's channels, VODs and clips through OpenVibe.Search (GET /api/search asks anonymously for owner=live at Search's internal origin and returns only public fields; 503 when Search does not answer), with type chips, More paging and the query in the URL; results built as DOM nodes, snippets keep only <mark>; Search in the navbar; a known page, noindex; a link to search the whole network

OP OpenVibers committed 25 Sept 2026 · ace582336b · 13 files, +301 −5


Changes

ddiff --git a/public/css/features/search.css b/public/css/features/search.css
nnew file mode 100644
iindex 00000000..4644d966
---- /dev/null
++++ b/public/css/features/search.css
@@@ -0,0 +1,24 @@
++/* The /search page (public/js/app-search.js, fragment search.html). Theme tokens only. */
++.ls-wrap [hidden] { display: none !important; }
++.ls-wrap { max-width: 860px; margin: 0 auto; padding: 20px 16px 48px; }
++.ls-h1 { margin: 0; font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem); font-weight: 800; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
++.ls-sub { margin: 6px 0 14px; color: var(--text-secondary); font-size: 0.92rem; }
++.ls-network { white-space: nowrap; }
++.ls-form { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
++.ls-box { display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); color: var(--text-muted); }
++.ls-box:focus-within { border-color: var(--accent); }
++.ls-box input { flex: 1; min-width: 0; padding: 12px 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 1rem; }
++.ls-types { display: flex; flex-wrap: wrap; gap: 8px; }
++.ls-type { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font: inherit; font-size: 0.85rem; cursor: pointer; }
++.ls-type[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }
++.ls-status { color: var(--text-muted); font-size: 0.9rem; min-height: 1.2em; margin: 4px 0 10px; }
++.ls-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
++.ls-hit { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); min-width: 0; }
++.ls-title { font-weight: 700; font-size: 1.02rem; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; }
++.ls-title:hover { color: var(--accent); }
++.ls-meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin: 4px 0; font-size: 0.8rem; color: var(--text-muted); }
++.ls-kind { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--text-secondary); }
++.ls-live { color: var(--live-red); font-weight: 700; }
++.ls-snippet { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; overflow-wrap: anywhere; }
++.ls-snippet mark { background: var(--bg-tertiary); color: var(--text-primary); border-radius: 3px; padding: 0 2px; box-shadow: inset 0 -2px 0 var(--accent); }
++.ls-more-wrap { display: flex; justify-content: center; margin-top: 16px; }
ddiff --git a/public/features.json b/public/features.json
iindex d9311f67..e40b96a3 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -283,6 +283,15 @@
"js": [
"/js/home-featured.js"
]
++ },
++ "search": {
++ "fragment": "search",
++ "css": [
++ "/css/features/search.css"
++ ],
++ "js": [
++ "/js/app-search.js"
++ ]
}
},
"routes": [
@@@ -304,6 +313,12 @@
"moments"
]
},
++ {
++ "path": "^/search/?$",
++ "features": [
++ "search"
++ ]
++ },
{
"path": "^/vod/[^/]+",
"features": [
ddiff --git a/public/fragments/search.html b/public/fragments/search.html
nnew file mode 100644
iindex 00000000..aa546477
---- /dev/null
++++ b/public/fragments/search.html
@@@ -0,0 +1,18 @@
++<div class="ls-wrap">
++ <header class="ls-head">
++ <h1 class="ls-h1"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</h1>
++ <p class="ls-sub">Channels, VODs and clips on OpenVibe.Live. <a class="ls-network" href="https://search.openvibe.network/">Search the whole OpenVibe network</a></p>
++ </header>
++ <form class="ls-form" role="search" action="/search" method="get">
++ <label class="ls-box"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" maxlength="200" placeholder="Search channels, VODs and clips" aria-label="Search channels, VODs and clips" autocomplete="off"></label>
++ <div class="ls-types" role="group" aria-label="Show">
++ <button type="button" class="ls-type" data-ls-type="" aria-pressed="true">All</button>
++ <button type="button" class="ls-type" data-ls-type="channel" aria-pressed="false">Channels</button>
++ <button type="button" class="ls-type" data-ls-type="vod" aria-pressed="false">VODs</button>
++ <button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
++ </div>
++ </form>
++ <p class="ls-status" role="status" aria-live="polite"></p>
++ <ol class="ls-results"></ol>
++ <div class="ls-more-wrap"><button type="button" class="btn btn-outline ls-more" hidden>More results</button></div>
++</div>
ddiff --git a/public/index.html b/public/index.html
iindex 5a7b9c3e..e40c1c40 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -680,6 +680,7 @@
<!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── -->
<section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
++ <section id="page-search" class="page" data-fragment="search"><!--ov:fragment:search--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
ddiff --git a/public/js/app-search.js b/public/js/app-search.js
nnew file mode 100644
iindex 00000000..ca203b5b
---- /dev/null
++++ b/public/js/app-search.js
@@@ -0,0 +1,125 @@
++/**
++ * The /search page (fragment search.html): Live's channels, VODs and clips through OpenVibe.Search
++ * (GET /api/search, server/search/routes.js). The query lives in the URL (?q=&type=), so a search can
++ * be linked and Back works. Results are built as DOM nodes; a snippet keeps Search's <mark> and
++ * nothing else.
++ */
++(function () {
++ 'use strict';
++ const KIND = { channel: ['fa-tower-broadcast', 'Channel'], vod: ['fa-film', 'VOD'], clip: ['fa-scissors', 'Clip'] };
++ let seq = 0;
++
++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++
++ // Only text and <mark> survive: Search escapes the text and adds <mark>, but nothing else is trusted.
++ function snippetNode(html, fallback) {
++ const p = el('p', 'ls-snippet');
++ if (!html) { p.textContent = fallback || ''; return p; }
++ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
++ for (const n of doc.body.childNodes) {
++ if (n.nodeType === 3) p.append(n.textContent);
++ else if (n.nodeName === 'MARK') p.append(el('mark', null, n.textContent));
++ else p.append(n.textContent || '');
++ }
++ return p;
++ }
++
++ function localPath(url) {
++ try { const u = new URL(url); return u.origin === location.origin || u.hostname === 'openvibe.live' ? u.pathname + u.search : url; } catch { return '#'; }
++ }
++
++ function hit(r) {
++ const li = el('li', 'ls-hit');
++ const href = localPath(r.canonical_url);
++ const a = el('a', 'ls-title', r.title || 'Untitled');
++ a.href = href;
++ if (href.startsWith('/')) a.addEventListener('click', (e) => { if (typeof handleLinkClick === 'function') handleLinkClick(e, href); });
++ const meta = el('div', 'ls-meta');
++ const [icon, label] = KIND[r.type] || ['fa-circle', r.type];
++ const kind = el('span', 'ls-kind');
++ const i = el('i', `fa-solid ${icon}`); i.setAttribute('aria-hidden', 'true');
++ kind.append(i, ` ${label}`);
++ meta.append(kind);
++ const f = r.facets || {};
++ if (r.type === 'channel' && f.live) meta.append(el('span', 'ls-live', 'LIVE'));
++ if (r.type !== 'channel' && f.channel) meta.append(el('span', null, `@${f.channel}`));
++ if (f.category) meta.append(el('span', null, f.category));
++ if (r.type === 'channel' && Number.isFinite(f.followers)) meta.append(el('span', null, `${f.followers} follower${f.followers === 1 ? '' : 's'}`));
++ if (r.authorship === 'ai_generated') meta.append(el('span', 'ls-kind', 'AI clip'));
++ li.append(a, meta, snippetNode(r.snippet_html, r.summary));
++ return li;
++ }
++
++ function readUrl() {
++ const sp = new URLSearchParams(location.search);
++ const type = ['channel', 'vod', 'clip'].includes(sp.get('type')) ? sp.get('type') : '';
++ return { q: (sp.get('q') || '').trim().slice(0, 200), type };
++ }
++
++ async function run(root, { q, type }, cursor) {
++ const status = root.querySelector('.ls-status');
++ const list = root.querySelector('.ls-results');
++ const more = root.querySelector('.ls-more');
++ const mine = ++seq;
++ if (!cursor) list.replaceChildren();
++ more.hidden = true;
++ if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; return; }
++ status.textContent = 'Searching…';
++ const params = new URLSearchParams({ q });
++ if (type) params.set('type', type);
++ if (cursor) params.set('cursor', cursor);
++ let out;
++ try {
++ const res = await fetch(`/api/search?${params}`, { headers: { accept: 'application/json' } });
++ out = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(out.error || 'Search is not answering right now');
++ } catch (err) {
++ if (mine === seq) status.textContent = `${err.message}. Try again in a minute.`;
++ return;
++ }
++ if (mine !== seq) return;
++ for (const r of out.results || []) list.append(hit(r));
++ const n = list.children.length;
++ status.textContent = n ? `${n}${out.next_cursor ? '+' : ''} result${n === 1 ? '' : 's'} for “${q}”` : `Nothing public matches “${q}”.`;
++ if (out.next_cursor) { more.hidden = false; more.onclick = () => run(root, { q, type }, out.next_cursor); }
++ }
++
++ function setUrl(state) {
++ const sp = new URLSearchParams();
++ if (state.q) sp.set('q', state.q);
++ if (state.type) sp.set('type', state.type);
++ const url = `/search${sp.toString() ? `?${sp}` : ''}`;
++ if (url !== location.pathname + location.search) history.pushState({}, '', url);
++ }
++
++ window.loadSearchPage = function loadSearchPage() {
++ const root = document.querySelector('#page-search .ls-wrap');
++ if (!root) return;
++ const state = readUrl();
++ const form = root.querySelector('.ls-form');
++ const input = form.querySelector('input[name="q"]');
++ input.value = state.q;
++ for (const b of root.querySelectorAll('.ls-type')) b.setAttribute('aria-pressed', String(b.dataset.lsType === state.type));
++ if (!form.dataset.bound) {
++ form.dataset.bound = '1';
++ form.addEventListener('submit', (e) => {
++ e.preventDefault();
++ const next = { q: input.value.trim().slice(0, 200), type: readUrl().type };
++ setUrl(next);
++ run(root, next);
++ });
++ for (const b of root.querySelectorAll('.ls-type')) {
++ b.addEventListener('click', () => {
++ const next = { q: input.value.trim().slice(0, 200), type: b.dataset.lsType };
++ for (const o of root.querySelectorAll('.ls-type')) o.setAttribute('aria-pressed', String(o === b));
++ setUrl(next);
++ run(root, next);
++ });
++ }
++ }
++ if (typeof setPageTitle === 'function') setPageTitle(state.q ? `Search: ${state.q}` : 'Search');
++ else document.title = `${state.q ? `Search: ${state.q}` : 'Search'} — OpenVibe.Live`;
++ run(root, state);
++ if (!state.q) input.focus();
++ };
++})();
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 34bb7808..d5ca3e3d 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {
}
// Reserved paths (not usernames)
--const RESERVED = new Set(['content', 'moments', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
++const RESERVED = new Set(['content', 'moments', 'search', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
@@@ -1073,6 +1073,10 @@ function routeFromURL() {
// What the AI made: auto-clips, AI moments, AI recaps.
showPage('moments');
whenRouteReady('moments', () => loadMomentsPage());
++ } else if (segments[0] === 'search') {
++ // Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).
++ showPage('search');
++ whenRouteReady('search', () => loadSearchPage());
} else if (segments[0] === 'vod' && segments[1]) {
// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
showPage('vod-player');
@@@ -1348,7 +1352,7 @@ function showPage(page) {
}
// Highlight nav link
-- const pageToNav = { home: 'home', content: 'content', moments: 'moments', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };
++ const pageToNav = { home: 'home', content: 'content', moments: 'moments', search: 'search', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };
const navPage = pageToNav[page];
if (navPage) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex bf5dea85..60edc967 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -23,6 +23,7 @@
// VODs, clips and pastes people made are one feed; what the AI made is the other.
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
{ id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
++ { id: 'search', page: 'search', label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
ddiff --git a/server/index.js b/server/index.js
iindex c6533390..ba0ef78c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -706,6 +706,7 @@ app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
++app.use('/api/search', require('./search/routes').createSearchRouter()); // /search page → OpenVibe.Search (Live's channels, VODs, clips)
app.use('/api/home', require('./home/routes'));
app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/kiosk', require('./kiosk/routes'));
ddiff --git a/server/search/routes.js b/server/search/routes.js
nnew file mode 100644
iindex 00000000..4aaf7fd3
---- /dev/null
++++ b/server/search/routes.js
@@@ -0,0 +1,44 @@
++'use strict';
++/**
++ * GET /api/search?q=&type=&cursor= (roadmap WS-O task 10: product search boxes use the query API)
++ *
++ * Live's /search page asks OpenVibe.Search for Live's own documents (channels, VODs, clips:
++ * server/events/search-documents.js and search-media-documents.js), anonymously at Search's internal
++ * origin, so only public, published, indexable documents come back. → { results, next_cursor };
++ * 400 for a malformed query; 503 { error } when Search does not answer (the page says so).
++ */
++const express = require('express');
++
++const SEARCH_URL = (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/+$/, '');
++const TYPES = new Set(['channel', 'vod', 'clip']);
++const FIELDS = ['type', 'id', 'title', 'summary', 'canonical_url', 'facets', 'snippet_html', 'authorship', 'published_at', 'updated_at'];
++
++function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL, timeoutMs = 4000 } = {}) {
++ const router = express.Router();
++ router.get('/', async (req, res) => {
++ const q = String(req.query.q || '').trim().slice(0, 200);
++ if (!q) return res.json({ results: [], next_cursor: null });
++ const params = new URLSearchParams({ q, owner: 'live', limit: '24' });
++ if (TYPES.has(req.query.type)) params.set('type', req.query.type);
++ const cursor = String(req.query.cursor || '');
++ if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);
++ let r;
++ try {
++ r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
++ } catch (err) {
++ console.warn('[Search] /api/search:', err.message);
++ return res.status(503).json({ error: 'Search is not answering right now' });
++ }
++ const body = await r.json().catch(() => null);
++ if (r.status === 400) return res.status(400).json({ error: 'That search could not be read' });
++ if (!r.ok || !body || !Array.isArray(body.results)) return res.status(503).json({ error: 'Search is not answering right now' });
++ res.set('Cache-Control', 'private, max-age=30');
++ res.json({
++ results: body.results.map((d) => Object.fromEntries(FIELDS.filter((k) => d[k] !== undefined).map((k) => [k, d[k]]))),
++ next_cursor: body.next_cursor || null,
++ });
++ });
++ return router;
++}
++
++module.exports = { createSearchRouter };
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 2c3cdf7a..0495384f 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -228,6 +228,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
if (p === '/arena') return _arenaMeta();
++ // Search results (public/js/app-search.js) are never indexed; the page itself is findable.
++ if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
return null;
void bu;
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex 97ed11f8..d537e767 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -14,6 +14,7 @@
*
* / home
* /content /moments the feeds (people's work, AI's)
++ * /search Live's channels, VODs and clips through OpenVibe.Search
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
@@@ -41,7 +42,7 @@ const OK = 200;
const NOT_FOUND = 404;
// Pages that exist only at their exact path.
--const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);
++const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes', 'search']);
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex abe867a5..57162504 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -138,7 +138,7 @@ server.listen(0, '127.0.0.1', async () => {
console.log('Page status (soft 404s)');
await check('every real route answers 200', () => expectAll([
-- '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',
++ '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes', '/search',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
'/arena', '/arena/beef/4', '/arena/alice',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
@@@ -147,7 +147,7 @@ server.listen(0, '127.0.0.1', async () => {
], 200));
await check('unknown paths answer 404', () => expectAll([
-- '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',
++ '/nope', '/find', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',
'/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
ddiff --git a/test/search-page.test.js b/test/search-page.test.js
nnew file mode 100644
iindex 00000000..d6e18891
---- /dev/null
++++ b/test/search-page.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// Live's /search page (WS-O task 10: product search boxes use the query API): GET /api/search asks
++// OpenVibe.Search anonymously for owner=live only, passes a known type and a well-formed cursor, returns
++// only the public fields, and answers 503 when Search does not; an empty query asks nothing. /search is
++// a known page (not the SPA 404) and loads the search feature.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createSearchRouter } = require('../server/search/routes');
++
++(async () => {
++ const asked = [];
++ let mode = 'ok';
++ const fetchImpl = async (url, opts) => {
++ asked.push({ url: new URL(url), opts });
++ if (mode === 'down') throw new Error('connect ECONNREFUSED');
++ if (mode === 'bad') return new Response('{"code":"search.bad_query"}', { status: 400 });
++ if (mode === 'error') return new Response('{}', { status: 500 });
++ return new Response(JSON.stringify({ results: [{ owner: 'live', type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>', acl: ['secret'], revision: 3 }], next_cursor: 'c1' }), { status: 200 });
++ };
++ const app = express();
++ app.use('/api/search', createSearchRouter({ fetchImpl, baseUrl: 'http://search.test' }));
++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}${p}`); return { status: res.status, body: await res.json() }; };
++
++ let r = await get('/api/search?q=forum&type=vod&cursor=abc_1');
++ assert.strictEqual(r.status, 200);
++ const u = asked[0].url;
++ assert.strictEqual(u.origin + u.pathname, 'http://search.test/api/v1/search');
++ assert.strictEqual(u.searchParams.get('owner'), 'live');
++ assert.strictEqual(u.searchParams.get('type'), 'vod');
++ assert.strictEqual(u.searchParams.get('cursor'), 'abc_1');
++ assert.ok(!asked[0].opts.headers.authorization, 'anonymous: public documents only');
++ assert.deepStrictEqual(r.body, { results: [{ type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>' }], next_cursor: 'c1' });
++
++ await get('/api/search?q=forum&type=paste&cursor=bad%20cursor');
++ assert.strictEqual(asked[1].url.searchParams.get('type'), null, 'only Live\'s types');
++ assert.strictEqual(asked[1].url.searchParams.get('cursor'), null);
++
++ r = await get('/api/search?q=');
++ assert.deepStrictEqual(r.body, { results: [], next_cursor: null });
++ assert.strictEqual(asked.length, 2, 'an empty query asks nothing');
++
++ mode = 'down'; r = await get('/api/search?q=forum');
++ assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));
++ mode = 'error'; assert.strictEqual((await get('/api/search?q=forum')).status, 503);
++ mode = 'bad'; assert.strictEqual((await get('/api/search?q=forum')).status, 400);
++ srv.close();
++
++ const features = require('../public/features.json');
++ const route = features.routes.find((x) => new RegExp(x.path).test('/search'));
++ assert.deepStrictEqual(route && route.features, ['search']);
++ assert.strictEqual(features.features.search.fragment, 'search');
++ const fs = require('fs');
++ assert.ok(fs.existsSync(require('path').join(__dirname, '../public/fragments/search.html')));
++ const src = fs.readFileSync(require('path').join(__dirname, '../server/web/page-status.js'), 'utf8');
++ assert.ok(/EXACT = new Set\([^)]*'search'/.test(src), '/search is a known page');
++
++ console.log('search page: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });