Files
ObsiGate/tests/frontend/media-viewer.test.mjs
T

195 lines
8.5 KiB
JavaScript

#!/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.");
}