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",
|
"generate:session-share": "node scripts/generate-session-share.mjs",
|
||||||
"verify:icons": "node scripts/verify-icons.mjs",
|
"verify:icons": "node scripts/verify-icons.mjs",
|
||||||
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.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: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: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": {
|
"build": {
|
||||||
"appId": "com.obelisk.app",
|
"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 { commitInitialData, fetchInitialData } from './data.js';
|
||||||
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
||||||
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.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 { installFileReferenceHandler } from './file-references.mjs';
|
||||||
|
|
||||||
// Import shared renderer CSS globally
|
// Import shared renderer CSS globally
|
||||||
@@ -15,6 +17,10 @@ import '../styles/toolbar.css';
|
|||||||
import '../styles/list.css';
|
import '../styles/list.css';
|
||||||
import '../styles/detail.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);
|
const app = createApp(App);
|
||||||
|
|
||||||
app.use(router);
|
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 {
|
import {
|
||||||
defaultRangeExtractor,
|
defaultRangeExtractor,
|
||||||
elementScroll,
|
elementScroll,
|
||||||
@@ -6,6 +6,15 @@ import {
|
|||||||
useVirtualizer,
|
useVirtualizer,
|
||||||
} from '@tanstack/vue-virtual';
|
} from '@tanstack/vue-virtual';
|
||||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
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 = '') {
|
function estimatedTextHeight(text = '') {
|
||||||
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
|
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 virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
||||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
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) {
|
function resolveTimelineElement(instance = virtualizer?.value) {
|
||||||
return timelineElement?.value
|
return timelineElement?.value
|
||||||
|| [...(instance?.elementsCache?.values?.() || [])]
|
|| [...(instance?.elementsCache?.values?.() || [])]
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function highlightTextNodes(rootEl, query) {
|
|||||||
|
|
||||||
export function renderMarkdown(text, opts = {}) {
|
export function renderMarkdown(text, opts = {}) {
|
||||||
if (text == null) return '';
|
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 html = sanitizeMarkdown(window.marked.parse(text));
|
||||||
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||||
: opts.variant === 'compact' ? 'markdown-compact'
|
: opts.variant === 'compact' ? 'markdown-compact'
|
||||||
|
|||||||
@@ -364,6 +364,11 @@
|
|||||||
.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
.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; }
|
.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 {
|
.detail-section-divider {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
margin: 32px 0 14px; color: var(--muted);
|
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