diff --git a/packages/app/e2e/regression/session-header-controls.spec.ts b/packages/app/e2e/regression/session-header-controls.spec.ts index e8db2a2782ef..224ba79d9c91 100644 --- a/packages/app/e2e/regression/session-header-controls.spec.ts +++ b/packages/app/e2e/regression/session-header-controls.spec.ts @@ -23,6 +23,7 @@ for (const direction of ["ltr", "rtl"] as const) { await page.goto(stressSessionHref(fixture.targetID)) const header = page.locator("[data-session-title]") const more = header.getByRole("button", { name: "More options", exact: true }) + const project = header.getByRole("button", { name: fixture.project.name, exact: true }) const review = header.getByRole("button", { name: "Toggle review", exact: true }) const details = header.getByRole("button", { name: "Session details", exact: true }) await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) @@ -31,22 +32,40 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(details).toBeVisible() const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" }) await expect(status).toBeVisible() - await expect - .poll(async () => { - const boxes = await Promise.all( - [header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()), - ) - const [title, menu, sidebar, summary] = boxes - if (!title || !menu || !sidebar || !summary) return false - return direction === "ltr" - ? Math.abs(title.x + title.width - menu.x) <= 1 && - menu.x + menu.width <= summary.x && - summary.x + summary.width <= sidebar.x - : Math.abs(menu.x + menu.width - title.x) <= 1 && - sidebar.x + sidebar.width <= summary.x && - summary.x + summary.width <= menu.x - }) - .toBe(true) + const titleBounds = await header.getByRole("heading").boundingBox() + expect(titleBounds).not.toBeNull() + for (const editing of [false, true]) { + if (editing) { + await header.getByRole("heading").click() + await expect(header.getByRole("textbox")).toHaveValue(fixture.expected.targetTitle) + await expect(header.getByRole("textbox")).toBeFocused() + } + await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-left", "4px") + await expect(header.locator('[data-slot="session-title-child"]')).toHaveCSS("padding-right", "4px") + await expect + .poll(async () => { + const boxes = await Promise.all( + [project, header.locator('[data-slot="session-title-child"]'), more, review, details].map((control) => + control.boundingBox(), + ), + ) + const [icon, title, menu, sidebar, summary] = boxes + if (!icon || !title || !menu || !sidebar || !summary || !titleBounds) return false + if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false + return direction === "ltr" + ? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 && + Math.abs(menu.x - title.x - title.width - 2) <= 0.5 && + menu.x + menu.width <= summary.x && + summary.x + summary.width <= sidebar.x + : Math.abs(icon.x - title.x - title.width - 2) <= 0.5 && + Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 && + sidebar.x + sidebar.width <= summary.x && + summary.x + summary.width <= menu.x + }) + .toBe(true) + } + await header.getByRole("textbox").press("Escape") + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) await review.click() await expect(review).toHaveAttribute("aria-expanded", "true") @@ -55,6 +74,52 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(review).toHaveAttribute("aria-expanded", "false") await more.click() + const options = page.getByRole("menu") + await expect(options.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"]) + if (direction === "ltr") { + await expect + .poll(async () => { + const [button, menu] = await Promise.all([ + header.getByRole("button", { name: "More options", exact: true, includeHidden: true }).boundingBox(), + options.boundingBox(), + ]) + return button && menu ? Math.abs(button.x - menu.x) : Infinity + }) + .toBeLessThanOrEqual(1) + } + await expect + .poll(() => + options.evaluate((element) => { + const menu = element.getBoundingClientRect() + const rtl = getComputedStyle(element).direction === "rtl" + return Math.min( + ...Array.from(element.querySelectorAll('[data-slot="menu-v2-item-content"]'), (label) => { + const range = document.createRange() + range.selectNodeContents(label) + const text = range.getBoundingClientRect() + return rtl ? text.left - menu.left : menu.right - text.right + }), + ) + }), + ) + .toBeCloseTo(32, 0) + await expect + .poll(() => + options.evaluate((element) => { + const menu = element.getBoundingClientRect() + const divider = element.querySelector('[data-slot="menu-v2-separator"]')?.getBoundingClientRect() + const rows = Array.from(element.querySelectorAll('[role="menuitem"]'), (row) => row.getBoundingClientRect()) + return ( + !!divider && + Math.abs(divider.left - menu.left) <= 0.5 && + Math.abs(divider.right - menu.right) <= 0.5 && + rows.every( + (row) => Math.abs(row.left - menu.left - 2) <= 0.5 && Math.abs(menu.right - row.right - 2) <= 0.5, + ) + ) + }), + ) + .toBe(true) await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0) await page.keyboard.press("Escape") await status.click() diff --git a/packages/app/e2e/regression/session-project-menu.spec.ts b/packages/app/e2e/regression/session-project-menu.spec.ts new file mode 100644 index 000000000000..f387c7f5fba4 --- /dev/null +++ b/packages/app/e2e/regression/session-project-menu.spec.ts @@ -0,0 +1,250 @@ +import { expect, test } from "@playwright/test" +import { dict } from "../../src/runtime/i18n/ar" +import en from "../../src/runtime/i18n/en" +import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers" +import { mockOpenCodeServer } from "../utils/mock-server" + +test.use({ serviceWorkers: "block" }) + +for (const direction of ["ltr", "rtl"] as const) { + for (const workspace of [false, true]) { + test(`session project menu for ${workspace ? "worktree" : "local"} in ${direction}`, async ({ page }) => { + const copy = direction === "rtl" ? dict : en + const directory = workspace + ? "C:/OpenCode/Worktrees/مشروع-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name-to-exercise-the-full-path-tooltip" + : fixture.directory + const project = { + ...fixture.project, + name: workspace + ? "مشروع Timeline 42 with a long project name that needs truncation and enough additional text to wrap inside the tooltip" + : "Timeline project", + sandboxes: workspace ? [directory] : [], + icon: { + url: `data:image/svg+xml,${encodeURIComponent('')}`, + }, + } + await mockOpenCodeServer(page, { + directory, + project, + sessions: fixture.sessions.map((session) => ({ ...session, directory })), + provider: fixture.provider, + pageMessages, + }) + await installStressSessionTabs(page) + await page.addInitScript((direction) => { + localStorage.setItem( + "opencode.global.dat:language", + JSON.stringify({ locale: direction === "rtl" ? "ar" : "en" }), + ) + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, general: { ...settings.general, showProjectIcon: false } }), + ) + }, direction) + await page.setViewportSize({ width: workspace ? 900 : 1440, height: 900 }) + await page.goto(stressSessionHref(fixture.targetID)) + const header = page.locator("[data-session-title]") + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + await expect(page.locator("html")).toHaveAttribute("dir", direction) + + const trigger = header.getByRole("button", { name: project.name, exact: true }) + await expect(trigger).toBeEnabled() + await expect(trigger.locator("use")).toHaveAttribute( + "href", + `#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`, + ) + const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor) + await trigger.hover() + await expect(trigger).not.toHaveCSS("background-color", background) + await expect(page.getByRole("tooltip")).toHaveText(project.name) + await trigger.click() + + const menu = page.getByRole("menu", { name: project.name, exact: true }) + const settings = menu.getByRole("menuitem", { name: "Edit project", exact: true }) + const projectItem = menu.getByRole("menuitem", { name: project.name, exact: true }) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(page.getByRole("tooltip")).toBeHidden() + await expect(menu.getByText(project.name, { exact: true })).toBeVisible() + await expect(menu.locator('[data-slot="project-avatar-image"]')).toHaveAttribute("src", project.icon.url) + await expect(menu.getByText(directory, { exact: true })).toBeVisible() + await expect(menu.getByText(directory, { exact: true })).toHaveAttribute("dir", "ltr") + await expect(menu.locator('use[href="#opencode-v2-icon-folder"]')).toHaveCount(1) + await expect(menu).toHaveCSS("direction", direction) + await expect(menu.getByRole("menuitem")).toHaveText([project.name, directory, "Edit project"]) + await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled() + await expect(settings).toBeEnabled() + await expect + .poll(() => menu.evaluate((element) => element.getBoundingClientRect().width)) + .toBeLessThanOrEqual(320) + for (const text of [project.name, directory]) { + const label = menu.getByText(text, { exact: true }) + await expect(label).toHaveCSS("text-overflow", "ellipsis") + await expect(label).toHaveCSS("white-space", "nowrap") + if (workspace) { + await expect.poll(() => label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true) + } + } + await expect.poll(() => menu.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + const icons = menu.locator( + '[data-component="project-avatar-v2"], [data-slot="icon-svg"]:not([data-slot="session-project-open-icon"] *)', + ) + await expect(icons).toHaveCount(3) + await expect + .poll(async () => { + const [button, centers] = await Promise.all([ + trigger.boundingBox(), + icons.evaluateAll((elements) => + elements.map((element) => { + const box = element.getBoundingClientRect() + return box.x + box.width / 2 + }), + ), + ]) + return !!button && centers.every((center) => Math.abs(center - button.x - button.width / 2) <= 1) + }) + .toBe(true) + + if (!workspace) await page.clock.install() + for (const text of [project.name, directory]) { + const label = menu.getByText(text, { exact: true }) + const item = menu.getByRole("menuitem", { name: text, exact: true }) + const anchor = item.locator("..") + const openIcon = item.locator('[data-slot="session-project-open-icon"]') + const content = item.locator(".session-project-link-content") + const width = await label.evaluate((element) => element.getBoundingClientRect().width) + await expect(openIcon).toHaveCount(text === directory ? 1 : 0) + await anchor.hover() + await expect(content).toHaveCSS("mask-image", "none") + await expect.poll(() => label.evaluate((element) => element.getBoundingClientRect().width)).toBe(width) + if (text === directory) { + await expect(openIcon).toHaveCSS("opacity", "0") + await expect(openIcon.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-up-right") + await expect + .poll(() => openIcon.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width)) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const [row, icon] = await Promise.all([item.boundingBox(), openIcon.boundingBox()]) + if (!row || !icon) return false + return ( + Math.abs(row.y + row.height / 2 - icon.y - icon.height / 2) <= 0.5 && + Math.abs((direction === "rtl" ? icon.x - row.x : row.x + row.width - icon.x - icon.width) - 12) <= 0.5 + ) + }) + .toBe(true) + } + await expect(label).toHaveCSS("cursor", "default") + await expect(anchor).toHaveCSS("cursor", "default") + const tooltip = page.getByRole("tooltip") + if (workspace) { + await expect(tooltip).toHaveText(text) + await expect(tooltip).toHaveCSS("white-space", "normal") + await expect + .poll(() => tooltip.evaluate((element) => element.getBoundingClientRect().width)) + .toBeLessThanOrEqual(480) + await expect + .poll(() => + tooltip + .getByText(text, { exact: true }) + .evaluate( + (element) => + element.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(element).lineHeight), + ), + ) + .toBe(true) + await expect + .poll(async () => { + const [row, tip] = await Promise.all([anchor.boundingBox(), tooltip.boundingBox()]) + return !!row && !!tip && Math.abs(row.y - tip.y - tip.height - 2) <= 1 + }) + .toBe(true) + } + if (!workspace) { + await page.clock.runFor(500) + await expect(tooltip).toBeHidden() + } + await settings.hover() + await expect(tooltip).toBeHidden() + if (text === directory) await expect(openIcon).toHaveCSS("opacity", "0") + await expect(content).toHaveCSS("mask-image", "none") + } + + await page.keyboard.press("Escape") + await expect(menu).toBeHidden() + await expect(trigger).toBeFocused() + await trigger.press("ArrowDown") + await expect(projectItem).toBeFocused() + await page.keyboard.press("ArrowDown") + await expect(settings).toBeFocused() + await page.keyboard.press("Enter") + const dialog = page.getByRole("dialog") + await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible() + await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue( + project.name, + ) + await expect(menu).toBeHidden() + await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click() + await expect(dialog).toBeHidden() + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + + await page.setViewportSize({ width: 1440, height: 900 }) + for (const selected of [false, true]) { + if (selected) { + await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click() + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + } + await trigger.click() + await expect(projectItem).toBeEnabled() + const background = await projectItem.evaluate((element) => getComputedStyle(element).backgroundColor) + await projectItem.hover() + await expect(projectItem).not.toHaveCSS("background-color", background) + await projectItem.click() + await expect(page).toHaveURL(new URL("/", page.url()).href) + await expect(menu).toBeHidden() + const projectRow = page.locator('[data-component="home-project-row"]').filter({ hasText: project.name }) + await expect(projectRow).toBeVisible() + await expect(projectRow).toHaveAttribute("data-selected", "") + await expect( + page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`), + ).toBeVisible() + } + }) + } +} + +test("path arrow has a glyph when the page has an older icon sprite", async ({ page }) => { + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + sessions: fixture.sessions, + provider: fixture.provider, + pageMessages, + }) + await installStressSessionTabs(page) + await page.route( + (url) => url.pathname === stressSessionHref(fixture.targetID), + async (route) => { + const response = await route.fetch() + await route.fulfill({ + response, + body: (await response.text()).replace( + '