Files
hapi/e2e/history-load.spec.ts
T

616 lines
28 KiB
TypeScript

import { expect, test } from '@playwright/test'
// Regression: loading an older page prepends hundreds of messages at once.
// assistant-ui's tap scheduler aborts a flush with more than 50 dirty
// resources and drops the rest, so the thread never reflected the merged
// page (see patches/@assistant-ui%2Ftap@0.3.5.patch). This spec drives the
// real message-window store + HappyThread against a fake paginated API and
// pins the contract: one page per top approach, scroll position restored.
test('scroll-to-top loads one page per approach with correct scroll restore', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
// Wait for the initial tail sync plus the initial scroll-settling window.
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.waitForTimeout(2000)
const afterFirst = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
// Exactly one page loaded, the DOM shows it, scroll restored away from top.
expect(afterFirst.beforeReqs).toBe(1)
expect(afterFirst.childCount).toBe(400)
expect(afterFirst.scrollTop).toBeGreaterThan(1000)
// Idle watch: no further loads may happen without another scroll.
await page.waitForTimeout(2500)
const idleReqs = await page.evaluate(() => window.__probe.requests.filter((r) => r.direction === 'before').length)
expect(idleReqs).toBe(1)
// A fresh scrollbar-style approach to the top loads exactly one more page.
await viewport.dispatchEvent('pointerdown', { button: 0, pointerType: 'mouse' })
await page.evaluate(() => {
const element = document.querySelector('.app-scroll-y') as HTMLElement
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
await viewport.dispatchEvent('pointerup', { button: 0, pointerType: 'mouse' })
await page.waitForTimeout(2000)
const afterSecond = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
expect(afterSecond.beforeReqs).toBe(2)
expect(afterSecond.childCount).toBe(600)
expect(afterSecond.scrollTop).toBeGreaterThan(1000)
})
// A user may adjust position inside the preload area before a slow request
// finishes. The prepend must preserve their latest offset, not snap back to
// the stale top anchor captured when the request started.
test('scrolling within the preload area rebases the pending anchor', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?slowBefore=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(1)
const latestAnchor = await page.evaluate(() => {
const viewport = document.querySelector('.app-scroll-y') as HTMLElement
viewport.scrollTop = 100
viewport.dispatchEvent(new Event('scroll'))
const viewportRect = viewport.getBoundingClientRect()
const message = Array.from(
viewport.querySelectorAll<HTMLElement>('.happy-thread-messages > [id]')
).find((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
})
if (!message) throw new Error('Expected a visible message anchor')
return {
id: message.id,
topOffset: message.getBoundingClientRect().top - viewportRect.top
}
})
await expect.poll(async () => await page.evaluate(() =>
document.querySelector('.happy-thread-messages')?.childElementCount ?? 0
)).toBe(400)
const restoredOffset = await page.evaluate((anchorId) => {
const viewport = document.querySelector('.app-scroll-y') as HTMLElement
const message = document.getElementById(anchorId)
if (!message) throw new Error('Expected the latest anchor to remain rendered')
return message.getBoundingClientRect().top - viewport.getBoundingClientRect().top
}, latestAnchor.id)
expect(Math.abs(restoredOffset - latestAnchor.topOffset)).toBeLessThanOrEqual(2)
})
// At the 800-row history cap, applying another prepend evicts the newest 200
// rows. If the user reverses into that side while the request is in flight,
// invalidate the obsolete load rather than deleting their visible anchor.
test('reversing toward newer history cancels a prepend at the row cap', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?slowBefore=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
const approachTop = async () => {
await viewport.dispatchEvent('pointerdown', { button: 0, pointerType: 'mouse' })
await page.evaluate(() => {
const element = document.querySelector('.app-scroll-y') as HTMLElement
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
await viewport.dispatchEvent('pointerup', { button: 0, pointerType: 'mouse' })
}
for (let pageNumber = 1; pageNumber <= 3; pageNumber += 1) {
await approachTop()
await expect.poll(async () => await page.evaluate(() => ({
beforeReqs: window.__probe.requests.filter((request) => request.direction === 'before').length,
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0
}))).toEqual({
beforeReqs: pageNumber,
childCount: 200 + pageNumber * 200
})
}
await approachTop()
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(4)
const newerAnchor = await page.evaluate(() => {
const viewport = document.querySelector('.app-scroll-y') as HTMLElement
viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight - 1000)
viewport.dispatchEvent(new Event('scroll'))
const viewportRect = viewport.getBoundingClientRect()
const message = Array.from(
viewport.querySelectorAll<HTMLElement>('.happy-thread-messages > [id]')
).find((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
})
if (!message) throw new Error('Expected a visible newer-history anchor')
return {
id: message.id,
topOffset: message.getBoundingClientRect().top - viewportRect.top
}
})
const anchorSequence = Number(newerAnchor.id.match(/m-(\d+)/)?.[1])
expect(anchorSequence).toBeGreaterThan(1000)
// Wait beyond the fixture's 500ms response delay. The invalidated response
// must not apply or trim the capped window.
await page.waitForTimeout(1000)
const afterCancelledLoad = await page.evaluate((anchorId) => {
const viewport = document.querySelector('.app-scroll-y') as HTMLElement
const message = document.getElementById(anchorId)
return {
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((request) => request.direction === 'before').length,
anchorOffset: message
? message.getBoundingClientRect().top - viewport.getBoundingClientRect().top
: null
}
}, newerAnchor.id)
expect(afterCancelledLoad).toEqual({
childCount: 800,
beforeReqs: 4,
anchorOffset: expect.any(Number)
})
expect(Math.abs(afterCancelledLoad.anchorOffset! - newerAnchor.topOffset)).toBeLessThanOrEqual(2)
})
// The store and rendered thread must publish a capped prepend atomically. With
// throttled notification, there was a window where the store had already
// dropped m-1001…m-1200 while the DOM still exposed those rows to scrolling.
test('capped prepend has no post-apply stale-DOM reversal window', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
const approachTop = async () => {
await viewport.dispatchEvent('pointerdown', { button: 0, pointerType: 'mouse' })
await page.evaluate(() => {
const element = document.querySelector('.app-scroll-y') as HTMLElement
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
await viewport.dispatchEvent('pointerup', { button: 0, pointerType: 'mouse' })
}
for (let pageNumber = 1; pageNumber <= 3; pageNumber += 1) {
await approachTop()
await expect.poll(async () => await page.evaluate(() => ({
beforeReqs: window.__probe.requests.filter((request) => request.direction === 'before').length,
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0
}))).toEqual({
beforeReqs: pageNumber,
childCount: 200 + pageNumber * 200
})
}
await page.evaluate(() => {
delete document.documentElement.dataset.historyStoreDomGap
const deadline = Date.now() + 1500
const timer = window.setInterval(() => {
const state = window.__probe.windowState()
if (state.oldestSeq !== 201) {
if (Date.now() >= deadline) {
document.documentElement.dataset.historyStoreDomGap = 'timeout'
window.clearInterval(timer)
}
return
}
const viewport = document.querySelector('.app-scroll-y') as HTMLElement
const exposesEvictedRows = Array.from(
viewport.querySelectorAll<HTMLElement>('.happy-thread-messages > [id]')
).some((message) => Number(message.id.match(/m-(\d+)/)?.[1]) > 1000)
if (exposesEvictedRows) {
// Reproduce the dangerous input while the DOM still exposes
// rows that the store has already removed.
viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight - 1000)
viewport.dispatchEvent(new Event('scroll'))
document.documentElement.dataset.historyStoreDomGap = 'stale'
} else {
document.documentElement.dataset.historyStoreDomGap = 'atomic'
}
window.clearInterval(timer)
}, 0)
})
await approachTop()
await expect.poll(async () => await page.evaluate(() =>
document.documentElement.dataset.historyStoreDomGap ?? null
)).toBe('atomic')
})
// Regression: a normal tail synchronization invalidates any overlapping
// older-page request. That stop is transient: once synchronization drains,
// coverage must automatically issue a fresh request while the user remains
// near the top.
test('ordinary tail synchronization re-arms covered history loading', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?slowBefore=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(1)
await page.evaluate(async () => {
await window.__probe.refetch()
})
await expect.poll(async () => await page.evaluate(() => ({
beforeReqs: window.__probe.requests.filter((request) => request.direction === 'before').length,
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
scrollTop: Math.round((document.querySelector('.app-scroll-y') as HTMLElement).scrollTop)
}))).toEqual({
beforeReqs: 2,
childCount: 400,
scrollTop: expect.any(Number)
})
const restoredScrollTop = await viewport.evaluate((element) => Math.round(element.scrollTop))
expect(restoredScrollTop).toBeGreaterThan(1000)
await page.waitForTimeout(1000)
const idleReqs = await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)
expect(idleReqs).toBe(2)
})
// A short page can leave the top sentinel inside the preload margin after
// scroll restoration. That must not turn one approach into an opaque chain of
// older-page requests; a new approach is required for each automatic page.
test('short pages load once per top approach without automatic chaining', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?shortPages=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.waitForTimeout(2000)
const afterFirst = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
expect(afterFirst.beforeReqs).toBe(1)
expect(afterFirst.scrollTop).toBeGreaterThan(0)
// Remaining covered and idle must not fetch the next short page.
await page.waitForTimeout(2000)
const idleReqs = await page.evaluate(() => window.__probe.requests.filter((r) => r.direction === 'before').length)
expect(idleReqs).toBe(1)
// Leave the preload area, then make a fresh approach. Exactly one more
// page is allowed, even if that page is also too short to clear the margin.
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 500
})
await page.waitForTimeout(100)
await viewport.dispatchEvent('pointerdown', { button: 0, pointerType: 'mouse' })
await page.evaluate(() => {
const element = document.querySelector('.app-scroll-y') as HTMLElement
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
await viewport.dispatchEvent('pointerup', { button: 0, pointerType: 'mouse' })
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)).toBe(2)
await page.waitForTimeout(2000)
const afterSecondIdle = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)
expect(afterSecondIdle).toBe(2)
})
// Regression: a failed older-page load changes no useful geometry, so no new
// scroll/resize signal is guaranteed. The same logical load must retry with
// bounded backoff without requiring another gesture.
test('a failed older-page load retries automatically while covered', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?failBefore=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
// First attempt fails after ~50ms; backoff retries after 1000ms.
await page.waitForTimeout(4000)
const state = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
expect(state.beforeReqs).toBe(2)
expect(state.childCount).toBe(400)
expect(state.scrollTop).toBeGreaterThan(1000)
})
// Regression: when persistent failures burn through the bounded backoff, all
// automatic triggers go quiet — and at scrollTop=0 no further scroll events
// fire, so an explicit wheel-up / pull gesture is the only deterministic
// fallback left. It must route through the user-action path and recover.
test('wheel-up at the top loads older after automatic retries are exhausted', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?failBefore=4')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
const box = await viewport.boundingBox()
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.evaluate(() => {
// Keep a non-zero position inside the preload margin. Warning
// insertion/removal may emit browser scroll-anchoring events here;
// those events must share the active backoff run.
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 50
})
// Initial attempt + 3 bounded backoff retries (1s/2s/3s) all fail.
await page.waitForTimeout(8000)
const stuck = await page.evaluate(() => ({
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}))
expect(stuck.beforeReqs).toBe(4)
expect(stuck.childCount).toBe(200)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.mouse.wheel(0, -300)
await page.waitForTimeout(2000)
const recovered = await page.evaluate(() => ({
scrollTop: Math.round((document.querySelector('.app-scroll-y') as HTMLElement).scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}))
expect(recovered.beforeReqs).toBe(5)
expect(recovered.childCount).toBe(400)
expect(recovered.scrollTop).toBeGreaterThan(1000)
})
// Regression: a page whose rows are all filtered by normalizeDecryptedMessage
// renders zero height, so the sentinel cannot move and the restore leaves
// scrollTop unchanged. Automatic loading must remain paused instead of paging
// through the entire remaining history. Filtered rows must also stay out of
// the retained message-window budget.
test('a fully filtered page keeps automatic history loading paused', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?filteredOlder=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.waitForTimeout(3000)
const state = await page.evaluate(() => ({
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}))
expect(state.beforeReqs).toBe(1)
expect(state.childCount).toBe(200)
// A later content shrink (the same ResizeObserver signal produced when a
// full prepend window evicts rendered newer rows) must not revive the
// no-progress coverage run.
await page.evaluate(() => {
const rows = document.querySelectorAll<HTMLElement>('.happy-thread-messages > *')
const last = rows.item(rows.length - 1)
if (last) last.style.display = 'none'
})
await page.waitForTimeout(1000)
const afterResize = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)
expect(afterResize).toBe(1)
})
// Regression: an epoch mismatch makes the store deliberately stop the older
// load (reset + tail resync, typed stop outcome). Fresh-tail rendering can
// resize content and emit programmatic scroll signals; those signals must not
// re-arm the paused coverage run on their own.
test('an epoch-reset stop does not auto re-arm older requests', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?epochBump=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
// The stop triggers a tail resync and content resize; without the paused
// controller state each cycle would fire another older request.
await page.waitForTimeout(3000)
const state = await page.evaluate(() => ({
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}))
expect(state.beforeReqs).toBe(1)
expect(state.childCount).toBe(200)
// Scroll events can be browser/programmatic effects of a reset. Moving
// downward while the sentinel remains covered is not renewed older-history
// intent and must leave the automatic run paused.
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 50
})
await page.waitForTimeout(1000)
const afterDownwardScroll = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)
expect(afterDownwardScroll).toBe(1)
// A focused keyboard user can explicitly resume the paused run. Home is
// captured before the browser-generated scroll event, so that event is
// classified as user intent rather than reset-driven geometry.
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 500
})
await page.waitForTimeout(100)
await viewport.focus()
await viewport.press('Home')
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)).toBe(2)
// The second request deliberately stops again. A scrollbar-style pointer
// drag to the top is another explicit resume signal, while later
// programmatic scroll events remain blocked.
await page.waitForTimeout(1000)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 500
})
await page.waitForTimeout(100)
await viewport.dispatchEvent('pointerdown', { button: 0, pointerType: 'mouse' })
await page.evaluate(() => {
const element = document.querySelector('.app-scroll-y') as HTMLElement
element.scrollTop = 300
element.dispatchEvent(new Event('scroll'))
element.scrollTop = 0
element.dispatchEvent(new Event('scroll'))
})
await viewport.dispatchEvent('pointerup', { button: 0, pointerType: 'mouse' })
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)).toBe(3)
})
// Touch users need visible progress before the pull gesture becomes active.
// Crossing the threshold only arms the action; the request starts on release.
test('touch pull shows staged feedback and loads older on release', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?epochBump=1&slowBefore=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(1)
await expect(page.getByText('Loading…', { exact: true })).toBeHidden()
const dispatchTouch = async (type: 'touchstart' | 'touchmove' | 'touchend', clientY: number) => {
await viewport.evaluate((element, eventInit) => {
const event = new Event(eventInit.type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', {
value: eventInit.type === 'touchend' ? [] : [{ clientY: eventInit.clientY }]
})
element.dispatchEvent(event)
}, { type, clientY })
}
await dispatchTouch('touchstart', 100)
await dispatchTouch('touchmove', 115)
await expect(page.getByText('Keep pulling to load earlier messages')).toBeHidden()
await dispatchTouch('touchmove', 116)
await expect(page.getByText('Keep pulling to load earlier messages')).toBeVisible()
expect(await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(1)
await dispatchTouch('touchmove', 164)
await expect(page.getByText('Release to load earlier messages')).toBeVisible()
expect(await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(1)
await dispatchTouch('touchend', 164)
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((request) => request.direction === 'before').length
)).toBe(2)
await expect(page.getByText('Loading…', { exact: true })).toBeVisible()
})
// Regression: scroll/resize signals emitted while a failed page is in backoff
// must join the same logical load. The delayed retry may discover an epoch
// reset, which then stops the run without a third request.
test('scroll events cannot bypass backoff before an epoch-reset stop', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html?failBefore=1&epochBump=1')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await expect(page.getByText('fixture: forced before-page failure')).toBeVisible()
// Programmatic/browser scroll signals during backoff must not start an
// immediate second request.
await page.evaluate(() => {
const viewport = document.querySelector('.app-scroll-y')
viewport?.dispatchEvent(new Event('scroll'))
viewport?.dispatchEvent(new Event('scroll'))
})
await page.waitForTimeout(400)
const duringBackoff = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)
expect(duringBackoff).toBe(1)
// The controller-owned 1s retry returns the deliberate epoch-reset stop.
await expect.poll(async () => await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)).toBe(2)
const requestTimes = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').map((r) => r.at)
)
expect(requestTimes[1]! - requestTimes[0]!).toBeGreaterThanOrEqual(1000)
await page.waitForTimeout(1500)
const afterStop = await page.evaluate(() =>
window.__probe.requests.filter((r) => r.direction === 'before').length
)
expect(afterStop).toBe(2)
})