Files
deepseek-harness/apps/web/tests/file-browser.e2e.ts
T

540 lines
33 KiB
TypeScript

/**
* The workspace file browser over the shipped Web composition, a real
* workspace and the real dock, across docked and floating panes and a
* phone-width viewport. The package README owns the behavior asserted here.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Locator, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
/** Press at the centre of `from`, travel in steps, and release at `to`. */
async function drag(page: Page, from: Locator, to: { x: number; y: number }): Promise<void> {
const box = await from.boundingBox()
if (box === null) throw new Error('drag source is not rendered')
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(to.x, to.y, { steps: 12 })
await page.mouse.up()
}
/** Resize through the hit-tested corner, rather than dispatching events through covering content. */
async function resizeFloat(page: Page, panel: Locator, width: number, height: number): Promise<void> {
const before = await panel.boundingBox()
const handle = panel.locator('[data-dockkit-float-resize]')
const grip = await handle.boundingBox()
if (before === null || grip === null) throw new Error('floating resize geometry is not rendered')
await expect.poll(() => handle.evaluate((node) => {
const box = node.getBoundingClientRect()
return node.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2))
})).toBe(true)
await drag(page, handle, {
x: grip.x + grip.width / 2 + width - before.width,
y: grip.y + grip.height / 2 + height - before.height,
})
await expect.poll(async () => {
const after = await panel.boundingBox()
return after === null ? Infinity : Math.max(Math.abs(after.width - width), Math.abs(after.height - height))
}).toBeLessThanOrEqual(1)
}
/** Boot the shipped Web composition into a settled Session whose workspace holds `src/{a,b,c}.txt`, `README.md`, and `focus.html`. */
async function openWorkspace(
shot: string,
openInAppEnvironment?: LaunchEnvironmentSnapshot,
): Promise<{ page: Page; input: Locator; errors: ReturnType<typeof watchConsole> }> {
const scaffold = await launchWebScaffold({
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
paceMs: 5, compareReplaySession: false, ...openInAppEnvironment === undefined ? {} : { openInAppEnvironment },
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1680, height: 1000 } })
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'Win32' }) })
onTestFailed(() => saveFailureShot(page, shot))
const errors = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'test workspace')
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
await input.press('Enter')
const session = await settled
const cwd = scaffold.ctx.agents.get(session)?.session.header.cwd
if (cwd === undefined) throw new Error('Session has no workspace')
await mkdir(join(cwd, 'src'))
const lines = (name: string): string => Array.from({ length: 300 }, (_, index) => `${name} line ${index}\n`).join('')
await writeFile(join(cwd, 'src', 'a.txt'), lines('a'))
await writeFile(join(cwd, 'src', 'b.txt'), lines('b'))
await writeFile(join(cwd, 'src', 'c.txt'), lines('c'))
await writeFile(join(cwd, 'README.md'), '# Readme\n')
await writeFile(join(cwd, 'focus.html'), '<!doctype html><button>Preview focus</button>')
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
return { page, input, errors }
}
it('browses workspace files in tabs that share one pane tree', async () => {
const { page, input, errors } = await openWorkspace('web-e2e-file-browser')
await page.locator('[data-sidebar-right-expand]').click()
const column = page.locator('[data-rightbar-col]')
const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 960px !important; }' })
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
const tabs = column.locator('[data-dockkit-tab]')
const navigator = column.locator('[data-files-navigator]:visible')
const toggle = column.locator('[data-files-navigator-toggle]:visible')
const body = column.locator('[data-textpreview-state]:visible [data-textpreview-body]')
const path = column.locator('[data-textpreview-path]:visible')
const current = navigator.locator('[aria-current="true"]')
await column.locator('[data-files-page]').waitFor()
// The page's tree fills the page and stays shown: no toggle, no resize edge, and Escape leaves it.
await navigator.waitFor()
expect(await toggle.count()).toBe(0)
expect(await navigator.getByRole('separator').count()).toBe(0)
const pageContent = await column.locator('[data-files-content]').boundingBox()
const pageTree = await navigator.boundingBox()
if (pageContent === null || pageTree === null) throw new Error('files page geometry is not rendered')
expect(Math.abs(pageTree.width - pageContent.width)).toBeLessThanOrEqual(1)
await navigator.getByRole('button', { name: 'src', exact: true }).focus()
await page.keyboard.press('Escape')
expect(await navigator.count()).toBe(1)
const pageTab = await tabs.last().getAttribute('data-dockkit-tab')
const tabCount = await tabs.count()
// The page stays and opens the first file in a new tab after it in its pane, where the file's tree starts hidden.
await navigator.getByRole('button', { name: 'src', exact: true }).click()
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
expect(await column.locator(`[data-dockkit-tab="${pageTab}"]`).count()).toBe(1)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
// Picking the open file again from the page switches back to its tab, which is the visible change, so nothing glows.
const flash = column.locator('[data-sidebar-right-flash]')
await column.locator(`[data-dockkit-tab="${pageTab}"]`).click()
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await flash.count()).toBe(0)
expect(await tabs.count()).toBe(tabCount + 1)
// The preview's toggle shows the pane's tree without changing the files page.
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => current.textContent()).toBe('a.txt')
// Picking the open file again while its glow plays restarts the glow, which then lasts its whole 600ms animation.
const openFile = navigator.getByRole('button', { name: 'a.txt', exact: true })
await openFile.click()
// A glow replaced before it starts is dropped without a cancellation, so the repeat waits until the first one plays.
await expect.poll(() => flash.evaluate(pane => pane.getAnimations({ subtree: true })
.some(glow => Number(glow.currentTime) > 100))).toBe(true)
const first = await flash.getAttribute('data-sidebar-right-flash')
const repeat = await openFile.evaluate((button) => {
const pane = button.closest<HTMLElement>('[data-sidebar-right-flash]')
if (pane === null) throw new Error('the open file is outside the glowing pane')
const since = performance.now()
return new Promise<{ mark: string | undefined; lasted: number }>((resolve) => {
let mark: string | undefined
new MutationObserver((_, observer) => {
mark = pane.dataset.sidebarRightFlash ?? mark
if (pane.dataset.sidebarRightFlash !== undefined) return
observer.disconnect()
resolve({ mark, lasted: performance.now() - since })
}).observe(pane, { attributes: true, attributeFilter: ['data-sidebar-right-flash'] })
;(button as HTMLElement).click()
})
})
expect(repeat.mark).toBe(first === 'odd' ? 'even' : 'odd')
expect(repeat.lasted).toBeGreaterThan(450)
// In a wide row the tree is a card beside the preview: the body narrows by the card and its 8px margins, and still scrolls.
await column.locator('[data-files-navigator-mode="push"]:visible').waitFor()
const wideRow = await body.locator('..').boundingBox()
const wideBody = await body.boundingBox()
const wideTree = await navigator.boundingBox()
if (wideRow === null || wideBody === null || wideTree === null) throw new Error('preview row is not rendered')
expect(Math.abs(wideBody.width - (wideRow.width - wideTree.width - 16))).toBeLessThanOrEqual(1)
expect(wideTree.x).toBeGreaterThanOrEqual(wideBody.x + wideBody.width + 7)
await expect.poll(() => body.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true)
await page.mouse.move(wideBody.x + 40, wideBody.y + 80)
await page.mouse.wheel(0, 400)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0)
// A press on the preview leaves the tree shown.
await page.mouse.click(wideBody.x + 40, wideBody.y + 80)
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
await toggle.focus()
// In dark mode the card takes the filled tree's background, with a soft white shadow.
const theme = await page.locator('body').getAttribute('data-ds-dark-theme')
try {
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const cardPaint = await navigator.evaluate(node => ({
background: getComputedStyle(node).backgroundColor,
shadow: getComputedStyle(node).boxShadow,
}))
expect(cardPaint.shadow).toContain('color(srgb 1 1 1 / 0.08)')
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor()
expect(await navigator.evaluate(node => getComputedStyle(node).backgroundColor)).toBe(cardPaint.background)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await navigator.getByRole('separator').waitFor()
} finally {
await page.evaluate((value) => {
if (value === null) document.body.removeAttribute('data-ds-dark-theme')
else document.body.setAttribute('data-ds-dark-theme', value)
}, theme)
}
// The reader scrolls the preview and widens the tree.
await body.evaluate((node) => { node.scrollTop = 900 })
const edge = navigator.getByRole('separator', { name: 'Resize file tree' })
const grip = await edge.boundingBox()
if (grip === null) throw new Error('resize edge is not rendered')
await page.mouse.move(grip.x + grip.width / 2, grip.y + 120)
await page.mouse.down()
await page.mouse.move(grip.x + grip.width / 2 - 40, grip.y + 120, { steps: 4 })
await page.mouse.up()
await expect.poll(() => edge.getAttribute('aria-valuenow')).toBe('300')
// The files page appends another preview; it shares the pane's tree choice and width.
await column.locator(`[data-dockkit-tab="${pageTab}"]`).click()
await navigator.getByRole('button', { name: 'b.txt', exact: true }).click()
await column.locator(`[data-dockkit-tab="${pageTab}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${pageTab}"]`).click()
await expect.poll(() => path.textContent()).toContain('b.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await expect.poll(() => current.textContent()).toBe('b.txt')
expect(await navigator.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('true')
expect(await edge.getAttribute('aria-valuenow')).toBe('300')
// Clicking an open file returns to its tab, where the reader left off.
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(900)
await expect.poll(() => current.textContent()).toBe('a.txt')
// Workspace navigation has one open action; the row's shared context menu does not offer a second new-tab route.
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click({ button: 'right' })
const rowMenu = page.locator('[data-context-menu][role="menu"]')
await rowMenu.waitFor()
expect(await page.getByRole('menuitem', { name: 'Open in new tab', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
await rowMenu.waitFor({ state: 'detached' })
// A narrower pane floats the tree over the preview below its header; the body keeps the row's width and still scrolls.
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' })
await column.locator('[data-files-navigator-mode="overlay"]:visible').waitFor()
const header = await path.locator('..').boundingBox()
const tree = await navigator.boundingBox()
if (header === null || tree === null) throw new Error('browser geometry is not rendered')
expect(tree.y).toBeGreaterThanOrEqual(header.y + header.height - 1)
const middleRow = await body.locator('..').boundingBox()
const preview = await body.boundingBox()
if (middleRow === null || preview === null) throw new Error('preview body is not rendered')
expect(preview.width).toBeGreaterThan(middleRow.width - 1)
await body.evaluate((node) => { node.scrollTop = 0 })
await page.mouse.move(preview.x + 20, preview.y + 80)
await page.mouse.wheel(0, 400)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0)
await navigator.getByRole('button', { name: 'b.txt', exact: true }).click()
await expect.poll(() => current.textContent()).toBe('b.txt')
// The keyboard stays in the tree the revealed tab draws, so Escape reaches the row.
await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
expect(await navigator.count()).toBe(0)
expect(await toggle.evaluate(node => node === document.activeElement)).toBe(true)
// A row narrower than 400px is filled by the tree, without the resize edge; Escape still closes it.
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
await toggle.click()
await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor()
const narrowRow = await body.locator('..').boundingBox()
const narrowTree = await navigator.boundingBox()
if (narrowRow === null || narrowTree === null) throw new Error('narrow geometry is not rendered')
expect(Math.abs(narrowTree.width - narrowRow.width)).toBeLessThanOrEqual(1)
expect(await navigator.getByRole('separator').count()).toBe(0)
expect(await body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
await toggle.focus()
await page.keyboard.press('Tab')
expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false)
expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
await navigator.getByRole('button', { name: 'b.txt', exact: true }).focus()
await page.keyboard.press('Escape')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
// A phone-width viewport fills the row below the header with the tree, without the resize edge, and opening a file hides it.
// The resize edge holding the keyboard when the viewport narrows hands it to the tree.
await layout.evaluate((node) => { node.textContent = '' })
await toggle.click()
const resizeEdge = navigator.getByRole('separator', { name: 'Resize file tree' })
await resizeEdge.focus()
expect(await resizeEdge.evaluate(node => node === document.activeElement)).toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement)
&& !document.activeElement?.hasAttribute('data-files-navigator-resize'))).toBe(true)
// The panel slides into its fullscreen place, so the geometry is read once it settles.
await expect.poll(async () => {
const row = await body.locator('..').boundingBox()
const filled = await navigator.boundingBox()
return row === null || filled === null ? Number.POSITIVE_INFINITY : Math.abs(filled.width - row.width)
}).toBeLessThanOrEqual(1)
expect(await navigator.getByRole('separator').count()).toBe(0)
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
expect(await navigator.count()).toBe(0)
await page.setViewportSize({ width: 1680, height: 1000 })
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' })
// The workspace command shows the pane's tree, moves the keyboard into it, and keeps the file.
await input.focus()
await page.keyboard.press('Control+Alt+p')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true)
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await column.locator('[data-dockkit-add-tab]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
const htmlFilesTab = await tabs.filter({ hasText: 'Files' }).getAttribute('data-dockkit-tab')
await navigator.getByRole('button', { name: 'focus.html', exact: true }).click()
await column.locator(`[data-dockkit-tab="${htmlFilesTab}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${htmlFilesTab}"]`).click()
await expect.poll(() => path.textContent()).toContain('focus.html')
const frame = body.locator('[data-html-preview]')
await frame.waitFor()
for (const narrow of ['row', 'viewport'] as const) {
await toggle.focus()
if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
else await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
await page.keyboard.press('Tab')
expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await page.setViewportSize({ width: 1680, height: 1000 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
await frame.focus()
if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
else await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
expect(await frame.evaluate(node => document.activeElement === node)).toBe(false)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await page.setViewportSize({ width: 1680, height: 1000 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
}
// A preview dragged out into a floating panel keeps where the reader was in it.
await tabs.filter({ hasText: 'b.txt' }).click()
await expect.poll(() => path.textContent()).toContain('b.txt')
await body.evaluate((node) => { node.scrollTop = 600 })
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(600)
const surface = await page.locator('[data-dockkit-surface]').first().boundingBox()
if (surface === null) throw new Error('surface is not rendered')
await drag(page, tabs.filter({ hasText: 'b.txt' }), { x: surface.x - 240, y: surface.y + surface.height / 2 })
const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
await expect.poll(() => float.count()).toBe(1)
const floatPath = float.locator('[data-textpreview-path]')
await floatPath.waitFor()
const floatBody = float.locator('[data-textpreview-state] [data-textpreview-body]')
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
// The panel is a new pane, so its tree starts hidden; shown, it draws there too, and the panel keeps its resize handle.
const floatToggle = float.locator('[data-files-navigator-toggle]')
expect(await floatToggle.getAttribute('aria-expanded')).toBe('false')
expect(await float.locator('[data-files-navigator]').count()).toBe(0)
await floatToggle.click()
const before = await float.boundingBox()
if (before === null) throw new Error('float is not rendered')
const resize = float.locator('[data-dockkit-float-resize]')
await drag(page, resize, { x: before.x + Math.max(before.width + 80, 560), y: before.y + before.height + 60 })
await expect.poll(async () => (await float.boundingBox())?.width ?? before.width).toBeGreaterThan(before.width)
// Wide enough that the tree does not fill the panel.
expect((await float.boundingBox())!.width).toBeGreaterThanOrEqual(440)
const floatTree = float.locator('[data-files-navigator]')
await floatTree.waitFor()
expect(await floatToggle.getAttribute('aria-expanded')).toBe('true')
// Resize while the tree is open, through both sides of its fill breakpoint.
const openBox = await float.boundingBox()
if (openBox === null) throw new Error('floating preview is not rendered')
await resizeFloat(page, float, 360, openBox.height - 20)
await float.locator('[data-files-navigator-mode="fill"]').waitFor()
await resizeFloat(page, float, 560, openBox.height)
expect(await floatTree.count()).toBe(1)
const floatPane = await float.getAttribute('data-dockkit-float')
// A file nothing shows replaces the preview in its panel, which keeps its pane and its tree.
await floatTree.getByRole('button', { name: 'README.md', exact: true }).click()
await expect.poll(() => floatPath.textContent()).toContain('README.md')
expect(await float.count()).toBe(1)
expect(await float.getAttribute('data-dockkit-float')).toBe(floatPane)
expect(await float.locator('[data-files-navigator] [aria-current="true"]').textContent()).toBe('README.md')
// Navigating to the floating preview itself exposes its file when its tree fills the row.
const previewHeight = (await float.boundingBox())!.height
await resizeFloat(page, float, 360, previewHeight)
await float.locator('[data-files-navigator-mode="fill"]').waitFor()
await floatTree.getByRole('button', { name: 'README.md', exact: true }).click()
await expect.poll(() => floatToggle.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => floatToggle.evaluate(node => node === document.activeElement)).toBe(true)
await floatToggle.click()
await floatTree.waitFor()
// A file already shown is revealed in the column, which first shows another tab; the panel keeps its file.
// Revealing b.txt opened `src`.
const shown = column.locator('[data-dockkit-tab][aria-selected="true"]')
await column.locator('[data-dockkit-add-tab]').click()
await expect.poll(() => shown.textContent()).not.toContain('a.txt')
const guide = await shown.getAttribute('data-dockkit-tab')
const dockedTabs = await tabs.count()
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
const sourceFile = floatTree.getByRole('button', { name: 'a.txt', exact: true })
await sourceFile.click()
await expect.poll(() => shown.textContent()).toContain('a.txt')
const dockPreview = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible')
await expect.poll(() => dockPreview.locator('[data-files-navigator-toggle]').getAttribute('aria-expanded')).toBe('false')
expect(await dockPreview.locator('[data-textpreview-body]').evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
expect(await floatToggle.getAttribute('aria-expanded')).toBe('true')
expect(await sourceFile.evaluate(node => node === document.activeElement)).toBe(true)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await resizeFloat(page, float, 560, previewHeight)
expect(await tabs.count()).toBe(dockedTabs)
expect(await floatPath.textContent()).toContain('README.md')
expect(await float.count()).toBe(1)
expect(await tabs.filter({ hasText: 'a.txt' }).count()).toBe(1)
// The column offers a new tab again only once its guide closes.
await column.locator(`[data-dockkit-tab="${guide}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${guide}"]`).click()
await expect.poll(() => column.locator(`[data-dockkit-tab="${guide}"]`).count()).toBe(0)
// A floating files page stays and opens a file nothing shows in the active docked pane.
await column.locator('[data-dockkit-add-tab]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-page]').waitFor()
await drag(page, tabs.filter({ hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 })
await expect.poll(() => float.count()).toBe(2)
const pageFloat = float.filter({ has: page.locator('[data-files-page]') })
const filePageBox = await pageFloat.boundingBox()
if (filePageBox === null) throw new Error('floating files page is not rendered')
await resizeFloat(page, pageFloat, 360, filePageBox.height - 20)
await page.setViewportSize({ width: 390, height: 844 })
const phoneBox = await pageFloat.boundingBox()
if (phoneBox === null) throw new Error('phone-width files page is not rendered')
await resizeFloat(page, pageFloat, 340, phoneBox.height - 20)
expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1)
await page.setViewportSize({ width: 1680, height: 1000 })
const docked = await tabs.count()
await pageFloat.getByRole('button', { name: 'src', exact: true }).click()
await pageFloat.getByRole('button', { name: 'c.txt', exact: true }).click()
await column.locator('[data-textpreview-path]:visible', { hasText: 'c.txt' }).waitFor()
expect(await pageFloat.count()).toBe(1)
expect(await float.count()).toBe(2)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await tabs.count()).toBe(docked + 1)
// A file already shown is revealed in the column, and the floating page stays with its tree.
await pageFloat.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => column.locator('[data-dockkit-pane-active] [data-dockkit-tab][aria-selected="true"]').textContent()).toContain('a.txt')
expect(await pageFloat.count()).toBe(1)
expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1)
expect(await float.count()).toBe(2)
expect(await tabs.count()).toBe(docked + 1)
// With the docked area emptied and the column collapsed, a file from the floating page gets a docked pane in the expanded column.
const dockTabs = column.locator('[data-dockkit-pane] [data-dockkit-tab]')
while (await dockTabs.count() > 0) {
const id = await dockTabs.first().getAttribute('data-dockkit-tab')
await dockTabs.first().hover()
await column.locator(`[data-dockkit-tab-close="${id}"]`).click()
await expect.poll(() => column.locator(`[data-dockkit-tab="${id}"]`).count()).toBe(0)
}
const expand = page.locator('[data-sidebar-right-expand]')
await expand.waitFor()
expect(await pageFloat.count()).toBe(1)
await pageFloat.getByRole('button', { name: 'b.txt', exact: true }).click()
await column.locator('[data-textpreview-path]:visible', { hasText: 'b.txt' }).waitFor()
// Past the column's slide, it stays open with the file's tab alone: no default page beside it.
await expect.poll(() => column.evaluate(node => node.getAnimations({ subtree: true })
.filter(animation => animation.playState === 'running').length)).toBe(0)
expect(await expand.count()).toBe(0)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await dockTabs.count()).toBe(1)
expect(await dockTabs.first().textContent()).toContain('b.txt')
expect(await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(0)
expect((await column.boundingBox())?.width ?? 0).toBeGreaterThan(200)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
})
it('moves a file between the docked column and a floating panel from the file menu, keeping its reading position', async () => {
// The file menu's open actions belong to Open In, which ordinary scenarios leave out; remote launch facts suppress native probes.
const { page, errors } = await openWorkspace('web-e2e-file-browser-destination',
createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]))
const column = page.locator('[data-rightbar-col]')
const expand = page.locator('[data-sidebar-right-expand]')
const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
const docked = (name: string) => column.locator('[data-dockkit-pane] [data-dockkit-tab]', { hasText: name })
const menu = page.locator('[data-context-menu][role="menu"]')
// The files page floats and the column collapses, so every later open starts from a floating source pane.
await expand.click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-page]').waitFor()
const surface = await page.locator('[data-dockkit-surface]').first().boundingBox()
if (surface === null) throw new Error('surface is not rendered')
await drag(page, column.locator('[data-dockkit-tab]', { hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 })
await expect.poll(() => float.count()).toBe(1)
await column.locator('[data-sidebar-right-toggle]').click()
await expand.waitFor()
const pageFloat = float.filter({ has: page.locator('[data-files-page]') })
await pageFloat.getByRole('button', { name: 'src', exact: true }).click()
const row = pageFloat.getByRole('button', { name: 'a.txt', exact: true })
const choose = async (item: string): Promise<void> => {
await row.click({ button: 'right' })
await menu.waitFor()
await menu.getByRole('menuitem', { name: item, exact: true }).click()
await menu.waitFor({ state: 'detached' })
}
await row.click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).waitFor()
expect(await menu.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).count()).toBe(1)
await page.keyboard.press('Escape')
await menu.waitFor({ state: 'detached' })
// A file nothing shows opens in its own floating panel; the collapsed column stays collapsed.
await choose('Open in floating panel')
await expect.poll(() => float.count()).toBe(2)
const fileFloat = float.filter({ has: page.locator('[data-textpreview-path]', { hasText: 'a.txt' }) })
const floatBody = fileFloat.locator('[data-textpreview-state] [data-textpreview-body]')
await floatBody.waitFor()
expect(await expand.count()).toBe(1)
expect(await docked('a.txt').count()).toBe(0)
await expect.poll(() => floatBody.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true)
await floatBody.evaluate((node) => { node.scrollTop = 600 })
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
// Asking again reveals that panel rather than adding one.
await choose('Open in floating panel')
expect(await float.count()).toBe(2)
expect(await fileFloat.count()).toBe(1)
expect(await expand.count()).toBe(1)
// The floating file moves into the expanded column, where the reader left off.
await choose('Open in sidebar')
await expect.poll(() => fileFloat.count()).toBe(0)
expect(await float.count()).toBe(1)
await expect.poll(() => expand.count()).toBe(0)
await expect.poll(() => docked('a.txt').count()).toBe(1)
const dockBody = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible [data-textpreview-body]')
await expect.poll(() => dockBody.evaluate(node => node.scrollTop)).toBe(600)
// And back out into a floating panel, leaving the column open and its strip without the file.
await choose('Open in floating panel')
await expect.poll(() => fileFloat.count()).toBe(1)
expect(await float.count()).toBe(2)
await expect.poll(() => docked('a.txt').count()).toBe(0)
expect(await expand.count()).toBe(0)
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
})