Initial commit
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Exec panel scroll fixture</title>
|
||||
<style>
|
||||
/* Match the global resets used in production */
|
||||
* { box-sizing: border-box; min-width: 0; }
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0c111b;
|
||||
color: #e2e8f0;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: #141b2b;
|
||||
--bg: #0c111b;
|
||||
--border: rgba(148, 163, 184, 0.12);
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #8494a7;
|
||||
--accent: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Production panel CSS, copied verbatim from kanban.css ─────────── */
|
||||
.exec-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 199;
|
||||
}
|
||||
|
||||
.exec-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 560px;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exec-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exec-panel-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exec-panel-body {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.exec-task-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(20, 27, 43, 0.8);
|
||||
overflow: hidden;
|
||||
/* Mirrors production fix: prevent flex-shrink from squeezing cards. */
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.exec-task-card-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exec-task-card-body {
|
||||
padding: 4px 14px 14px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.exec-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exec-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.exec-section-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.exec-handoff-scroll {
|
||||
max-height: 28vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.exec-activity-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
border-radius: 6px;
|
||||
background: rgba(12, 17, 27, 0.3);
|
||||
}
|
||||
|
||||
/* ── Production progress block CSS, copied verbatim from chat.css ─── */
|
||||
.ptl-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ptl-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.ptl-entry {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.ptl-connector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ptl-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ptl-line {
|
||||
width: 1px;
|
||||
flex: 1;
|
||||
background: var(--border);
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.ptl-entry-last .ptl-line { display: none; }
|
||||
|
||||
.ptl-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 0 8px 8px;
|
||||
}
|
||||
|
||||
.ptl-tool-card {
|
||||
background: rgba(20, 27, 43, 0.78);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.msg-content-agent-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* ── Test markers (non-production) ───────────────────────────────── */
|
||||
[data-testid] { outline: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="exec-panel" data-testid="exec-panel">
|
||||
<div class="exec-panel-header" data-testid="exec-panel-header">
|
||||
<h3>Test role</h3>
|
||||
</div>
|
||||
<div class="exec-panel-identity" data-testid="exec-panel-identity">
|
||||
<div>Test agent — qa_lead</div>
|
||||
</div>
|
||||
<div class="exec-panel-body" data-testid="exec-panel-body">
|
||||
<!-- One expanded card with: projection, long Received From, Activity (300 entries), Artifacts, Passed To -->
|
||||
<div class="exec-task-card">
|
||||
<button class="exec-task-card-header"><span>#1 Big role turn</span></button>
|
||||
<div class="exec-task-card-body">
|
||||
<div style="font-size:11px;color:var(--text-secondary);margin:8px 0 12px;">Projection: projection-abc</div>
|
||||
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Received From</div>
|
||||
<div class="msg-content-agent-card exec-handoff-scroll" data-testid="received-from-scroll">
|
||||
<p id="received-from-content">PLACEHOLDER</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Activity <span>300</span></div>
|
||||
<div class="exec-section-content exec-activity-scroll" data-testid="activity-scroll">
|
||||
<div class="ptl-block" data-testid="activity-block">
|
||||
<div class="ptl-timeline" data-testid="activity-timeline">
|
||||
<!-- entries injected by JS below -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Artifacts</div>
|
||||
<ul style="margin:0;padding:0 0 0 18px;font-size:13px;line-height:1.6;">
|
||||
<li>artifact-1</li>
|
||||
<li>artifact-2</li>
|
||||
<li>artifact-3</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Passed To</div>
|
||||
<div class="msg-content-agent-card exec-handoff-scroll" data-testid="passed-to-scroll">
|
||||
<p id="passed-to-content">PLACEHOLDER</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sibling cards to verify outer panel scroll also works. Multiple
|
||||
cards each with their own large Activity stress the parent scroll. -->
|
||||
<div class="exec-task-card" data-testid="sibling-card">
|
||||
<button class="exec-task-card-header"><span>#2 Sibling role turn</span></button>
|
||||
<div class="exec-task-card-body">
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Activity <span>1</span></div>
|
||||
<div class="exec-section-content exec-activity-scroll">
|
||||
<div class="ptl-block">
|
||||
<div class="ptl-timeline">
|
||||
<div class="ptl-entry ptl-entry-last">
|
||||
<div class="ptl-connector"><div class="ptl-dot"></div></div>
|
||||
<div class="ptl-content">Single sibling entry</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-task-card" data-testid="trailing-card">
|
||||
<button class="exec-task-card-header"><span>#3 Trailing role turn</span></button>
|
||||
<div class="exec-task-card-body">
|
||||
<div class="exec-section">
|
||||
<div class="exec-section-header">Activity <span>large</span></div>
|
||||
<div class="exec-section-content exec-activity-scroll" data-testid="trailing-activity-scroll">
|
||||
<div class="ptl-block">
|
||||
<div class="ptl-timeline" data-testid="trailing-activity-timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Inject N activity entries. Override via ?entries=NN.
|
||||
const params = new URLSearchParams(location.search)
|
||||
const N = Math.max(1, parseInt(params.get('entries') || '300', 10))
|
||||
const handoffLines = Math.max(1, parseInt(params.get('handoff') || '40', 10))
|
||||
// When ?live=NN is set, additional entries are appended after load so we
|
||||
// can prove the scroll bottom remains reachable while the timeline grows
|
||||
// (mirrors a running role's snapshot ticks).
|
||||
const liveAppend = parseInt(params.get('live') || '0', 10)
|
||||
|
||||
const lorem = 'Received From summary line that may wrap to multiple lines depending on viewport width. '
|
||||
document.getElementById('received-from-content').textContent =
|
||||
Array.from({ length: handoffLines }, (_, i) => `(${i + 1}) ${lorem}`).join('\n')
|
||||
document.getElementById('passed-to-content').textContent =
|
||||
Array.from({ length: 6 }, (_, i) => `Handoff line ${i + 1}: passed to next work item with summary.`).join('\n')
|
||||
|
||||
function buildEntry(globalIdx, isLast) {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = `ptl-entry${isLast ? ' ptl-entry-last' : ''}`
|
||||
entry.dataset.entryIndex = String(globalIdx)
|
||||
if (isLast) entry.dataset.testid = 'last-activity-entry'
|
||||
entry.innerHTML = `
|
||||
<div class="ptl-connector"><div class="ptl-dot"></div>${isLast ? '' : '<div class="ptl-line"></div>'}</div>
|
||||
<div class="ptl-content">
|
||||
<div>Entry #${globalIdx + 1}: ${globalIdx % 3 === 0 ? 'Tool call: list_files' : globalIdx % 3 === 1 ? 'Thinking about the task' : 'Status update'}</div>
|
||||
${globalIdx % 5 === 0 ? '<div class="ptl-tool-card" style="margin-top:6px;padding:8px;">extra detail line A<br/>extra detail line B</div>' : ''}
|
||||
</div>
|
||||
`
|
||||
return entry
|
||||
}
|
||||
|
||||
const timeline = document.querySelector('[data-testid="activity-timeline"]')
|
||||
function rebuildTimeline(total) {
|
||||
timeline.innerHTML = ''
|
||||
for (let i = 0; i < total; i++) {
|
||||
timeline.appendChild(buildEntry(i, i === total - 1))
|
||||
}
|
||||
}
|
||||
rebuildTimeline(N)
|
||||
|
||||
// Populate the trailing card's activity too — it lives at the bottom of
|
||||
// the panel-body, so reaching its last entry exercises both the outer
|
||||
// panel scroll AND the inner activity scroll in series.
|
||||
const trailingTimeline = document.querySelector('[data-testid="trailing-activity-timeline"]')
|
||||
const trailingCount = Math.max(1, parseInt(params.get('trailing') || '120', 10))
|
||||
for (let i = 0; i < trailingCount; i++) {
|
||||
const isLast = i === trailingCount - 1
|
||||
const entry = buildEntry(i, isLast)
|
||||
if (isLast) entry.dataset.testid = 'trailing-last-entry'
|
||||
trailingTimeline.appendChild(entry)
|
||||
}
|
||||
|
||||
// Expose a global hook so the playwright test can drive the live append.
|
||||
window.__appendEntries = function (count) {
|
||||
const current = timeline.children.length
|
||||
// Strip the previous 'last' marker.
|
||||
const prevLast = timeline.querySelector('[data-testid="last-activity-entry"]')
|
||||
if (prevLast) {
|
||||
prevLast.classList.remove('ptl-entry-last')
|
||||
prevLast.removeAttribute('data-testid')
|
||||
// Restore the ptl-line that was hidden on the last entry.
|
||||
if (!prevLast.querySelector('.ptl-line')) {
|
||||
const dot = prevLast.querySelector('.ptl-dot')
|
||||
const line = document.createElement('div')
|
||||
line.className = 'ptl-line'
|
||||
dot && dot.parentNode && dot.parentNode.appendChild(line)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isLast = i === count - 1
|
||||
timeline.appendChild(buildEntry(current + i, isLast))
|
||||
}
|
||||
}
|
||||
|
||||
if (liveAppend > 0) {
|
||||
// Append a few entries on a microtask so the test can wait for them.
|
||||
queueMicrotask(() => window.__appendEntries(liveAppend))
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,311 @@
|
||||
import { chromium } from 'playwright'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
/**
|
||||
* Standalone Playwright check that mirrors the production exec-panel layout
|
||||
* and asserts that:
|
||||
*
|
||||
* 1. The Activity scroll container is actually scrollable
|
||||
* (scrollHeight strictly greater than clientHeight).
|
||||
* 2. After scrolling to the maximum allowed position
|
||||
* (scrollTop = scrollHeight − clientHeight), the LAST entry is fully
|
||||
* contained inside the container's visible rect — i.e. its bottom
|
||||
* edge sits at-or-above the container's bottom edge.
|
||||
* 3. The outer panel-body can still scroll past the focused card to
|
||||
* reveal sibling cards (regression guard for the `overscroll-behavior`
|
||||
* / `min-height: 0` fixes).
|
||||
*
|
||||
* The fixture is a static HTML page that copies the production CSS for the
|
||||
* panel and the progress timeline so we are testing the same cascade the
|
||||
* real UI sees, without spinning up the React app.
|
||||
*
|
||||
* Run: node --import tsx ./tests/exec-panel-scroll.spec.ts
|
||||
*/
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
const FIXTURE_URL = pathToFileURL(resolve(__dirname, 'exec-panel-scroll.html')).toString()
|
||||
|
||||
interface ScrollMetrics {
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
scrollTop: number
|
||||
maxScrollTop: number
|
||||
containerBottom: number
|
||||
lastEntryBottom: number
|
||||
lastEntryTop: number
|
||||
lastEntryVisible: boolean
|
||||
}
|
||||
|
||||
async function readActivityMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const container = document.querySelector('[data-testid="activity-scroll"]') as HTMLElement
|
||||
const lastEntry = document.querySelector('[data-testid="last-activity-entry"]') as HTMLElement
|
||||
if (!container || !lastEntry) {
|
||||
throw new Error('test fixture missing required nodes')
|
||||
}
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const eRect = lastEntry.getBoundingClientRect()
|
||||
return {
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
scrollTop: container.scrollTop,
|
||||
maxScrollTop: container.scrollHeight - container.clientHeight,
|
||||
containerBottom: cRect.bottom,
|
||||
lastEntryBottom: eRect.bottom,
|
||||
lastEntryTop: eRect.top,
|
||||
// Allow a 1px tolerance for sub-pixel rounding.
|
||||
lastEntryVisible: eRect.bottom <= cRect.bottom + 1 && eRect.top >= cRect.top - 1,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function scrollActivityToBottom(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('[data-testid="activity-scroll"]') as HTMLElement
|
||||
container.scrollTop = container.scrollHeight
|
||||
})
|
||||
// Allow layout to settle.
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
|
||||
async function scrollOuterPanelToBottom(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const body = document.querySelector('[data-testid="exec-panel-body"]') as HTMLElement
|
||||
body.scrollTop = body.scrollHeight
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
|
||||
async function siblingCardVisible(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => {
|
||||
const card = document.querySelector('[data-testid="sibling-card"]') as HTMLElement
|
||||
const body = document.querySelector('[data-testid="exec-panel-body"]') as HTMLElement
|
||||
if (!card || !body) return false
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const bodyRect = body.getBoundingClientRect()
|
||||
// Sibling card's top must be inside the visible body region.
|
||||
return cardRect.top >= bodyRect.top - 1 && cardRect.top <= bodyRect.bottom + 1
|
||||
})
|
||||
}
|
||||
|
||||
interface CaseResult {
|
||||
name: string
|
||||
pass: boolean
|
||||
detail: string
|
||||
}
|
||||
|
||||
async function runCase(
|
||||
page: Page,
|
||||
url: string,
|
||||
name: string,
|
||||
): Promise<CaseResult[]> {
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.goto(url)
|
||||
// Give the inline injection script a tick.
|
||||
await page.waitForSelector('[data-testid="last-activity-entry"]', { state: 'attached' })
|
||||
await page.waitForTimeout(50)
|
||||
|
||||
const beforeScroll = await readActivityMetrics(page)
|
||||
const results: CaseResult[] = []
|
||||
|
||||
// (1) Scroll is needed at all.
|
||||
results.push({
|
||||
name: `${name} — Activity has scrollable overflow`,
|
||||
pass: beforeScroll.scrollHeight > beforeScroll.clientHeight + 10,
|
||||
detail: `scrollHeight=${beforeScroll.scrollHeight} clientHeight=${beforeScroll.clientHeight}`,
|
||||
})
|
||||
|
||||
// (2) After scrolling to bottom, last entry is fully visible inside
|
||||
// the activity container (the headline complaint the user filed).
|
||||
await scrollActivityToBottom(page)
|
||||
const afterScroll = await readActivityMetrics(page)
|
||||
results.push({
|
||||
name: `${name} — Last activity entry visible at bottom of scroll`,
|
||||
pass: afterScroll.lastEntryVisible,
|
||||
detail: `containerBottom=${afterScroll.containerBottom.toFixed(1)} lastEntryBottom=${afterScroll.lastEntryBottom.toFixed(1)} lastEntryTop=${afterScroll.lastEntryTop.toFixed(1)} scrollTop=${afterScroll.scrollTop} maxScrollTop=${afterScroll.maxScrollTop}`,
|
||||
})
|
||||
|
||||
// (3) Outer panel-body must still scroll so sibling cards are reachable.
|
||||
await scrollOuterPanelToBottom(page)
|
||||
const sibling = await siblingCardVisible(page)
|
||||
results.push({
|
||||
name: `${name} — Outer panel scroll reveals sibling card`,
|
||||
pass: sibling,
|
||||
detail: sibling ? 'sibling visible' : 'sibling NOT in viewport',
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const browser: Browser = await chromium.launch()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
const cases: { name: string; query: string }[] = [
|
||||
{ name: 'few entries, short handoff', query: '?entries=20&handoff=4' },
|
||||
{ name: 'many entries, short handoff', query: '?entries=300&handoff=4' },
|
||||
{ name: 'many entries, long handoff', query: '?entries=300&handoff=80' },
|
||||
{ name: 'huge entries, huge handoff', query: '?entries=600&handoff=120' },
|
||||
]
|
||||
|
||||
let allResults: CaseResult[] = []
|
||||
for (const c of cases) {
|
||||
const url = `${FIXTURE_URL}${c.query}`
|
||||
const results = await runCase(page, url, c.name)
|
||||
allResults = allResults.concat(results)
|
||||
}
|
||||
|
||||
// Live-append regression: load fewer entries up front, scroll to bottom,
|
||||
// then append new ones (mirrors a running role's snapshot tick) and verify
|
||||
// the user can still reach the very last entry by scrolling again.
|
||||
for (const liveCase of [
|
||||
{ name: 'live append small', start: 50, append: 20 },
|
||||
{ name: 'live append medium', start: 200, append: 50 },
|
||||
{ name: 'live append while at bottom', start: 100, append: 30 },
|
||||
]) {
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.goto(`${FIXTURE_URL}?entries=${liveCase.start}&handoff=12`)
|
||||
await page.waitForSelector('[data-testid="last-activity-entry"]', { state: 'attached' })
|
||||
await page.waitForTimeout(50)
|
||||
await scrollActivityToBottom(page)
|
||||
const beforeAppend = await readActivityMetrics(page)
|
||||
await page.evaluate((n) => (window as unknown as { __appendEntries: (n: number) => void }).__appendEntries(n), liveCase.append)
|
||||
await page.waitForTimeout(80)
|
||||
await scrollActivityToBottom(page)
|
||||
const afterAppend = await readActivityMetrics(page)
|
||||
allResults.push({
|
||||
name: `${liveCase.name} — last entry visible after live append`,
|
||||
pass: afterAppend.lastEntryVisible && afterAppend.scrollHeight > beforeAppend.scrollHeight,
|
||||
detail: `scrollHeight ${beforeAppend.scrollHeight} → ${afterAppend.scrollHeight}; lastEntryBottom=${afterAppend.lastEntryBottom.toFixed(1)} containerBottom=${afterAppend.containerBottom.toFixed(1)}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Smaller viewport — emulates a laptop screen. 55vh is much smaller, the
|
||||
// outer panel-body is also smaller, so any latent layout issue surfaces.
|
||||
for (const viewportCase of [
|
||||
{ name: 'small viewport', width: 1280, height: 600 },
|
||||
{ name: 'tiny viewport', width: 1280, height: 480 },
|
||||
]) {
|
||||
await page.setViewportSize({ width: viewportCase.width, height: viewportCase.height })
|
||||
await page.goto(`${FIXTURE_URL}?entries=300&handoff=40`)
|
||||
await page.waitForSelector('[data-testid="last-activity-entry"]', { state: 'attached' })
|
||||
await page.waitForTimeout(50)
|
||||
await scrollActivityToBottom(page)
|
||||
const m = await readActivityMetrics(page)
|
||||
allResults.push({
|
||||
name: `${viewportCase.name} — Activity reaches bottom (${viewportCase.width}×${viewportCase.height})`,
|
||||
pass: m.lastEntryVisible,
|
||||
detail: `containerBottom=${m.containerBottom.toFixed(1)} lastEntryBottom=${m.lastEntryBottom.toFixed(1)}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Trailing card test — the LAST card in the panel. User must scroll the
|
||||
// outer panel-body all the way down, AND the trailing activity must then
|
||||
// be reachable to its bottom. This is the most common real-world case
|
||||
// the user described ("some roles can't scroll to bottom") because the
|
||||
// role they care about is often the latest one created.
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.goto(`${FIXTURE_URL}?entries=300&handoff=40&trailing=180`)
|
||||
await page.waitForSelector('[data-testid="trailing-last-entry"]', { state: 'attached' })
|
||||
await page.waitForTimeout(50)
|
||||
// Walk the outer panel down so the trailing card is in view, then drive
|
||||
// the trailing activity's own scroll to the bottom.
|
||||
await page.evaluate(() => {
|
||||
const body = document.querySelector('[data-testid="exec-panel-body"]') as HTMLElement
|
||||
body.scrollTop = body.scrollHeight
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await page.evaluate(() => {
|
||||
const trailing = document.querySelector('[data-testid="trailing-activity-scroll"]') as HTMLElement
|
||||
trailing.scrollTop = trailing.scrollHeight
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trailingMetrics = await page.evaluate(() => {
|
||||
const trailing = document.querySelector('[data-testid="trailing-activity-scroll"]') as HTMLElement
|
||||
const last = document.querySelector('[data-testid="trailing-last-entry"]') as HTMLElement
|
||||
const body = document.querySelector('[data-testid="exec-panel-body"]') as HTMLElement
|
||||
const panel = document.querySelector('[data-testid="exec-panel"]') as HTMLElement
|
||||
const t = trailing.getBoundingClientRect()
|
||||
const l = last.getBoundingClientRect()
|
||||
return {
|
||||
// Must be inside the trailing container AND inside the visible viewport.
|
||||
visibleInContainer: l.bottom <= t.bottom + 1 && l.top >= t.top - 1,
|
||||
visibleInViewport: l.top >= 0 && l.bottom <= window.innerHeight + 1,
|
||||
cBottom: t.bottom,
|
||||
lBottom: l.bottom,
|
||||
lTop: l.top,
|
||||
cTop: t.top,
|
||||
bodyScrollTop: body.scrollTop,
|
||||
bodyScrollHeight: body.scrollHeight,
|
||||
bodyClientHeight: body.clientHeight,
|
||||
panelHeight: panel.getBoundingClientRect().height,
|
||||
viewportHeight: window.innerHeight,
|
||||
}
|
||||
})
|
||||
allResults.push({
|
||||
name: 'trailing card — last entry visible at bottom of trailing activity',
|
||||
pass: trailingMetrics.visibleInContainer,
|
||||
detail: `cTop=${trailingMetrics.cTop.toFixed(1)} cBottom=${trailingMetrics.cBottom.toFixed(1)} lTop=${trailingMetrics.lTop.toFixed(1)} lBottom=${trailingMetrics.lBottom.toFixed(1)}`,
|
||||
})
|
||||
allResults.push({
|
||||
name: 'trailing card — last entry actually visible in viewport (the user-visible bug)',
|
||||
pass: trailingMetrics.visibleInViewport,
|
||||
detail: `lBottom=${trailingMetrics.lBottom.toFixed(1)} viewport=${trailingMetrics.viewportHeight} bodyScrollTop=${trailingMetrics.bodyScrollTop} bodyMax=${trailingMetrics.bodyScrollHeight - trailingMetrics.bodyClientHeight}`,
|
||||
})
|
||||
|
||||
// Wheel-driven scroll: the user scrolls with the mouse wheel, not by
|
||||
// setting scrollTop programmatically. Wheel events should be able to
|
||||
// reach the same maximum scrollTop.
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.goto(`${FIXTURE_URL}?entries=200&handoff=20`)
|
||||
await page.waitForSelector('[data-testid="last-activity-entry"]', { state: 'attached' })
|
||||
await page.waitForTimeout(50)
|
||||
// Send many wheel ticks targeted at the activity scroll container.
|
||||
await page.evaluate(async () => {
|
||||
const container = document.querySelector('[data-testid="activity-scroll"]') as HTMLElement
|
||||
container.focus?.()
|
||||
for (let i = 0; i < 200; i++) {
|
||||
container.dispatchEvent(new WheelEvent('wheel', { deltaY: 200, bubbles: true, cancelable: true }))
|
||||
// jsdom doesn't drive real scroll from wheel, so simulate by adding to scrollTop too.
|
||||
container.scrollTop += 200
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
||||
}
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const wheelMetrics = await readActivityMetrics(page)
|
||||
allResults.push({
|
||||
name: 'wheel-driven scroll — last entry reachable',
|
||||
pass: wheelMetrics.lastEntryVisible,
|
||||
detail: `scrollTop=${wheelMetrics.scrollTop} maxScrollTop=${wheelMetrics.maxScrollTop} lastEntryBottom=${wheelMetrics.lastEntryBottom.toFixed(1)} containerBottom=${wheelMetrics.containerBottom.toFixed(1)}`,
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
|
||||
let failed = 0
|
||||
for (const r of allResults) {
|
||||
const tag = r.pass ? 'PASS' : 'FAIL'
|
||||
const line = `[${tag}] ${r.name} :: ${r.detail}`
|
||||
if (r.pass) {
|
||||
console.log(line)
|
||||
} else {
|
||||
console.error(line)
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} of ${allResults.length} assertions failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nAll ${allResults.length} assertions passed`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user