feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m57s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m18s

This commit is contained in:
2026-09-23 10:41:15 -04:00
parent e2417cb5ab
commit e3c6789776
21 changed files with 1738 additions and 211 deletions
+72 -36
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
/**
* ObsiGate — Audio/video viewer tests (roadmap #109).
* ObsiGate — Media viewers tests (roadmap #109 + #110).
*
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
* sandbox to avoid importing the full browser-only viewer chain. The rest are
* static checks on the source, CSS and backend wiring.
* 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
*/
@@ -19,6 +19,7 @@ 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");
@@ -37,16 +38,18 @@ function test(label, fn) {
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = viewer.indexOf("export function formatMediaDuration");
const end = viewer.indexOf("function buildDownloadUrl");
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
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(
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
`${np.slice(begin, end).replace(/\bexport /g, "")}\n` +
`globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` +
`globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`,
sandbox,
);
const { formatMediaDuration, buildMediaUrl } = sandbox;
const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox;
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
assert.equal(formatMediaDuration(0), "0:00");
@@ -61,49 +64,76 @@ test("buildMediaUrl encodes vault and path", () => {
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
});
// ── Static checks: viewer wiring ───────────────────────────────────────────
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("audio viewer uses a native <audio> element and stream URL", () => {
test("viewer delegates inline rendering to NowPlaying.attachInline", () => {
const start = viewer.indexOf("export function renderAudioViewer");
const stop = viewer.indexOf("export function renderVideoViewer");
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, /el\("audio"/);
assert.match(block, /controls: ""/);
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
assert.match(block, /NowPlaying\.attachInline\(area, data\)/);
});
test("video viewer uses a native <video> element with playsinline", () => {
const start = viewer.indexOf("export function renderVideoViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("video"/);
assert.match(block, /playsinline: ""/);
test("renderFile hands the shared element to the dock before a re-render", () => {
assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/);
assert.doesNotMatch(viewer, /_mediaViewerCleanup/);
});
test("unsupported codec triggers the binary fallback UI", () => {
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /export function renderMediaFallback/);
// ── 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("playback is stopped when the content area is re-rendered", () => {
assert.match(viewer, /_mediaViewerCleanup/);
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
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\(\)/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the audio/video viewer layout", () => {
test("style.css defines the media viewers and the Now Playing dock", () => {
assert.match(css, /\.audio-viewer-container/);
assert.match(css, /\.audio-stage\s*\{/);
assert.match(css, /\.audio-artwork\s*\{/);
assert.match(css, /\.video-stage\s*\{/);
assert.match(css, /\.video-player\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("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 ─────────────────────────────
@@ -114,8 +144,15 @@ test("utils.js maps media extensions to audio-lines / video icons", () => {
assert.match(utils, /"\.webm": "video"/);
});
test("media viewer strings exist in FR and EN", () => {
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
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}"`));
}
@@ -130,7 +167,6 @@ test("service worker never caches streamed media", () => {
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
});