blob: de339f408d2b618a1ecccf709efd06cd6f72c7d3 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Native history geometry gate with raw frame and browser timing evidence.
// History storage is simulated; real Host admission is measured separately.
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { chromium } from '@playwright/test';
import { startStaticServer } from '../storybook-visual-smoke.mjs';
import { outputDir, report, summarize } from './report.mjs';
const server = await startStaticServer('apps/desktop/storybook-static');
const browser = await chromium.launch({ headless: true });
const samples = [];
const scenes = (
process.env.SCROLL_WINDOW_SCENES ?? 'history-window-traversal,virtual-history-mixed-content'
).split(',');
const trials = Number(process.env.SCROLL_WINDOW_TRIALS ?? 3);
assert(Number.isInteger(trials) && trials > 0);
await mkdir(outputDir, { recursive: true });
try {
for (const scene of scenes) {
for (let trial = 0; trial < trials; trial++) {
const page = await browser.newPage({ viewport: { width: 1352, height: 932 } });
if (process.env.SCROLL_WINDOW_CPU_RATE) {
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', {
rate: Number(process.env.SCROLL_WINDOW_CPU_RATE),
});
}
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(
`${server.baseUrl}/iframe.html?id=product-shell-official-appshell--${scene}&viewMode=story`,
);
await page.locator('[data-chat-scroll-container]').waitFor();
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(() => !document.querySelector('.maka-markdown-pending'));
await page.waitForTimeout(1200);
await page.evaluate(() => {
const root = document.querySelector('[data-chat-scroll-container]');
const probe = (window.__scrollWindow = {
frames: [],
events: [],
tasks: [],
longAnimationFrames: [],
running: true,
phase: 'start',
});
for (const type of ['wheel', 'scroll', 'scrollend']) {
root.addEventListener(
type,
(event) =>
probe.events.push({
type,
ms: performance.now(),
top: root.scrollTop,
height: root.scrollHeight,
delta: event.deltaY,
phase: probe.phase,
}),
{ passive: true },
);
}
new PerformanceObserver((list) =>
probe.tasks.push(
...list.getEntries().map((entry) => ({
ms: entry.startTime,
duration: entry.duration,
})),
),
).observe({ type: 'longtask' });
// Keep browser attribution beside the geometry samples. Storybook's
// own observers also run here; a long task alone is not evidence that
// mounting a Turn or parsing Markdown caused it. This observer only
// records completed long frames and performs no DOM measurements.
if (!PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
throw new Error('The performance browser must support long animation frame attribution');
}
new PerformanceObserver((list) =>
probe.longAnimationFrames.push(...list.getEntries().map((entry) => entry.toJSON())),
).observe({ type: 'long-animation-frame' });
let previousText;
// Follow an actual visible glyph, not the top of a provisional shell
// or a tall Turn whose beginning can be far outside the viewport.
const readingText = (top) => {
const box = root.getBoundingClientRect();
for (let y = top + 8; y < top + root.clientHeight; y += 48) {
const caret = document.caretRangeFromPoint(box.left + box.width / 2, y);
const node = caret?.startContainer;
if (
!node ||
node.nodeType !== Node.TEXT_NODE ||
!node.parentElement?.closest('.maka-turn') ||
!node.textContent.length
)
continue;
const start = Math.min(caret.startOffset, node.textContent.length - 1);
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + 1);
const rect = range.getBoundingClientRect();
if (rect.height > 0 && rect.top >= top && rect.bottom <= top + root.clientHeight) {
return { range, node, top: rect.top };
}
}
};
const frame = () => {
const top = root.getBoundingClientRect().top;
const sample = {
ms: performance.now(),
top: root.scrollTop,
height: root.scrollHeight,
viewport: root.clientHeight,
count: root.querySelectorAll('.maka-turn[data-turn-id]').length,
visibleCount: [...root.querySelectorAll('.maka-turn[data-turn-id]')].filter((turn) => {
const box = turn.getBoundingClientRect();
return box.bottom > top && box.top < top + root.clientHeight;
}).length,
phase: probe.phase,
readerDelta:
previousText?.node.isConnected && previousText.range.getClientRects().length
? previousText.range.getBoundingClientRect().top - previousText.top
: null,
membership: [...root.querySelectorAll('[data-transcript-turn-id]')]
.map((turn) => turn.dataset.transcriptTurnId)
.join(','),
};
probe.frames.push(sample);
previousText = readingText(top);
if (probe.running) requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
});
const box = await page.locator('[data-chat-scroll-container]').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (const [direction, delta] of [
['up', -450],
['down', 450],
['up-repeat', -450],
['down-repeat', 450],
]) {
for (let burst = 0; burst < 12; burst++) {
await page.evaluate((phase) => {
window.__scrollWindow.phase = phase;
}, `${direction}-${burst}`);
for (let tick = 0; tick < 24; tick++) {
await page.mouse.wheel(0, delta);
await page.waitForTimeout(16);
}
await page.evaluate((phase) => {
window.__scrollWindow.phase = phase;
}, `${direction}-pause`);
await page.waitForTimeout(350);
const reached = await page.evaluate((direction) => {
const root = document.querySelector('[data-chat-scroll-container]');
return direction.startsWith('up')
? root.scrollTop <= 1
: root.scrollHeight - root.scrollTop - root.clientHeight <= 1;
}, direction);
if (reached) break;
}
}
await page.waitForTimeout(500);
const data = await page.evaluate(() => {
window.__scrollWindow.running = false;
return window.__scrollWindow;
});
assert.equal(errors.length, 0, errors.join('\n'));
assert(
data.events.some((event) => event.type === 'scroll'),
'native input must move the transcript',
);
assert(
data.frames.some((frame) => frame.top <= 1 && frame.phase.startsWith('up')),
'upward traversal must reach the edge',
);
const last = data.frames.at(-1);
assert(last.height - last.viewport - last.top <= 1, 'return traversal must reach the tail');
const shrink = data.frames.slice(1).flatMap((frame, i) => {
const delta = frame.height - data.frames[i].height;
return delta < -1
? [{ ...frame, delta, sameMembership: frame.membership === data.frames[i].membership }]
: [];
});
const blankFrames = data.frames.filter((frame) => frame.visibleCount === 0);
// Evicting real rows legitimately changes the window extent. Revisited
// rows must not resize when the window itself has not changed.
const revisitShrink = shrink.filter(
(frame) => frame.phase.startsWith('up-repeat') && frame.sameMembership,
);
const coldChanges = data.frames.slice(1).flatMap((frame, i) => {
const previous = data.frames[i];
if (!frame.phase.startsWith('up-') || frame.phase.startsWith('up-repeat')) return [];
const delta = frame.height - previous.height;
return Math.abs(delta) > 1
? [{ delta, sameMembership: frame.membership === previous.membership }]
: [];
});
const row = {
scene,
trial,
shrinkCount: shrink.length,
blankFrames: blankFrames.length,
revisitShrinkCount: revisitShrink.length,
shrinkPx: -shrink.reduce((sum, frame) => sum + frame.delta, 0),
coldStableMembershipChangePx: coldChanges
.filter((change) => change.sameMembership)
.reduce((sum, change) => sum + Math.abs(change.delta), 0),
coldRangeChangePx: coldChanges
.filter((change) => !change.sameMembership)
.reduce((sum, change) => sum + Math.abs(change.delta), 0),
coldGrowthPx: coldChanges
.filter((change) => change.delta > 0)
.reduce((sum, change) => sum + change.delta, 0),
coldMaxReversePx: Math.max(
0,
...data.frames
.slice(1)
.flatMap((frame, i) =>
frame.phase.startsWith('up-') &&
!frame.phase.startsWith('up-repeat') &&
frame.phase === data.frames[i].phase &&
!frame.phase.endsWith('pause') &&
frame.readerDelta !== null
? [-frame.readerDelta]
: [],
),
),
readerSamples: data.frames.filter((frame) => frame.readerDelta !== null).length,
maxLongTaskMs: Math.max(0, ...data.tasks.map((task) => task.duration)),
longTaskMs: data.tasks.reduce((sum, task) => sum + task.duration, 0),
maxMounted: Math.max(...data.frames.map((frame) => frame.count)),
minMounted: Math.min(...data.frames.map((frame) => frame.count)),
maxFrameGapMs: Math.max(
...data.frames.slice(1).map((frame, i) => frame.ms - data.frames[i].ms),
),
};
samples.push(row);
await writeFile(
path.join(outputDir, `scroll-window-${scene}-${trial}.json`),
JSON.stringify({ row, shrink, ...data }, null, 2),
);
await page.screenshot({ path: path.join(outputDir, `scroll-window-${scene}-${trial}.png`) });
console.log(JSON.stringify(row));
assert.equal(blankFrames.length, 0, 'native traversal must not expose an empty transcript');
assert.equal(revisitShrink.length, 0, 'revisiting measured history must preserve its extent');
assert(
data.frames.some(
(frame) =>
frame.phase.startsWith('up-') &&
!frame.phase.startsWith('up-repeat') &&
frame.readerDelta !== null,
),
'the glyph probe must observe rendered text during the cold upward traversal',
);
assert(row.coldMaxReversePx <= 1, 'height corrections must not reverse an upward reader');
if (scene === 'virtual-history-mixed-content' && trial === 0) {
// Reuse the real paging/mounting path at a different layout width.
// Old offscreen boxes remain provisional until visited, then converge;
// a second visit must not fall back to the previous width's heights.
const resizeSamples = [];
for (const width of [900, 1352]) {
await page.setViewportSize({ width, height: 932 });
const heights = [];
for (let visit = 0; visit < 2; visit++) {
for (const direction of [-1, 1]) {
let reached = false;
for (let step = 0; step < 250; step++) {
reached = await page.evaluate(async (direction) => {
const root = document.querySelector('[data-chat-scroll-container]');
root.dispatchEvent(
new WheelEvent('wheel', { bubbles: true, deltaY: direction * 400 }),
);
root.scrollTop += direction * root.clientHeight;
root.dispatchEvent(new Event('scroll'));
for (let frame = 0; frame < 5; frame++) await new Promise(requestAnimationFrame);
return direction < 0
? root.scrollTop <= 1
: root.scrollHeight - root.clientHeight - root.scrollTop <= 1;
}, direction);
if (reached) break;
}
assert(reached, 'resized history traversal must reach its edge');
}
heights.push(
await page.evaluate(
() => document.querySelector('[data-chat-scroll-container]').scrollHeight,
),
);
}
resizeSamples.push({ width, heights });
assert(
Math.abs(heights[0] - heights[1]) <= 1,
`remeasured history must converge at width ${width}: ${heights}`,
);
}
assert(
Math.abs(resizeSamples.at(-1).heights[1] - last.height) <= 1,
'returning to the original width must recover its measured extent',
);
await writeFile(
path.join(outputDir, 'virtual-history-resize.json'),
JSON.stringify(resizeSamples, null, 2),
);
console.log(JSON.stringify({ scenario: 'virtual-history-resize', samples: resizeSamples }));
}
await page.close();
}
}
await report(
'frontend-scroll-window',
{
browser: browser.version(),
cpuThrottlingRate: Number(process.env.SCROLL_WINDOW_CPU_RATE ?? 1),
samples,
viewport: '1352x932',
conditions: `${trials} fresh documents per scene; native wheel, 24 ticks of 450px with >=16ms spacing and 350ms pauses. Full up/down traversal; fonts and Markdown ready. Same driver on baseline and experiment refs.`,
limits:
'Simulated history callbacks, not real Host/store timing or exact touchpad replay. Range-change frames can also contain height corrections, so stable-membership changes are only a lower bound on correction. Geometry and long tasks are separate outcomes. Small samples are not a robust timing distribution. No performance threshold.',
},
scenes.flatMap((scene) =>
[
'shrinkCount',
'shrinkPx',
'coldStableMembershipChangePx',
'coldRangeChangePx',
'coldGrowthPx',
'coldMaxReversePx',
'maxLongTaskMs',
'longTaskMs',
'maxMounted',
'minMounted',
'maxFrameGapMs',
].map((metric) => ({
scenario: scene,
metric,
...summarize(samples.filter((row) => row.scene === scene).map((row) => row[metric])),
})),
),
);
} finally {
await browser.close();
await server.close();
}