import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { execSync } from "node:child_process" import { modKey, serverUrl } from "./utils" import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, projectMenuTriggerSelector, projectWorkspacesToggleSelector, titlebarRightSelector, popoverBodySelector, listItemSelector, listItemKeySelector, listItemKeyStartsWithSelector, workspaceItemSelector, workspaceMenuTriggerSelector, } from "./selectors" import type { createSdk } from "./utils" export async function defocus(page: Page) { await page .evaluate(() => { const el = document.activeElement if (el instanceof HTMLElement) el.blur() }) .catch(() => undefined) } export async function openPalette(page: Page) { await defocus(page) await page.keyboard.press(`${modKey}+P`) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() await expect(dialog.getByRole("textbox").first()).toBeVisible() return dialog } export async function closeDialog(page: Page, dialog: Locator) { await page.keyboard.press("Escape") const closed = await dialog .waitFor({ state: "detached", timeout: 1500 }) .then(() => true) .catch(() => false) if (closed) return await page.keyboard.press("Escape") const closedSecond = await dialog .waitFor({ state: "detached", timeout: 1500 }) .then(() => true) .catch(() => false) if (closedSecond) return await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) await expect(dialog).toHaveCount(0) } export async function isSidebarClosed(page: Page) { const main = page.locator("main") const classes = (await main.getAttribute("class")) ?? "" return classes.includes("xl:border-l") } export async function toggleSidebar(page: Page) { await defocus(page) await page.keyboard.press(`${modKey}+B`) } export async function openSidebar(page: Page) { if (!(await isSidebarClosed(page))) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const visible = await button .isVisible() .then((x) => x) .catch(() => false) if (visible) await button.click() if (!visible) await toggleSidebar(page) const main = page.locator("main") const opened = await expect(main) .not.toHaveClass(/xl:border-l/, { timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) return await toggleSidebar(page) await expect(main).not.toHaveClass(/xl:border-l/) } export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const visible = await button .isVisible() .then((x) => x) .catch(() => false) if (visible) await button.click() if (!visible) await toggleSidebar(page) const main = page.locator("main") const closed = await expect(main) .toHaveClass(/xl:border-l/, { timeout: 1500 }) .then(() => true) .catch(() => false) if (closed) return await toggleSidebar(page) await expect(main).toHaveClass(/xl:border-l/) } export async function openSettings(page: Page) { await defocus(page) const dialog = page.getByRole("dialog") await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) const opened = await dialog .waitFor({ state: "visible", timeout: 3000 }) .then(() => true) .catch(() => false) if (opened) return dialog await page.getByRole("button", { name: "Settings" }).first().click() await expect(dialog).toBeVisible() return dialog } export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { await page.addInitScript( (args: { directory: string; serverUrl: string; extra: string[] }) => { const key = "opencode.global.dat:server" const raw = localStorage.getItem(key) const parsed = (() => { if (!raw) return undefined try { return JSON.parse(raw) as unknown } catch { return undefined } })() const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} const list = Array.isArray(store.list) ? store.list : [] const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} const projects = store.projects && typeof store.projects === "object" ? store.projects : {} const nextProjects = { ...(projects as Record) } const add = (origin: string, directory: string) => { const current = nextProjects[origin] const items = Array.isArray(current) ? current : [] const existing = items.filter( (p): p is { worktree: string; expanded?: boolean } => !!p && typeof p === "object" && "worktree" in p && typeof (p as { worktree?: unknown }).worktree === "string", ) if (existing.some((p) => p.worktree === directory)) return nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing] } const directories = [args.directory, ...args.extra] for (const directory of directories) { add("local", directory) add(args.serverUrl, directory) } localStorage.setItem( key, JSON.stringify({ list, projects: nextProjects, lastProject, }), ) }, { directory: input.directory, serverUrl, extra: input.extra ?? [] }, ) } export async function createTestProject() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) await fs.writeFile(path.join(root, "README.md"), "# e2e\n") execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { cwd: root, stdio: "ignore", }) return root } export async function cleanupTestProject(directory: string) { await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) } export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] } export async function hoverSessionItem(page: Page, sessionID: string) { const sessionEl = page.locator(sessionItemSelector(sessionID)).first() await expect(sessionEl).toBeVisible() await sessionEl.hover() return sessionEl } export async function openSessionMoreMenu(page: Page, sessionID: string) { await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) const scroller = page.locator(".session-scroller").first() await expect(scroller).toBeVisible() await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) .filter({ has: page.getByRole("menuitem", { name: /rename/i }) }) .filter({ has: page.getByRole("menuitem", { name: /archive/i }) }) .filter({ has: page.getByRole("menuitem", { name: /delete/i }) }) .first() const opened = await menu .isVisible() .then((x) => x) .catch(() => false) if (opened) return menu const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() await expect(menu).toBeVisible() return menu } export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) { const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first() await expect(item).toBeVisible() await item.click({ force: options?.force }) } export async function confirmDialog(page: Page, buttonName: string | RegExp) { const dialog = page.getByRole("dialog").first() await expect(dialog).toBeVisible() const button = dialog.getByRole("button").filter({ hasText: buttonName }).first() await expect(button).toBeVisible() await button.click() } export async function openSharePopover(page: Page) { const rightSection = page.locator(titlebarRightSelector) const shareButton = rightSection.getByRole("button", { name: "Share" }).first() await expect(shareButton).toBeVisible() const popoverBody = page .locator(popoverBodySelector) .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .first() const opened = await popoverBody .isVisible() .then((x) => x) .catch(() => false) if (!opened) { await shareButton.click() await expect(popoverBody).toBeVisible() } return { rightSection, popoverBody } } export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { const button = page.getByRole("button").filter({ hasText: buttonName }).first() await expect(button).toBeVisible() await button.click() } export async function clickListItem( container: Locator | Page, filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string }, ): Promise { let item: Locator if (typeof filter === "string" || filter instanceof RegExp) { item = container.locator(listItemSelector).filter({ hasText: filter }).first() } else if (filter.keyStartsWith) { item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first() } else if (filter.key) { item = container.locator(listItemKeySelector(filter.key)).first() } else if (filter.text) { item = container.locator(listItemSelector).filter({ hasText: filter.text }).first() } else { throw new Error("Invalid filter provided to clickListItem") } await expect(item).toBeVisible() await item.click() return item } export async function withSession( sdk: ReturnType, title: string, callback: (session: { id: string; title: string }) => Promise, ): Promise { const session = await sdk.session.create({ title }).then((r) => r.data) if (!session?.id) throw new Error("Session create did not return an id") try { return await callback(session) } finally { await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) } } export async function openStatusPopover(page: Page) { await defocus(page) const rightSection = page.locator(titlebarRightSelector) const trigger = rightSection.getByRole("button", { name: /status/i }).first() const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') }) const opened = await popoverBody .isVisible() .then((x) => x) .catch(() => false) if (!opened) { await expect(trigger).toBeVisible() await trigger.click() await expect(popoverBody).toBeVisible() } return { rightSection, popoverBody } } export async function openProjectMenu(page: Page, projectSlug: string) { const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() await expect(trigger).toHaveCount(1) await trigger.focus() await page.keyboard.press("Enter") const menu = page.locator(dropdownMenuContentSelector).first() const opened = await menu .waitFor({ state: "visible", timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) { const viewport = page.viewportSize() const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 const y = viewport ? Math.max(viewport.height - 5, 0) : 800 await page.mouse.move(x, y) return menu } await trigger.click({ force: true }) await expect(menu).toBeVisible() const viewport = page.viewportSize() const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 const y = viewport ? Math.max(viewport.height - 5, 0) : 800 await page.mouse.move(x, y) return menu } export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { const current = await page .getByRole("button", { name: "New workspace" }) .first() .isVisible() .then((x) => x) .catch(() => false) if (current === enabled) return await openProjectMenu(page, projectSlug) const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first() await expect(toggle).toBeVisible() await toggle.click({ force: true }) const expected = enabled ? "New workspace" : "New session" await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() } export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { const item = page.locator(workspaceItemSelector(workspaceSlug)).first() await expect(item).toBeVisible() await item.hover() const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first() await expect(trigger).toBeVisible() await trigger.click({ force: true }) const menu = page.locator(dropdownMenuContentSelector).first() await expect(menu).toBeVisible() return menu }