From 386e3d23978b4a815a34468085feab5d4ffdd6be Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 11 Aug 2026 17:06:28 +0700 Subject: [PATCH] test(app): close the three gaps left by the pinned-sessions PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #189 shipped with the pinned section covered only through the sidebar row menu. Three behaviours it introduced had nothing behind them. - The five-row cap: pinning a sixth session now has to leave the list capped and scrolling, with all six rows still rendered, and Recents still holding its height. PINNED_VISIBLE_ROWS appeared in no test before this. - The in-session header menu: pinning from the ⋯ beside a session title has to show up in the sidebar. That is the cross-surface agreement DIVERGENCES singles out as the silent breakage — the header resolves the pin's server independently, and a mismatch files the pin under a key the sidebar never reads. - Deleting a pinned session has to drop the pin from storage, and pins have to be stored under the server they were made against rather than in one flat list. Each was run against a deliberate break of the behaviour it covers and fails there. The per-server test needed tightening to earn that: the first version asserted only that some single key existed, which a hardcoded "all" satisfied, so it now asserts the exact server key. Co-Authored-By: Claude Opus 5 (1M context) --- .../sidebar-pinned-sessions.spec.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) 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] } }) +})