Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions packages/app/e2e/regression/session-actions-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
113 changes: 113 additions & 0 deletions packages/app/e2e/regression/sidebar-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <aside> owned by the home screen, so opening a
// session left the window with no navigation but the tab strip. It is app chrome
// now, and it carries the recent sessions — which is only true if it survives
// the route change and if its rows actually open what they name.

const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]')
const recents = (page: Page) => sidebar(page).locator('[data-section="recents"]')
const rows = (page: Page) => recents(page).locator('[data-component="sidebar-session-row"]')
const newSession = (page: Page) => recents(page).locator('[data-action="sidebar-new-session"]')
const viewAll = (page: Page) => recents(page).locator('[data-action="sidebar-view-all"]')
const settings = (page: Page) => sidebar(page).getByRole("button", { name: "Settings" })

async function boot(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
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 expectAppVisible(sidebar(page))
await expectAppVisible(viewAll(page))
}

test("stays on screen once a session is open", async ({ page }) => {
await boot(page)

await rows(page).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)

// The whole point of the move out of home.tsx: still mounted, still listing,
// still offering the bottom nav, on a route that is not "/".
await expect(sidebar(page)).toBeVisible()
await expect(recents(page)).toBeVisible()
await expect(settings(page)).toBeVisible()
expect(new URL(page.url()).pathname).not.toBe("/")
})

test("lists recent sessions newest first", async ({ page }) => {
await boot(page)

// The fixture's target session was updated a second after the source one, so
// ordering by last update — not by the order the server listed them — puts it
// on top.
await expect(rows(page)).toHaveCount(2)
await expect(rows(page).first()).toHaveAttribute("title", fixture.expected.targetTitle)
await expect(rows(page).nth(1)).toHaveAttribute("title", fixture.expected.sourceTitle)
})

test("opens the session a row names, in a tab of its own", async ({ page }) => {
await boot(page)

// The second row, so a passing test cannot be one that opens whatever the
// list happened to put first.
await rows(page).nth(1).click()
await expectSessionTitle(page, fixture.expected.sourceTitle)

const tabs = page.locator('[data-slot="titlebar-tabs"] a')
await expect(tabs).toHaveCount(1)
await expect(tabs.first()).toHaveAttribute("href", new RegExp(`/session/${fixture.sourceID}$`))
})

test("marks the row whose session is already open", async ({ page }) => {
await boot(page)

await rows(page).nth(1).click()
await expectSessionTitle(page, fixture.expected.sourceTitle)

// data-selected is how a row says it already holds a tab; without it every row
// looks the same once you are inside one of them. On the click that opened the
// session, with no reload: the rows update in place rather than being rebuilt,
// so the marker lands on the row you clicked and on no other.
await expect(recents(page).locator('[data-component="sidebar-session-row"][data-selected]')).toHaveCount(1)
await expect(rows(page).nth(1)).toHaveAttribute("data-selected", "")
})

test("View all leads back to the recents screen from inside a session", async ({ page }) => {
await boot(page)

await rows(page).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)

await viewAll(page).click()
await expect.poll(() => new URL(page.url()).pathname).toBe("/")
// The placeholder picks up the project name when one is selected, so match
// the stem rather than the whole string.
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeVisible()
})

test("New session opens a draft without leaving the sidebar behind", async ({ page }) => {
await boot(page)
await expectAppVisible(newSession(page))

await newSession(page).click()

await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
await expect(sidebar(page)).toBeVisible()
})
Loading
Loading