Files

443 lines
22 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test"
import { seed, sessionHref, type SeedInput } from "../utils/app"
import { browseFiles, openFileLauncher } from "../utils/side-panel"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { fileNode } from "../utils/workspace"
// The source and target sessions; the child session is not a home row.
const rows = 2
async function openStress(page: Page, extra: SeedInput = {}) {
await seed(page, {
usedProjects: { local: [fixture.project.id] },
tabs: [fixture.sourceID, fixture.targetID],
...extra,
})
}
for (const position of ["top", "bottom"] as const) {
test(`mobile session tabs switch views and work with ${position} navigation`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, { fileList: () => [] })
await openStress(page, {
settings: { general: { mobileTitlebarPosition: position } },
})
await page.goto(sessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(navigation).toHaveCount(1)
await expect(message).toBeVisible()
await expect(composer).toBeVisible()
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "More..."])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
if (position === "top") {
const titlebar = page.locator('[data-slot="titlebar-v2"]')
await expect(titlebar).toHaveCSS("padding-top", "8px")
await expect(titlebar).toHaveCSS("height", "36px")
}
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 1 && bounds.x <= 2 && bounds.width >= 386 && bounds.width <= 388
})
.toBe(true)
await expect
.poll(async () => {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const dock = await page.locator('[data-component="session-composer-dock"]').boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
if (!bar || !input || !dock || !panel) return false
if (position === "bottom")
return bar.y >= dock.y + dock.height && Math.abs(bar.y + bar.height - panel.y - panel.height) <= 1
return Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
await expect(page.locator("[data-session-title]")).toHaveCount(0)
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
await expect(drawer).toHaveAttribute("data-open", "")
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen")).toBeVisible()
await page.getByRole("button", { name: "Back to app", exact: true }).click()
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await more.click()
await expect(views).toBeVisible()
await expect(more).toHaveAttribute("aria-expanded", "true")
for (const name of ["Files", "Terminal", "Usage", "Session details"])
await expect(views.getByRole("button", { name, exact: true })).toBeVisible()
await expect(views).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(views).toBeHidden()
await expect(more).toHaveAttribute("aria-expanded", "false")
await expect(more).toBeFocused()
await expect(picker).toHaveText("Session")
await more.press("Enter")
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
const usage = page.locator('[data-slot="session-usage-content"]')
await expect(usage).toHaveCSS("padding-top", "16px")
await expect(usage).toHaveCSS("padding-inline-start", "16px")
await expect(usage).toHaveCSS("padding-inline-end", "16px")
await expect(composer).toBeHidden()
await more.click()
await expect(views.getByRole("button", { name: "Usage", exact: true })).toHaveAttribute("aria-pressed", "true")
await expect(views.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await views.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
await views.getByRole("button", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details).toHaveAttribute("data-drawer-probe", "single")
await expect(page.getByRole("dialog")).toHaveCount(1)
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px")
await expect(details.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
await expect(details.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await views.getByRole("button", { name: "Session details", exact: true }).click()
await expect(details.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(details).not.toHaveAttribute("data-transitioning")
await page.keyboard.press("Escape")
await expect(details).toBeHidden()
await expect(more).toBeFocused()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await page.locator('[data-slot="session-location-bar"] [data-slot="session-location-trigger"]').click()
const projectMenu = page.locator('[data-slot="session-location-card"]')
await projectMenu.getByRole("menuitem", { name: "No changes", exact: true }).click()
await expect(projectMenu).toBeHidden()
await expect(picker).toHaveText("Changes")
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
await expect(composer).toBeHidden()
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(views).toBeHidden()
await expect(picker).toHaveText("More...")
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
await expect(composer).toBeHidden()
await more.click()
await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Escape")
await expect(views).toBeHidden()
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
await expect(message).toBeVisible()
// The view resets to Session whenever the routed session changes, including through Home.
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const openTabs = async () => {
await trigger.click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
}
const openTab = async (title: string) => {
await openTabs()
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(title)
}
await more.click()
await views.getByRole("button", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTab(fixture.expected.targetTitle)
await expect(picker).toHaveText("Session")
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(picker).toHaveText("More...")
await openTabs()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await openTab(fixture.expected.sourceTitle)
await expect(picker).toHaveText("Session")
await page.setViewportSize({ width: 1280, height: 900 })
await expect(picker).toBeHidden()
await expect(page.locator("[data-session-title]")).toBeVisible()
})
}
test("the project menu's changes row switches the view and keeps the side tabs", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await mockStressTimeline(page, {
fileList: (path) => (path ? [] : [fileNode(fixture.directory, "README.md")]),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
})
await openStress(page)
await page.goto(sessionHref(fixture.targetID))
// One click previews the file in place of the Open file tab.
const panel = page.locator("#review-panel")
const readme = panel.getByRole("tab", { name: "README.md", exact: true })
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await openFileLauncher(panel)
await panel.getByRole("treeitem", { name: "README.md", exact: true }).click()
await expect(readme).toHaveAttribute("aria-selected", "true")
await expect(panel.getByRole("tab", { name: "Open file", exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const more = tabs.getByRole("tab", { name: "More...", exact: true })
const views = page.getByRole("dialog", { name: "More options", exact: true })
await page.locator('[data-slot="session-location-bar"] [data-slot="session-location-trigger"]').click()
const projectMenu = page.locator('[data-slot="session-location-card"]')
await projectMenu.getByRole("menuitem", { name: "No changes", exact: true }).click()
await expect(projectMenu).toBeHidden()
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
await expect(
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 1280, height: 900 })
await expect(readme).toHaveAttribute("aria-selected", "true")
// After a reload forgets the preview, a stored Open file tab is still the slot a narrow-screen file open replaces.
const launcher = panel.getByRole("tab", { name: "Open file", exact: true })
await browseFiles(page)
await expect(launcher).toHaveAttribute("aria-selected", "true")
// The launcher opens beside the preview; closing the file leaves only the launcher stored.
await readme.click({ button: "middle" })
await expect(readme).toHaveCount(0)
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.reload()
await expect(launcher).toHaveAttribute("aria-selected", "true")
await page.setViewportSize({ width: 390, height: 844 })
await more.click()
await views.getByRole("button", { name: "Files", exact: true }).click()
const files = page.locator('[data-slot="session-mobile-files"]')
await files.getByRole("treeitem", { name: "README.md", exact: true }).click()
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 900 })
await expect(readme).toHaveAttribute("aria-selected", "true")
await expect(launcher).toHaveCount(0)
})
test("a narrow-screen palette pick keeps the view, and the side region shows the file when wide", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page, {
fileList: (path) => (path ? [] : [fileNode(fixture.directory, "README.md")]),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: ({ query }) => ("README.md".includes(query) ? [fileNode(fixture.directory, "README.md")] : []),
persistentPty: {},
})
await openStress(page)
await page.goto(sessionHref(fixture.targetID))
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
const terminal = page.locator("#terminal-panel")
const views = page.getByRole("dialog", { name: "More options", exact: true })
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
await views.getByRole("button", { name: "Terminal", exact: true }).click()
await expect(views).toBeHidden()
await expect(terminal.locator("textarea")).toBeEditable()
await page.keyboard.press("ControlOrMeta+p")
const dialog = page.getByRole("dialog")
await dialog.getByRole("combobox").fill("README")
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("More...")
await expect(terminal).toBeVisible()
await page.setViewportSize({ width: 1280, height: 900 })
const panel = page.locator("#review-panel")
await expect(panel.getByRole("tab", { name: "README.md", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
})
test.describe("touch", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await mockStressTimeline(page)
await openStress(page)
await page.goto("/")
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
})
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
const projects = page.getByRole("button", { name: "Projects", exact: true })
await projects.click()
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
await expect(picker).toBeHidden()
await expect(projects).toContainText(fixture.project.name)
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveCount(0)
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
await expect(help).toBeVisible()
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(fixture.expected.sourceTitle)
await expect(trigger).toBeFocused()
await trigger.click()
await drawer.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(drawer).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
})
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await page
.locator('[data-slot="mobile-tabs-drawer"]')
.getByRole("button", { name: "Settings", exact: true })
.click()
const settings = page.getByTestId("settings-screen")
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
await expect(settings).toBeVisible()
await menu.click()
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
await expect
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
})
test.describe("tab drawer", () => {
test.beforeEach(async ({ page }) => {
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
})
test("reorders session tabs with touch", async ({ page }) => {
const tabs = page.locator('[data-slot="vertical-tabs"] a')
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
const targetBox = await target.boundingBox()
const sourceBox = await source.boundingBox()
expect(targetBox).not.toBeNull()
expect(sourceBox).not.toBeNull()
await touchDrag(page, source, {
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
})
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
})
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
const homeBox = await drawer.getByRole("button", { name: "Home", exact: true }).boundingBox()
const box = await tab.boundingBox()
expect(homeBox).not.toBeNull()
expect(box).not.toBeNull()
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
const touch = { pointerType: "touch", pointerId: 1, isPrimary: true, clientX: point.x, clientY: point.y }
await tab.dispatchEvent("pointerdown", touch)
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
await expect(rename).toBeVisible()
await expect(drawer).toBeVisible()
await tab.filter({ visible: true }).dispatchEvent("pointerup", touch)
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
await expect(rename).toBeHidden()
await expect(drawer).toBeVisible()
})
})
})
async function touchDrag(
page: Page,
source: Locator,
input: { from: { x: number; y: number }; to: { x: number; y: number } },
) {
const client = await page.context().newCDPSession(page)
await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ ...input.from, id: 1 }] })
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
})
await expect(source).toHaveCount(2)
await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ ...input.to, id: 1 }] })
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
}