#!/usr/bin/env node /** * ObsiGate — Media viewers tests (roadmap #109 + #110). * * Pure helpers are evaluated in a VM sandbox to avoid importing the * browser-only module chain. The rest are static checks on the source, CSS, * i18n and backend wiring. * * Usage: node tests/frontend/media-viewer.test.mjs */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import vm from "node:vm"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, "..", ".."); const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8"); const np = readFileSync(path.join(ROOT, "frontend", "js", "now-playing.js"), "utf8"); const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"); const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8"); const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8"); const filesMedia = readFileSync(path.join(ROOT, "backend", "routers", "files_media.py"), "utf8"); const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8"); const routerHelpers = readFileSync(path.join(ROOT, "backend", "routers", "helpers.py"), "utf8"); const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"); const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"); function test(label, fn) { try { fn(); console.log(" \u2713 " + label); } catch (err) { console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300)); process.exitCode = 1; } } // ── Pure helpers (VM sandbox) ────────────────────────────────────────────── const begin = np.indexOf("const AUDIO_EXTS"); const end = np.indexOf("function downloadUrl"); assert.ok(begin !== -1 && end > begin, "now-playing helper block not found"); const sandbox = {}; vm.createContext(sandbox); vm.runInContext( `${np.slice(begin, end).replace(/\bexport /g, "")}\n` + `globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` + `globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`, sandbox, ); const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox; test("formatMediaDuration renders m:ss and h:mm:ss", () => { assert.equal(formatMediaDuration(0), "0:00"); assert.equal(formatMediaDuration(65), "1:05"); assert.equal(formatMediaDuration(3725), "1:02:05"); assert.equal(formatMediaDuration(NaN), ""); assert.equal(formatMediaDuration(-1), ""); }); test("buildMediaUrl encodes vault and path", () => { const url = buildMediaUrl("My Vault", "café clip.mp4"); assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4"); }); test("isAudioPath / isVideoPath recognise media extensions", () => { for (const p of ["a.mp3", "a.M4A", "dir/b.flac", "c.opus", "d.oga"]) assert.equal(isAudioPath(p), true, p); for (const p of ["a.mp4", "a.WEBM", "dir/b.mov", "c.m4v"]) assert.equal(isVideoPath(p), true, p); for (const p of ["a.md", "a.png", "noext", ""]) { assert.equal(isAudioPath(p), false, p); assert.equal(isVideoPath(p), false, p); } }); // ── Static checks: viewer delegates to the persistent controller ────────── test("viewer.js dispatches is_audio / is_video to the media viewers", () => { assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/); assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/); }); test("viewer delegates inline rendering to NowPlaying.attachInline", () => { const start = viewer.indexOf("export function renderAudioViewer"); const stop = viewer.indexOf("export function renderFile"); assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found"); const block = viewer.slice(start, stop); assert.match(block, /NowPlaying\.attachInline\(area, data\)/); }); test("renderFile hands the shared element to the dock before a re-render", () => { assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/); assert.doesNotMatch(viewer, /_mediaViewerCleanup/); }); // ── Static checks: global Now Playing controller (#110) ─────────────────── test("now-playing owns ONE media element reused across surfaces", () => { assert.match(np, /media\.dataset\.npKey === `\$\{data\.vault\}::\$\{data\.path\}`/); assert.match(np, /function ensureMedia\(kind\)/); assert.match(np, /slot\.appendChild\(media\)/); assert.match(np, /dock\.appendChild\(S\.media\)/); }); test("now-playing shows an unsupported fallback on media error", () => { assert.match(np, /media\.addEventListener\('error',\s*\(\)\s*=>\s*onMediaError\(\)\)/); assert.match(np, /export function renderFallback/); }); test("now-playing wires Media Session, PiP and persistence", () => { assert.match(np, /navigator\.mediaSession\.metadata/); assert.match(np, /setActionHandler\('nexttrack'/); assert.match(np, /requestPictureInPicture/); assert.match(np, /const LS_KEY = 'obsigate-now-playing'/); assert.match(np, /function restore\(\)/); }); test("now-playing exposes the public controller API and is initialised at boot", () => { assert.match(np, /export const NowPlaying = \{/); assert.match(np, /attachInline/); assert.match(np, /notifyTabClosed/); const app = readFileSync(path.join(ROOT, "frontend", "js", "app.js"), "utf8"); assert.match(app, /initNowPlaying\(\)/); }); test("video mini-window is freely positioned (no corner snapping)", () => { assert.doesNotMatch(np, /nearestCorner/); assert.match(np, /function clampToViewport/); assert.match(np, /left: Math\.round\(r\.left\),\s*\n\s*top: Math\.round\(r\.top\)/); assert.match(np, /bindVideoDrag\(dock\);/); }); // ── Static checks: CSS ───────────────────────────────────────────────────── test("style.css defines the media viewers and the Now Playing dock", () => { assert.match(css, /\.audio-viewer-container/); assert.match(css, /\.video-stage\s*\{/); assert.match(css, /\.np-dock--audio\s*\{/); assert.match(css, /\.np-dock--video\s*\{/); assert.match(css, /\.np-expanded\s*\{/); assert.match(css, /body\.np-active \.content-area/); }); test("mobile dock uses a grid so actions never overlap the progress bar", () => { assert.match(css, /grid-template-areas:\s*\n\s*"progress progress progress progress"/); assert.match(css, /\.np-dock--audio \[data-np="expand"\]/); }); test("style.css no longer references undefined --surface1 / --text-dim vars", () => { assert.doesNotMatch(css, /var\(--surface1\)/); assert.doesNotMatch(css, /var\(--text-dim\)/); }); // ── Static checks: icons, i18n, service worker ───────────────────────────── test("utils.js maps media extensions to audio-lines / video icons", () => { assert.match(utils, /"\.mp3": "audio-lines"/); assert.match(utils, /"\.flac": "audio-lines"/); assert.match(utils, /"\.mp4": "video"/); assert.match(utils, /"\.webm": "video"/); }); test("player/viewer media strings exist in FR and EN", () => { for (const key of [ "viewer.media_unsupported", "viewer.media_too_large", "player.now_playing", "player.close", "player.reopen_tab", "player.continues", ]) { assert.match(fr, new RegExp(`"${key}"`)); assert.match(en, new RegExp(`"${key}"`)); } }); test("service worker never caches streamed media", () => { assert.match(sw, /startsWith\('\/api\/media\/'\)/); assert.match(sw, /endsWith\('\/thumb'\)/); }); // ── Static checks: backend ───────────────────────────────────────────────── test("backend exposes /api/media and the shared Range helper", () => { // #85 T6c : routes dans backend/routers/files_media.py, helper Range partagé // dans backend/routers/helpers.py, branche audio/vidéo dans files_read.py assert.match(filesMedia, /@router\.get\("\/api\/media\/\{vault_name\}"/); assert.match(routerHelpers, /def stream_file_with_range\(/); assert.match(filesRead, /is_audio\(ext\) or is_video\(ext\)/); }); if (process.exitCode) { console.error("\nMedia viewer tests FAILED"); } else { console.log("\nAll media viewer tests passed."); }