|
|
@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
|
|
import {
|
|
|
dropdownMenuTriggerSelector,
|
|
|
dropdownMenuContentSelector,
|
|
|
+ projectSwitchSelector,
|
|
|
projectMenuTriggerSelector,
|
|
|
projectCloseMenuSelector,
|
|
|
projectWorkspacesToggleSelector,
|
|
|
@@ -23,6 +24,16 @@ import {
|
|
|
workspaceMenuTriggerSelector,
|
|
|
} from "./selectors"
|
|
|
|
|
|
+const phase = new WeakMap<Page, "test" | "cleanup">()
|
|
|
+
|
|
|
+export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
|
|
+ phase.set(page, value)
|
|
|
+}
|
|
|
+
|
|
|
+export function healthPhase(page: Page) {
|
|
|
+ return phase.get(page) ?? "test"
|
|
|
+}
|
|
|
+
|
|
|
export async function defocus(page: Page) {
|
|
|
await page
|
|
|
.evaluate(() => {
|
|
|
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|
|
}
|
|
|
|
|
|
export async function isSidebarClosed(page: Page) {
|
|
|
- const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
|
- await expect(button).toBeVisible()
|
|
|
+ const button = await waitSidebarButton(page, "isSidebarClosed")
|
|
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
|
|
}
|
|
|
|
|
|
+async function errorBoundaryText(page: Page) {
|
|
|
+ const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
|
|
+ if (!(await title.isVisible().catch(() => false))) return
|
|
|
+
|
|
|
+ const description = await page
|
|
|
+ .getByText(/an error occurred while loading the application\./i)
|
|
|
+ .first()
|
|
|
+ .textContent()
|
|
|
+ .catch(() => "")
|
|
|
+ const detail = await page
|
|
|
+ .getByRole("textbox", { name: /error details/i })
|
|
|
+ .first()
|
|
|
+ .inputValue()
|
|
|
+ .catch(async () =>
|
|
|
+ (
|
|
|
+ (await page
|
|
|
+ .getByRole("textbox", { name: /error details/i })
|
|
|
+ .first()
|
|
|
+ .textContent()
|
|
|
+ .catch(() => "")) ?? ""
|
|
|
+ ).trim(),
|
|
|
+ )
|
|
|
+
|
|
|
+ return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+export async function assertHealthy(page: Page, context: string) {
|
|
|
+ const text = await errorBoundaryText(page)
|
|
|
+ if (!text) return
|
|
|
+ console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
|
|
+ throw new Error(`Error boundary during ${context}\n${text}`)
|
|
|
+}
|
|
|
+
|
|
|
+async function waitSidebarButton(page: Page, context: string) {
|
|
|
+ const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
|
+ const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
|
|
+ await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
|
|
+ await assertHealthy(page, context)
|
|
|
+ return button
|
|
|
+}
|
|
|
+
|
|
|
export async function toggleSidebar(page: Page) {
|
|
|
await defocus(page)
|
|
|
await page.keyboard.press(`${modKey}+B`)
|
|
|
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
|
|
export async function openSidebar(page: Page) {
|
|
|
if (!(await isSidebarClosed(page))) return
|
|
|
|
|
|
- const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
|
+ const button = await waitSidebarButton(page, "openSidebar")
|
|
|
await button.click()
|
|
|
|
|
|
const opened = await expect(button)
|
|
|
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
|
|
|
export async function closeSidebar(page: Page) {
|
|
|
if (await isSidebarClosed(page)) return
|
|
|
|
|
|
- const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
|
+ const button = await waitSidebarButton(page, "closeSidebar")
|
|
|
await button.click()
|
|
|
|
|
|
const closed = await expect(button)
|
|
|
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
|
|
}
|
|
|
|
|
|
export async function openSettings(page: Page) {
|
|
|
+ await assertHealthy(page, "openSettings")
|
|
|
await defocus(page)
|
|
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
|
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
|
|
|
|
|
|
if (opened) return dialog
|
|
|
|
|
|
+ await assertHealthy(page, "openSettings")
|
|
|
+
|
|
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
|
await expect(dialog).toBeVisible()
|
|
|
return dialog
|
|
|
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|
|
|
|
|
export async function createTestProject() {
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
|
|
+ const id = `e2e-${path.basename(root)}`
|
|
|
|
|
|
- await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
|
|
+ await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
|
|
|
|
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
|
|
+ await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
|
|
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', {
|
|
|
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
|
|
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
|
|
}
|
|
|
|
|
|
+async function probeSession(page: Page) {
|
|
|
+ return page
|
|
|
+ .evaluate(() => {
|
|
|
+ const win = window as E2EWindow
|
|
|
+ const current = win.__opencode_e2e?.model?.current
|
|
|
+ if (!current) return null
|
|
|
+ return { dir: current.dir, sessionID: current.sessionID }
|
|
|
+ })
|
|
|
+ .catch(() => null as { dir?: string; sessionID?: string } | null)
|
|
|
+}
|
|
|
+
|
|
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
|
|
let prev = ""
|
|
|
let next = ""
|
|
|
await expect
|
|
|
.poll(
|
|
|
- () => {
|
|
|
+ async () => {
|
|
|
+ await assertHealthy(page, "waitSlug")
|
|
|
const slug = slugFromUrl(page.url())
|
|
|
if (!slug) return ""
|
|
|
if (skip.includes(slug)) return ""
|
|
|
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
|
|
|
await expect
|
|
|
.poll(
|
|
|
async () => {
|
|
|
+ await assertHealthy(page, "waitDir")
|
|
|
const slug = slugFromUrl(page.url())
|
|
|
if (!slug) return ""
|
|
|
return resolveSlug(slug)
|
|
|
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
|
|
|
return { directory: target, slug: base64Encode(target) }
|
|
|
}
|
|
|
|
|
|
+export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
|
|
+ const target = await resolveDirectory(input.directory)
|
|
|
+ await expect
|
|
|
+ .poll(
|
|
|
+ async () => {
|
|
|
+ await assertHealthy(page, "waitSession")
|
|
|
+ const slug = slugFromUrl(page.url())
|
|
|
+ if (!slug) return false
|
|
|
+ const resolved = await resolveSlug(slug).catch(() => undefined)
|
|
|
+ if (!resolved || resolved.directory !== target) return false
|
|
|
+ if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
|
|
+
|
|
|
+ const state = await probeSession(page)
|
|
|
+ if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
|
|
+ if (state?.dir) {
|
|
|
+ const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
|
|
+ if (dir !== target) return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return page
|
|
|
+ .locator(promptSelector)
|
|
|
+ .first()
|
|
|
+ .isVisible()
|
|
|
+ .catch(() => false)
|
|
|
+ },
|
|
|
+ { timeout: 45_000 },
|
|
|
+ )
|
|
|
+ .toBe(true)
|
|
|
+ return { directory: target, slug: base64Encode(target) }
|
|
|
+}
|
|
|
+
|
|
|
+export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
|
|
+ const sdk = createSdk(directory)
|
|
|
+ const target = await resolveDirectory(directory)
|
|
|
+
|
|
|
+ await expect
|
|
|
+ .poll(
|
|
|
+ async () => {
|
|
|
+ const data = await sdk.session
|
|
|
+ .get({ sessionID })
|
|
|
+ .then((x) => x.data)
|
|
|
+ .catch(() => undefined)
|
|
|
+ if (!data?.directory) return ""
|
|
|
+ return resolveDirectory(data.directory).catch(() => data.directory)
|
|
|
+ },
|
|
|
+ { timeout },
|
|
|
+ )
|
|
|
+ .toBe(target)
|
|
|
+
|
|
|
+ await expect
|
|
|
+ .poll(
|
|
|
+ async () => {
|
|
|
+ const items = await sdk.session
|
|
|
+ .messages({ sessionID, limit: 20 })
|
|
|
+ .then((x) => x.data ?? [])
|
|
|
+ .catch(() => [])
|
|
|
+ return items.some((item) => item.info.role === "user")
|
|
|
+ },
|
|
|
+ { timeout },
|
|
|
+ )
|
|
|
+ .toBe(true)
|
|
|
+}
|
|
|
+
|
|
|
export function sessionIDFromUrl(url: string) {
|
|
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
|
|
return match?.[1]
|
|
|
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
|
|
|
}
|
|
|
|
|
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
|
|
+ await openSidebar(page)
|
|
|
+ const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
|
|
+ await expect(item).toBeVisible()
|
|
|
+ await item.hover()
|
|
|
+
|
|
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
|
|
await expect(trigger).toHaveCount(1)
|
|
|
+ await expect(trigger).toBeVisible()
|
|
|
|
|
|
const menu = page
|
|
|
.locator(dropdownMenuContentSelector)
|
|
|
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
|
|
|
|
|
const clicked = await trigger
|
|
|
- .click({ timeout: 1500 })
|
|
|
+ .click({ force: true, timeout: 1500 })
|
|
|
.then(() => true)
|
|
|
.catch(() => false)
|
|
|
|