Make terminal scroll pressure benchmark diagnose responsive paths (#4846)
* Instrument terminal scroll pressure attempts * Trace terminal scroll input timing * Reduce PTY renderer in-flight window * Revert "Reduce PTY renderer in-flight window" This reverts commit 86775aeb68968e88b98c3a4c1b710eed5b0833b3. * Probe alternate scroll paths after slow wheel * Gate scroll pressure on responsive terminal path * Address scroll benchmark review cleanup
This commit is contained in:
parent
675376023f
commit
102cbdbda2
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<HTMLElement>('.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<void> {
|
||||
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<void> {
|
||||
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<HTMLElement>('.xterm'),
|
||||
pane.container.querySelector<HTMLElement>('.xterm-viewport'),
|
||||
pane.container.querySelector<HTMLElement>('.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<ActiveTerminalScrollState> {
|
||||
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<HTMLElement>('.xterm-viewport')
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
scrollTop: viewport?.scrollTop ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<ScrollMeasurement> {
|
||||
const target = await page.evaluate(() => {
|
||||
|
|
@ -120,45 +116,94 @@ export async function measureActiveTerminalWheelScroll(page: Page): Promise<Scro
|
|||
}
|
||||
}, TIMER_SAMPLE_MS)
|
||||
|
||||
const start = performance.now()
|
||||
await page.mouse.move(target.x, target.y)
|
||||
await page.mouse.wheel(0, -1200)
|
||||
let afterViewportY = target.beforeViewportY
|
||||
while (performance.now() - start < 75) {
|
||||
afterViewportY = await readActiveTerminalViewportY(page)
|
||||
if (afterViewportY < target.beforeViewportY) {
|
||||
break
|
||||
let watcherStopped = false
|
||||
try {
|
||||
const start = performance.now()
|
||||
const attempts: ScrollAttemptMeasurement[] = []
|
||||
let afterViewportY = await measureScrollAttempt(page, attempts, 'cdpWheel', async () => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<HTMLElement>('.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<void>
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
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<ActiveTerminalScrollState> {
|
||||
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<void> {
|
||||
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<HTMLElement>('.xterm'),
|
||||
pane.container.querySelector<HTMLElement>('.xterm-viewport'),
|
||||
pane.container.querySelector<HTMLElement>('.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<number> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue