diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs index 49b259e..514c2ec 100644 --- a/app/tests/electron-session-images.mjs +++ b/app/tests/electron-session-images.mjs @@ -23,6 +23,7 @@ const channels = [ let failures = 0; let messages = []; +const FILLER_COUNT = 40; function assert(condition, message) { if (condition) console.log(`PASS: ${message}`); @@ -41,10 +42,45 @@ async function waitFor(webContents, expression, message, timeoutMs = 8_000) { 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(); + 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 (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' @@ -54,15 +90,8 @@ function startImageServer() { 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(); + if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300); + else sendSvg(response, smallSvg); }); return new Promise((resolve, reject) => { server.once('error', reject); @@ -71,6 +100,13 @@ function startImageServer() { 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); + }, }); }); }); @@ -105,7 +141,7 @@ function registerHandlers() { } async function run() { - const { server, baseUrl } = await startImageServer(); + const { server, baseUrl, heldRequestCount, releaseHeldImage } = await startImageServer(); let win = null; try { messages = [ @@ -149,6 +185,30 @@ async function run() { 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, + }, + ...Array.from({ length: FILLER_COUNT }, (_, offset) => ({ + uuid: `message-${7 + 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({ @@ -171,7 +231,16 @@ async function run() { 'session list', ); await win.webContents.executeJavaScript(`(() => { - window.__wideImageLayout = new Promise(resolve => { + 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'); @@ -180,9 +249,10 @@ async function run() { 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(() => { - resolve({ + settle({ before, after: row.getBoundingClientRect().height, }); @@ -199,7 +269,10 @@ async function run() { `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`, 'image fixture timeline', ); - const resize = await win.webContents.executeJavaScript('window.__wideImageLayout', true); + 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')`, @@ -272,6 +345,126 @@ async function run() { && 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 = distance => win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const wrapRect = wrap.getBoundingClientRect(); + const row = document.querySelector('[data-uuid="message-6"]').closest('.virtual-timeline-row'); + const rowRect = row.getBoundingClientRect(); + wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance}; + return wrap.scrollTop; + })()`, 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(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(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 = 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 -= 12; + 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 < 1500) { + requestAnimationFrame(frame); + return; + } + wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.dispatchEvent(new Event('scrollend')); + resolve({ maxResidual, example }); + } + requestAnimationFrame(frame); + })`, true); + 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)})`, + ); } finally { win?.destroy(); await new Promise(resolve => server.close(resolve)); @@ -279,7 +472,7 @@ async function run() { } app.whenReady() - .then(run) + .then(() => withDeadline(run(), 'the session image suite to finish', 180_000)) .catch(error => { failures++; console.error(error.stack || error);