diff --git a/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts b/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts index 00582c05a7..f35001f0e3 100644 --- a/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts +++ b/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts @@ -1,3 +1,4 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { fixture, pageMessages } from "../smoke/session-timeline.fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -145,3 +146,144 @@ test("the View all list grows a Pinned group above the dated ones", async ({ pag expect(text.indexOf("Pinned")).toBeLessThan(text.indexOf(TARGET)) expect(text.indexOf(TARGET)).toBeLessThan(text.indexOf(SOURCE)) }) + +// --- coverage for the paths the first pass left unguarded --- + +const PINNED_ROW_LIMIT = 5 +// Header (28) + gap (4) + five rows at a 32px pitch, less the last row's gap. +const PINNED_MAX_BODY = PINNED_ROW_LIMIT * 32 - 4 + +function manySessions(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `ses_pin_${index}`, + slug: `pin-${index}`, + projectID: (fixture.project as { id: string }).id, + directory: fixture.directory, + title: `Pinnable session ${index}`, + version: "dev", + // Descending, so index 0 is newest and sits at the top of Recents. + time: { created: 1700000000000 - index, updated: 1700000000000 - index }, + })) +} + +async function bootWith(page: Page, sessions: ReturnType) { + await mockOpenCodeServer(page, { + sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + await expect(rowsIn(page, "recents").first()).toBeVisible({ timeout: 30_000 }) +} + +test("Pinned scrolls past its row limit instead of growing into Recents", async ({ page }) => { + await bootWith(page, manySessions(PINNED_ROW_LIMIT + 2)) + const recentsBefore = (await section(page, "recents").boundingBox())! + + // Pin one past the limit. Each pin moves that row out of Recents, so the + // first Recents row is a different session every time. + for (let pinned = 0; pinned < PINNED_ROW_LIMIT + 1; pinned++) { + await openFirstRowMenu(page, "recents") + await menuItem(page, "Pin").click() + await expect(rowsIn(page, "pinned")).toHaveCount(pinned + 1) + } + + // All six are rendered — Pinned scrolls, unlike Recents, which truncates its + // list to what fits. + await expect(rowsIn(page, "pinned")).toHaveCount(PINNED_ROW_LIMIT + 1) + + // ScrollView caps the root and scrolls an inner viewport, so the overflow has + // to be read off `.scroll-view__viewport`, not off the element carrying the + // max-height. + const list = section(page, "pinned").locator('[data-slot="sidebar-pinned-list"]') + const capped = await list.evaluate((el) => el.clientHeight) + expect(capped).toBeLessThanOrEqual(PINNED_MAX_BODY + 1) + const viewport = await list + .locator(".scroll-view__viewport") + .evaluate((el) => ({ client: el.clientHeight, scroll: el.scrollHeight })) + expect(viewport.scroll).toBeGreaterThan(viewport.client) + + // And Recents keeps its space rather than being pushed out by the sixth pin. + const recentsAfter = (await section(page, "recents").boundingBox())! + expect(recentsAfter.height).toBeGreaterThan(recentsBefore.height - PINNED_MAX_BODY - 40) +}) + +test("pinning from the in-session header menu shows up in the sidebar", async ({ page }) => { + await boot(page) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectAppVisible(page.locator("main").getByText(TARGET).first()) + + // The header resolves the server a pin belongs to independently of the + // sidebar (params.serverKey ?? the current connection). If the two ever + // disagree the pin lands under a key the sidebar does not read, and this row + // never appears — which is exactly the failure DIVERGENCES warns about. + const trigger = page.locator("main").getByRole("button", { name: "More options" }).first() + await expectAppVisible(trigger) + await trigger.click() + await expectAppVisible(menuContent(page)) + await expect(menuItem(page, "Pin")).toHaveCount(1) + await menuItem(page, "Pin").click() + + await expect(rowsIn(page, "pinned")).toHaveCount(1) + await expect(rowsIn(page, "pinned").first()).toContainText(TARGET) + + // And the header now offers the other direction. + await trigger.click() + await expectAppVisible(menuContent(page)) + await expect(menuItem(page, "Unpin")).toHaveCount(1) +}) + +test("deleting a pinned session drops its pin", async ({ page }) => { + const sessions = manySessions(2) + await bootWith(page, sessions) + // The mock serves `/session` from this array by reference, so removing the + // row here is what stops the index refetch resurrecting it. Registered after + // the mock so it wins for DELETE and falls through for everything else. + await page.route("**/session/*", async (route) => { + if (route.request().method() !== "DELETE") return route.fallback() + const id = new URL(route.request().url()).pathname.split("/").pop() + const index = sessions.findIndex((session) => session.id === id) + if (index >= 0) sessions.splice(index, 1) + return route.fulfill({ status: 200, contentType: "application/json", body: "true" }) + }) + + await openFirstRowMenu(page, "recents") + await menuItem(page, "Pin").click() + await expect(rowsIn(page, "pinned")).toHaveCount(1) + + await openFirstRowMenu(page, "pinned") + await menuItem(page, "Delete").click() + await page.getByRole("button", { name: "Delete session" }).click() + + await expect(rowsIn(page, "pinned")).toHaveCount(0) + // The id must leave storage too: left behind, it would silently re-pin a + // future session that reused it. + await expect + .poll(async () => page.evaluate(() => localStorage.getItem("opencode.global.dat:sidebar.pinned") ?? "")) + .not.toContain("ses_pin_0") +}) + +test("pins are stored per server, not in one flat list", async ({ page }) => { + await boot(page) + await pinFirstRecent(page) + + // The store is a record keyed by the server the pin was made against: session + // ids are only unique within one, so a flat list would let a pin follow an id + // onto a different server. Asserting the exact key, not just "some key" — + // anything constant would satisfy the looser check. + const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + const stored = await page.evaluate(() => localStorage.getItem("opencode.global.dat:sidebar.pinned")) + expect(stored).not.toBeNull() + expect(JSON.parse(stored!)).toEqual({ sessions: { [serverKey]: [fixture.targetID] } }) +})