feat: support complet des images — arborescence, visionneuse, indexation #108
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Failing after 1m16s
CI / e2e (push) Skipped

This commit is contained in:
2026-09-23 07:47:04 -04:00
parent 69cee4d93a
commit eccbf7474e
31 changed files with 1317 additions and 138 deletions
+12
View File
@@ -94,6 +94,18 @@ def test_vault_dir(tmp_path: Path) -> str:
# Non-markdown file
(vault / "config.json").write_text('{"key": "value"}', encoding="utf-8")
# Image attachments (roadmap #108) — indexed as metadata-only binaries and
# listed in the tree / browse endpoint.
import base64
(vault / "chatScreenshot.png").write_bytes(base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
))
(vault / "vector-icon.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"></svg>',
encoding="utf-8",
)
# File with accents in title
(vault / "café_crème.md").write_text(
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
+98
View File
@@ -0,0 +1,98 @@
/**
* E2E tests for the ObsiGate image viewer (roadmap #108).
*
* Fixtures : `test_vault/sample-image.png` (96x64) + `test_vault/sample-vector.svg`.
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js --headed
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
test('affiche l\'image dans la visionneuse dédiée (URL /api/image)', async ({ page }) => {
// #108-B1 — l'image isolée doit pointer vers /api/image (octets), pas /raw (JSON).
const imageResponsePromise = page.waitForResponse(
(r) => r.url().includes('/api/image/') && r.status() === 200,
{ timeout: 15000 },
);
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const main = page.locator('#content-area .image-viewer-container img.image-main');
await expect(main).toBeVisible({ timeout: 10000 });
await expect(main).toHaveAttribute('src', /\/api\/image\/TestVault\?path=/);
const resp = await imageResponsePromise;
expect(resp.headers()['content-type']).toContain('image/png');
// Le badge de zoom démarre à 100 %.
await expect(page.locator('#content-area .image-zoom-badge')).toHaveText('100%');
});
test('le zoom molette et le reset modifient la transform', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
await expect(page.locator('#content-area .image-stage')).toBeVisible({ timeout: 10000 });
const badge = page.locator('#content-area .image-zoom-badge');
await expect(badge).toHaveText('100%');
await page.locator('#content-area .image-stage').hover();
await page.mouse.wheel(0, -240);
await expect(badge).not.toHaveText('100%', { timeout: 5000 });
const transform = await page.locator('#content-area img.image-main').evaluate(
(el) => getComputedStyle(el).transform,
);
expect(transform).not.toBe('none');
// Double-clic = réinitialisation.
await page.locator('#content-area .image-stage').dblclick();
await expect(badge).toHaveText('100%');
});
test('navigue entre les images du dossier via la pellicule', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const strip = page.locator('#content-area .image-nav-strip');
await expect(strip).toBeVisible({ timeout: 10000 });
// sample-image.png et sample-vector.svg partagent le dossier racine.
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
await page.locator('#content-area .image-nav-strip img.image-thumb').first().click();
await expect(page.locator('#content-area .image-title')).toBeVisible();
});
});
+119
View File
@@ -0,0 +1,119 @@
#!/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 \|\| \[\]\)/);
});
// ── 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*\{/);
});
// ── 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 <img> at /api/image", () => {
assert.match(main, /img_url = f"\/api\/image\//);
assert.match(main, /f'<img src="\{img_url\}"/);
});
if (process.exitCode) {
console.error("\nImage viewer tests FAILED");
} else {
console.log("\nAll image viewer tests passed.");
}
+109
View File
@@ -0,0 +1,109 @@
# tests/test_image_api.py — Image serving & viewer API (roadmap #108-B/C)
import base64
from pathlib import Path
import pytest
PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
)
SVG_DOC = b'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><script>alert(1)</script></svg>'
def _write_image(vault_dir: str, name: str, content: bytes) -> None:
(Path(vault_dir) / name).write_bytes(content)
# ═══════════════════════════════════════════════════════════════════
# /api/image — byte serving (BUG fixed in #108-B1)
# ═══════════════════════════════════════════════════════════════════
class TestImageEndpoint:
def test_serves_bytes_and_mime(self, client, test_vault_dir):
_write_image(test_vault_dir, "pic.png", PNG_1x1)
resp = client.get("/api/image/TestVault", params={"path": "pic.png"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == PNG_1x1
def test_missing_image_404(self, client):
resp = client.get("/api/image/TestVault", params={"path": "nope.png"})
assert resp.status_code == 404
def test_svg_gets_sandbox_header(self, client, test_vault_dir):
_write_image(test_vault_dir, "vector.svg", SVG_DOC)
resp = client.get("/api/image/TestVault", params={"path": "vector.svg"})
assert resp.status_code == 200
assert resp.headers.get("content-security-policy") == "sandbox"
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.content == SVG_DOC
def test_png_keeps_the_global_csp(self, client, test_vault_dir):
_write_image(test_vault_dir, "pic2.png", PNG_1x1)
resp = client.get("/api/image/TestVault", params={"path": "pic2.png"})
csp = resp.headers.get("content-security-policy", "")
assert csp != "sandbox"
assert "default-src" in csp
# ═══════════════════════════════════════════════════════════════════
# /api/file — standalone image view points at /api/image, not /raw
# ═══════════════════════════════════════════════════════════════════
class TestImageViewMetadata:
def test_file_view_uses_image_endpoint(self, client):
resp = client.get("/api/file/TestVault", params={"path": "chatScreenshot.png"})
assert resp.status_code == 200
data = resp.json()
assert data["is_image"] is True
assert data["image_mime"] == "image/png"
assert data["size_bytes"] > 0
assert "/api/image/TestVault?path=chatScreenshot.png" in data["html"]
# The JSON raw endpoint must NOT be used as an <img> source.
assert "/raw?path=" not in data["html"]
def test_file_view_url_encoded(self, client, test_vault_dir):
_write_image(test_vault_dir, "café image.png", PNG_1x1)
resp = client.get("/api/file/TestVault", params={"path": "café image.png"})
assert resp.status_code == 200
html = resp.json()["html"]
assert "/api/image/TestVault?path=caf%C3%A9%20image.png" in html
# ═══════════════════════════════════════════════════════════════════
# /api/media/{vault}/thumb — thumbnails (roadmap #108-C)
# ═══════════════════════════════════════════════════════════════════
class TestThumbnailEndpoint:
def test_png_thumbnail_is_webp(self, client, tmp_path, monkeypatch):
pytest.importorskip("PIL")
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "chatScreenshot.png", "size": 64},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/webp")
def test_svg_thumbnail_falls_back_to_original(self, client, tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "vector-icon.svg"},
)
assert resp.status_code == 200
assert "svg" in resp.headers["content-type"]
def test_thumb_rejects_non_image(self, client):
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "config.json"},
)
assert resp.status_code == 400
def test_missing_thumb_404(self, client):
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "nope.png"},
)
assert resp.status_code == 404
+105
View File
@@ -0,0 +1,105 @@
# tests/test_image_indexing.py — Images in the tree & index (roadmap #108-A)
import asyncio
import base64
from pathlib import Path
from backend.indexer import _index_single_file_sync, _scan_vault
from backend.services.vaults import browse_directory
from backend.watcher import VaultEventHandler
PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
)
def _make_vault(tmp_path: Path) -> Path:
vault = tmp_path / "Vault"
vault.mkdir()
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
(vault / "chatScreenshot.png").write_bytes(PNG_1x1)
assets = vault / "assets"
assets.mkdir()
(assets / "photo.jpg").write_bytes(PNG_1x1)
return vault
class TestScanVaultImages:
def test_image_indexed_without_content(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
by_path = {f["path"]: f for f in result["files"]}
assert "chatScreenshot.png" in by_path
image = by_path["chatScreenshot.png"]
assert image["content"] == "" # never read the bytes (#108-A2)
assert image["content_preview"] == ""
assert image["extension"] == ".png"
assert image["size"] > 0
assert "assets/photo.jpg" in by_path
def test_images_present_in_paths(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
assert "chatScreenshot.png" in files
assert "assets/photo.jpg" in files
def test_no_binary_content_leaks(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
for f in result["files"]:
if f["extension"] in (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"):
assert f["content"] == ""
class TestSingleFileImage:
def test_metadata_only(self, tmp_path):
vault = _make_vault(tmp_path)
info = _index_single_file_sync(
"V", str(vault), str(vault / "chatScreenshot.png")
)
assert info is not None
assert info["content"] == ""
assert info["extension"] == ".png"
assert info["path"] == "chatScreenshot.png"
class TestWatcherRelevance:
def test_image_extensions_relevant(self, tmp_path):
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
assert handler._is_relevant(str(tmp_path / "a.png")) is True
assert handler._is_relevant(str(tmp_path / "a.jpg")) is True
assert handler._is_relevant(str(tmp_path / "a.svg")) is True
assert handler._is_relevant(str(tmp_path / "a.exe")) is False
class TestBrowseAndDashboard:
def test_browse_lists_image(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
items = resp.json()["items"]
names = {i["name"]: i for i in items}
assert "chatScreenshot.png" in names
assert names["chatScreenshot.png"]["type"] == "file"
assert names["chatScreenshot.png"]["extension"] == ".png"
def test_dashboard_counts_images(self, client):
resp = client.get("/api/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["total_images"] >= 2
vault = next(v for v in data["vaults"] if v["name"] == "TestVault")
assert vault["image_count"] >= 2
def test_ext_filter_returns_images(self, client):
resp = client.get("/api/search/advanced", params={"q": "ext:png"})
assert resp.status_code == 200
paths = [r["path"] for r in resp.json()["results"]]
assert any(p.endswith(".png") for p in paths)
class TestBrowseService:
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
# The shared index is populated by the app_with_vault fixture.
data = browse_directory("TestVault", "")
names = [i["name"] for i in data["items"]]
assert "chatScreenshot.png" in names
+7 -1
View File
@@ -160,9 +160,15 @@ class TestSupportedExtensions:
for ext in [".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"]:
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
def test_image_extensions_supported(self):
# #108 — images are indexed (name/size/mtime, empty content) so they
# show up in the tree and in file listings.
for ext in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]:
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
def test_binary_not_supported(self):
assert ".png" not in SUPPORTED_EXTENSIONS
assert ".exe" not in SUPPORTED_EXTENSIONS
assert ".zip" not in SUPPORTED_EXTENSIONS
# ═══════════════════════════════════════════════════════════════════
+11 -2
View File
@@ -70,11 +70,20 @@ class TestFileCRUD:
assert info is None
def test_index_single_file_sync_unsupported(self, test_vault_dir):
bin_file = Path(test_vault_dir) / "test_image.png"
bin_file.write_bytes(b"fake png data")
bin_file = Path(test_vault_dir) / "test_binary.exe"
bin_file.write_bytes(b"fake exe data")
info = _index_single_file_sync("TestVault", test_vault_dir, str(bin_file))
assert info is None
def test_index_single_file_sync_image_metadata_only(self, test_vault_dir):
# #108-A2 — images are indexed (name/size/mtime) but never read.
img_file = Path(test_vault_dir) / "test_image.png"
img_file.write_bytes(b"fake png data")
info = _index_single_file_sync("TestVault", test_vault_dir, str(img_file))
assert info is not None
assert info["extension"] == ".png"
assert info["content"] == ""
def test_add_and_remove_file(self, client):
path = "crud_test_unique_12345.md"
file_info = {