test(renderer): cover image reader anchoring, and fail instead of hang
The wide-image probe resolved only from a load handler that a regression could keep from ever firing, and it was awaited bare, so a broken build hung the run instead of reporting a failure. Every renderer probe now runs against a deadline, the probe rejects on image error, and the suite as a whole is bounded. Adds a held image endpoint so an above-viewport image can be made to finish at a moment the test controls, and asserts the reader does not move -- both at rest and mid-gesture, which is where it regressed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dde076153a
commit
a9e6d1bab8
@@ -23,6 +23,7 @@ const channels = [
|
|||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
let messages = [];
|
let messages = [];
|
||||||
|
const FILLER_COUNT = 40;
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (condition) console.log(`PASS: ${message}`);
|
if (condition) console.log(`PASS: ${message}`);
|
||||||
@@ -41,10 +42,45 @@ async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
|
|||||||
throw new Error(`Timed out waiting for ${message}`);
|
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() {
|
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 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>';
|
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();
|
||||||
|
const sendSvg = (response, svg) => {
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
response.end(svg);
|
||||||
|
};
|
||||||
const server = createServer((request, response) => {
|
const server = createServer((request, response) => {
|
||||||
|
if (heldResponses.has(request.url)) {
|
||||||
|
if (releasedPaths.has(request.url)) sendSvg(response, heldSvg);
|
||||||
|
else heldResponses.get(request.url).push(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const svg = request.url === '/wide.svg'
|
const svg = request.url === '/wide.svg'
|
||||||
? wideSvg
|
? wideSvg
|
||||||
: request.url === '/small.svg'
|
: request.url === '/small.svg'
|
||||||
@@ -54,15 +90,8 @@ function startImageServer() {
|
|||||||
response.writeHead(404).end();
|
response.writeHead(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const send = () => {
|
if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300);
|
||||||
response.writeHead(200, {
|
else sendSvg(response, smallSvg);
|
||||||
'Content-Type': 'image/svg+xml',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
response.end(svg);
|
|
||||||
};
|
|
||||||
if (request.url === '/wide.svg') setTimeout(send, 300);
|
|
||||||
else send();
|
|
||||||
});
|
});
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
server.once('error', reject);
|
server.once('error', reject);
|
||||||
@@ -71,6 +100,13 @@ function startImageServer() {
|
|||||||
resolve({
|
resolve({
|
||||||
server,
|
server,
|
||||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||||
|
heldRequestCount: path => heldResponses.get(path)?.length ?? 0,
|
||||||
|
releaseHeldImage(path) {
|
||||||
|
releasedPaths.add(path);
|
||||||
|
const pending = heldResponses.get(path) ?? [];
|
||||||
|
heldResponses.set(path, []);
|
||||||
|
for (const response of pending) sendSvg(response, heldSvg);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -105,7 +141,7 @@ function registerHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const { server, baseUrl } = await startImageServer();
|
const { server, baseUrl, heldRequestCount, releaseHeldImage } = await startImageServer();
|
||||||
let win = null;
|
let win = null;
|
||||||
try {
|
try {
|
||||||
messages = [
|
messages = [
|
||||||
@@ -149,6 +185,30 @@ async function run() {
|
|||||||
content_type: 'text',
|
content_type: 'text',
|
||||||
is_meta: 0,
|
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,
|
||||||
|
},
|
||||||
|
...Array.from({ length: FILLER_COUNT }, (_, offset) => ({
|
||||||
|
uuid: `message-${7 + offset}`,
|
||||||
|
type: offset % 2 === 0 ? 'user' : 'assistant',
|
||||||
|
timestamp: new Date(Date.UTC(2026, 6, 30, 1, offset)).toISOString(),
|
||||||
|
text: `Filler ${offset}. ${'Timeline body copy that gives the row a realistic height. '.repeat(6)}`,
|
||||||
|
content_type: 'text',
|
||||||
|
is_meta: 0,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
registerHandlers();
|
registerHandlers();
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -171,7 +231,16 @@ async function run() {
|
|||||||
'session list',
|
'session list',
|
||||||
);
|
);
|
||||||
await win.webContents.executeJavaScript(`(() => {
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
window.__wideImageLayout = new Promise(resolve => {
|
window.__wideImageLayout = new Promise((resolve, reject) => {
|
||||||
|
const fail = reason => {
|
||||||
|
observer.disconnect();
|
||||||
|
reject(new Error(reason));
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => fail('wide image never reported a layout'), 8000);
|
||||||
|
const settle = value => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
const message = document.querySelector('[data-uuid="message-1"]');
|
const message = document.querySelector('[data-uuid="message-1"]');
|
||||||
const host = message?.querySelector('obelisk-session-image');
|
const host = message?.querySelector('obelisk-session-image');
|
||||||
@@ -180,9 +249,10 @@ async function run() {
|
|||||||
if (!image || !row) return;
|
if (!image || !row) return;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
const before = row.getBoundingClientRect().height;
|
const before = row.getBoundingClientRect().height;
|
||||||
|
image.addEventListener('error', () => fail('wide image failed to load'), { once: true });
|
||||||
image.addEventListener('load', () => {
|
image.addEventListener('load', () => {
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
resolve({
|
settle({
|
||||||
before,
|
before,
|
||||||
after: row.getBoundingClientRect().height,
|
after: row.getBoundingClientRect().height,
|
||||||
});
|
});
|
||||||
@@ -199,7 +269,10 @@ async function run() {
|
|||||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`,
|
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`,
|
||||||
'image fixture timeline',
|
'image fixture timeline',
|
||||||
);
|
);
|
||||||
const resize = await win.webContents.executeJavaScript('window.__wideImageLayout', true);
|
const resize = await withDeadline(
|
||||||
|
win.webContents.executeJavaScript('window.__wideImageLayout', true),
|
||||||
|
'wide image row remeasurement',
|
||||||
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win.webContents,
|
win.webContents,
|
||||||
`document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
|
`document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
|
||||||
@@ -272,6 +345,126 @@ async function run() {
|
|||||||
&& layout.brokenText.includes('Missing timeline fixture'),
|
&& layout.brokenText.includes('Missing timeline fixture'),
|
||||||
`failed images remain contained with fallback text (${JSON.stringify(layout)})`,
|
`failed images remain contained with fallback text (${JSON.stringify(layout)})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Reader position must survive an above-viewport image finishing. ---
|
||||||
|
// Both fixtures sit above the viewport for the rest of the run, so this also
|
||||||
|
// confirms a mounted row fetches its image without being on screen.
|
||||||
|
assert(
|
||||||
|
heldRequestCount('/held-rest.svg') > 0 && heldRequestCount('/held-scroll.svg') > 0,
|
||||||
|
'mounted rows request their images while off screen'
|
||||||
|
+ ` (${heldRequestCount('/held-rest.svg')}, ${heldRequestCount('/held-scroll.svg')})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parkAbove = distance => win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const row = document.querySelector('[data-uuid="message-6"]').closest('.virtual-timeline-row');
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance};
|
||||||
|
return wrap.scrollTop;
|
||||||
|
})()`, true);
|
||||||
|
|
||||||
|
const captureGeometry = () => win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const rows = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => ({
|
||||||
|
uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
|
||||||
|
rect: row.getBoundingClientRect(),
|
||||||
|
}))
|
||||||
|
.filter(row => row.uuid && row.rect.bottom > wrapRect.top && row.rect.top < wrapRect.bottom)
|
||||||
|
.sort((left, right) => left.rect.top - right.rect.top);
|
||||||
|
return {
|
||||||
|
scrollTop: wrap.scrollTop,
|
||||||
|
firstVisible: rows[0]?.uuid || null,
|
||||||
|
tops: Object.fromEntries(rows.map(row => [row.uuid, row.rect.top - wrapRect.top])),
|
||||||
|
};
|
||||||
|
})()`, true);
|
||||||
|
|
||||||
|
await parkAbove(420);
|
||||||
|
// Longer than isScrollingResetDelay so the virtualizer is genuinely at rest.
|
||||||
|
await delay(700);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
|
||||||
|
'held rest image still pending above the viewport',
|
||||||
|
);
|
||||||
|
const restBefore = await captureGeometry();
|
||||||
|
releaseHeldImage('/held-rest.svg');
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
|
||||||
|
'held rest image load',
|
||||||
|
);
|
||||||
|
await delay(250);
|
||||||
|
const restAfter = await captureGeometry();
|
||||||
|
const restAnchor = restBefore.firstVisible;
|
||||||
|
const restDrift = restAnchor !== null && restAfter.tops[restAnchor] !== undefined
|
||||||
|
? restAfter.tops[restAnchor] - restBefore.tops[restAnchor]
|
||||||
|
: Number.NaN;
|
||||||
|
assert(
|
||||||
|
Math.abs(restDrift) <= 1,
|
||||||
|
'an image loading above the viewport does not move the reader position'
|
||||||
|
+ ` (${JSON.stringify({ anchor: restAnchor, drift: restDrift, scrollTop: [restBefore.scrollTop, restAfter.scrollTop] })})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same guarantee mid-gesture: scrolling back through history is when rows
|
||||||
|
// above the viewport are most likely to still be settling their media.
|
||||||
|
await parkAbove(2_000);
|
||||||
|
await delay(700);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="message-6"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
|
||||||
|
'held scroll image still pending above the viewport',
|
||||||
|
);
|
||||||
|
const scrollProbe = win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
|
||||||
|
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true }));
|
||||||
|
let previous = null;
|
||||||
|
let maxResidual = 0;
|
||||||
|
let example = null;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
wrap.scrollTop -= 12;
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const scrollTop = wrap.scrollTop;
|
||||||
|
const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => [
|
||||||
|
row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
|
||||||
|
row.getBoundingClientRect().top - wrapRect.top,
|
||||||
|
])
|
||||||
|
.filter(([uuid, top]) => uuid && top > -200 && top < wrapRect.height));
|
||||||
|
if (previous) {
|
||||||
|
for (const [uuid, top] of rows) {
|
||||||
|
if (!previous.rows.has(uuid)) continue;
|
||||||
|
const residual = (top - previous.rows.get(uuid)) + (scrollTop - previous.scrollTop);
|
||||||
|
if (Math.abs(residual) > Math.abs(maxResidual)) {
|
||||||
|
maxResidual = residual;
|
||||||
|
example = { uuid, residual, scrollTop };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous = { rows, scrollTop };
|
||||||
|
if (now - startedAt < 1500) {
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.dispatchEvent(new Event('scrollend'));
|
||||||
|
resolve({ maxResidual, example });
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})`, true);
|
||||||
|
await delay(500);
|
||||||
|
releaseHeldImage('/held-scroll.svg');
|
||||||
|
const scrolling = await withDeadline(scrollProbe, 'backward scroll residual probe');
|
||||||
|
assert(
|
||||||
|
Math.abs(scrolling.maxResidual) <= 2,
|
||||||
|
'an image loading above the viewport does not move visible rows mid-scroll'
|
||||||
|
+ ` (${JSON.stringify(scrolling)})`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
win?.destroy();
|
win?.destroy();
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise(resolve => server.close(resolve));
|
||||||
@@ -279,7 +472,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady()
|
app.whenReady()
|
||||||
.then(run)
|
.then(() => withDeadline(run(), 'the session image suite to finish', 180_000))
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
failures++;
|
failures++;
|
||||||
console.error(error.stack || error);
|
console.error(error.stack || error);
|
||||||
|
|||||||
Reference in New Issue
Block a user