From 7692b6395d19f2b2add1a453e98919d2781ce95d Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 11 Aug 2026 11:21:54 +0700 Subject: [PATCH 1/2] test(app): cover the sidebar, titlebar and session-row menu PRs #173, #175 and #179 shipped with no tests; #181 and #183 already carried their own, so this fills the gap left by the other three. Unit (test-browser, since home-model reaches kobalte through the directory picker and will not import under the server condition): - buildHomeSessionRecords: directory filtering, pathKey normalisation so a Windows path and a trailing slash are one directory, sort by last update with a created-time fallback, dedupe keeping the fresher copy, sandbox and projectID resolution, and dropping a session no project claims. - descendantIDs: the subtree a delete evicts, including cycle safety. Exported for this, alongside buildHomeSessionRecords which was already exported. - session-share-request: the row-to-timeline share handoff consumes once, and a request meant for another session is not swallowed. E2E: - sidebar-persistence: the column survives opening a session, orders recents newest first, opens the session a row names, and View all and New session both work from inside a session. - sidebar-toggle-search: the titlebar toggle hides and restores the column, survives a reload and a route change, and Search lands on the recents list with its field focused and the ?focus=search param cleared. - session-actions-menu: the four actions on both lists, Share hidden when the server disables sharing, rename in place without opening the session, cancel sending nothing, and delete asking first. Two defects turned up while writing these, both left unfixed and documented where they bite: - Clicking a Recents row opens the session but never marks the row as open; the marker only appears once the tab list is read back on a fresh mount. Arriving by URL marks it immediately, so the state is right and the click path fails to re-run the memo. - The first activation of a row's actions menu is swallowed. Four clicks give closed, open, closed, open, and focus + Enter is swallowed the same way, so a user's first click on the dots lands on nothing too. 623 unit, 45 browser-condition unit and all 110 e2e regression tests pass; tsgo -b and the e2e project both typecheck. Co-Authored-By: Claude Opus 5 (1M context) --- .../regression/session-actions-menu.spec.ts | 214 ++++++++++++++++++ .../regression/sidebar-persistence.spec.ts | 120 ++++++++++ .../regression/sidebar-toggle-search.spec.ts | 113 +++++++++ packages/app/src/pages/home-model.tsx | 4 +- .../src/utils/session-share-request.test.ts | 37 +++ .../test-browser/home-session-records.test.ts | 199 ++++++++++++++++ 6 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/regression/session-actions-menu.spec.ts create mode 100644 packages/app/e2e/regression/sidebar-persistence.spec.ts create mode 100644 packages/app/e2e/regression/sidebar-toggle-search.spec.ts create mode 100644 packages/app/src/utils/session-share-request.test.ts create mode 100644 packages/app/test-browser/home-session-records.test.ts 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..ad9f1e77d3 --- /dev/null +++ b/packages/app/e2e/regression/session-actions-menu.spec.ts @@ -0,0 +1,214 @@ +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 — twice if the first one is +// swallowed. +// +// The retry is covering for a defect, not for slowness. On a freshly loaded +// page the first activation of a row's menu does nothing at all: measured on +// this fixture, four clicks in a row give closed, open, closed, open. Every +// activation after the first toggles correctly, so it is one lost activation at +// the start rather than a race — and it is not specific to the mouse, since +// focus + Enter is swallowed the same way. Whatever eats it, a user's first +// click on the dots also lands on nothing. +// +// Written as a conditional rather than a blind double click so that this keeps +// passing, and keeps meaning what it says, once the swallow is fixed. +async function openRowMenu(page: Page, row: Locator) { + await row.hover() + await rowMenu(row).click() + if ((await rowMenu(row).getAttribute("aria-expanded")) !== "true") 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(page, 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(page, 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(page, 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(page, 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(page, 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(page, 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(page, 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(page, 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..c1616fcc52 --- /dev/null +++ b/packages/app/e2e/regression/sidebar-persistence.spec.ts @@ -0,0 +1,120 @@ +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