#!/usr/bin/env node /** * ObsiGate — Image viewer tests (roadmap #108-D). * * Pure helpers (clampImageZoom / isImagePath / buildImageUrl) are evaluated in * a VM sandbox to avoid importing the full browser-only viewer chain. The rest * are static checks on the source and CSS. * * Usage: node tests/frontend/image-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 utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"); const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); const main = readFileSync(path.join(ROOT, "backend", "main.py"), "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 = viewer.indexOf("const IMAGE_EXTS"); const end = viewer.indexOf("function formatBytes"); assert.ok(begin !== -1 && end !== -1, "image viewer helper block not found"); const sandbox = {}; vm.createContext(sandbox); vm.runInContext( `${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.clampImageZoom = clampImageZoom; globalThis.isImagePath = isImagePath; globalThis.buildImageUrl = buildImageUrl;`, sandbox, ); const { clampImageZoom, isImagePath, buildImageUrl } = sandbox; test("clampImageZoom bounds to [0.1, 8]", () => { assert.equal(clampImageZoom(0.01), 0.1); assert.equal(clampImageZoom(100), 8); assert.equal(clampImageZoom(2), 2); assert.equal(clampImageZoom(NaN), 1); }); test("isImagePath recognises image extensions (case-insensitive)", () => { for (const p of ["a.png", "a.JPG", "dir/b.jpeg", "x/y/z.svg", "p.webp", "q.bmp", "r.ico", "g.gif"]) { assert.equal(isImagePath(p), true, p); } for (const p of ["a.md", "a.txt", "noext", "a.PDF", ""]) { assert.equal(isImagePath(p), false, p); } }); test("buildImageUrl encodes vault and path", () => { const url = buildImageUrl("My Vault", "café image.png"); assert.equal(url, "/api/image/My%20Vault?path=caf%C3%A9%20image.png"); }); // ── Static checks: viewer wiring ─────────────────────────────────────────── test("viewer.js dispatches is_image to renderImageViewer", () => { assert.match(viewer, /if \(data\.is_image\) \{\s*renderImageViewer\(area, data\);/); }); test("renderImageViewer never uses the JSON raw endpoint as an image src", () => { const start = viewer.indexOf("export function renderImageViewer"); const stop = viewer.indexOf("export function renderFile"); assert.ok(start !== -1 && stop > start, "renderImageViewer block not found"); const block = viewer.slice(start, stop); assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw"); assert.match(block, /buildImageUrl\(vault, path\)/); }); test("image viewer supports wheel zoom, drag pan and double-click reset", () => { assert.match(viewer, /stage\.addEventListener\("wheel"/); assert.match(viewer, /stage\.addEventListener\("pointerdown"/); assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/); assert.match(viewer, /renderImageViewer/); }); test("image viewer exposes thumbnail navigation via /api/media thumb", () => { assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/); assert.match(viewer, /let siblings = \[\]/); assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/); }); // BUG-072 — lightbox + metadata panel must survive ←/→ (and filmstrip) navigation. test("image viewer persists lightbox + metadata state across navigation", () => { assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/); assert.match(viewer, /let _imageViewerNavPending = false/); // Both navigation paths flag the next render as an image→image navigation. assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, siblings\[next\]\.path\)/); assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, s\.path\)/); // renderFile resets the state only when the next render is not a carried-over image. assert.match(viewer, /if \(!data\.is_image \|\| !_imageViewerNavPending\)/); // The persisted state drives the container class and the panel visibility. assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/); assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/); }); // ── Static checks: CSS ───────────────────────────────────────────────────── test("style.css defines the image viewer layout + lightbox", () => { assert.match(css, /\.image-stage\s*\{/); assert.match(css, /\.image-nav-strip\s*\{/); assert.match(css, /\.image-meta-panel\s*\{/); assert.match(css, /\.image-viewer-container\.lightbox\s*\{/); }); // BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip, // and it stays visible in lightbox mode. test("style.css lays the metadata panel out as a right sidebar", () => { assert.match(css, /\.image-viewer-body\s*\{[\s\S]*?display:\s*flex/); const meta = /\.image-meta-panel\s*\{([^}]*)\}/.exec(css); assert.ok(meta, ".image-meta-panel rule not found"); assert.match(meta[1], /border-left:/); assert.doesNotMatch(meta[1], /border-top:/); // The lightbox hide rule targets the filmstrip only — not the metadata sidebar. assert.match(css, /\.image-viewer-container\.lightbox \.image-nav-strip\s*\{/); assert.doesNotMatch(css, /\.image-viewer-container\.lightbox[^{]*\.image-meta-panel/); }); // ── Static checks: icon mapping + backend ────────────────────────────────── test("utils.js maps image extensions to the Lucide 'image' icon", () => { assert.match(utils, /"\.png": "image"/); assert.match(utils, /"\.svg": "image"/); }); test("backend api_file_view points at /api/image", () => { assert.match(main, /img_url = f"\/api\/image\//); assert.match(main, /f'