| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785 |
- 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 { terminalAttr, type E2EWindow } from "../src/testing/terminal"
- import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
- import {
- dropdownMenuTriggerSelector,
- dropdownMenuContentSelector,
- projectMenuTriggerSelector,
- projectCloseMenuSelector,
- projectWorkspacesToggleSelector,
- titlebarRightSelector,
- popoverBodySelector,
- listItemSelector,
- listItemKeySelector,
- listItemKeyStartsWithSelector,
- terminalSelector,
- workspaceItemSelector,
- workspaceMenuTriggerSelector,
- } from "./selectors"
- export async function defocus(page: Page) {
- await page
- .evaluate(() => {
- const el = document.activeElement
- if (el instanceof HTMLElement) el.blur()
- })
- .catch(() => undefined)
- }
- async function terminalID(term: Locator) {
- const id = await term.getAttribute(terminalAttr)
- if (id) return id
- throw new Error(`Active terminal missing ${terminalAttr}`)
- }
- export async function terminalConnects(page: Page, input?: { term?: Locator }) {
- const term = input?.term ?? page.locator(terminalSelector).first()
- const id = await terminalID(term)
- return page.evaluate((id) => {
- return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
- }, id)
- }
- export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
- const term = input?.term ?? page.locator(terminalSelector).first()
- const id = await terminalID(term)
- await page.evaluate((id) => {
- ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
- }, id)
- }
- async function terminalReady(page: Page, term?: Locator) {
- const next = term ?? page.locator(terminalSelector).first()
- const id = await terminalID(next)
- return page.evaluate((id) => {
- const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
- return !!state?.connected && (state.settled ?? 0) > 0
- }, id)
- }
- async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
- const next = input.term ?? page.locator(terminalSelector).first()
- const id = await terminalID(next)
- return page.evaluate(
- (input) => {
- const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
- return state?.rendered.includes(input.token) ?? false
- },
- { id, token: input.token },
- )
- }
- export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
- const term = input?.term ?? page.locator(terminalSelector).first()
- const timeout = input?.timeout ?? 10_000
- await expect(term).toBeVisible()
- await expect(term.locator("textarea")).toHaveCount(1)
- await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
- }
- export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
- const term = input.term ?? page.locator(terminalSelector).first()
- const timeout = input.timeout ?? 10_000
- await waitTerminalReady(page, { term, timeout })
- const textarea = term.locator("textarea")
- await term.click()
- await expect(textarea).toBeFocused()
- await page.keyboard.type(input.cmd)
- await page.keyboard.press("Enter")
- await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
- }
- 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 button = page.getByRole("button", { name: /toggle sidebar/i }).first()
- await expect(button).toBeVisible()
- return (await button.getAttribute("aria-expanded")) !== "true"
- }
- 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()
- await button.click()
- const opened = await expect(button)
- .toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (opened) return
- await toggleSidebar(page)
- await expect(button).toHaveAttribute("aria-expanded", "true")
- }
- export async function closeSidebar(page: Page) {
- if (await isSidebarClosed(page)) return
- const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
- await button.click()
- const closed = await expect(button)
- .toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (closed) return
- await toggleSidebar(page)
- await expect(button).toHaveAttribute("aria-expanded", "false")
- }
- 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<string, unknown>) : {}
- 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<string, unknown>) }
- 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 config core.fsmonitor false", { cwd: root, stdio: "ignore" })
- execSync("git add -A", { cwd: root, stdio: "ignore" })
- execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
- cwd: root,
- stdio: "ignore",
- })
- return resolveDirectory(root)
- }
- export async function cleanupTestProject(directory: string) {
- try {
- execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
- } catch {}
- await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
- }
- export function slugFromUrl(url: string) {
- return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
- }
- export async function waitSlug(page: Page, skip: string[] = []) {
- let prev = ""
- let next = ""
- await expect
- .poll(
- () => {
- const slug = slugFromUrl(page.url())
- if (!slug) return ""
- if (skip.includes(slug)) return ""
- if (slug !== prev) {
- prev = slug
- next = ""
- return ""
- }
- next = slug
- return slug
- },
- { timeout: 45_000 },
- )
- .not.toBe("")
- return next
- }
- 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(`[data-session-id="${sessionID}"]`).last()
- 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(".scroll-view__viewport").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<Locator> {
- 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
- }
- async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
- const data = await sdk.session
- .status()
- .then((x) => x.data ?? {})
- .catch(() => undefined)
- return data?.[sessionID]
- }
- async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
- let prev = ""
- await expect
- .poll(
- async () => {
- const info = await sdk.session
- .get({ sessionID })
- .then((x) => x.data)
- .catch(() => undefined)
- if (!info) return true
- const next = `${info.title}:${info.time.updated ?? info.time.created}`
- if (next !== prev) {
- prev = next
- return false
- }
- return true
- },
- { timeout },
- )
- .toBe(true)
- }
- export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
- await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
- }
- export async function cleanupSession(input: {
- sessionID: string
- directory?: string
- sdk?: ReturnType<typeof createSdk>
- }) {
- const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
- if (!sdk) throw new Error("cleanupSession requires sdk or directory")
- await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
- const current = await status(sdk, input.sessionID).catch(() => undefined)
- if (current && current.type !== "idle") {
- await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
- await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
- }
- await stable(sdk, input.sessionID).catch(() => undefined)
- await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
- }
- export async function withSession<T>(
- sdk: ReturnType<typeof createSdk>,
- title: string,
- callback: (session: { id: string; title: string }) => Promise<T>,
- ): Promise<T> {
- 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 cleanupSession({ sdk, sessionID: session.id })
- }
- }
- const seedSystem = [
- "You are seeding deterministic e2e UI state.",
- "Follow the user's instruction exactly.",
- "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
- "Do not call any extra tools.",
- ].join(" ")
- const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
- const timeout = input.timeout ?? 30_000
- const end = Date.now() + timeout
- while (Date.now() < end) {
- const value = await input.probe()
- if (value !== undefined) return value
- await new Promise((resolve) => setTimeout(resolve, 250))
- }
- }
- const seed = async <T>(input: {
- sessionID: string
- prompt: string
- sdk: ReturnType<typeof createSdk>
- probe: () => Promise<T | undefined>
- timeout?: number
- attempts?: number
- }) => {
- for (let i = 0; i < (input.attempts ?? 2); i++) {
- await input.sdk.session.promptAsync({
- sessionID: input.sessionID,
- agent: "build",
- system: seedSystem,
- parts: [{ type: "text", text: input.prompt }],
- })
- const value = await wait({ probe: input.probe, timeout: input.timeout })
- if (value !== undefined) return value
- }
- }
- export async function seedSessionQuestion(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- questions: Array<{
- header: string
- question: string
- options: Array<{ label: string; description: string }>
- multiple?: boolean
- custom?: boolean
- }>
- },
- ) {
- const first = input.questions[0]
- if (!first) throw new Error("Question seed requires at least one question")
- const text = [
- "Your only valid response is one question tool call.",
- `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
- "Do not output plain text.",
- "After calling the tool, wait for the user response.",
- ].join("\n")
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 30_000,
- probe: async () => {
- const list = await sdk.question.list().then((x) => x.data ?? [])
- return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
- },
- })
- if (!result) throw new Error("Timed out seeding question request")
- return { id: result.id }
- }
- export async function seedSessionPermission(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- permission: string
- patterns: string[]
- description?: string
- },
- ) {
- const text = [
- "Your only valid response is one bash tool call.",
- `Use this JSON input: ${JSON.stringify({
- command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
- workdir: "/",
- description: input.description ?? `seed ${input.permission} permission request`,
- })}`,
- "Do not output plain text.",
- ].join("\n")
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 30_000,
- probe: async () => {
- const list = await sdk.permission.list().then((x) => x.data ?? [])
- return list.find((item) => item.sessionID === input.sessionID)
- },
- })
- if (!result) throw new Error("Timed out seeding permission request")
- return { id: result.id }
- }
- export async function seedSessionTask(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- description: string
- prompt: string
- subagentType?: string
- },
- ) {
- const text = [
- "Your only valid response is one task tool call.",
- `Use this JSON input: ${JSON.stringify({
- description: input.description,
- prompt: input.prompt,
- subagent_type: input.subagentType ?? "general",
- })}`,
- "Do not output plain text.",
- "Wait for the task to start and return the child session id.",
- ].join("\n")
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 90_000,
- probe: async () => {
- const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
- const part = messages
- .flatMap((message) => message.parts)
- .find((part) => {
- if (part.type !== "tool" || part.tool !== "task") return false
- if (!("state" in part) || !part.state || typeof part.state !== "object") return false
- if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
- if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
- if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
- return false
- if (!("sessionId" in part.state.metadata)) return false
- return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
- })
- if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
- if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
- if (!("sessionId" in part.state.metadata)) return
- const id = part.state.metadata.sessionId
- if (typeof id !== "string" || !id) return
- const child = await sdk.session
- .get({ sessionID: id })
- .then((x) => x.data)
- .catch(() => undefined)
- if (!child?.id) return
- return { sessionID: id }
- },
- })
- if (!result) throw new Error("Timed out seeding task tool")
- return result
- }
- export async function seedSessionTodos(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- todos: Array<{ content: string; status: string; priority: string }>
- },
- ) {
- const text = [
- "Your only valid response is one todowrite tool call.",
- `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
- "Do not output plain text.",
- ].join("\n")
- const target = JSON.stringify(input.todos)
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 30_000,
- probe: async () => {
- const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
- if (JSON.stringify(todos) !== target) return
- return true
- },
- })
- if (!result) throw new Error("Timed out seeding todos")
- return true
- }
- export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
- const [questions, permissions] = await Promise.all([
- sdk.question.list().then((x) => x.data ?? []),
- sdk.permission.list().then((x) => x.data ?? []),
- ])
- await Promise.all([
- ...questions
- .filter((item) => item.sessionID === sessionID)
- .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
- ...permissions
- .filter((item) => item.sessionID === sessionID)
- .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
- ])
- return true
- }
- 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)
- const menu = page
- .locator(dropdownMenuContentSelector)
- .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
- .first()
- const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
- const clicked = await trigger
- .click({ timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (clicked) {
- const opened = await menu
- .waitFor({ state: "visible", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (opened) {
- await expect(close).toBeVisible()
- return menu
- }
- }
- await trigger.focus()
- await page.keyboard.press("Enter")
- const opened = await menu
- .waitFor({ state: "visible", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (opened) {
- await expect(close).toBeVisible()
- return menu
- }
- throw new Error(`Failed to open project menu: ${projectSlug}`)
- }
- 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
- const flip = async (timeout?: number) => {
- const menu = await openProjectMenu(page, projectSlug)
- const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
- await expect(toggle).toBeVisible()
- return toggle.click({ force: true, timeout })
- }
- const flipped = await flip(1500)
- .then(() => true)
- .catch(() => false)
- if (!flipped) await flip()
- 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
- }
|