Merge remote-tracking branch 'origin/master' into feat/pwsh-ui-parity
# Conflicts: # apps/web/tsconfig.json
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Synthetic long-chat history for browser behavior contracts. The fixture is
|
||||
// generated through Session so pagination exercises the same event shapes as
|
||||
// persisted conversations, while unique markers let tests identify semantic
|
||||
// rows without depending on CSS-module names or the eventual virtualizer DOM.
|
||||
// persisted conversations, while unique markers identify semantic rows
|
||||
// without depending on CSS-module names or virtualizer DOM positions.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
|
||||
420
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
420
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
// Web e2e scenario: the input card holds one horizontal position across the
|
||||
// Chat and Trajectory tabs.
|
||||
//
|
||||
// The composer seat is the same node in both tabs, but it measures itself
|
||||
// against a different edge in each (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
|
||||
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
|
||||
// scroller's content box — the box a space-consuming scrollbar shortens. A view
|
||||
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
|
||||
// Trajectory declares and which moves the column's own scrolling into the view)
|
||||
// gets an absolutely positioned seat instead, laid out against the padding box,
|
||||
// which the scrollbar never reduces.
|
||||
//
|
||||
// So the two tabs disagreed by exactly the bar's width for as long as the
|
||||
// transcript overflowed: the card jumped sideways on every tab switch, and
|
||||
// inside Chat alone at the moment a growing transcript started to scroll. The
|
||||
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
|
||||
// and states the overlay branch as a scroll container on the same axes, so both
|
||||
// edges are the same edge.
|
||||
//
|
||||
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
|
||||
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
|
||||
// can assert the declarations exist but not that the two states land in the same
|
||||
// place. What is asserted here is the user-visible fact — the card does not move
|
||||
// — measured as the distance between the two tabs' card rectangles.
|
||||
//
|
||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||
// container's bar consumes no layout width at all, so the two tabs agree before
|
||||
// this change as much as after it and every comparison below holds vacuously —
|
||||
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
|
||||
// and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||
// and a bar that occupies layout space is what the product actually draws.
|
||||
//
|
||||
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
|
||||
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
|
||||
// same two tabs through it, which is what keeps the equal rectangles above from
|
||||
// being explained by a tab switch that never reached the layout. It is the
|
||||
// reported symptom as a number: the card moves 4px, half the 8px band, on each
|
||||
// edge.
|
||||
//
|
||||
// Zero model calls: a seeded cold session renders from its log, and switching
|
||||
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
|
||||
/**
|
||||
* Committed golden of where the input card sits in each tab, at a wide viewport
|
||||
* (card at its width cap) and a narrow one (card shrinking with the column).
|
||||
*
|
||||
* Absolute coordinates are deliberately absent: they depend on the sidebar's
|
||||
* laid-out width and on font metrics, so committing them would produce a fixture
|
||||
* that has to be re-recorded per platform. What is recorded is the distance
|
||||
* between the two tabs' rectangles, which is zero when the reservation holds and
|
||||
* the bar's width when it does not — including under the control, so the golden
|
||||
* carries the difference the fix removes rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TAB_GEOMETRY',
|
||||
title: 'COMPOSER_TAB_GEOMETRY long session',
|
||||
turns: 24,
|
||||
})
|
||||
const SEED_ID = 'composer-tab-geometry-web-e2e'
|
||||
|
||||
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
|
||||
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
|
||||
const NARROW_VIEWPORT = { width: 800, height: 1000 }
|
||||
|
||||
/**
|
||||
* Resize to one measurement viewport after the responsive sidebar and center
|
||||
* column finish their track transition.
|
||||
* @param page - the page under test.
|
||||
* @param viewport - the viewport dimensions to apply.
|
||||
* @param sidebarCollapsed - the sidebar state expected at this width.
|
||||
*/
|
||||
async function setMeasuredViewport(
|
||||
page: Page,
|
||||
viewport: { width: number; height: number },
|
||||
sidebarCollapsed: boolean,
|
||||
): Promise<void> {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
|
||||
state: sidebarCollapsed ? 'attached' : 'detached',
|
||||
timeout: 10_000,
|
||||
})
|
||||
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
|
||||
const deadline = performance.now() + 5_000
|
||||
let previous = host.getBoundingClientRect().width
|
||||
let stableFrames = 0
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
|
||||
const current = host.getBoundingClientRect().width
|
||||
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
|
||||
if (stableFrames >= 3) return
|
||||
previous = current
|
||||
}
|
||||
throw new Error('conversation width did not settle after the viewport changed')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix cascade, injected into the page: the reservation dropped and the
|
||||
* overlay branch back to a hidden box. `!important` beats the module rules
|
||||
* without a rebuild, and the id lets the control be lifted again in the same
|
||||
* session.
|
||||
*/
|
||||
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
|
||||
const CONTROL_CSS = `
|
||||
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
|
||||
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
|
||||
`
|
||||
|
||||
/** The column scroller and the input card as the browser lays them out, in one tab. */
|
||||
interface TabMetrics {
|
||||
/** Resolved `scrollbar-gutter` on the column's scroller. */
|
||||
gutter: string
|
||||
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
|
||||
overflowX: string
|
||||
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
|
||||
overflowY: string
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** True when the column's scroller actually scrolls — only Chat does. */
|
||||
scrolls: boolean
|
||||
/** Left edge of the input card in viewport coordinates. */
|
||||
cardLeft: number
|
||||
/** Right edge of the input card. */
|
||||
cardRight: number
|
||||
/** Width of the input card, capped at the composer card max width. */
|
||||
cardWidth: number
|
||||
}
|
||||
|
||||
/** One tab's metrics beside the other's, plus the distances between them. */
|
||||
interface TabComparison {
|
||||
chat: TabMetrics
|
||||
trajectory: TabMetrics
|
||||
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
|
||||
leftShift: number
|
||||
/** Distance between the two tabs' card right edges. */
|
||||
rightShift: number
|
||||
/** Difference between the two tabs' card widths. */
|
||||
widthShift: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the column scroller and the input card in the tab currently shown.
|
||||
* @param page - the page under test.
|
||||
* @returns the scroller's resolved overflow style and the card's rectangle.
|
||||
*/
|
||||
function measureTab(page: Page): Promise<TabMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (host === null) throw new Error('conversation column scroller not in the DOM')
|
||||
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
|
||||
if (card === null) throw new Error('no input card inside the composer seat')
|
||||
const style = getComputedStyle(host)
|
||||
const hostRect = host.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
overflowX: style.overflowX,
|
||||
overflowY: style.overflowY,
|
||||
band: hostRect.width - host.clientWidth,
|
||||
scrolls: host.scrollHeight > host.clientHeight,
|
||||
cardLeft: cardRect.left,
|
||||
cardRight: cardRect.right,
|
||||
cardWidth: cardRect.width,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one tab and wait for the view that owns it to be laid out.
|
||||
* @param page - the page under test.
|
||||
* @param tab - the tab to show.
|
||||
*/
|
||||
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
|
||||
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
|
||||
// Both measurements are taken after a paint, so a rectangle read mid-transition
|
||||
// cannot be reported as a shift the cascade did not cause.
|
||||
await page.evaluate(() => new Promise<void>((settle) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure both tabs and the distances between them, leaving Chat shown.
|
||||
* @param page - the page under test.
|
||||
* @returns each tab's metrics and the card's displacement between them.
|
||||
*/
|
||||
async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
await showTab(page, 'Chat')
|
||||
const chat = await measureTab(page)
|
||||
await showTab(page, 'Trajectory')
|
||||
const trajectory = await measureTab(page)
|
||||
await showTab(page, 'Chat')
|
||||
return {
|
||||
chat,
|
||||
trajectory,
|
||||
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
|
||||
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
|
||||
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* @param page - the page under test.
|
||||
* @returns the comparison as the column laid out before this change.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
const style = document.createElement('style')
|
||||
style.id = id
|
||||
style.textContent = css
|
||||
document.head.append(style)
|
||||
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
|
||||
try {
|
||||
return await compareTabs(page)
|
||||
} finally {
|
||||
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the seeded session from the sidebar search.
|
||||
*
|
||||
* Cold summaries carry the temp workspace's basename, so the persisted first
|
||||
* message is the stable identity to search for, and the query itself drives the
|
||||
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
|
||||
* is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
const deadline = Date.now() + 60_000
|
||||
for (;;) {
|
||||
if (await results.count() === 1) break
|
||||
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
await results.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body.
|
||||
* @param wide - comparison at the viewport where the card sits at its width cap.
|
||||
* @param narrow - comparison at the viewport where the card shrinks with the column.
|
||||
* @param control - comparison at the wide viewport with the reservation removed.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
|
||||
const section = (name: string, comparison: TabComparison): string[] => [
|
||||
`## ${name}`,
|
||||
'',
|
||||
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
|
||||
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
|
||||
`- Chat reserved band: ${String(comparison.chat.band)}px`,
|
||||
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
|
||||
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
|
||||
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
|
||||
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
|
||||
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
|
||||
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
|
||||
'',
|
||||
]
|
||||
return [
|
||||
'# Input card position across the Chat and Trajectory tabs',
|
||||
'',
|
||||
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
|
||||
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
|
||||
...section('Wide viewport, reservation removed in the page (control)', control),
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe('web e2e: input card position across view tabs', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, FIXTURE.log, SEED_ID)
|
||||
// Scrollbars must take layout space here or the scenario proves nothing;
|
||||
// see the file header for the measurement behind dropping this argument.
|
||||
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
|
||||
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSeededSession(page)
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// Vacuity guard, in two parts. A transcript that does not overflow gives
|
||||
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
|
||||
// would make the tabs agree without the reservation doing anything.
|
||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||
const comparison = await compareTabs(page)
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
// The reservation reaches both states, which is the whole change: the same
|
||||
// band, on a box that scrolls and on one that only holds a view.
|
||||
expect(comparison.chat.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.band).toBe(comparison.chat.band)
|
||||
// Declared as a scroll container on both axes rather than left to compute:
|
||||
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
|
||||
// horizontal axis computes to `auto` beside a scrolling one.
|
||||
expect(comparison.trajectory.overflowY).toBe('auto')
|
||||
expect(comparison.trajectory.overflowX).toBe('hidden')
|
||||
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
|
||||
expect(comparison.trajectory.scrolls).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place when the tab changes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const comparison = await compareTabs(page)
|
||||
// The reported symptom as a number. At this viewport the card sits at its
|
||||
// width cap, so the pre-fix shift showed up as a centring difference — half
|
||||
// the band on each edge — rather than as a width change.
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const capped = await measureTab(page)
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const comparison = await compareTabs(page)
|
||||
// The other geometry, and a different failure: below the cap the card takes
|
||||
// the column's width, so an unreserved gutter changed its WIDTH by the whole
|
||||
// band instead of shifting it by half. Asserted against the capped
|
||||
// measurement rather than against the cap's pixel value, which belongs to
|
||||
// the stylesheet.
|
||||
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('moves the card again once the reservation is removed in the page', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// The control: without it, equal rectangles could also mean the tab switch
|
||||
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
|
||||
// its bar and the Trajectory branch goes back to a hidden box with none, and
|
||||
// the card moves by half the band on each edge.
|
||||
const comparison = await compareTabsWithoutReservation(page)
|
||||
expect(comparison.chat.gutter).toBe('auto')
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
expect(comparison.trajectory.band).toBe(0)
|
||||
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
|
||||
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
|
||||
// Restoring the sheet restores the fix, so the control cannot leak into the
|
||||
// remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed tab geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const wide = await compareTabs(page)
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const narrow = await compareTabs(page)
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const control = await compareTabsWithoutReservation(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// The seeded session is generated in-process, so the geometry golden is the
|
||||
// whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
127
apps/web/tests/math-rendering.e2e.ts
Normal file
127
apps/web/tests/math-rendering.e2e.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'math-rendering-web-e2e'
|
||||
const DONE = 'MATH_RENDERING_DONE'
|
||||
|
||||
/** Build a settled assistant reply that exercises every supported math delimiter. */
|
||||
function mathFixture(): string {
|
||||
const session = Session.create(SessionId('math-rendering-source'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render this mathematical proof.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Math rendering',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## Math rendering',
|
||||
'',
|
||||
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
|
||||
'',
|
||||
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
|
||||
'',
|
||||
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
|
||||
'',
|
||||
'| Symbol | Value |',
|
||||
'| --- | --- |',
|
||||
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: settled Markdown math rendering', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
|
||||
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
|
||||
expect(await page.locator('.katex-error').count()).toBe(0)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
# Input card position across the Chat and Trajectory tabs
|
||||
|
||||
## Wide viewport (1680px, card at its cap)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Narrow viewport (800px, card shrinking with the column)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Wide viewport, reservation removed in the page (control)
|
||||
|
||||
- Chat: scrollbar-gutter auto, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 0px
|
||||
- input card left edge moves between tabs: 4px
|
||||
- input card right edge moves between tabs: 4px
|
||||
- input card width changes between tabs: 0px
|
||||
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal file
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal file
@@ -0,0 +1,47 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Math rendering" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Render this mathematical proof. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "Math rendering" [level=2]
|
||||
- paragraph:
|
||||
- text: Inline dollar
|
||||
- math: θ
|
||||
- text: and backslash
|
||||
- math: 1 5
|
||||
- text: .
|
||||
- math: π 4 < θ < π 2
|
||||
- math: θ ∈ ( π 4 , π 2 ) . (1)
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "Symbol Value":
|
||||
- columnheader "Symbol"
|
||||
- columnheader "Value"
|
||||
- rowgroup:
|
||||
- row:
|
||||
- cell:
|
||||
- math: θ
|
||||
- cell:
|
||||
- math: 1 5
|
||||
- paragraph: MATH_RENDERING_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
@@ -5,7 +5,7 @@
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline":
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
|
||||
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Browser contract for the tail-paged, virtualized Trajectory ledger. The
|
||||
// scenario proves that semantic row identity survives an older-page prepend,
|
||||
// DOM mounting stays bounded, and every scroll range remains reachable.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'trajectory-virtualization-e2e'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TRAJECTORY_VIRTUAL',
|
||||
title: 'TRAJECTORY_VIRTUAL long ledger',
|
||||
turns: 88,
|
||||
})
|
||||
const MAX_MOUNTED_ROWS = 160
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
|
||||
const STREAM_TEXT = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
).join('') + STREAM_MARKER
|
||||
|
||||
const STREAM_CHUNKS: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from({ length: 80 }, (_, index): StreamChunk => ({
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text: `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
})),
|
||||
{ type: 'text-delta', index: 0, text: STREAM_MARKER },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } },
|
||||
{ type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly clientHeight: number
|
||||
readonly scrollHeight: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface RowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function openTrajectory(page: Page): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
const pane = page.locator('[data-trajectory-scroll]')
|
||||
await pane.waitFor({ timeout: 30_000 })
|
||||
await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]')
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function logicalRows(page: Page): Promise<number> {
|
||||
const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount')
|
||||
if (raw === null || !/^\d+$/.test(raw)) {
|
||||
throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return Number(raw)
|
||||
}
|
||||
|
||||
async function mountedRows(page: Page): Promise<number> {
|
||||
return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count()
|
||||
}
|
||||
|
||||
async function geometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate(host => ({
|
||||
clientHeight: host.clientHeight,
|
||||
scrollHeight: host.scrollHeight,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
}
|
||||
|
||||
async function scrollToRatio(page: Page, ratio: number): Promise<void> {
|
||||
await page.locator('[data-trajectory-scroll]').evaluate((host, value) => {
|
||||
const maximum = Math.max(0, host.scrollHeight - host.clientHeight)
|
||||
host.scrollTop = Math.round(maximum * value)
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
}, ratio)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function firstVisibleRow(page: Page): Promise<RowAnchor> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host) => {
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find((candidate) => {
|
||||
const box = candidate.getBoundingClientRect()
|
||||
return candidate.dataset.requestOnly !== 'true'
|
||||
&& box.bottom > hostBox.top
|
||||
&& box.top < hostBox.bottom
|
||||
})
|
||||
const key = row?.dataset.trajectoryRowKey
|
||||
if (row === undefined || key === undefined) {
|
||||
throw new Error('trajectory scrollport has no visible semantic row')
|
||||
}
|
||||
return { key, top: row.getBoundingClientRect().top - hostBox.top }
|
||||
})
|
||||
}
|
||||
|
||||
async function rowTop(page: Page, key: string): Promise<number | null> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey)
|
||||
return row === undefined
|
||||
? null
|
||||
: row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function loadToFirstTurn(page: Page): Promise<void> {
|
||||
const marker = FIXTURE.markers.user(1)
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await scrollToRatio(page, 0)
|
||||
if (await page.getByText(marker, { exact: false }).count() > 0) return
|
||||
const before = await logicalRows(page)
|
||||
await expect.poll(async () => ({
|
||||
marker: await page.getByText(marker, { exact: false }).count() > 0,
|
||||
rows: await logicalRows(page),
|
||||
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
|
||||
}
|
||||
throw new Error('trajectory did not reach the first turn after twelve older-page requests')
|
||||
}
|
||||
|
||||
describe('web e2e: Trajectory virtualization over tail-paged history', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let replayDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
|
||||
const replayFixture = join(replayDir, 'session.jsonl')
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayFixture, FIXTURE.log)
|
||||
await writeFile(replayOverride, JSON.stringify([{
|
||||
kind: 'chunks',
|
||||
chunks: STREAM_CHUNKS,
|
||||
} satisfies ReplayEntry]))
|
||||
scaffold = await launchWebScaffold({
|
||||
paceMs: 10,
|
||||
replayFixture,
|
||||
replayOverride,
|
||||
})
|
||||
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization'))
|
||||
await openSeed(page)
|
||||
|
||||
let held = false
|
||||
let releaseHistory: () => void = () => {}
|
||||
let finishHeldRequest: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
|
||||
await page.route('**/api/session.history', async (route) => {
|
||||
const request = route.request().postDataJSON() as {
|
||||
method?: string
|
||||
payload?: { beforeSeq?: number }
|
||||
}
|
||||
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
||||
held = true
|
||||
await gate
|
||||
try {
|
||||
await route.continue()
|
||||
} finally {
|
||||
finishHeldRequest()
|
||||
}
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
try {
|
||||
await openTrajectory(page)
|
||||
const initialRows = await logicalRows(page)
|
||||
expect(initialRows).toBeGreaterThan(0)
|
||||
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await scrollToRatio(page, 0)
|
||||
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
|
||||
const anchor = await firstVisibleRow(page)
|
||||
const selectedRow = page.locator(
|
||||
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
|
||||
)
|
||||
await selectedRow.click()
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
|
||||
await nextPaint(page)
|
||||
await expect.poll(async () => {
|
||||
const top = await rowTop(page, anchor.key)
|
||||
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
|
||||
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await loadToFirstTurn(page)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
const fullRows = await logicalRows(page)
|
||||
|
||||
await scrollToRatio(page, 0.5)
|
||||
const middle = await geometry(page)
|
||||
const maximum = middle.scrollHeight - middle.clientHeight
|
||||
expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25)
|
||||
expect(middle.scrollTop).toBeLessThan(maximum * 0.75)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect(await mountedRows(page)).toBeLessThan(fullRows)
|
||||
|
||||
await scrollToRatio(page, 1)
|
||||
await expect.poll(async () => {
|
||||
const value = await geometry(page)
|
||||
return value.scrollHeight - value.clientHeight - value.scrollTop
|
||||
}, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
const trajectoryScroll = page.locator('[data-trajectory-scroll]')
|
||||
await trajectoryScroll.evaluate((host) => {
|
||||
const measuredWindow = window as Window & { __trajectoryScrollCalls?: number }
|
||||
measuredWindow.__trajectoryScrollCalls = 0
|
||||
const original = host.scrollTo.bind(host)
|
||||
const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => {
|
||||
measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1
|
||||
Reflect.apply(original, host, args)
|
||||
}
|
||||
host.scrollTo = trackedScrollTo as typeof host.scrollTo
|
||||
})
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('Stream one deterministic response while Trajectory remains visible.')
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
const streamingScrollCalls = await trajectoryScroll.evaluate(() => {
|
||||
return (window as Window & { __trajectoryScrollCalls?: number })
|
||||
.__trajectoryScrollCalls ?? 0
|
||||
})
|
||||
expect(streamingScrollCalls).toBeLessThanOrEqual(5)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({ pageErrors: [], warnings: [] })
|
||||
} finally {
|
||||
releaseHistory()
|
||||
if (held) await heldRequestFinished
|
||||
await page.unroute('**/api/session.history')
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
{ timeout: 10_000 },
|
||||
).not.toBeUndefined()
|
||||
// First adoption births a blank Session+Agent whose workspace attach must
|
||||
// settle before a test may delete the registration; the reuse path (same
|
||||
// canonical cwd already has a blank session) creates no agent, so callers
|
||||
// opt in only where a fresh attach is possible.
|
||||
// settle before a test may delete the registration; re-registration after
|
||||
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
|
||||
// path is gone), so callers opt in only where a fresh attach is possible.
|
||||
if (options.waitForAgent === true) {
|
||||
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(agentsBefore)
|
||||
@@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
|
||||
|
||||
// Re-registering the exact deleted path immediately, without a reload, is
|
||||
// a supported reversible flow. It creates a fresh Workspace id without
|
||||
// re-adopting the retained Session.
|
||||
// a supported reversible flow. It creates a fresh Workspace id and does
|
||||
// NOT re-adopt the retained (non-blank) Session; the New Session flow
|
||||
// mints a fresh blank session and attaches it to the new registration
|
||||
// (the old cwd-only blank reuse is gone, so the account is never empty).
|
||||
await adoptDirectory(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
@@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
|
||||
expect(reregistered?.id).toBeDefined()
|
||||
expect(reregistered?.id).not.toBe(workspace.id)
|
||||
expect(reregistered?.sessionIds).toEqual([])
|
||||
await expect.poll(
|
||||
() => reregistered?.sessionIds ?? [],
|
||||
{ timeout: 10_000 },
|
||||
).not.toEqual([])
|
||||
expect(reregistered?.sessionIds).not.toContain(SEED_ID)
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
|
||||
|
||||
Reference in New Issue
Block a user