|
| 1 | +import { expect, type Locator, type Page } from "@playwright/test" |
| 2 | +import fs from "node:fs/promises" |
| 3 | +import os from "node:os" |
| 4 | +import path from "node:path" |
| 5 | +import { execSync } from "node:child_process" |
| 6 | +import { modKey, serverUrl } from "./utils" |
| 7 | +import { |
| 8 | + sessionItemSelector, |
| 9 | + dropdownMenuTriggerSelector, |
| 10 | + dropdownMenuContentSelector, |
| 11 | + titlebarRightSelector, |
| 12 | + popoverBodySelector, |
| 13 | + listItemSelector, |
| 14 | + listItemKeySelector, |
| 15 | + listItemKeyStartsWithSelector, |
| 16 | +} from "./selectors" |
| 17 | +import type { createSdk } from "./utils" |
| 18 | + |
| 19 | +export async function defocus(page: Page) { |
| 20 | + await page.mouse.click(5, 5) |
| 21 | +} |
| 22 | + |
| 23 | +export async function openPalette(page: Page) { |
| 24 | + await defocus(page) |
| 25 | + await page.keyboard.press(`${modKey}+P`) |
| 26 | + |
| 27 | + const dialog = page.getByRole("dialog") |
| 28 | + await expect(dialog).toBeVisible() |
| 29 | + await expect(dialog.getByRole("textbox").first()).toBeVisible() |
| 30 | + return dialog |
| 31 | +} |
| 32 | + |
| 33 | +export async function closeDialog(page: Page, dialog: Locator) { |
| 34 | + await page.keyboard.press("Escape") |
| 35 | + const closed = await dialog |
| 36 | + .waitFor({ state: "detached", timeout: 1500 }) |
| 37 | + .then(() => true) |
| 38 | + .catch(() => false) |
| 39 | + |
| 40 | + if (closed) return |
| 41 | + |
| 42 | + await page.keyboard.press("Escape") |
| 43 | + const closedSecond = await dialog |
| 44 | + .waitFor({ state: "detached", timeout: 1500 }) |
| 45 | + .then(() => true) |
| 46 | + .catch(() => false) |
| 47 | + |
| 48 | + if (closedSecond) return |
| 49 | + |
| 50 | + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) |
| 51 | + await expect(dialog).toHaveCount(0) |
| 52 | +} |
| 53 | + |
| 54 | +export async function isSidebarClosed(page: Page) { |
| 55 | + const main = page.locator("main") |
| 56 | + const classes = (await main.getAttribute("class")) ?? "" |
| 57 | + return classes.includes("xl:border-l") |
| 58 | +} |
| 59 | + |
| 60 | +export async function toggleSidebar(page: Page) { |
| 61 | + await defocus(page) |
| 62 | + await page.keyboard.press(`${modKey}+B`) |
| 63 | +} |
| 64 | + |
| 65 | +export async function openSidebar(page: Page) { |
| 66 | + if (!(await isSidebarClosed(page))) return |
| 67 | + await toggleSidebar(page) |
| 68 | + await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) |
| 69 | +} |
| 70 | + |
| 71 | +export async function closeSidebar(page: Page) { |
| 72 | + if (await isSidebarClosed(page)) return |
| 73 | + await toggleSidebar(page) |
| 74 | + await expect(page.locator("main")).toHaveClass(/xl:border-l/) |
| 75 | +} |
| 76 | + |
| 77 | +export async function openSettings(page: Page) { |
| 78 | + await defocus(page) |
| 79 | + |
| 80 | + const dialog = page.getByRole("dialog") |
| 81 | + await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) |
| 82 | + |
| 83 | + const opened = await dialog |
| 84 | + .waitFor({ state: "visible", timeout: 3000 }) |
| 85 | + .then(() => true) |
| 86 | + .catch(() => false) |
| 87 | + |
| 88 | + if (opened) return dialog |
| 89 | + |
| 90 | + await page.getByRole("button", { name: "Settings" }).first().click() |
| 91 | + await expect(dialog).toBeVisible() |
| 92 | + return dialog |
| 93 | +} |
| 94 | + |
| 95 | +export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { |
| 96 | + await page.addInitScript( |
| 97 | + (args: { directory: string; serverUrl: string; extra: string[] }) => { |
| 98 | + const key = "opencode.global.dat:server" |
| 99 | + const raw = localStorage.getItem(key) |
| 100 | + const parsed = (() => { |
| 101 | + if (!raw) return undefined |
| 102 | + try { |
| 103 | + return JSON.parse(raw) as unknown |
| 104 | + } catch { |
| 105 | + return undefined |
| 106 | + } |
| 107 | + })() |
| 108 | + |
| 109 | + const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {} |
| 110 | + const list = Array.isArray(store.list) ? store.list : [] |
| 111 | + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} |
| 112 | + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} |
| 113 | + const nextProjects = { ...(projects as Record<string, unknown>) } |
| 114 | + |
| 115 | + const add = (origin: string, directory: string) => { |
| 116 | + const current = nextProjects[origin] |
| 117 | + const items = Array.isArray(current) ? current : [] |
| 118 | + const existing = items.filter( |
| 119 | + (p): p is { worktree: string; expanded?: boolean } => |
| 120 | + !!p && |
| 121 | + typeof p === "object" && |
| 122 | + "worktree" in p && |
| 123 | + typeof (p as { worktree?: unknown }).worktree === "string", |
| 124 | + ) |
| 125 | + |
| 126 | + if (existing.some((p) => p.worktree === directory)) return |
| 127 | + nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing] |
| 128 | + } |
| 129 | + |
| 130 | + const directories = [args.directory, ...args.extra] |
| 131 | + for (const directory of directories) { |
| 132 | + add("local", directory) |
| 133 | + add(args.serverUrl, directory) |
| 134 | + } |
| 135 | + |
| 136 | + localStorage.setItem( |
| 137 | + key, |
| 138 | + JSON.stringify({ |
| 139 | + list, |
| 140 | + projects: nextProjects, |
| 141 | + lastProject, |
| 142 | + }), |
| 143 | + ) |
| 144 | + }, |
| 145 | + { directory: input.directory, serverUrl, extra: input.extra ?? [] }, |
| 146 | + ) |
| 147 | +} |
| 148 | + |
| 149 | +export async function createTestProject() { |
| 150 | + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) |
| 151 | + |
| 152 | + await fs.writeFile(path.join(root, "README.md"), "# e2e\n") |
| 153 | + |
| 154 | + execSync("git init", { cwd: root, stdio: "ignore" }) |
| 155 | + execSync("git add -A", { cwd: root, stdio: "ignore" }) |
| 156 | + execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', { |
| 157 | + cwd: root, |
| 158 | + stdio: "ignore", |
| 159 | + }) |
| 160 | + |
| 161 | + return root |
| 162 | +} |
| 163 | + |
| 164 | +export async function cleanupTestProject(directory: string) { |
| 165 | + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) |
| 166 | +} |
| 167 | + |
| 168 | +export function sessionIDFromUrl(url: string) { |
| 169 | + const match = /\/session\/([^/?#]+)/.exec(url) |
| 170 | + return match?.[1] |
| 171 | +} |
| 172 | + |
| 173 | +export async function hoverSessionItem(page: Page, sessionID: string) { |
| 174 | + const sessionEl = page.locator(sessionItemSelector(sessionID)).first() |
| 175 | + await expect(sessionEl).toBeVisible() |
| 176 | + await sessionEl.hover() |
| 177 | + return sessionEl |
| 178 | +} |
| 179 | + |
| 180 | +export async function openSessionMoreMenu(page: Page, sessionID: string) { |
| 181 | + const sessionEl = await hoverSessionItem(page, sessionID) |
| 182 | + |
| 183 | + const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first() |
| 184 | + await expect(menuTrigger).toBeVisible() |
| 185 | + await menuTrigger.click() |
| 186 | + |
| 187 | + const menu = page.locator(dropdownMenuContentSelector).first() |
| 188 | + await expect(menu).toBeVisible() |
| 189 | + return menu |
| 190 | +} |
| 191 | + |
| 192 | +export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) { |
| 193 | + const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first() |
| 194 | + await expect(item).toBeVisible() |
| 195 | + await item.click({ force: options?.force }) |
| 196 | +} |
| 197 | + |
| 198 | +export async function confirmDialog(page: Page, buttonName: string | RegExp) { |
| 199 | + const dialog = page.getByRole("dialog").first() |
| 200 | + await expect(dialog).toBeVisible() |
| 201 | + |
| 202 | + const button = dialog.getByRole("button").filter({ hasText: buttonName }).first() |
| 203 | + await expect(button).toBeVisible() |
| 204 | + await button.click() |
| 205 | +} |
| 206 | + |
| 207 | +export async function openSharePopover(page: Page) { |
| 208 | + const rightSection = page.locator(titlebarRightSelector) |
| 209 | + const shareButton = rightSection.getByRole("button", { name: "Share" }).first() |
| 210 | + await expect(shareButton).toBeVisible() |
| 211 | + |
| 212 | + const popoverBody = page |
| 213 | + .locator(popoverBodySelector) |
| 214 | + .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) |
| 215 | + .first() |
| 216 | + |
| 217 | + const opened = await popoverBody |
| 218 | + .isVisible() |
| 219 | + .then((x) => x) |
| 220 | + .catch(() => false) |
| 221 | + |
| 222 | + if (!opened) { |
| 223 | + await shareButton.click() |
| 224 | + await expect(popoverBody).toBeVisible() |
| 225 | + } |
| 226 | + return { rightSection, popoverBody } |
| 227 | +} |
| 228 | + |
| 229 | +export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { |
| 230 | + const button = page.getByRole("button").filter({ hasText: buttonName }).first() |
| 231 | + await expect(button).toBeVisible() |
| 232 | + await button.click() |
| 233 | +} |
| 234 | + |
| 235 | +export async function clickListItem( |
| 236 | + container: Locator | Page, |
| 237 | + filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string }, |
| 238 | +): Promise<Locator> { |
| 239 | + let item: Locator |
| 240 | + |
| 241 | + if (typeof filter === "string" || filter instanceof RegExp) { |
| 242 | + item = container.locator(listItemSelector).filter({ hasText: filter }).first() |
| 243 | + } else if (filter.keyStartsWith) { |
| 244 | + item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first() |
| 245 | + } else if (filter.key) { |
| 246 | + item = container.locator(listItemKeySelector(filter.key)).first() |
| 247 | + } else if (filter.text) { |
| 248 | + item = container.locator(listItemSelector).filter({ hasText: filter.text }).first() |
| 249 | + } else { |
| 250 | + throw new Error("Invalid filter provided to clickListItem") |
| 251 | + } |
| 252 | + |
| 253 | + await expect(item).toBeVisible() |
| 254 | + await item.click() |
| 255 | + return item |
| 256 | +} |
| 257 | + |
| 258 | +export async function withSession<T>( |
| 259 | + sdk: ReturnType<typeof createSdk>, |
| 260 | + title: string, |
| 261 | + callback: (session: { id: string; title: string }) => Promise<T>, |
| 262 | +): Promise<T> { |
| 263 | + const session = await sdk.session.create({ title }).then((r) => r.data) |
| 264 | + if (!session?.id) throw new Error("Session create did not return an id") |
| 265 | + |
| 266 | + try { |
| 267 | + return await callback(session) |
| 268 | + } finally { |
| 269 | + await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) |
| 270 | + } |
| 271 | +} |
0 commit comments