|
|
@@ -20,7 +20,7 @@
|
|
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
|
import { join } from 'node:path'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
-import type { Browser, Locator, Page } from 'playwright'
|
|
|
+import type { Browser, ConsoleMessage, Locator, Page } from 'playwright'
|
|
|
import { chromium } from 'playwright'
|
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
|
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
@@ -360,14 +360,13 @@ describe('web e2e: shipped right Sidebar', () => {
|
|
|
expect(panelWidth).toBeGreaterThan(0)
|
|
|
expect(await width(conversation)).toBe(centerBefore - panelWidth)
|
|
|
await expect.poll(async () => await expand.count()).toBe(0)
|
|
|
- // The corner keeps its footprint, so the utilities' right edge stays where
|
|
|
- // it was relative to the conversation's own right edge.
|
|
|
- expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1)
|
|
|
+ // The corner seat collapses with its button, so the utilities' right edge
|
|
|
+ // moves out toward the conversation's own.
|
|
|
const utilitiesAfter = await utilities.boundingBox()
|
|
|
const conversationAfter = await conversation.boundingBox()
|
|
|
if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
|
|
|
const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
|
|
|
- expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
|
|
|
+ expect(gapAfter).toBeLessThan(gapBefore)
|
|
|
|
|
|
// The panel is in the column, not over it, and carries the seeded tab —
|
|
|
// whose body arrives through the Files type's keyed registration, not from
|
|
|
@@ -391,20 +390,20 @@ describe('web e2e: shipped right Sidebar', () => {
|
|
|
expect(await centreY(selector), selector).toBe(textLine)
|
|
|
}
|
|
|
|
|
|
- // Files is permanent. A manual guide is closable and suppresses another
|
|
|
- // add control in its pane until it is closed.
|
|
|
+ // A manual guide is closable beside Files and suppresses another add
|
|
|
+ // control in its pane until it is closed.
|
|
|
const addTab = column.locator('[data-dockkit-add-tab]')
|
|
|
const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
|
|
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
|
|
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
|
|
- expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
+ expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
|
|
await expect.poll(async () => await addTab.count()).toBe(1)
|
|
|
expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
|
|
|
await addTab.click()
|
|
|
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
|
|
|
await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
|
|
|
await expect.poll(async () => await addTab.count()).toBe(0)
|
|
|
- expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
+ expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
|
|
const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
|
|
expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
|
|
// Back to the seeded shape the cases below start from.
|
|
|
@@ -592,6 +591,72 @@ describe('web e2e: shipped right Sidebar', () => {
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
})
|
|
|
|
|
|
+ it('survives grip drags past both clamps on a squeezed viewport', async () => {
|
|
|
+ // Regression: an overshoot drag swept the panel through widths where a
|
|
|
+ // width-blocked split control hid itself, which changed the very strip
|
|
|
+ // measurement that had blocked it — a layout-effect feedback loop that
|
|
|
+ // crashed the pane (React error #185) and unmounted the rightbar slot
|
|
|
+ // entry while the column still believed it was expanded, so neither the
|
|
|
+ // panel nor the header's expand button remained. The crash surfaces only
|
|
|
+ // as a console error, which the scaffold tripwire does not watch, so this
|
|
|
+ // case collects console errors itself.
|
|
|
+ const viewport = page.viewportSize()
|
|
|
+ if (viewport === null) throw new Error('expected a fixed viewport')
|
|
|
+ const frame = page.locator('[class*="frame"]').first()
|
|
|
+ const column = page.locator('[data-rightbar-col]')
|
|
|
+ const panel = column.locator('[data-sidebar-right-panel]')
|
|
|
+ const consoleErrors: string[] = []
|
|
|
+ const collect = (message: ConsoleMessage): void => {
|
|
|
+ if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600))
|
|
|
+ }
|
|
|
+ page.on('console', collect)
|
|
|
+ try {
|
|
|
+ await page.setViewportSize({ width: 1000, height: viewport.height })
|
|
|
+ await ensureExpanded(page, column)
|
|
|
+ const grip = frame.locator('[data-side="rightbar"]')
|
|
|
+ // The frame reads the new viewport through a throttled ResizeObserver,
|
|
|
+ // a couple of frames after the resize; until then the grip sits at the
|
|
|
+ // old frame's coordinates. Press only a grip aligned with the panel's
|
|
|
+ // left edge (the handle is 8px wide, centred on the seam).
|
|
|
+ await expect.poll(async () => {
|
|
|
+ const gripBox = await grip.boundingBox()
|
|
|
+ const panelBox = await panel.boundingBox()
|
|
|
+ if (gripBox === null || panelBox === null) return Number.NaN
|
|
|
+ return Math.abs(gripBox.x + 4 - panelBox.x)
|
|
|
+ }).toBeLessThanOrEqual(1)
|
|
|
+ // Narrow with overshoot: drag the grip far right past the clamp floor.
|
|
|
+ const from = await centre(grip)
|
|
|
+ await page.mouse.move(from.x, from.y)
|
|
|
+ await page.mouse.down()
|
|
|
+ await page.mouse.move(980, from.y, { steps: 30 })
|
|
|
+ await page.mouse.up()
|
|
|
+ // The panel holds its floor, still open, with its grip still rendered.
|
|
|
+ await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302)
|
|
|
+ expect(await width(panel)).toBeGreaterThanOrEqual(300)
|
|
|
+ expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
|
|
+ expect(await grip.count()).toBe(1)
|
|
|
+ // Widen with overshoot to the far left: clamped by the frame's range.
|
|
|
+ const back = await centre(grip)
|
|
|
+ await page.mouse.move(back.x, back.y)
|
|
|
+ await page.mouse.down()
|
|
|
+ await page.mouse.move(20, back.y, { steps: 30 })
|
|
|
+ await page.mouse.up()
|
|
|
+ const widened = await width(panel)
|
|
|
+ expect(widened).toBeGreaterThan(302)
|
|
|
+ expect(widened).toBeLessThan(1000)
|
|
|
+ expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
|
|
+ expect(await grip.count()).toBe(1)
|
|
|
+ expect(consoleErrors).toEqual([])
|
|
|
+ expect(tripwire.pageErrors).toEqual([])
|
|
|
+ expect(tripwire.warnings).toEqual([])
|
|
|
+ } finally {
|
|
|
+ page.off('console', collect)
|
|
|
+ await page.setViewportSize(viewport)
|
|
|
+ await ensureExpanded(page, column)
|
|
|
+ await setPanelWidth(page, Math.round(viewport.width * 0.45))
|
|
|
+ }
|
|
|
+ }, 60_000)
|
|
|
+
|
|
|
it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
|
|
|
const files = (scaffold.ctx as unknown as {
|
|
|
get(name: string): {
|
|
|
@@ -850,33 +915,52 @@ describe('web e2e: shipped right Sidebar', () => {
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
}, 90_000)
|
|
|
|
|
|
- it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => {
|
|
|
+ it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
|
|
|
const column = await resetSidebar(page)
|
|
|
const panes = column.locator('[data-dockkit-pane]')
|
|
|
- expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
+ expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
|
|
await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
|
|
|
await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
|
|
|
await panes.first().locator('[data-dockkit-split-button]').click()
|
|
|
await expect.poll(async () => await panes.count()).toBe(2)
|
|
|
- await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }),
|
|
|
- await pointIn(panes.nth(1), 0.5, 0.5))
|
|
|
- await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME])
|
|
|
- const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
|
|
- expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
- await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
|
|
|
- await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
|
|
|
|
|
|
- // The remaining ordinary document is also unclosable while alone.
|
|
|
- expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
- await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5))
|
|
|
+ // Closing a pane's last tab drops the pane: there is no separate
|
|
|
+ // "close pane" gesture, and none is needed.
|
|
|
+ await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
|
|
|
await expect.poll(async () => await panes.count()).toBe(1)
|
|
|
- const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
|
|
- await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
|
|
- await documentTab.locator('[data-dockkit-tab-close]').click()
|
|
|
- await expect.poll(async () => await documentTab.count()).toBe(0)
|
|
|
- expect(await panes.count()).toBe(1)
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
|
|
+
|
|
|
+ // Leave a guide as the sole docked tab.
|
|
|
+ await column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
|
|
+ .locator('[data-dockkit-tab-close]').click()
|
|
|
+ await column.locator('[data-dockkit-add-tab]').click()
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
|
|
|
+ await column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
|
|
+ .locator('[data-dockkit-tab-close]').click()
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
|
|
|
+
|
|
|
+ // The guide standing as the docked surface's only tab draws no close
|
|
|
+ // control, sits quiet (no capsule, no hover fill), and a secondary press
|
|
|
+ // opens no menu: an empty menu never shows.
|
|
|
expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
|
|
+ expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
|
|
|
+ await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
|
|
|
+ expect(await page.locator('[data-dockkit-tab-menu]').count()).toBe(0)
|
|
|
+
|
|
|
+ // Any other tab standing alone closes together with the column. Open the
|
|
|
+ // sample file, close the guide (an ordinary close with two tabs), then
|
|
|
+ // close the file: the column collapses in the same gesture, and the
|
|
|
+ // settle rule reseeds the current default, so reopening shows Files.
|
|
|
+ await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
|
|
|
+ await column.locator('[data-dockkit-tab-close]').first().click()
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
|
|
|
+ await column.locator('[data-dockkit-tab-close]').first().click()
|
|
|
+ await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
|
|
|
+ await expandOf(page).click()
|
|
|
+ await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
|
|
+ expect(await column.locator('[data-files-state="tree"]').count()).toBe(1)
|
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
@@ -959,8 +1043,8 @@ describe('web e2e: shipped right Sidebar', () => {
|
|
|
// as a layout defect that is not there.
|
|
|
expect(await width(column)).toBeGreaterThan(300)
|
|
|
await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
|
|
|
- await expect.poll(async () => await guide.locator('p').first().innerText())
|
|
|
- .toBe('侧栏用来放你想一直看着的东西。')
|
|
|
+ await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
|
|
|
+ .toBe('工作区文件')
|
|
|
await shot(zhPage, '05-guide-copy-zh')
|
|
|
|
|
|
expect(zhTripwire.pageErrors).toEqual([])
|