diff --git a/app/package.json b/app/package.json index 1d4bf70..8926bef 100644 --- a/app/package.json +++ b/app/package.json @@ -20,9 +20,11 @@ "generate:session-share": "node scripts/generate-session-share.mjs", "verify:icons": "node scripts/verify-icons.mjs", "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", + "test:electron:images": "electron-vite build && electron --no-sandbox tests/electron-session-images.mjs", "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs", "test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs", - "test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs" + "test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs", + "test:electron:all": "electron-vite build && node tests/run-electron-suites.mjs" }, "build": { "appId": "com.obelisk.app", diff --git a/app/src/renderer/src/components/SessionImage.ce.vue b/app/src/renderer/src/components/SessionImage.ce.vue new file mode 100644 index 0000000..d918048 --- /dev/null +++ b/app/src/renderer/src/components/SessionImage.ce.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js index 6584fbb..d468d0d 100644 --- a/app/src/renderer/src/main.js +++ b/app/src/renderer/src/main.js @@ -6,6 +6,8 @@ import router from './router.js'; import { commitInitialData, fetchInitialData } from './data.js'; import { noteSessionUpdated, sessionLiveState } from './session-live.mjs'; import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs'; +import { registerSessionImageElement } from './session-image-element.js'; +import { configureMarkdownImages } from './markdown-image-renderer.js'; import { installFileReferenceHandler } from './file-references.mjs'; // Import shared renderer CSS globally @@ -15,6 +17,10 @@ import '../styles/toolbar.css'; import '../styles/list.css'; import '../styles/detail.css'; +registerSessionImageElement(); +// marked is loaded globally via CDN in index.html, ahead of this module. +configureMarkdownImages(window.marked); + const app = createApp(App); app.use(router); diff --git a/app/src/renderer/src/markdown-image-renderer.js b/app/src/renderer/src/markdown-image-renderer.js new file mode 100644 index 0000000..e0ae6e6 --- /dev/null +++ b/app/src/renderer/src/markdown-image-renderer.js @@ -0,0 +1,90 @@ +import { SESSION_IMAGE_TAG } from './session-image-contract.js'; + +const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']); +const NAMED_ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}; +let configuredMarked = null; + +// marked escapes the alt text and title it hands to the renderer, and the +// attributes are re-escaped on the way back out, so they have to be decoded +// once in between. Doing that through a detached element's innerHTML would +// mean parsing untrusted markup, so decode the entities marked actually emits +// in a single pass instead -- one pass, so `&lt;` stays `<`. +function decodeMarkedAttribute(value) { + return String(value ?? '').replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, entity) => { + if (entity[0] === '#') { + const codePoint = entity[1] === 'x' || entity[1] === 'X' + ? Number.parseInt(entity.slice(2), 16) + : Number.parseInt(entity.slice(1), 10); + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return whole; + try { + return String.fromCodePoint(codePoint); + } catch { + return whole; + } + } + return NAMED_ENTITIES[entity.toLowerCase()] ?? whole; + }); +} + +function escapeAttribute(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function isSafeImageSource(source) { + try { + const url = new URL(source, globalThis.document?.baseURI ?? 'file:///'); + return SAFE_IMAGE_PROTOCOLS.has(url.protocol) + || (url.protocol === 'data:' && /^data:image\//i.test(source)); + } catch { + return false; + } +} + +// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the +// image token instead. Accepting both keeps an upgrade from silently turning +// every session image into fallback text. +export function normalizeMarkdownImageToken(hrefOrToken, title, text) { + if (hrefOrToken && typeof hrefOrToken === 'object') { + return { + href: hrefOrToken.href ?? '', + title: hrefOrToken.title ?? '', + text: hrefOrToken.text ?? '', + }; + } + return { href: hrefOrToken ?? '', title: title ?? '', text: text ?? '' }; +} + +// A source the app will not load is rendered through the same element with no +// src, so a blocked image and an image that fails to load look the same to the +// reader instead of being two different pieces of UI. +export function renderSessionMarkdownImage(hrefOrToken, title, text) { + const token = normalizeMarkdownImageToken(hrefOrToken, title, text); + const source = decodeMarkedAttribute(token.href).trim(); + const alt = decodeMarkedAttribute(token.text); + const accessibleTitle = decodeMarkedAttribute(token.title); + const attributes = [`alt="${escapeAttribute(alt)}"`]; + if (source && isSafeImageSource(source)) attributes.unshift(`src="${escapeAttribute(source)}"`); + if (accessibleTitle) attributes.push(`title="${escapeAttribute(accessibleTitle)}"`); + return `<${SESSION_IMAGE_TAG} ${attributes.join(' ')}>`; +} + +export function configureMarkdownImages(marked) { + if (!marked || configuredMarked === marked) return; + marked.use({ + renderer: { + image: renderSessionMarkdownImage, + }, + }); + configuredMarked = marked; +} diff --git a/app/src/renderer/src/session-image-contract.js b/app/src/renderer/src/session-image-contract.js new file mode 100644 index 0000000..ea1d67e --- /dev/null +++ b/app/src/renderer/src/session-image-contract.js @@ -0,0 +1,16 @@ +// Shared constants for the session image element. Kept free of the component +// import so the Markdown renderer (and its tests) do not have to pull a .vue +// module into scope just to know the tag name. + +export const SESSION_IMAGE_TAG = 'obelisk-session-image'; + +// Fired from inside the session image element (composed, so they cross the +// shadow boundary). The virtualized timeline uses them to tell real media +// growth apart from an estimate correction. +// +// Pending is announced when the element mounts with a source, not when the +// image loads: a decoder sizes an image from its header and grows the row well +// before the load event, so a row is only known to be settled once loading has +// actually finished. +export const SESSION_IMAGE_PENDING_EVENT = 'obelisk-session-image-pending'; +export const SESSION_IMAGE_SETTLED_EVENT = 'obelisk-session-image-settled'; diff --git a/app/src/renderer/src/session-image-element.js b/app/src/renderer/src/session-image-element.js new file mode 100644 index 0000000..abaea24 --- /dev/null +++ b/app/src/renderer/src/session-image-element.js @@ -0,0 +1,8 @@ +import { defineCustomElement } from 'vue'; +import SessionImage from './components/SessionImage.ce.vue'; +import { SESSION_IMAGE_TAG } from './session-image-contract.js'; + +export function registerSessionImageElement() { + if (customElements.get(SESSION_IMAGE_TAG)) return; + customElements.define(SESSION_IMAGE_TAG, defineCustomElement(SessionImage)); +} diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs index 3bedb69..4df377a 100644 --- a/app/src/renderer/src/session-timeline-viewport.mjs +++ b/app/src/renderer/src/session-timeline-viewport.mjs @@ -1,4 +1,4 @@ -import { computed, nextTick, ref } from 'vue'; +import { computed, nextTick, onScopeDispose, ref } from 'vue'; import { defaultRangeExtractor, elementScroll, @@ -6,6 +6,15 @@ import { useVirtualizer, } from '@tanstack/vue-virtual'; import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs'; +import { + SESSION_IMAGE_PENDING_EVENT, + SESSION_IMAGE_SETTLED_EVENT, +} from './session-image-contract.js'; + +// How long after an image settles its row still counts as "grew because media +// finished" rather than "grew because the estimate was wrong". Covers the +// remeasurement that follows the final state change. +const MEDIA_SETTLE_WINDOW_MS = 1_000; function estimatedTextHeight(text = '') { return Math.min(560, Math.ceil(String(text).length / 72) * 20); @@ -126,6 +135,82 @@ export function useSessionTimelineViewport({ const virtualRows = computed(() => virtualizer.value.getVirtualItems()); const totalSize = computed(() => virtualizer.value.getTotalSize()); + // virtual-core deliberately skips scroll compensation when an already-measured + // row above the viewport is re-measured during an upward scroll, because that + // is normally an estimate correction and compensating it makes rows jump while + // the reader scrolls back. Media growth is not an estimate correction: the row + // really did get taller, so leaving it uncompensated pushes everything the + // reader is looking at down the screen. + // + // A row counts as settling from the moment an image mounts until loading + // finishes, because a decoder grows the row from the image header long before + // the load event -- keying off load alone would miss the first and largest + // growth. Counted, not flagged, so a message with several images stays marked + // until the last of them is done. + const mediaPending = new Map(); + const mediaSettledAt = new Map(); + + function rowKeyFor(event) { + const row = event.target?.closest?.('.virtual-timeline-row'); + if (!row) return null; + const timeline = timelineElement?.value; + if (timeline && !timeline.contains(row)) return null; + const index = Number(row.dataset?.index); + if (!Number.isInteger(index)) return null; + return items.value[index]?.key ?? index; + } + + function noteMediaPending(event) { + const key = rowKeyFor(event); + if (key === null) return; + mediaPending.set(key, (mediaPending.get(key) ?? 0) + 1); + mediaSettledAt.delete(key); + } + + function noteMediaSettled(event) { + const key = rowKeyFor(event); + if (key === null) return; + const outstanding = (mediaPending.get(key) ?? 0) - 1; + if (outstanding > 0) mediaPending.set(key, outstanding); + else mediaPending.delete(key); + const now = performance.now(); + for (const [settledKey, at] of mediaSettledAt) { + if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(settledKey); + } + mediaSettledAt.set(key, now); + } + + function isMediaSettling(key) { + if (mediaPending.has(key)) return true; + const at = mediaSettledAt.get(key); + if (at === undefined) return false; + if (performance.now() - at > MEDIA_SETTLE_WINDOW_MS) { + mediaSettledAt.delete(key); + return false; + } + return true; + } + + // Bound to the document rather than to the timeline element, because rows + // announce their images while mounting -- before a ref-driven listener would + // be in place to hear the first one. + const eventTarget = globalThis.document ?? null; + eventTarget?.addEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending); + eventTarget?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled); + onScopeDispose(() => { + eventTarget?.removeEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending); + eventTarget?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled); + }); + + virtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + if (item.start >= instance.getScrollOffset() + instance.scrollAdjustments) return false; + // Never measured before: the estimate is what placed the reader, so the + // estimate-to-actual difference always has to be taken out. + if (!instance.itemSizeCache.has(item.key)) return true; + if (isMediaSettling(item.key)) return true; + return instance.scrollDirection !== 'backward'; + }; + function resolveTimelineElement(instance = virtualizer?.value) { return timelineElement?.value || [...(instance?.elementsCache?.values?.() || [])] diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js index 6998a1d..d4690fc 100644 --- a/app/src/renderer/src/utils.js +++ b/app/src/renderer/src/utils.js @@ -95,7 +95,7 @@ export function highlightTextNodes(rootEl, query) { export function renderMarkdown(text, opts = {}) { if (text == null) return ''; - // marked is loaded globally via CDN in index.html + // marked is loaded globally via CDN in index.html and configured at startup. const html = sanitizeMarkdown(window.marked.parse(text)); const cls = opts.variant === 'msg' ? 'markdown-msg' : opts.variant === 'compact' ? 'markdown-compact' diff --git a/app/src/renderer/styles/detail.css b/app/src/renderer/styles/detail.css index dea8388..fcf6bd0 100644 --- a/app/src/renderer/styles/detail.css +++ b/app/src/renderer/styles/detail.css @@ -364,6 +364,11 @@ .markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; } .markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } +/* Session images cap themselves against the reading column. Compact Markdown + renders in much smaller surfaces (subagent panes, memory rows, tool results), + where a viewport-sized image would swamp the block it belongs to. */ +.markdown-compact { --session-image-max-block: 240px; } + .detail-section-divider { display: flex; align-items: center; gap: 10px; margin: 32px 0 14px; color: var(--muted); diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs new file mode 100644 index 0000000..b5b1e3b --- /dev/null +++ b/app/tests/electron-session-images.mjs @@ -0,0 +1,633 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { deflateSync } from 'node:zlib'; + +// A real PNG. The fixture writes it in full and then holds the response open, +// so Blink sizes and lays the image out from the buffered bytes while the load +// event waits for the response to complete. An SVG served in one shot lays out +// and fires load together, which hides that gap entirely. +function crc32(buffer) { + let crc = ~0; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return ~crc >>> 0; +} + +function pngChunk(type, data) { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, checksum]); +} + +function buildPng(width, height) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; // bit depth + header[9] = 0; // greyscale + const scanlines = Buffer.alloc(height * (width + 1), 0x40); + for (let row = 0; row < height; row++) scanlines[row * (width + 1)] = 0; // filter: none + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(scanlines)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +const here = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(here, '..'); +const sessionId = 'session-image-test'; +const channels = [ + 'db:getSessions', + 'db:getSessionMessages', + 'db:getSessionToolCalls', + 'db:getSessionToolResults', + 'db:getSessionSubagents', + 'db:getSessionWorkflows', + 'db:getSessionSummaries', + 'db:getMemories', + 'db:getProjects', + 'db:getStats', + 'settings:get', +]; + +let failures = 0; +let messages = []; +const FILLER_COUNT = 40; + +function assert(condition, message) { + if (condition) console.log(`PASS: ${message}`); + else { + failures++; + console.error(`FAIL: ${message}`); + } +} + +async function waitFor(webContents, expression, message, timeoutMs = 8_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return; + await delay(40); + } + throw new Error(`Timed out waiting for ${message}`); +} + +// Renderer-side probes resolve from event handlers that a regression can keep +// from ever firing. Racing every one of them against a deadline keeps a broken +// build reporting a failure instead of hanging the run. +async function withDeadline(promise, message, timeoutMs = 10_000) { + let timer = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${message}`)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function startImageServer() { + const wideSvg = ''; + const smallSvg = ''; + // Held until the test releases them, so an above-viewport image can be made + // to finish loading at a moment the test controls. + const heldSvg = ''; + const heldPaths = ['/held-rest.svg', '/held-scroll.svg']; + const heldResponses = new Map(heldPaths.map(path => [path, []])); + const releasedPaths = new Set(); + // The bytes go out in one write and the response stays open, so the row grows + // from the decoded header while the load event waits for completion. + const progressivePng = buildPng(900, 1200); + let progressiveResponses = []; + const sendSvg = (response, svg) => { + response.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-store', + }); + response.end(svg); + }; + const server = createServer((request, response) => { + if (request.url === '/held-progressive.png') { + response.writeHead(200, { + 'Content-Type': 'image/png', + 'Cache-Control': 'no-store', + }); + progressiveResponses.push(response); + return; + } + if (heldResponses.has(request.url)) { + if (releasedPaths.has(request.url)) sendSvg(response, heldSvg); + else heldResponses.get(request.url).push(response); + return; + } + const svg = request.url === '/wide.svg' + ? wideSvg + : request.url === '/small.svg' + ? smallSvg + : null; + if (!svg) { + response.writeHead(404).end(); + return; + } + if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300); + else sendSvg(response, smallSvg); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + resolve({ + server, + baseUrl: `http://127.0.0.1:${address.port}`, + heldRequestCount: path => heldResponses.get(path)?.length ?? 0, + releaseHeldImage(path) { + releasedPaths.add(path); + const pending = heldResponses.get(path) ?? []; + heldResponses.set(path, []); + for (const response of pending) sendSvg(response, heldSvg); + }, + progressiveRequestCount: () => progressiveResponses.length, + // Delivers the whole image now and completes the response completeMs + // later, reproducing the gap a real image opens between the row growing + // and the load event firing. + releaseProgressiveImage(completeMs) { + const pending = progressiveResponses; + progressiveResponses = []; + for (const response of pending) { + response.write(progressivePng); + setTimeout(() => response.end(), completeMs); + } + }, + }); + }); + }); +} + +function sessionSummary() { + return { + id: sessionId, + title: 'Session image rendering', + project: 'image-fixture', + project_path: '/tmp/image-fixture', + source: 'codex', + started_at: '2026-07-30T00:00:00.000Z', + ended_at: '2026-07-30T00:05:00.000Z', + message_count: messages.length, + git_branch: 'main', + }; +} + +function registerHandlers() { + ipcMain.handle('db:getSessions', () => [sessionSummary()]); + ipcMain.handle('db:getSessionMessages', () => messages); + ipcMain.handle('db:getSessionToolCalls', () => []); + ipcMain.handle('db:getSessionToolResults', () => []); + ipcMain.handle('db:getSessionSubagents', () => []); + ipcMain.handle('db:getSessionWorkflows', () => []); + ipcMain.handle('db:getSessionSummaries', () => []); + ipcMain.handle('db:getMemories', () => []); + ipcMain.handle('db:getProjects', () => [{ project: 'image-fixture', count: 1 }]); + ipcMain.handle('db:getStats', () => ({})); + ipcMain.handle('settings:get', () => ({})); +} + +async function run() { + const { + server, baseUrl, heldRequestCount, releaseHeldImage, + progressiveRequestCount, releaseProgressiveImage, + } = await startImageServer(); + let win = null; + try { + messages = [ + { + uuid: 'message-0', + type: 'user', + timestamp: '2026-07-30T00:00:00.000Z', + text: 'Image rendering fixtures', + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-1', + type: 'assistant', + timestamp: '2026-07-30T00:01:00.000Z', + text: `Wide image\n\n![Wide timeline fixture](${baseUrl}/wide.svg "Wide fixture")`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-2', + type: 'assistant', + timestamp: '2026-07-30T00:02:00.000Z', + text: `Small image\n\n![Small timeline fixture](${baseUrl}/small.svg)`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-3', + type: 'assistant', + timestamp: '2026-07-30T00:03:00.000Z', + text: 'Broken image\n\n![Missing timeline fixture](file:///obelisk-fixtures/missing-session-image.png)', + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-4', + type: 'user', + timestamp: '2026-07-30T00:04:00.000Z', + text: 'Following message', + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-5', + type: 'assistant', + timestamp: '2026-07-30T00:05:00.000Z', + text: `Held image (at rest)\n\n![Held rest fixture](${baseUrl}/held-rest.svg)`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-6', + type: 'assistant', + timestamp: '2026-07-30T00:06:00.000Z', + text: `Held image (during scroll)\n\n![Held scroll fixture](${baseUrl}/held-scroll.svg)`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-7', + type: 'assistant', + timestamp: '2026-07-30T00:07:00.000Z', + text: `Progressive image\n\n![Progressive fixture](${baseUrl}/held-progressive.png)`, + content_type: 'text', + is_meta: 0, + }, + ...Array.from({ length: FILLER_COUNT }, (_, offset) => ({ + uuid: `message-${8 + offset}`, + type: offset % 2 === 0 ? 'user' : 'assistant', + timestamp: new Date(Date.UTC(2026, 6, 30, 1, offset)).toISOString(), + text: `Filler ${offset}. ${'Timeline body copy that gives the row a realistic height. '.repeat(6)}`, + content_type: 'text', + is_meta: 0, + })), + ]; + registerHandlers(); + win = new BrowserWindow({ + show: false, + width: 1200, + height: 800, + webPreferences: { + preload: join(appRoot, 'out', 'preload', 'index.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), { + hash: '/sessions', + }); + await waitFor( + win.webContents, + `document.body.textContent.includes('Session image rendering')`, + 'session list', + ); + await win.webContents.executeJavaScript(`(() => { + window.__wideImageLayout = new Promise((resolve, reject) => { + const fail = reason => { + observer.disconnect(); + reject(new Error(reason)); + }; + const timer = setTimeout(() => fail('wide image never reported a layout'), 8000); + const settle = value => { + clearTimeout(timer); + resolve(value); + }; + const observer = new MutationObserver(() => { + const message = document.querySelector('[data-uuid="message-1"]'); + const host = message?.querySelector('obelisk-session-image'); + const image = host?.shadowRoot?.querySelector('img'); + const row = message?.closest('.virtual-timeline-row'); + if (!image || !row) return; + observer.disconnect(); + const before = row.getBoundingClientRect().height; + image.addEventListener('error', () => fail('wide image failed to load'), { once: true }); + image.addEventListener('load', () => { + requestAnimationFrame(() => requestAnimationFrame(() => { + settle({ + before, + after: row.getBoundingClientRect().height, + }); + })); + }, { once: true }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + window.location.hash = ${JSON.stringify(`/sessions/${sessionId}`)}; + })()`, true); + + await waitFor( + win.webContents, + `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`, + 'image fixture timeline', + ); + const resize = await withDeadline( + win.webContents.executeJavaScript('window.__wideImageLayout', true), + 'wide image row remeasurement', + ); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`, + 'small image load', + ); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-3"] obelisk-session-image')?.shadowRoot?.querySelector('.is-error')`, + 'failed image state', + ); + await delay(100); + + const layout = await win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const wideMessage = document.querySelector('[data-uuid="message-1"]'); + const wideRow = wideMessage.closest('.virtual-timeline-row'); + const nextRow = document.querySelector('[data-uuid="message-2"]').closest('.virtual-timeline-row'); + const wideHost = wideMessage.querySelector('obelisk-session-image'); + const wideImage = wideHost.shadowRoot.querySelector('img'); + const smallHost = document.querySelector('[data-uuid="message-2"] obelisk-session-image'); + const smallImage = smallHost.shadowRoot.querySelector('img'); + const brokenHost = document.querySelector('[data-uuid="message-3"] obelisk-session-image'); + const wideHostRect = wideHost.getBoundingClientRect(); + const wideImageRect = wideImage.getBoundingClientRect(); + const smallImageRect = smallImage.getBoundingClientRect(); + const wideRowRect = wideRow.getBoundingClientRect(); + const nextRowRect = nextRow.getBoundingClientRect(); + // Row spacing is applied by the virtualizer, not by CSS on the row, so + // calibrate against the spacing the rest of this timeline is using rather + // than hard-coding the current value. + const mountedRows = [...document.querySelectorAll('.virtual-timeline-row')] + .map(row => ({ index: Number(row.dataset.index), rect: row.getBoundingClientRect() })) + .sort((left, right) => left.index - right.index); + const gaps = mountedRows + .slice(1) + .map((row, offset) => row.rect.top - mountedRows[offset].rect.bottom) + .sort((left, right) => left - right); + return { + wrapClientWidth: wrap.clientWidth, + wrapScrollWidth: wrap.scrollWidth, + wideHostWidth: wideHostRect.width, + wideImageWidth: wideImageRect.width, + wideImageHeight: wideImageRect.height, + wideNaturalWidth: wideImage.naturalWidth, + wideNaturalHeight: wideImage.naturalHeight, + smallImageWidth: smallImageRect.width, + smallNaturalWidth: smallImage.naturalWidth, + rowHeight: wideRowRect.height, + rowGap: nextRowRect.top - wideRowRect.bottom, + typicalRowGap: gaps[Math.floor(gaps.length / 2)] ?? null, + brokenText: brokenHost.shadowRoot.querySelector('figcaption')?.textContent || '', + }; + })()`, true); + + assert( + resize.after > resize.before + 100, + `image load grows and remeasures its virtual row (${JSON.stringify(resize)})`, + ); + assert( + layout.wrapScrollWidth <= layout.wrapClientWidth + 1, + `wide images do not add session-level horizontal overflow (${JSON.stringify(layout)})`, + ); + assert( + layout.wideImageWidth <= layout.wideHostWidth + 1 + && Math.abs( + layout.wideImageWidth / layout.wideImageHeight + - layout.wideNaturalWidth / layout.wideNaturalHeight + ) < 0.01, + `wide images fit their host and preserve aspect ratio (${JSON.stringify(layout)})`, + ); + assert( + layout.smallImageWidth <= layout.smallNaturalWidth + 1, + `small images keep their intrinsic width (${JSON.stringify(layout)})`, + ); + assert( + layout.rowHeight > layout.wideImageHeight + && layout.typicalRowGap !== null + && Math.abs(layout.rowGap - layout.typicalRowGap) <= 1, + `measured image rows do not overlap the following row (${JSON.stringify(layout)})`, + ); + assert( + layout.brokenText.includes('Image unavailable') + && layout.brokenText.includes('Missing timeline fixture'), + `failed images remain contained with fallback text (${JSON.stringify(layout)})`, + ); + + // --- Reader position must survive an above-viewport image finishing. --- + // Both fixtures sit above the viewport for the rest of the run, so this also + // confirms a mounted row fetches its image without being on screen. + assert( + heldRequestCount('/held-rest.svg') > 0 && heldRequestCount('/held-scroll.svg') > 0, + 'mounted rows request their images while off screen' + + ` (${heldRequestCount('/held-rest.svg')}, ${heldRequestCount('/held-scroll.svg')})`, + ); + + const parkAbove = (uuid, distance) => win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const wrapRect = wrap.getBoundingClientRect(); + const row = document.querySelector('[data-uuid="${uuid}"]').closest('.virtual-timeline-row'); + const rowRect = row.getBoundingClientRect(); + wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance}; + return wrap.scrollTop; + })()`, true); + + // Scrolls backward and reports the largest movement of a visible row that + // scroll input does not account for. A row growing above the viewport + // without compensation shows up here as a residual the size of the growth; + // a compensated one leaves the residual at zero. + const backwardScrollProbe = ({ durationMs = 1_500, stepPx = 12 } = {}) => + win.webContents.executeJavaScript(`new Promise(resolve => { + const wrap = document.querySelector('.detail-wrap'); + const blockAutomaticScrollEnd = event => event.stopImmediatePropagation(); + wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true })); + let previous = null; + let maxResidual = 0; + let example = null; + const startedAt = performance.now(); + function frame(now) { + wrap.scrollTop -= ${stepPx}; + const wrapRect = wrap.getBoundingClientRect(); + const scrollTop = wrap.scrollTop; + const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')] + .map(row => [ + row.querySelector('[data-uuid]')?.getAttribute('data-uuid'), + row.getBoundingClientRect().top - wrapRect.top, + ]) + .filter(([uuid, top]) => uuid && top > -200 && top < wrapRect.height)); + if (previous) { + for (const [uuid, top] of rows) { + if (!previous.rows.has(uuid)) continue; + const residual = (top - previous.rows.get(uuid)) + (scrollTop - previous.scrollTop); + if (Math.abs(residual) > Math.abs(maxResidual)) { + maxResidual = residual; + example = { uuid, residual, scrollTop }; + } + } + } + previous = { rows, scrollTop }; + if (now - startedAt < ${durationMs}) { + requestAnimationFrame(frame); + return; + } + wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.dispatchEvent(new Event('scrollend')); + resolve({ maxResidual, example }); + } + requestAnimationFrame(frame); + })`, true); + + const captureGeometry = () => win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const wrapRect = wrap.getBoundingClientRect(); + const rows = [...document.querySelectorAll('.virtual-timeline-row')] + .map(row => ({ + uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'), + rect: row.getBoundingClientRect(), + })) + .filter(row => row.uuid && row.rect.bottom > wrapRect.top && row.rect.top < wrapRect.bottom) + .sort((left, right) => left.rect.top - right.rect.top); + return { + scrollTop: wrap.scrollTop, + firstVisible: rows[0]?.uuid || null, + tops: Object.fromEntries(rows.map(row => [row.uuid, row.rect.top - wrapRect.top])), + }; + })()`, true); + + await parkAbove('message-6', 420); + // Longer than isScrollingResetDelay so the virtualizer is genuinely at rest. + await delay(700); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`, + 'held rest image still pending above the viewport', + ); + const restBefore = await captureGeometry(); + releaseHeldImage('/held-rest.svg'); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`, + 'held rest image load', + ); + await delay(250); + const restAfter = await captureGeometry(); + const restAnchor = restBefore.firstVisible; + const restDrift = restAnchor !== null && restAfter.tops[restAnchor] !== undefined + ? restAfter.tops[restAnchor] - restBefore.tops[restAnchor] + : Number.NaN; + assert( + Math.abs(restDrift) <= 1, + 'an image loading above the viewport does not move the reader position' + + ` (${JSON.stringify({ anchor: restAnchor, drift: restDrift, scrollTop: [restBefore.scrollTop, restAfter.scrollTop] })})`, + ); + + // Same guarantee mid-gesture: scrolling back through history is when rows + // above the viewport are most likely to still be settling their media. + await parkAbove('message-6', 2_000); + await delay(700); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-6"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`, + 'held scroll image still pending above the viewport', + ); + const scrollProbe = backwardScrollProbe(); + await delay(500); + releaseHeldImage('/held-scroll.svg'); + const scrolling = await withDeadline(scrollProbe, 'backward scroll residual probe'); + assert( + Math.abs(scrolling.maxResidual) <= 2, + 'an image loading above the viewport does not move visible rows mid-scroll' + + ` (${JSON.stringify(scrolling)})`, + ); + + // A progressively decoded image lays out from its header, so the row grows + // long before the load event. Waiting for load to mark the row would leave + // that first, largest growth uncompensated. + await parkAbove('message-7', 2_000); + await delay(700); + assert( + progressiveRequestCount() > 0, + `the progressive fixture was requested (${progressiveRequestCount()})`, + ); + await win.webContents.executeJavaScript(`(() => { + const host = document.querySelector('[data-uuid="message-7"] obelisk-session-image'); + const row = host.closest('.virtual-timeline-row'); + const image = host.shadowRoot.querySelector('img'); + const timing = { + start: performance.now(), + placeholderHeight: row.getBoundingClientRect().height, + grewAt: null, + loadedAt: null, + height: 0, + }; + // ResizeObserver reports the current size straight away; only a later, + // larger measurement is the image arriving. + timing.height = timing.placeholderHeight; + const since = () => Math.round(performance.now() - timing.start); + new ResizeObserver(entries => { + for (const entry of entries) { + if (entry.contentRect.height <= timing.height + 100) continue; + timing.height = entry.contentRect.height; + if (timing.grewAt === null) timing.grewAt = since(); + } + }).observe(row); + image.addEventListener('load', () => { timing.loadedAt = since(); }, { once: true }); + window.__progressiveTiming = timing; + })()`, true); + const progressiveProbe = backwardScrollProbe({ durationMs: 3_400, stepPx: 6 }); + await delay(400); + // Blink holds partial image data back for about a second before flushing it + // to the decoder, so the response has to stay open well past that for the + // row to grow before the load event. + releaseProgressiveImage(2_000); + const progressive = await withDeadline( + progressiveProbe, + 'progressive scroll residual probe', + 15_000, + ); + const timing = await win.webContents.executeJavaScript('window.__progressiveTiming', true); + assert( + timing.grewAt !== null && timing.loadedAt !== null && timing.grewAt < timing.loadedAt - 100, + `the fixture grows its row before the load event, as a chunked image does (${JSON.stringify(timing)})`, + ); + assert( + Math.abs(progressive.maxResidual) <= 2, + 'a progressively decoded image does not move visible rows before it finishes' + + ` (${JSON.stringify(progressive)})`, + ); + } finally { + win?.destroy(); + await new Promise(resolve => server.close(resolve)); + } +} + +app.whenReady() + .then(() => withDeadline(run(), 'the session image suite to finish', 180_000)) + .catch(error => { + failures++; + console.error(error.stack || error); + }) + .finally(() => { + for (const channel of channels) ipcMain.removeHandler(channel); + app.exit(failures ? 1 : 0); + }); diff --git a/app/tests/run-electron-suites.mjs b/app/tests/run-electron-suites.mjs new file mode 100644 index 0000000..2e1ac61 --- /dev/null +++ b/app/tests/run-electron-suites.mjs @@ -0,0 +1,32 @@ +// Runs every Electron regression suite against one build and reports a single +// summary. Each suite already prints its own PASS/FAIL lines; this exists so a +// new suite is covered by one command instead of only being run by whoever +// remembers it is there. + +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import electron from 'electron' + +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const suites = [ + 'electron-concurrency.mjs', + 'electron-session-images.mjs', + 'electron-session-virtualization.mjs', + 'electron-session-reader-state.mjs', + 'electron-file-references.mjs', +] + +const failed = [] +for (const suite of suites) { + console.log(`\n=== ${suite} ===`) + const result = spawnSync(electron, ['--no-sandbox', path.join('tests', suite)], { + cwd: appRoot, + stdio: 'inherit', + }) + if (result.status !== 0) failed.push(`${suite} (exit ${result.status ?? 'signal'})`) +} + +console.log(`\n${suites.length - failed.length}/${suites.length} Electron suites passed`) +for (const failure of failed) console.error(`FAILED: ${failure}`) +process.exit(failed.length ? 1 : 0) diff --git a/tests/markdown-image-renderer.test.mjs b/tests/markdown-image-renderer.test.mjs new file mode 100644 index 0000000..84c992b --- /dev/null +++ b/tests/markdown-image-renderer.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + normalizeMarkdownImageToken, + renderSessionMarkdownImage, +} from '../app/src/renderer/src/markdown-image-renderer.js'; + +// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the +// token. Getting this wrong degrades silently: every session image turns into +// fallback text because the href is no longer a string. +test('accepts the positional renderer signature', () => { + assert.deepEqual( + normalizeMarkdownImageToken('http://example.test/a.png', 'A title', 'Alt text'), + { href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' }, + ); +}); + +test('accepts the token renderer signature', () => { + assert.deepEqual( + normalizeMarkdownImageToken({ + type: 'image', + href: 'http://example.test/a.png', + title: 'A title', + text: 'Alt text', + }), + { href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' }, + ); +}); + +test('fills in the fields marked leaves null', () => { + assert.deepEqual( + normalizeMarkdownImageToken({ href: 'http://example.test/a.png', title: null, text: '' }), + { href: 'http://example.test/a.png', title: '', text: '' }, + ); + assert.deepEqual( + normalizeMarkdownImageToken('http://example.test/a.png', null, null), + { href: 'http://example.test/a.png', title: '', text: '' }, + ); +}); + +test('renders an allowed source as the session image element', () => { + assert.equal( + renderSessionMarkdownImage('http://example.test/a.png', '', 'Alt text'), + '', + ); + assert.equal( + renderSessionMarkdownImage('data:image/png;base64,AAAA', '', ''), + '', + ); +}); + +test('carries the title through when marked supplies one', () => { + assert.equal( + renderSessionMarkdownImage('file:///shots/a.png', 'A title', 'Alt'), + '', + ); +}); + +test('drops a source the app will not load, keeping one unavailable state', () => { + for (const href of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/a.png', '']) { + assert.equal( + renderSessionMarkdownImage(href, '', 'Alt text'), + '', + `expected ${href || '(empty)'} to render without a src`, + ); + } +}); + +test('decodes what marked escaped exactly once, then re-escapes it', () => { + assert.equal( + renderSessionMarkdownImage('http://example.test/a.png?x=1&y=2', '', '"quoted" & 'single''), + '', + ); + // A single decoding pass, so text that was literally `<` in the source + // does not decay into a real angle bracket. + assert.equal( + renderSessionMarkdownImage('http://example.test/a.png', '', '&lt;script&gt;'), + '', + ); +}); + +test('never lets alt text break out of the attribute', () => { + const html = renderSessionMarkdownImage('http://example.test/a.png', '', '">'); + assert.equal(html.includes('onerror=alert(1)>'), false); + assert.equal( + html, + '', + ); +});