mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
616 lines
28 KiB
TypeScript
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)
|
|
})
|