Merge pull request #25 from tommy0103/fix/session-image-review
[Sessions] Render Markdown images within message bounds
This commit is contained in:
+3
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
SESSION_IMAGE_PENDING_EVENT,
|
||||
SESSION_IMAGE_SETTLED_EVENT,
|
||||
} from '../session-image-contract.js';
|
||||
|
||||
defineOptions({ name: 'SessionImage' });
|
||||
|
||||
const props = defineProps({
|
||||
// Absent when the Markdown renderer refused the source. The element still
|
||||
// renders, in its error state, so a blocked source and a source that fails
|
||||
// to load are one piece of UI rather than two.
|
||||
src: { type: String, default: '' },
|
||||
alt: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const figure = ref(null);
|
||||
const status = ref(props.src ? 'loading' : 'error');
|
||||
const accessibleLabel = computed(() => props.alt || props.title || 'Session image');
|
||||
|
||||
// The events are composed so they leave the shadow root, and dispatched
|
||||
// synchronously so the timeline knows the row's size is about to change for a
|
||||
// reason the reader did not cause -- before the resize observation lands.
|
||||
function announce(target, type) {
|
||||
target?.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
// Announced from mount rather than from load: the row grows as soon as the
|
||||
// decoder knows the intrinsic size, which for anything bigger than a trivial
|
||||
// image is long before loading finishes.
|
||||
onMounted(() => {
|
||||
if (props.src) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
|
||||
});
|
||||
|
||||
watch(() => props.src, source => {
|
||||
status.value = source ? 'loading' : 'error';
|
||||
if (source) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
|
||||
});
|
||||
|
||||
function handleLoad(event) {
|
||||
status.value = 'loaded';
|
||||
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
|
||||
}
|
||||
|
||||
function handleError(event) {
|
||||
status.value = 'error';
|
||||
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure
|
||||
ref="figure"
|
||||
class="session-image"
|
||||
:class="`is-${status}`"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<img
|
||||
v-if="status !== 'error'"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:title="title || undefined"
|
||||
decoding="async"
|
||||
@load="handleLoad"
|
||||
@error="handleError"
|
||||
>
|
||||
<figcaption v-else role="status">
|
||||
<span>Image unavailable</span>
|
||||
<span v-if="accessibleLabel" class="image-label">{{ accessibleLabel }}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Custom properties cross the shadow boundary, so the app's tokens are the
|
||||
single source of truth for these colours. --session-image-max-block lets a
|
||||
host context (a compact Markdown block, say) cap the image lower than the
|
||||
session timeline does. */
|
||||
:host {
|
||||
display: block;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
margin: 0.7em 0;
|
||||
contain: inline-size;
|
||||
}
|
||||
|
||||
.session-image {
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
overflow: clip;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--session-image-backdrop, rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
inline-size: auto;
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
max-block-size: var(--session-image-max-block, min(70vh, 720px));
|
||||
object-fit: contain;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
min-block-size: 48px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
display: flex;
|
||||
min-block-size: 48px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
color: var(--muted);
|
||||
font: var(--text-sm, 12px)/1.5 var(--font-mono);
|
||||
}
|
||||
|
||||
.image-label::before {
|
||||
content: '·';
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '<')
|
||||
.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(' ')}></${SESSION_IMAGE_TAG}>`;
|
||||
}
|
||||
|
||||
export function configureMarkdownImages(marked) {
|
||||
if (!marked || configuredMarked === marked) return;
|
||||
marked.use({
|
||||
renderer: {
|
||||
image: renderSessionMarkdownImage,
|
||||
},
|
||||
});
|
||||
configuredMarked = marked;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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?.() || [])]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1176" height="768" viewBox="0 0 1176 768"><rect width="1176" height="768" fill="#7c3aed"/></svg>';
|
||||
const smallSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80"><rect width="120" height="80" fill="#22c55e"/></svg>';
|
||||
// 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 = '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1200" viewBox="0 0 900 1200"><rect width="900" height="1200" fill="#0ea5e9"/></svg>';
|
||||
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`,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
},
|
||||
{
|
||||
uuid: 'message-2',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-30T00:02:00.000Z',
|
||||
text: `Small image\n\n`,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
},
|
||||
{
|
||||
uuid: 'message-3',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-30T00:03:00.000Z',
|
||||
text: 'Broken image\n\n',
|
||||
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`,
|
||||
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`,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
},
|
||||
{
|
||||
uuid: 'message-7',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-30T00:07:00.000Z',
|
||||
text: `Progressive image\n\n`,
|
||||
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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
'<obelisk-session-image src="http://example.test/a.png" alt="Alt text"></obelisk-session-image>',
|
||||
);
|
||||
assert.equal(
|
||||
renderSessionMarkdownImage('data:image/png;base64,AAAA', '', ''),
|
||||
'<obelisk-session-image src="data:image/png;base64,AAAA" alt=""></obelisk-session-image>',
|
||||
);
|
||||
});
|
||||
|
||||
test('carries the title through when marked supplies one', () => {
|
||||
assert.equal(
|
||||
renderSessionMarkdownImage('file:///shots/a.png', 'A title', 'Alt'),
|
||||
'<obelisk-session-image src="file:///shots/a.png" alt="Alt" title="A title"></obelisk-session-image>',
|
||||
);
|
||||
});
|
||||
|
||||
test('drops a source the app will not load, keeping one unavailable state', () => {
|
||||
for (const href of ['javascript:alert(1)', 'data:text/html,<b>x</b>', 'ftp://example.test/a.png', '']) {
|
||||
assert.equal(
|
||||
renderSessionMarkdownImage(href, '', 'Alt text'),
|
||||
'<obelisk-session-image alt="Alt text"></obelisk-session-image>',
|
||||
`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''),
|
||||
'<obelisk-session-image src="http://example.test/a.png?x=1&y=2"'
|
||||
+ ' alt=""quoted" & \'single\'"></obelisk-session-image>',
|
||||
);
|
||||
// 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;'),
|
||||
'<obelisk-session-image src="http://example.test/a.png"'
|
||||
+ ' alt="&lt;script&gt;"></obelisk-session-image>',
|
||||
);
|
||||
});
|
||||
|
||||
test('never lets alt text break out of the attribute', () => {
|
||||
const html = renderSessionMarkdownImage('http://example.test/a.png', '', '"><img src=x onerror=alert(1)>');
|
||||
assert.equal(html.includes('onerror=alert(1)>'), false);
|
||||
assert.equal(
|
||||
html,
|
||||
'<obelisk-session-image src="http://example.test/a.png"'
|
||||
+ ' alt=""><img src=x onerror=alert(1)>"></obelisk-session-image>',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user