diff --git a/app/package.json b/app/package.json index 66aef48..0706f25 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "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" }, diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs new file mode 100644 index 0000000..49b259e --- /dev/null +++ b/app/tests/electron-session-images.mjs @@ -0,0 +1,290 @@ +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'; + +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 = []; + +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}`); +} + +function startImageServer() { + const wideSvg = ''; + const smallSvg = ''; + const server = createServer((request, response) => { + const svg = request.url === '/wide.svg' + ? wideSvg + : request.url === '/small.svg' + ? smallSvg + : null; + if (!svg) { + response.writeHead(404).end(); + return; + } + const send = () => { + response.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-store', + }); + response.end(svg); + }; + if (request.url === '/wide.svg') setTimeout(send, 300); + else send(); + }); + 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}`, + }); + }); + }); +} + +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 } = 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, + }, + ]; + 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 => { + 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('load', () => { + requestAnimationFrame(() => requestAnimationFrame(() => { + resolve({ + 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 win.webContents.executeJavaScript('window.__wideImageLayout', true); + 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(); + 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, + 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.rowGap >= 13, + `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)})`, + ); + } finally { + win?.destroy(); + await new Promise(resolve => server.close(resolve)); + } +} + +app.whenReady() + .then(run) + .catch(error => { + failures++; + console.error(error.stack || error); + }) + .finally(() => { + for (const channel of channels) ipcMain.removeHandler(channel); + app.exit(failures ? 1 : 0); + });