mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Delete design-session citations (decision/audit/plan ordinals, stack positions), change narration, review choreography, and reviewer-addressed justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and generator templates; restate every affected fact as current-state contract prose. Fix generated docs at their sources and regenerate the catalogs and cordis-surface regions; re-paste type-equiv blocks; update every bilingual counterpart and re-record the pairs. Record the citation rule in the committed-artifact-citations Agent Note.
421 lines
20 KiB
TypeScript
421 lines
20 KiB
TypeScript
// 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.
|
|
//
|
|
// Without a shared reservation the two tabs disagree by exactly the bar's
|
|
// width for as long as the transcript overflows: the card jumps sideways on
|
|
// every tab switch, and inside Chat alone at the moment a growing transcript
|
|
// starts to scroll. The column 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 with
|
|
// and without the reservation and every comparison below holds vacuously —
|
|
// measured: the unreserved 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 unreserved 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 shift the unreserved cascade produces 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 unreserved cascade, injected into the page: the reservation dropped and
|
|
* the overlay branch forced 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 unreserved cascade in the page for one measurement, then lift it.
|
|
* @param page - the page under test.
|
|
* @returns the comparison as the column lays out without the reservation.
|
|
*/
|
|
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 point: 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 unreserved cascade's shift shows 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 changes 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 unreserved cascade the Chat scroller
|
|
// keeps its bar and the Trajectory branch becomes 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 reservation, 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([])
|
|
})
|
|
})
|