diff --git a/packages/app/e2e/regression/session-actions-menu.spec.ts b/packages/app/e2e/regression/session-actions-menu.spec.ts new file mode 100644 index 0000000000..5154d04782 --- /dev/null +++ b/packages/app/e2e/regression/session-actions-menu.spec.ts @@ -0,0 +1,203 @@ +import { expect, test, type Locator, type Page, type Request } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// Rename, Share, Archive and Delete used to be reachable only from the menu +// beside a session title, i.e. only after opening the session. The same menu now +// hangs off every row in the sidebar's Recents and on the "View all" list, and +// the four items are one component behind all three call sites — so what is +// worth testing is that a row can actually drive each action, on both lists. + +const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]') +const sidebarRows = (page: Page) => sidebar(page).locator('[data-component="sidebar-session-row"]') +const homeRows = (page: Page) => page.locator('[data-component="home-session-row"]') +// The menu is a sibling of the row button, not a child of it — both sit in the +// wrapper that owns the hover state. +const rowMenu = (row: Locator) => row.locator("xpath=..").locator('[data-action="session-menu"]') + +const item = (page: Page, name: string | RegExp) => page.getByRole("menuitem", { name }) +const dialog = (page: Page) => page.locator('[data-slot="dialog-content"]') + +// Hover to reveal the trigger, then click it once. The hover is not optional: +// the trigger is pointer-events-none until its row is hovered, and Playwright +// hit-tests before it moves the mouse, so a bare click never becomes actionable. +async function openRowMenu(row: Locator) { + await row.hover() + await rowMenu(row).click() + await expect(rowMenu(row)).toHaveAttribute("aria-expanded", "true") +} + +type Sent = { method: string; url: string } + +async function boot(page: Page, options?: { share?: string }) { + const sent: Sent[] = [] + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + if (options?.share !== undefined) { + // Registered after the mock so it wins the route: sharing is read off the + // directory-scoped config, which the mock otherwise answers with {}. + await page.route("**/config**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ share: options.share }), + }), + ) + } + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + page.on("request", (request: Request) => { + if (request.method() === "GET" || request.method() === "OPTIONS") return + sent.push({ method: request.method(), url: request.url() }) + }) + await page.goto("/") + await expectAppVisible(sidebar(page)) + await expectAppVisible(sidebarRows(page).first()) + return { sent } +} + +const wroteTo = (sent: Sent[], sessionID: string, method?: string) => + sent.filter((request) => request.url.includes(`/session/${sessionID}`) && (!method || request.method === method)) + +test("reveals the trigger on hover, and keeps it out of the way otherwise", async ({ page }) => { + await boot(page) + const row = sidebarRows(page).first() + + // Hidden but present: a row at rest is just its title, and the dots fade in + // under the pointer. Faded rather than unmounted, so the row does not reflow + // as the pointer crosses the list — hence opacity rather than visibility, an + // opacity-0 button being both "visible" and "in viewport" as far as Playwright + // is concerned. The fade lives on the wrapper, not on the button. + const reveal = rowMenu(row).locator("xpath=..") + await expect(rowMenu(row)).toHaveCount(1) + await expect(reveal).toHaveCSS("opacity", "0") + + await row.hover() + await expect(reveal).toHaveCSS("opacity", "1") +}) + +test("a sidebar row offers the same four actions as the session header", async ({ page }) => { + await boot(page) + + await openRowMenu(sidebarRows(page).first()) + + await expect(item(page, "Rename")).toBeVisible() + await expect(item(page, /^Share/)).toBeVisible() + await expect(item(page, "Archive")).toBeVisible() + await expect(item(page, /^Delete/)).toBeVisible() +}) + +test("hides Share when the server has sharing disabled", async ({ page }) => { + await boot(page, { share: "disabled" }) + + await openRowMenu(sidebarRows(page).first()) + + // Offering it here would open the session and then leave the share popover + // refusing to appear, which is worse than not offering it at all. + await expect(item(page, "Rename")).toBeVisible() + await expect(item(page, "Archive")).toBeVisible() + await expect(item(page, /^Share/)).toHaveCount(0) +}) + +test("renames a session from its row, without opening it", async ({ page }) => { + const { sent } = await boot(page) + + await openRowMenu(sidebarRows(page).first()) + await item(page, "Rename").click() + + // Renaming used to mean opening the session first; the row edits in place and + // the route does not move. + const input = sidebar(page).locator('[data-component="inline-input"]') + await expectAppVisible(input) + await expect(input).toBeFocused() + expect(new URL(page.url()).pathname).toBe("/") + + await input.fill("Renamed from the sidebar") + await input.press("Enter") + + await expect(input).toHaveCount(0) + await expect.poll(() => wroteTo(sent, fixture.targetID).length).toBeGreaterThan(0) +}) + +test("sends nothing when a rename is cancelled", async ({ page }) => { + const { sent } = await boot(page) + + await openRowMenu(sidebarRows(page).first()) + await item(page, "Rename").click() + + const input = sidebar(page).locator('[data-component="inline-input"]') + await expectAppVisible(input) + await input.fill("Discarded") + await input.press("Escape") + + await expect(input).toHaveCount(0) + await expect(sidebarRows(page).first()).toHaveAttribute("title", fixture.expected.targetTitle) + expect(wroteTo(sent, fixture.targetID)).toEqual([]) +}) + +test("asks before deleting, and does nothing when the ask is declined", async ({ page }) => { + const { sent } = await boot(page) + + await openRowMenu(sidebarRows(page).first()) + await item(page, /^Delete/).click() + + await expectAppVisible(dialog(page)) + // The confirmation names the session, because the row it was opened from is + // no longer the thing you are looking at, and a delete takes the session's + // children with it. + await expect(dialog(page)).toContainText(fixture.expected.targetTitle) + + await dialog(page).getByRole("button", { name: "Cancel" }).click() + await expect(dialog(page)).toHaveCount(0) + expect(wroteTo(sent, fixture.targetID, "DELETE")).toEqual([]) + await expect(sidebarRows(page)).toHaveCount(2) +}) + +test("deletes the session once the ask is confirmed", async ({ page }) => { + const { sent } = await boot(page) + + await openRowMenu(sidebarRows(page).first()) + await item(page, /^Delete/).click() + + await expectAppVisible(dialog(page)) + await dialog(page).getByRole("button", { name: "Delete session" }).click() + + await expect(dialog(page)).toHaveCount(0) + await expect.poll(() => wroteTo(sent, fixture.targetID, "DELETE").length).toBeGreaterThan(0) +}) + +test("archives a session from its row", async ({ page }) => { + const { sent } = await boot(page) + + await openRowMenu(sidebarRows(page).first()) + await item(page, "Archive").click() + + // Archive is the one action with no confirmation step, so the request is the + // whole of it. + await expect.poll(() => wroteTo(sent, fixture.targetID).length).toBeGreaterThan(0) +}) + +test("the View all list carries the same menu", async ({ page }) => { + await boot(page) + await expectAppVisible(homeRows(page).first()) + + await openRowMenu(homeRows(page).first()) + + await expect(item(page, "Rename")).toBeVisible() + await expect(item(page, "Archive")).toBeVisible() + await expect(item(page, /^Delete/)).toBeVisible() +}) diff --git a/packages/app/e2e/regression/sidebar-persistence.spec.ts b/packages/app/e2e/regression/sidebar-persistence.spec.ts new file mode 100644 index 0000000000..853ddda6a6 --- /dev/null +++ b/packages/app/e2e/regression/sidebar-persistence.spec.ts @@ -0,0 +1,113 @@ +import { expect, test, type Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +// The sidebar used to be an