195 lines
8.5 KiB
JavaScript
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.");
|
|
}
|