324 lines
18 KiB
TypeScript
324 lines
18 KiB
TypeScript
/** Real-composition coverage of nested process disclosure geometry and accessible summaries. */
|
|
import { fileURLToPath } from 'node:url'
|
|
import { chromium } from 'playwright'
|
|
import { expect, it } from 'vitest'
|
|
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
|
import { compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts'
|
|
import { newEnglishPage } from './support.ts'
|
|
|
|
it('keeps completed step work collapsed inside an expanded turn and bounds its scroll area', async () => {
|
|
const fixture = createChatScrollFixture({ markerPrefix: 'STEP_PROCESS', title: 'Step process disclosure', turns: 88 })
|
|
const scaffold = await launchWebScaffold({})
|
|
try {
|
|
const log = fixture.log.trimEnd().split('\n').map((line, index) => {
|
|
if (index === 0) return line
|
|
const event = JSON.parse(line) as Record<string, unknown>
|
|
return JSON.stringify({ ...event, time: 1_800_000_000_000 })
|
|
}).join('\n') + '\n'
|
|
await seedSession(scaffold, log, 'step-process-e2e')
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await newEnglishPage(browser, 900)
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByText('Ungrouped', { exact: true }).waitFor()
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1))
|
|
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
|
await results.click()
|
|
const outer = page.locator('[data-turn-process="88"]')
|
|
await outer.waitFor()
|
|
expect(await page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(1)
|
|
expect(await outer.textContent()).toMatch(/^Took /)
|
|
await page.mouse.move(0, 0)
|
|
const outerColor = await outer.evaluate(element => getComputedStyle(element).color)
|
|
expect(await outer.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
await outer.hover()
|
|
await expect.poll(() => outer.evaluate(
|
|
(element, initialColor) => getComputedStyle(element).color === initialColor,
|
|
outerColor,
|
|
)).toBe(false)
|
|
expect(await outer.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/turn-collapsed.md', import.meta.url)),
|
|
await outer.ariaSnapshot(), webSnapshotMode())
|
|
await outer.click()
|
|
const group = page.locator('[data-step-process][data-chat-turn="88"]').first()
|
|
const toggle = group.getByRole('button', { name: 'Ran commands', exact: true })
|
|
await toggle.waitFor()
|
|
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
|
|
const activityIcon = toggle.locator('[data-step-process-icon]')
|
|
const chevron = toggle.locator('[data-step-process-chevron]')
|
|
await page.mouse.move(0, 0)
|
|
const toggleColor = await toggle.evaluate(element => getComputedStyle(element).color)
|
|
expect(await toggle.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
expect(await activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
expect(await chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
|
await toggle.hover()
|
|
await expect.poll(() => toggle.evaluate(
|
|
(element, initialColor) => getComputedStyle(element).color === initialColor,
|
|
toggleColor,
|
|
)).toBe(false)
|
|
expect(await toggle.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
|
await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('0px')
|
|
expect(await group.locator('[data-step-process-body]').getAttribute('hidden')).toBe('until-found')
|
|
const nextMessage = group.locator('xpath=following-sibling::*[1]')
|
|
expect(await nextMessage.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
|
expect(await group.evaluate((element) => {
|
|
const next = element.nextElementSibling
|
|
return next === null ? null : next.getBoundingClientRect().top - element.getBoundingClientRect().bottom
|
|
})).toBe(16)
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/collapsed.md', import.meta.url)),
|
|
await group.ariaSnapshot(), webSnapshotMode())
|
|
await toggle.click()
|
|
const body = group.locator('[data-step-process-body]')
|
|
expect(await body.getAttribute('hidden')).toBeNull()
|
|
await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
|
await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
expect(await chevron.locator('path').getAttribute('d'))
|
|
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
|
expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('16px')
|
|
const leafSpacing = await body.evaluate((element) => {
|
|
const rows = [...element.children].filter(row => !row.hasAttribute('hidden'))
|
|
return rows.slice(1).map((row, index) => ({
|
|
gap: row.getBoundingClientRect().top - rows[index]!.getBoundingClientRect().bottom,
|
|
shrink: getComputedStyle(row).flexShrink,
|
|
}))
|
|
})
|
|
expect(leafSpacing).toEqual([{ gap: 8, shrink: '0' }, { gap: 8, shrink: '0' }])
|
|
// Expand the individual tool cards so their output exceeds the process viewport.
|
|
for (const row of await body.locator('[data-sample="bash"]').all()) await row.click()
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = 0
|
|
element.dispatchEvent(new Event('scroll'))
|
|
})
|
|
await expect.poll(() => body.getAttribute('data-scroll-up')).toBeNull()
|
|
await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true')
|
|
await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' })
|
|
const geometry = await body.evaluate((element) => {
|
|
const style = getComputedStyle(element)
|
|
element.scrollTop = (element.scrollHeight - element.clientHeight) / 2
|
|
element.dispatchEvent(new Event('scroll'))
|
|
return { height: element.clientHeight, max: style.maxHeight, overflow: style.overflowY,
|
|
scrollable: element.scrollHeight > element.clientHeight, top: element.scrollTop }
|
|
})
|
|
expect(geometry.height).toBeLessThanOrEqual(400)
|
|
expect(geometry.overflow).toBe('auto')
|
|
expect(geometry.scrollable).toBe(true)
|
|
expect(geometry.top).toBeGreaterThan(0)
|
|
await expect.poll(() => body.getAttribute('data-scroll-up')).toBe('true')
|
|
await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true')
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight
|
|
element.dispatchEvent(new Event('scroll'))
|
|
})
|
|
await expect.poll(() => body.getAttribute('data-scroll-down')).toBeNull()
|
|
await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' })
|
|
await outer.click()
|
|
await outer.click()
|
|
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
|
|
await toggle.click()
|
|
const resetGeometry = await body.evaluate(element => ({ height: element.clientHeight, scroll: element.scrollHeight }))
|
|
expect(resetGeometry.scroll).toBeLessThanOrEqual(resetGeometry.height)
|
|
await toggle.click()
|
|
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
it('chains wheel scrolling from a secondary process range to the conversation', async () => {
|
|
const fixture = createChatScrollFixture({
|
|
markerPrefix: 'STEP_PROCESS_SCROLL', title: 'Step process scrolling', turns: 96,
|
|
})
|
|
const scaffold = await launchWebScaffold({})
|
|
try {
|
|
await seedSession(scaffold, fixture.log, 'step-process-scroll-e2e')
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await newEnglishPage(browser, 900)
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByText('Ungrouped', { exact: true }).waitFor()
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1))
|
|
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
|
await results.click()
|
|
|
|
const outer = page.locator('[data-turn-process="88"]')
|
|
await outer.click()
|
|
const group = page.locator('[data-step-process][data-chat-turn="88"]').first()
|
|
const toggle = group.getByRole('button', { name: 'Ran commands', exact: true })
|
|
await toggle.click()
|
|
const body = group.locator('[data-step-process-body]')
|
|
const host = page.locator('[data-conversation-scroll]')
|
|
const wheelBody = async (deltaY: number): Promise<void> => {
|
|
const box = await body.boundingBox()
|
|
if (box === null) throw new Error('step process body has no layout box')
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
await page.mouse.wheel(0, deltaY)
|
|
}
|
|
|
|
const shortGeometry = await body.evaluate((element) => {
|
|
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
|
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
|
return {
|
|
hostMax: scrollHost.scrollHeight - scrollHost.clientHeight,
|
|
hostTop: scrollHost.scrollTop,
|
|
scrollable: element.scrollHeight > element.clientHeight,
|
|
}
|
|
})
|
|
expect(shortGeometry.scrollable).toBe(false)
|
|
expect(shortGeometry.hostTop).toBeGreaterThan(120)
|
|
expect(shortGeometry.hostMax - shortGeometry.hostTop).toBeGreaterThan(120)
|
|
await wheelBody(-120)
|
|
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(shortGeometry.hostTop)
|
|
const shortHostAfterUp = await host.evaluate(element => element.scrollTop)
|
|
await wheelBody(120)
|
|
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(shortHostAfterUp)
|
|
|
|
for (const row of await body.locator('[data-sample="bash"]').all()) await row.click()
|
|
await body.hover()
|
|
const middle = await body.evaluate((element) => {
|
|
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
|
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
|
element.scrollTop = (element.scrollHeight - element.clientHeight) / 2
|
|
element.dispatchEvent(new Event('scroll'))
|
|
return {
|
|
bodyTop: element.scrollTop,
|
|
hostTop: scrollHost.scrollTop,
|
|
scrollable: element.scrollHeight > element.clientHeight,
|
|
}
|
|
})
|
|
expect(middle.scrollable).toBe(true)
|
|
await wheelBody(80)
|
|
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(middle.bodyTop)
|
|
expect(await host.evaluate(element => element.scrollTop)).toBe(middle.hostTop)
|
|
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = 0
|
|
element.dispatchEvent(new Event('scroll'))
|
|
})
|
|
await body.hover()
|
|
const hostBeforeTopChain = await host.evaluate(element => element.scrollTop)
|
|
await wheelBody(-120)
|
|
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(hostBeforeTopChain)
|
|
expect(await body.evaluate(element => element.scrollTop)).toBe(0)
|
|
|
|
await body.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight
|
|
element.dispatchEvent(new Event('scroll'))
|
|
})
|
|
await body.hover()
|
|
const bottomEdge = await body.evaluate((element) => {
|
|
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
|
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
|
return {
|
|
hostTop: scrollHost.scrollTop,
|
|
maxBodyTop: element.scrollHeight - element.clientHeight,
|
|
}
|
|
})
|
|
await wheelBody(120)
|
|
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(bottomEdge.hostTop)
|
|
expect(await body.evaluate(element => element.scrollTop)).toBe(bottomEdge.maxBodyTop)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
it('keeps a waking notice above and independent of the turn disclosure', async () => {
|
|
const fixture = createChatScrollFixture({ markerPrefix: 'TURN_TRIGGER', title: 'Background task notice', turns: 1,
|
|
wakingInput: { source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'PR merge check completed' },
|
|
content: 'background job bash-39 (bash: PR merge check) finished [status: completed, exit code 0]. Read its output with job_output.' } })
|
|
const scaffold = await launchWebScaffold({})
|
|
try {
|
|
const log = fixture.log.trimEnd().split('\n').map((line, index) => index === 0 ? line
|
|
: JSON.stringify({ ...JSON.parse(line) as Record<string, unknown>, time: 1_800_000_000_000 })).join('\n') + '\n'
|
|
await seedSession(scaffold, log, 'turn-trigger-e2e')
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await newEnglishPage(browser, 900)
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByText('Ungrouped', { exact: true }).waitFor()
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill('PR merge check')
|
|
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
|
await results.click()
|
|
const notice = page.locator('[data-turn-trigger]')
|
|
const button = notice.getByRole('button')
|
|
const outer = page.locator('[data-turn-process="1"]')
|
|
await notice.waitFor()
|
|
expect(await button.getAttribute('aria-expanded')).toBe('false')
|
|
expect(await button.textContent()).toContain('Background task completed')
|
|
const noticeBox = await notice.boundingBox()
|
|
const outerBox = await outer.boundingBox()
|
|
expect(noticeBox!.y + noticeBox!.height).toBeLessThan(outerBox!.y)
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/trigger-collapsed.md', import.meta.url)),
|
|
(await notice.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode())
|
|
await button.click()
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/trigger-expanded.md', import.meta.url)),
|
|
(await notice.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode())
|
|
expect(await outer.isEnabled()).toBe(false)
|
|
expect(await outer.getAttribute('aria-expanded')).toBeNull()
|
|
expect(await outer.locator('svg').count()).toBe(0)
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/empty-turn.md', import.meta.url)),
|
|
await outer.ariaSnapshot(), webSnapshotMode())
|
|
expect(await button.getAttribute('aria-expanded')).toBe('true')
|
|
expect(await notice.isVisible()).toBe(true)
|
|
await page.screenshot({ path: '/tmp/dsh-turn-trigger-expanded.png' })
|
|
} finally { await browser.close() }
|
|
} finally { await scaffold.close() }
|
|
})
|
|
|
|
|
|
it('places hover-only actions below trailing reasoning in a stopped turn', async () => {
|
|
const fixture = createChatScrollFixture({ markerPrefix: 'STOPPED_FOOTER', title: 'Stopped turn footer', turns: 1,
|
|
stopAfterReasoning: true })
|
|
const scaffold = await launchWebScaffold({})
|
|
try {
|
|
await seedSession(scaffold, fixture.log, 'stopped-footer-e2e')
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await newEnglishPage(browser, 900)
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByText('Ungrouped', { exact: true }).waitFor()
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1))
|
|
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
|
await results.click()
|
|
const tail = page.locator('[data-turn-tail="1"]')
|
|
await tail.waitFor()
|
|
expect(await tail.getAttribute('data-actions-reveal')).toBe('hover')
|
|
const lastGroup = page.locator('[data-step-process]').last()
|
|
await lastGroup.getByRole('button', { name: 'Analysis completed', exact: true }).click()
|
|
const think = lastGroup.getByRole('button', { name: 'Think', exact: true })
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/think-collapsed.md', import.meta.url)),
|
|
await think.ariaSnapshot(), webSnapshotMode())
|
|
expect(await lastGroup.getByText('Inspect the remaining work before continuing.', { exact: true }).isVisible()).toBe(false)
|
|
await think.click()
|
|
expect(await lastGroup.getByText('Inspect the remaining work before continuing.', { exact: true }).isVisible()).toBe(true)
|
|
await think.click()
|
|
const groupBox = await lastGroup.boundingBox()
|
|
const tailBox = await tail.boundingBox()
|
|
expect(tailBox!.y).toBeGreaterThanOrEqual(groupBox!.y + groupBox!.height)
|
|
const copy = tail.getByRole('button', { name: 'Copy', exact: true })
|
|
const actions = copy.locator('..')
|
|
await page.mouse.move(0, 0)
|
|
await expect.poll(() => actions.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
|
await tail.hover()
|
|
await expect.poll(() => actions.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/stopped-footer.md', import.meta.url)),
|
|
(await tail.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode())
|
|
} finally { await browser.close() }
|
|
} finally { await scaffold.close() }
|
|
})
|