|
|
@@ -20,9 +20,10 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
|
|
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
|
|
|
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
|
|
|
import { join } from 'node:path'
|
|
|
import {
|
|
|
- assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
|
|
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
|
|
compareOrRefreshGolden, fixtureUserPrompts,
|
|
|
launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
|
|
|
webSnapshotMode, type WebScaffold,
|
|
|
@@ -39,6 +40,13 @@ const UI_EXPANDED_EXPECTED = fileURLToPath(
|
|
|
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
|
|
|
const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
|
|
|
const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md')
|
|
|
+// The pinned-header geometry golden: a pure-CSS, user-visible behavior that
|
|
|
+// changes no DOM and no accessible name, so the aria goldens cannot capture it
|
|
|
+// (docs/testing.md, "when a snapshot test is required", still requires a
|
|
|
+// keyless snapshot). Following composer-draft-scroll's geometry golden, it
|
|
|
+// records platform-independent semantic booleans about the pinned compaction
|
|
|
+// header, no absolute pixels.
|
|
|
+const STICKY_GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'sticky-geometry.expected.md')
|
|
|
const MODE = webSnapshotMode()
|
|
|
const SEED_ID = 'seeded-history-web-e2e'
|
|
|
|
|
|
@@ -137,7 +145,16 @@ function withCompaction(raw: string, meter: TokenMeter): string {
|
|
|
sourceCommandId: commandId,
|
|
|
summary: [{
|
|
|
type: 'text',
|
|
|
- text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
|
|
+ text: '## Cold resume compact summary\n\n- The exact summary remains available.\n\n'
|
|
|
+ // A fenced code block gives the summary body a sticky-bannered
|
|
|
+ // descendant (CodeBlock pins its banner at z-index 6). The block is
|
|
|
+ // long enough that its banner has room to hold below the pinned
|
|
|
+ // header, which is where its Copy control must stay clickable; the
|
|
|
+ // list makes the body overflow the shrunk viewport.
|
|
|
+ + '```ts\nfunction resume(): boolean {\n'
|
|
|
+ + Array.from({ length: 26 }, (_, index) => ` const step${index + 1} = read(${index + 1})`).join('\n')
|
|
|
+ + '\n return true\n}\n```\n\n'
|
|
|
+ + Array.from({ length: 40 }, (_, index) => `- Retained fact ${index + 1}: the reader still sees the pre-compaction surface.`).join('\n'),
|
|
|
}],
|
|
|
shadowedRange: { start: first, end: last },
|
|
|
shadowedSeqs: surfaceSeqs,
|
|
|
@@ -189,7 +206,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
|
|
let seededThroughSeq = -1
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
- scaffold = await launchWebScaffold({})
|
|
|
+ scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : {
|
|
|
+ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
|
|
|
+ })
|
|
|
// Composer recording uses a child workspace; seedSession owns the scaffold root.
|
|
|
const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
|
|
|
await mkdir(sessionCwd, { recursive: true })
|
|
|
@@ -437,20 +456,193 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
|
|
await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
|
|
|
})
|
|
|
|
|
|
- it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
|
|
+ it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
|
|
const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
|
|
|
await marker.waitFor({ timeout: 10_000 })
|
|
|
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
|
|
- await marker.click()
|
|
|
- await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
|
|
- await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
|
|
|
- timeout: 5_000,
|
|
|
- }).toBe(1)
|
|
|
- expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
|
|
|
- // Restore the shared page state for any later case.
|
|
|
- await marker.click()
|
|
|
- await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
|
|
+ // Collapsed, the marker is not pinned: the sticky rule's `:has()` gate
|
|
|
+ // needs the body sibling, which only exists while open. jsdom computes no
|
|
|
+ // sticky layout, so this real-browser layer proves the CSS resolves.
|
|
|
+ const collapsedPosition = await marker.evaluate(element => getComputedStyle(element).position)
|
|
|
+ expect(collapsedPosition).not.toBe('sticky')
|
|
|
+ const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
|
|
+ // Captured so a failure in the cleanup below cannot replace the assertion
|
|
|
+ // that actually failed.
|
|
|
+ let bodyError: unknown
|
|
|
+ try {
|
|
|
+ await marker.click()
|
|
|
+ await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
|
|
+ await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
|
|
|
+ timeout: 5_000,
|
|
|
+ }).toBe(1)
|
|
|
+ expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
|
|
|
+ // Open, the toggle pins to the scroll container's top.
|
|
|
+ const openStyle = await marker.evaluate((element) => {
|
|
|
+ const style = getComputedStyle(element)
|
|
|
+ return { position: style.position, top: style.top, zIndex: Number.parseInt(style.zIndex, 10) }
|
|
|
+ })
|
|
|
+ expect(openStyle.position).toBe('sticky')
|
|
|
+ expect(openStyle.top).toBe('0px')
|
|
|
+ // The summary body carries a fenced code block whose own banner pins at
|
|
|
+ // z-index 6; the toggle must outrank it, or a code-block summary would
|
|
|
+ // re-bury the toggle. Sample the banner inside THIS summary body, not a
|
|
|
+ // code block elsewhere on the page.
|
|
|
+ const bannerZ = await page.locator('[class*="compactionBody"] [class*="bannerWrap"]').first().evaluate(
|
|
|
+ element => Number.parseInt(getComputedStyle(element).zIndex, 10),
|
|
|
+ )
|
|
|
+ expect(openStyle.zIndex).toBeGreaterThan(bannerZ)
|
|
|
+ // Hovering the open toggle must keep an OPAQUE fill: the default hover
|
|
|
+ // token is translucent and would let the scrolling prose bleed through
|
|
|
+ // the moment the pointer lands to collapse it. The alpha token, if
|
|
|
+ // present, is the fourth comma value (`rgba(r, g, b, a)`) or the value
|
|
|
+ // after `/` in the space form; three channels mean opaque. A color that
|
|
|
+ // parses to neither returns -1, which fails loud instead of passing as
|
|
|
+ // opaque.
|
|
|
+ await marker.hover()
|
|
|
+ const hoverAlpha = await marker.evaluate((element) => {
|
|
|
+ const bg = getComputedStyle(element).backgroundColor
|
|
|
+ const inner = /^rgba?\((.+)\)$/.exec(bg.trim())?.[1]
|
|
|
+ if (inner === undefined) return -1
|
|
|
+ const slashAlpha = inner.split('/')[1]
|
|
|
+ if (slashAlpha !== undefined) return Number.parseFloat(slashAlpha)
|
|
|
+ const channels = inner.split(/[\s,]+/).filter(token => token.length > 0)
|
|
|
+ const commaAlpha = channels[3]
|
|
|
+ if (commaAlpha !== undefined) return Number.parseFloat(commaAlpha)
|
|
|
+ if (channels.length === 3) return 1
|
|
|
+ return -1
|
|
|
+ })
|
|
|
+ expect(hoverAlpha).toBe(1)
|
|
|
+ // Scroll so the summary's code banner reaches its own stuck position.
|
|
|
+ // The banner's sticky offset holds it below the pinned header's band, so
|
|
|
+ // the point this case samples is the banner's Copy control: the header
|
|
|
+ // must not cover it. Shrinking the viewport first forces overflow
|
|
|
+ // regardless of summary length.
|
|
|
+ await page.setViewportSize({ width: originalViewport.width, height: 360 })
|
|
|
+ const geom = await marker.evaluate((button) => {
|
|
|
+ const container = button.closest('[data-conversation-scroll]') as HTMLElement
|
|
|
+ const banner = container.querySelector('[class*="compactionBody"] [class*="bannerWrap"]') as HTMLElement
|
|
|
+ const copy = banner.querySelector('button') as HTMLElement
|
|
|
+ // Both the toggle and the code banner are sticky, so a rect taken while
|
|
|
+ // either is stuck reports the stuck position rather than its content
|
|
|
+ // offset. Measure both unstuck, so the target below does not depend on
|
|
|
+ // where the scrollport happened to be when this case started.
|
|
|
+ const markerInline = button.style.position
|
|
|
+ const bannerInline = banner.style.position
|
|
|
+ const bannerTopInline = banner.style.top
|
|
|
+ button.style.position = 'static'
|
|
|
+ banner.style.position = 'static'
|
|
|
+ banner.style.top = 'auto'
|
|
|
+ const containerTop = container.getBoundingClientRect().top
|
|
|
+ const markerStaticTop = button.getBoundingClientRect().top - containerTop + container.scrollTop
|
|
|
+ const bannerStaticTop = banner.getBoundingClientRect().top - containerTop + container.scrollTop
|
|
|
+ const headerHeight = button.getBoundingClientRect().height
|
|
|
+ button.style.position = markerInline
|
|
|
+ banner.style.position = bannerInline
|
|
|
+ banner.style.top = bannerTopInline
|
|
|
+ // The banner sticks once its static top passes the band the toggle
|
|
|
+ // occupies. Land the static top 8px above the scrollport top: if the
|
|
|
+ // banner still pinned at top 0, 8px of it would sit under the toggle,
|
|
|
+ // so this position distinguishes the offset from the uncovered case.
|
|
|
+ // The banner must hold at the band's bottom edge, and its Copy control
|
|
|
+ // must stay the topmost element at its own center.
|
|
|
+ container.scrollTop = Math.max(0, bannerStaticTop + 8)
|
|
|
+ const markerRect = button.getBoundingClientRect()
|
|
|
+ const bannerRect = banner.getBoundingClientRect()
|
|
|
+ const copyRect = copy.getBoundingClientRect()
|
|
|
+ const currentContainerTop = container.getBoundingClientRect().top
|
|
|
+ const markerProbe = document.elementFromPoint(
|
|
|
+ markerRect.left + markerRect.width / 2,
|
|
|
+ markerRect.top + markerRect.height / 2,
|
|
|
+ )
|
|
|
+ const copyProbe = document.elementFromPoint(
|
|
|
+ copyRect.left + copyRect.width / 2,
|
|
|
+ copyRect.top + copyRect.height / 2,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ scrollTop: container.scrollTop,
|
|
|
+ // The header's own content offset now lies above the scrollport top,
|
|
|
+ // so its rect top can equal the scrollport top only through stickiness
|
|
|
+ // — this is the precondition that makes the pinning assertion mean
|
|
|
+ // something.
|
|
|
+ staticAboveViewport: container.scrollTop > markerStaticTop,
|
|
|
+ markerTop: markerRect.top,
|
|
|
+ containerTop: currentContainerTop,
|
|
|
+ bannerTop: bannerRect.top,
|
|
|
+ bannerStuck: Math.abs(bannerRect.top - (currentContainerTop + headerHeight)) <= 1,
|
|
|
+ bannerBelowHeader: bannerRect.top >= markerRect.bottom - 1,
|
|
|
+ markerOwnsCenter: button.contains(markerProbe),
|
|
|
+ copyOwnsCenter: copy.contains(copyProbe),
|
|
|
+ }
|
|
|
+ })
|
|
|
+ expect(geom.scrollTop).toBeGreaterThan(0)
|
|
|
+ expect(geom.staticAboveViewport).toBe(true)
|
|
|
+ expect(Math.abs(geom.markerTop - geom.containerTop)).toBeLessThanOrEqual(1)
|
|
|
+ expect(geom.bannerStuck).toBe(true)
|
|
|
+ expect(geom.bannerBelowHeader).toBe(true)
|
|
|
+ expect(geom.markerOwnsCenter).toBe(true)
|
|
|
+ expect(geom.copyOwnsCenter).toBe(true)
|
|
|
+ // Keyless geometry golden for this user-visible, DOM-invariant CSS
|
|
|
+ // behavior: platform-independent semantic facts, no absolute pixels.
|
|
|
+ // Every line is a value asserted just above, so a regression reddens the
|
|
|
+ // expect first; compareOrRefreshGolden writes the file in refresh mode
|
|
|
+ // and byte-compares it in replay.
|
|
|
+ const stickyGolden = [
|
|
|
+ '# Compaction marker sticky header (pinned over a code-block summary)',
|
|
|
+ '',
|
|
|
+ '## Collapsed',
|
|
|
+ '',
|
|
|
+ `- header is not sticky: ${String(collapsedPosition !== 'sticky')}`,
|
|
|
+ '',
|
|
|
+ '## Open, pinned at the scroll container top',
|
|
|
+ '',
|
|
|
+ `- header position is sticky: ${String(openStyle.position === 'sticky')}`,
|
|
|
+ `- header pins to the top edge: ${String(openStyle.top === '0px')}`,
|
|
|
+ `- header outranks the summary code-block banner: ${String(openStyle.zIndex > bannerZ)}`,
|
|
|
+ `- hover fill stays fully opaque: ${String(hoverAlpha === 1)}`,
|
|
|
+ '',
|
|
|
+ '## Scrolled so the summary code banner reaches its sticky offset',
|
|
|
+ '',
|
|
|
+ `- container is scrolled off its top: ${String(geom.scrollTop > 0)}`,
|
|
|
+ `- header's static position sits above the scrollport: ${String(geom.staticAboveViewport)}`,
|
|
|
+ `- header holds at the scrollport top: ${String(Math.abs(geom.markerTop - geom.containerTop) <= 1)}`,
|
|
|
+ `- header owns the center point (toggle stays clickable): ${String(geom.markerOwnsCenter)}`,
|
|
|
+ `- summary code banner holds below the header band: ${String(geom.bannerStuck)}`,
|
|
|
+ `- summary code banner stays clear of the header: ${String(geom.bannerBelowHeader)}`,
|
|
|
+ `- banner Copy control owns its own center: ${String(geom.copyOwnsCenter)}`,
|
|
|
+ ].join('\n').trimEnd()
|
|
|
+ await compareOrRefreshGolden(STICKY_GEOMETRY_EXPECTED, stickyGolden, MODE)
|
|
|
+ } catch (error) {
|
|
|
+ bodyError = error
|
|
|
+ }
|
|
|
+ // Restore the shared page state whether or not the body failed. Order
|
|
|
+ // matters: collapse the marker, restore the viewport, then re-enter
|
|
|
+ // follow-bottom. The control is what clears the off-floor ownership a
|
|
|
+ // programmatic `scrollTop` assignment leaves in ChatView's reader-movement
|
|
|
+ // ledger, so click it when it is there. It appears only after that ledger
|
|
|
+ // settles (`scrollend` or the sampling interval), and it never renders at
|
|
|
+ // all when the collapse's shrink clamp already re-entered follow, so the
|
|
|
+ // assertion is the restored state — no control, and the scrollport on its
|
|
|
+ // floor — rather than the control's presence.
|
|
|
+ try {
|
|
|
+ if (await marker.getAttribute('aria-expanded') === 'true') await marker.click()
|
|
|
+ await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
|
|
+ await page.setViewportSize(originalViewport)
|
|
|
+ const scrollport = page.locator('[data-conversation-scroll]')
|
|
|
+ const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
|
+ await expect.poll(async () => {
|
|
|
+ if (await backToBottom.count() > 0) await backToBottom.click()
|
|
|
+ const atFloor = await scrollport.evaluate((host: HTMLElement) =>
|
|
|
+ Math.abs(host.scrollHeight - host.clientHeight - host.scrollTop) <= 1)
|
|
|
+ return await backToBottom.count() === 0 && atFloor
|
|
|
+ }, { timeout: 15_000 }).toBe(true)
|
|
|
+ } catch (cleanupError) {
|
|
|
+ // The body's own assertion is the diagnosis; a cleanup failure would
|
|
|
+ // replace it, and the state it failed to restore shows up in the next
|
|
|
+ // case's golden.
|
|
|
+ if (bodyError === undefined) throw cleanupError
|
|
|
+ }
|
|
|
+ if (bodyError !== undefined) throw bodyError
|
|
|
})
|
|
|
|
|
|
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
|
|
|
@@ -539,6 +731,48 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
|
|
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
|
|
|
})
|
|
|
|
|
|
+ it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-sidebar-reload'))
|
|
|
+ await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
|
|
+ const column = page.locator('[data-rightbar-col]')
|
|
|
+ await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
|
|
+ const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')
|
|
|
+ const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
|
|
+ const warningStart = tripwire.warnings.length
|
|
|
+ await page.reload({ waitUntil: 'load' })
|
|
|
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
|
|
+ await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
|
|
+ expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId)
|
|
|
+ const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
|
|
+ expect(restoredPreview).toBe(preview)
|
|
|
+ })
|
|
|
+
|
|
|
+ it.skipIf(MODE === 'record' || process.platform === 'win32')('offers explicit terminal replacement after restoring the recorded Session', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-terminal-unavailable'))
|
|
|
+ await page.locator('[data-dockkit-add-tab]').click()
|
|
|
+ await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click()
|
|
|
+ const sessionId = SessionId(SEED_ID)
|
|
|
+ const terminals = () => scaffold.ctx.terminalController.list(sessionId)
|
|
|
+ await expect.poll(() => terminals().length).toBe(1)
|
|
|
+ const previous = terminals()[0]!.id
|
|
|
+ const agent = scaffold.ctx.agents.get(sessionId)
|
|
|
+ if (agent === undefined) throw new Error('Recorded Session did not attach an Agent for its terminal')
|
|
|
+ await scaffold.ctx.terminalController.close(agent, previous)
|
|
|
+ const warningStart = tripwire.warnings.length
|
|
|
+ await page.reload({ waitUntil: 'load' })
|
|
|
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
|
|
+ const terminal = page.locator('[data-sidebar-terminal]')
|
|
|
+ await expect.poll(() => terminal.getByRole('alert').innerText()).toContain('no longer exists')
|
|
|
+ expect(terminals()).toEqual([])
|
|
|
+ const expected = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
|
|
|
+ await compareOrRefreshGolden(expected, await terminal.ariaSnapshot(), MODE)
|
|
|
+ await terminal.getByRole('button', { name: 'New terminal', exact: true }).click()
|
|
|
+ await expect.poll(() => terminals().length).toBe(1)
|
|
|
+ expect(terminals()[0]!.id).not.toBe(previous)
|
|
|
+ await terminal.getByRole('status').waitFor({ state: 'hidden' })
|
|
|
+ await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor()
|
|
|
+ })
|
|
|
+
|
|
|
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
|
|
// No replay fixture was installed and the llm seam is open — any stray
|
|
|
// stream would have failed the turn loudly. Cleanliness pins the wire.
|
|
|
@@ -546,7 +780,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
await assertFixtureInventory(SNAPSHOT_DIR, [
|
|
|
'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
|
|
|
- 'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
|
|
|
+ 'session.v3.jsonl', 'sticky-geometry.expected.md', 'ui.expected.md', 'ui-expanded.expected.md',
|
|
|
])
|
|
|
})
|
|
|
})
|