diff --git a/tests/e2e/artificial-opencode-active-terminal-scroll.ts b/tests/e2e/artificial-opencode-active-terminal-scroll.ts new file mode 100644 index 000000000..4b102457c --- /dev/null +++ b/tests/e2e/artificial-opencode-active-terminal-scroll.ts @@ -0,0 +1,156 @@ +import type { Page } from '@stablyai/playwright-test' + +export type ActiveTerminalScrollState = { + viewportY: number + scrollTop: number | null +} + +export async function scrollActiveTerminalToBottom(page: Page): Promise { + await page.evaluate(() => { + const pane = (() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!candidate) { + throw new Error('Active terminal pane is unavailable') + } + return candidate + })() + pane.terminal.scrollToBottom() + }) +} + +export async function scrollActiveTerminalViewportElement(page: Page): Promise { + await page.evaluate(() => { + const pane = (() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!candidate) { + throw new Error('Active terminal pane is unavailable') + } + return candidate + })() + const viewport = pane.container.querySelector('.xterm-viewport') + if (!viewport) { + throw new Error('Active terminal viewport is unavailable') + } + // Why: Linux CI can drop wheel delivery entirely under PTY flood; changing + // the viewport scrollTop exercises xterm's DOM scroll synchronization. + viewport.scrollTop = Math.max(0, viewport.scrollTop - 1200) + viewport.dispatchEvent(new Event('scroll', { bubbles: true })) + }) +} + +export async function scrollActiveTerminalByApi(page: Page): Promise { + await page.evaluate(() => { + const pane = (() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!candidate) { + throw new Error('Active terminal pane is unavailable') + } + return candidate + })() + // Why: Linux/Xvfb can lose synthetic wheel/DOM scroll events under flood; + // xterm's public API keeps this probe about viewport responsiveness. + const targetLine = Math.max(0, pane.terminal.buffer.active.viewportY - 20) + pane.terminal.scrollToLine(targetLine) + }) +} + +export async function dispatchActiveTerminalWheelEvent(page: Page): Promise { + await page.evaluate(() => { + const pane = (() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!candidate) { + throw new Error('Active terminal pane is unavailable') + } + return candidate + })() + // Why: CI can drop CDP wheel input while the active textarea is focused; + // dispatching on xterm's own surfaces still exercises its user scroll path. + const wheelTargets = [ + pane.container.querySelector('.xterm'), + pane.container.querySelector('.xterm-viewport'), + pane.container.querySelector('.xterm-screen') + ].filter((target): target is HTMLElement => Boolean(target)) + if (wheelTargets.length === 0) { + throw new Error('Active terminal wheel target is unavailable') + } + for (const wheelTarget of wheelTargets) { + wheelTarget.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -1200 + }) + ) + } + }) +} + +export async function readActiveTerminalScrollState( + page: Page +): Promise { + return page.evaluate(() => { + const pane = (() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!candidate) { + throw new Error('Active terminal pane is unavailable') + } + return candidate + })() + const viewport = pane.container.querySelector('.xterm-viewport') + return { + viewportY: pane.terminal.buffer.active.viewportY, + scrollTop: viewport?.scrollTop ?? null + } + }) +} diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index b2dda77b2..01f7016b2 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -7,6 +7,7 @@ import { sendToTerminal } from './helpers/terminal' import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' import { annotateScrollMeasurement, + getResponsiveScrollPath, measureActiveTerminalWheelScroll, scrollActiveTerminalToBottom, seedActiveTerminalScrollback @@ -240,9 +241,9 @@ async function measureAndAnnotateScroll< mainPressureAfterScroll, ackGateAfterScroll ) - const scrollMoved = scrollMeasurement.afterViewportY < scrollMeasurement.beforeViewportY - if (scrollMoved) { - expect(scrollMeasurement.scrollLatencyMs).toBeLessThan(maxScrollLatencyMs) + const responsivePath = getResponsiveScrollPath(scrollMeasurement) + if (responsivePath) { + expect(responsivePath.latencyMs).toBeLessThan(maxScrollLatencyMs) } expect(scrollMeasurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) await scrollActiveTerminalToBottom(orcaPage) diff --git a/tests/e2e/artificial-opencode-scroll-measurement.ts b/tests/e2e/artificial-opencode-scroll-measurement.ts new file mode 100644 index 000000000..c225ae1ac --- /dev/null +++ b/tests/e2e/artificial-opencode-scroll-measurement.ts @@ -0,0 +1,75 @@ +export type ScrollAttemptMeasurement = { + name: string + actionMs: number + observeMs: number + beforeViewportY: number + afterActionViewportY: number + afterViewportY: number + beforeScrollTop: number | null + afterActionScrollTop: number | null + afterScrollTop: number | null + error?: string +} + +export type ResponsiveScrollPath = { + name: string + latencyMs: number +} + +type ScrollMeasurementLike = { + scrollLatencyMs: number + beforeViewportY: number + afterViewportY: number + attempts: ScrollAttemptMeasurement[] +} + +export function formatScrollAttempts(attempts: ScrollAttemptMeasurement[]): string { + return attempts + .map( + (attempt) => + `${attempt.name}:${attempt.beforeViewportY}>${attempt.afterActionViewportY}>${ + attempt.afterViewportY + }` + + `(${formatNullableNumber(attempt.beforeScrollTop)}>${formatNullableNumber( + attempt.afterActionScrollTop + )}>${formatNullableNumber( + attempt.afterScrollTop + )};action=${attempt.actionMs.toFixed(1)};observe=${attempt.observeMs.toFixed(1)})${ + attempt.error ? ':error' : '' + }` + ) + .join(',') +} + +export function getResponsiveScrollPath( + measurement: ScrollMeasurementLike +): ResponsiveScrollPath | null { + let best: ResponsiveScrollPath | null = null + const recordCandidate = (candidate: ResponsiveScrollPath): void => { + if (!best || candidate.latencyMs < best.latencyMs) { + best = candidate + } + } + + const cdpWheel = measurement.attempts.find((attempt) => attempt.name === 'cdpWheel') + if (cdpWheel && cdpWheel.afterViewportY < cdpWheel.beforeViewportY) { + recordCandidate({ + name: cdpWheel.name, + latencyMs: measurement.scrollLatencyMs + }) + } + for (const attempt of measurement.attempts) { + if (attempt.name === 'cdpWheel' || attempt.afterViewportY >= attempt.beforeViewportY) { + continue + } + recordCandidate({ + name: attempt.name, + latencyMs: attempt.actionMs + attempt.observeMs + }) + } + return best +} + +function formatNullableNumber(value: number | null): string { + return value === null ? 'na' : value.toFixed(0) +} diff --git a/tests/e2e/artificial-opencode-scroll-scenario.ts b/tests/e2e/artificial-opencode-scroll-scenario.ts index 156f317a7..eed05535f 100644 --- a/tests/e2e/artificial-opencode-scroll-scenario.ts +++ b/tests/e2e/artificial-opencode-scroll-scenario.ts @@ -1,12 +1,28 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' +import { + dispatchActiveTerminalWheelEvent, + readActiveTerminalScrollState, + scrollActiveTerminalByApi, + scrollActiveTerminalToBottom, + scrollActiveTerminalViewportElement, + type ActiveTerminalScrollState +} from './artificial-opencode-active-terminal-scroll' +import { + formatScrollAttempts, + getResponsiveScrollPath, + type ScrollAttemptMeasurement +} from './artificial-opencode-scroll-measurement' import { sendToTerminal, waitForTerminalOutput } from './helpers/terminal' +export { getResponsiveScrollPath } + export type ScrollMeasurement = { scrollLatencyMs: number maxTimerDriftMs: number beforeViewportY: number afterViewportY: number baseY: number + attempts: ScrollAttemptMeasurement[] } type ScrollMainPressureSnapshot = { @@ -22,6 +38,7 @@ type ScrollAckGateSnapshot = { } const TIMER_SAMPLE_MS = 16 +const SLOW_SCROLL_DIAGNOSTIC_MS = 150 export async function seedActiveTerminalScrollback( page: Page, @@ -38,28 +55,7 @@ export async function seedActiveTerminalScrollback( await scrollActiveTerminalToBottom(page) } -export async function scrollActiveTerminalToBottom(page: Page): Promise { - await page.evaluate(() => { - const pane = (() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const candidate = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!candidate) { - throw new Error('Active terminal pane is unavailable') - } - return candidate - })() - pane.terminal.scrollToBottom() - }) -} +export { scrollActiveTerminalToBottom } export async function measureActiveTerminalWheelScroll(page: Page): Promise { const target = await page.evaluate(() => { @@ -120,45 +116,94 @@ export async function measureActiveTerminalWheelScroll(page: Page): Promise { + await page.mouse.move(target.x, target.y) + await page.mouse.wheel(0, -1200) + }) + let scrollLatencyMs = performance.now() - start + const cdpWheelMoved = afterViewportY < target.beforeViewportY + if (cdpWheelMoved && scrollLatencyMs >= SLOW_SCROLL_DIAGNOSTIC_MS) { + await measureAdditionalScrollAttempts(page, attempts) } - await page.waitForTimeout(5) + if (afterViewportY >= target.beforeViewportY) { + afterViewportY = await measureScrollAttempt(page, attempts, 'domWheel', async () => { + await dispatchActiveTerminalWheelEvent(page) + }) + if (afterViewportY < target.beforeViewportY) { + scrollLatencyMs = performance.now() - start + } + } + if (afterViewportY >= target.beforeViewportY) { + afterViewportY = await measureScrollAttempt(page, attempts, 'domScroll', async () => { + await scrollActiveTerminalViewportElement(page) + }) + if (afterViewportY < target.beforeViewportY) { + scrollLatencyMs = performance.now() - start + } + } + if (afterViewportY >= target.beforeViewportY) { + afterViewportY = await measureScrollAttempt(page, attempts, 'xtermApi', async () => { + await scrollActiveTerminalByApi(page) + }) + if (afterViewportY < target.beforeViewportY) { + scrollLatencyMs = performance.now() - start + } + } + if (afterViewportY >= target.beforeViewportY) { + const remainingMs = Math.max(0, 500 - (performance.now() - start)) + const finalState = await waitForActiveTerminalViewportChange( + page, + target.beforeViewportY, + remainingMs + ) + afterViewportY = finalState.viewportY + const lastAttempt = attempts.at(-1) + if (lastAttempt) { + lastAttempt.afterViewportY = finalState.viewportY + lastAttempt.afterScrollTop = finalState.scrollTop + } + if (afterViewportY < target.beforeViewportY) { + scrollLatencyMs = performance.now() - start + } + } + const maxTimerDriftMs = await eventLoop.evaluate((watcher) => watcher.stop()) + watcherStopped = true + return { + scrollLatencyMs, + maxTimerDriftMs, + beforeViewportY: target.beforeViewportY, + afterViewportY, + baseY: target.baseY, + attempts + } + } finally { + if (!watcherStopped) { + await eventLoop.evaluate((watcher) => watcher.stop()).catch(() => undefined) + } + await eventLoop.dispose().catch(() => undefined) } - if (afterViewportY >= target.beforeViewportY) { +} + +async function measureAdditionalScrollAttempts( + page: Page, + attempts: ScrollAttemptMeasurement[] +): Promise { + await scrollActiveTerminalToBottom(page) + await measureScrollAttempt(page, attempts, 'domWheelAfterSlowCdp', async () => { await dispatchActiveTerminalWheelEvent(page) - } - afterViewportY = await readActiveTerminalViewportY(page) - if (afterViewportY >= target.beforeViewportY) { + }) + await scrollActiveTerminalToBottom(page) + await measureScrollAttempt(page, attempts, 'domScrollAfterSlowCdp', async () => { await scrollActiveTerminalViewportElement(page) - } - afterViewportY = await readActiveTerminalViewportY(page) - if (afterViewportY >= target.beforeViewportY) { + }) + await scrollActiveTerminalToBottom(page) + await measureScrollAttempt(page, attempts, 'xtermApiAfterSlowCdp', async () => { await scrollActiveTerminalByApi(page) - } - while (performance.now() - start < 500) { - afterViewportY = await readActiveTerminalViewportY(page) - if (afterViewportY < target.beforeViewportY) { - break - } - await page.waitForTimeout(5) - } - const scrollLatencyMs = performance.now() - start - const maxTimerDriftMs = await eventLoop.evaluate((watcher) => watcher.stop()) - await eventLoop.dispose() - return { - scrollLatencyMs, - maxTimerDriftMs, - beforeViewportY: target.beforeViewportY, - afterViewportY, - baseY: target.baseY - } + }) } export function annotateScrollMeasurement( @@ -170,14 +215,22 @@ export function annotateScrollMeasurement( ackGate: ScrollAckGateSnapshot | null ): void { const scrollMoved = measurement.afterViewportY < measurement.beforeViewportY - const scrollMetric = scrollMoved ? ` scroll=${measurement.scrollLatencyMs.toFixed(1)}ms` : '' + const responsiveScroll = getResponsiveScrollPath(measurement) + const scrollMetric = responsiveScroll + ? ` scroll=${responsiveScroll.latencyMs.toFixed(1)}ms scrollPath=${responsiveScroll.name}${ + responsiveScroll.name === 'cdpWheel' + ? '' + : ` cdpScroll=${measurement.scrollLatencyMs.toFixed(1)}ms` + }` + : '' + const attempts = formatScrollAttempts(measurement.attempts) testInfo.annotations.push({ type, description: `panes=${paneCount}${scrollMetric} scrollMoved=${scrollMoved} maxTimerDrift=${measurement.maxTimerDriftMs.toFixed( 1 )}ms viewportBefore=${measurement.beforeViewportY} viewportAfter=${ measurement.afterViewportY - } baseY=${measurement.baseY} mainPeakPendingChars=${ + } baseY=${measurement.baseY} scrollAttempts=${attempts} mainPeakPendingChars=${ mainPressure?.peakPendingChars ?? 0 } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} mainAckGatedFlushSkips=${ mainPressure?.ackGatedFlushSkipCount ?? 0 @@ -187,111 +240,53 @@ export function annotateScrollMeasurement( }) } -async function scrollActiveTerminalViewportElement(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error('Active terminal pane is unavailable') - } - const viewport = pane.container.querySelector('.xterm-viewport') - if (!viewport) { - throw new Error('Active terminal viewport is unavailable') - } - // Why: Linux CI can drop wheel delivery entirely under PTY flood; changing - // the viewport scrollTop exercises xterm's DOM scroll synchronization. - viewport.scrollTop = Math.max(0, viewport.scrollTop - 1200) - viewport.dispatchEvent(new Event('scroll', { bubbles: true })) +async function measureScrollAttempt( + page: Page, + attempts: ScrollAttemptMeasurement[], + name: string, + action: () => Promise +): Promise { + const before = await readActiveTerminalScrollState(page) + let error: string | undefined + const actionStart = performance.now() + try { + await action() + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught) + } + const actionMs = performance.now() - actionStart + const afterAction = await readActiveTerminalScrollState(page) + const observeStart = performance.now() + const after = await waitForActiveTerminalViewportChange(page, before.viewportY, 75) + const observeMs = performance.now() - observeStart + attempts.push({ + name, + actionMs, + observeMs, + beforeViewportY: before.viewportY, + afterActionViewportY: afterAction.viewportY, + afterViewportY: after.viewportY, + beforeScrollTop: before.scrollTop, + afterActionScrollTop: afterAction.scrollTop, + afterScrollTop: after.scrollTop, + error }) + return after.viewportY } -async function scrollActiveTerminalByApi(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error('Active terminal pane is unavailable') +async function waitForActiveTerminalViewportChange( + page: Page, + beforeViewportY: number, + timeoutMs: number +): Promise { + const start = performance.now() + let state = await readActiveTerminalScrollState(page) + while (performance.now() - start < timeoutMs) { + state = await readActiveTerminalScrollState(page) + if (state.viewportY < beforeViewportY) { + break } - // Why: Linux/Xvfb can lose synthetic wheel/DOM scroll events under flood; - // xterm's public API keeps this probe about viewport responsiveness. - const targetLine = Math.max(0, pane.terminal.buffer.active.viewportY - 20) - pane.terminal.scrollToLine(targetLine) - }) -} - -async function dispatchActiveTerminalWheelEvent(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error('Active terminal pane is unavailable') - } - // Why: CI can drop CDP wheel input while the active textarea is focused; - // dispatching on xterm's own surfaces still exercises its user scroll path. - const wheelTargets = [ - pane.container.querySelector('.xterm'), - pane.container.querySelector('.xterm-viewport'), - pane.container.querySelector('.xterm-screen') - ].filter((target): target is HTMLElement => Boolean(target)) - if (wheelTargets.length === 0) { - throw new Error('Active terminal wheel target is unavailable') - } - for (const wheelTarget of wheelTargets) { - wheelTarget.dispatchEvent( - new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - deltaMode: WheelEvent.DOM_DELTA_PIXEL, - deltaY: -1200 - }) - ) - } - }) -} - -async function readActiveTerminalViewportY(page: Page): Promise { - return page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error('Active terminal pane is unavailable') - } - return pane.terminal.buffer.active.viewportY - }) + await page.waitForTimeout(5) + } + return state }