Skip to content

Commit d460614

Browse files
authored
fix: lots of desktop stability, better e2e error logging (anomalyco#18300)
1 parent 7866dbc commit d460614

File tree

16 files changed

+457
-313
lines changed

16 files changed

+457
-313
lines changed

.github/workflows/test.yml

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,17 @@ jobs:
5050

5151
e2e:
5252
name: e2e (${{ matrix.settings.name }})
53-
needs: unit
5453
strategy:
5554
fail-fast: false
5655
matrix:
5756
settings:
5857
- name: linux
5958
host: blacksmith-4vcpu-ubuntu-2404
60-
playwright: bunx playwright install --with-deps
6159
- name: windows
6260
host: blacksmith-4vcpu-windows-2025
63-
playwright: bunx playwright install
6461
runs-on: ${{ matrix.settings.host }}
6562
env:
66-
PLAYWRIGHT_BROWSERS_PATH: 0
63+
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
6764
defaults:
6865
run:
6966
shell: bash
@@ -76,9 +73,28 @@ jobs:
7673
- name: Setup Bun
7774
uses: ./.github/actions/setup-bun
7875

76+
- name: Read Playwright version
77+
id: playwright-version
78+
run: |
79+
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
80+
echo "version=$version" >> "$GITHUB_OUTPUT"
81+
82+
- name: Cache Playwright browsers
83+
id: playwright-cache
84+
uses: actions/cache@v4
85+
with:
86+
path: ${{ github.workspace }}/.playwright-browsers
87+
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
88+
89+
- name: Install Playwright system dependencies
90+
if: runner.os == 'Linux'
91+
working-directory: packages/app
92+
run: bunx playwright install-deps chromium
93+
7994
- name: Install Playwright browsers
95+
if: steps.playwright-cache.outputs.cache-hit != 'true'
8096
working-directory: packages/app
81-
run: ${{ matrix.settings.playwright }}
97+
run: bunx playwright install chromium
8298

8399
- name: Run app e2e tests
84100
run: bun --cwd packages/app test:e2e:local

packages/app/e2e/actions.ts

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
99
import {
1010
dropdownMenuTriggerSelector,
1111
dropdownMenuContentSelector,
12+
projectSwitchSelector,
1213
projectMenuTriggerSelector,
1314
projectCloseMenuSelector,
1415
projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
2324
workspaceMenuTriggerSelector,
2425
} from "./selectors"
2526

27+
const phase = new WeakMap<Page, "test" | "cleanup">()
28+
29+
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
30+
phase.set(page, value)
31+
}
32+
33+
export function healthPhase(page: Page) {
34+
return phase.get(page) ?? "test"
35+
}
36+
2637
export async function defocus(page: Page) {
2738
await page
2839
.evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
196207
}
197208

198209
export async function isSidebarClosed(page: Page) {
199-
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
200-
await expect(button).toBeVisible()
210+
const button = await waitSidebarButton(page, "isSidebarClosed")
201211
return (await button.getAttribute("aria-expanded")) !== "true"
202212
}
203213

214+
async function errorBoundaryText(page: Page) {
215+
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
216+
if (!(await title.isVisible().catch(() => false))) return
217+
218+
const description = await page
219+
.getByText(/an error occurred while loading the application\./i)
220+
.first()
221+
.textContent()
222+
.catch(() => "")
223+
const detail = await page
224+
.getByRole("textbox", { name: /error details/i })
225+
.first()
226+
.inputValue()
227+
.catch(async () =>
228+
(
229+
(await page
230+
.getByRole("textbox", { name: /error details/i })
231+
.first()
232+
.textContent()
233+
.catch(() => "")) ?? ""
234+
).trim(),
235+
)
236+
237+
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
238+
}
239+
240+
export async function assertHealthy(page: Page, context: string) {
241+
const text = await errorBoundaryText(page)
242+
if (!text) return
243+
console.log(`[e2e:error-boundary][${context}]\n${text}`)
244+
throw new Error(`Error boundary during ${context}\n${text}`)
245+
}
246+
247+
async function waitSidebarButton(page: Page, context: string) {
248+
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
249+
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
250+
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
251+
await assertHealthy(page, context)
252+
return button
253+
}
254+
204255
export async function toggleSidebar(page: Page) {
205256
await defocus(page)
206257
await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
209260
export async function openSidebar(page: Page) {
210261
if (!(await isSidebarClosed(page))) return
211262

212-
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
263+
const button = await waitSidebarButton(page, "openSidebar")
213264
await button.click()
214265

215266
const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
226277
export async function closeSidebar(page: Page) {
227278
if (await isSidebarClosed(page)) return
228279

229-
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
280+
const button = await waitSidebarButton(page, "closeSidebar")
230281
await button.click()
231282

232283
const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
241292
}
242293

243294
export async function openSettings(page: Page) {
295+
await assertHealthy(page, "openSettings")
244296
await defocus(page)
245297

246298
const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
253305

254306
if (opened) return dialog
255307

308+
await assertHealthy(page, "openSettings")
309+
256310
await page.getByRole("button", { name: "Settings" }).first().click()
257311
await expect(dialog).toBeVisible()
258312
return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
314368

315369
export async function createTestProject() {
316370
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
371+
const id = `e2e-${path.basename(root)}`
317372

318-
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
373+
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
319374

320375
execSync("git init", { cwd: root, stdio: "ignore" })
376+
await fs.writeFile(path.join(root, ".git", "opencode"), id)
321377
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
322378
execSync("git add -A", { cwd: root, stdio: "ignore" })
323379
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) {
339395
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
340396
}
341397

398+
async function probeSession(page: Page) {
399+
return page
400+
.evaluate(() => {
401+
const win = window as E2EWindow
402+
const current = win.__opencode_e2e?.model?.current
403+
if (!current) return null
404+
return { dir: current.dir, sessionID: current.sessionID }
405+
})
406+
.catch(() => null as { dir?: string; sessionID?: string } | null)
407+
}
408+
342409
export async function waitSlug(page: Page, skip: string[] = []) {
343410
let prev = ""
344411
let next = ""
345412
await expect
346413
.poll(
347-
() => {
414+
async () => {
415+
await assertHealthy(page, "waitSlug")
348416
const slug = slugFromUrl(page.url())
349417
if (!slug) return ""
350418
if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
374442
await expect
375443
.poll(
376444
async () => {
445+
await assertHealthy(page, "waitDir")
377446
const slug = slugFromUrl(page.url())
378447
if (!slug) return ""
379448
return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
386455
return { directory: target, slug: base64Encode(target) }
387456
}
388457

458+
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
459+
const target = await resolveDirectory(input.directory)
460+
await expect
461+
.poll(
462+
async () => {
463+
await assertHealthy(page, "waitSession")
464+
const slug = slugFromUrl(page.url())
465+
if (!slug) return false
466+
const resolved = await resolveSlug(slug).catch(() => undefined)
467+
if (!resolved || resolved.directory !== target) return false
468+
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
469+
470+
const state = await probeSession(page)
471+
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
472+
if (state?.dir) {
473+
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
474+
if (dir !== target) return false
475+
}
476+
477+
return page
478+
.locator(promptSelector)
479+
.first()
480+
.isVisible()
481+
.catch(() => false)
482+
},
483+
{ timeout: 45_000 },
484+
)
485+
.toBe(true)
486+
return { directory: target, slug: base64Encode(target) }
487+
}
488+
489+
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
490+
const sdk = createSdk(directory)
491+
const target = await resolveDirectory(directory)
492+
493+
await expect
494+
.poll(
495+
async () => {
496+
const data = await sdk.session
497+
.get({ sessionID })
498+
.then((x) => x.data)
499+
.catch(() => undefined)
500+
if (!data?.directory) return ""
501+
return resolveDirectory(data.directory).catch(() => data.directory)
502+
},
503+
{ timeout },
504+
)
505+
.toBe(target)
506+
507+
await expect
508+
.poll(
509+
async () => {
510+
const items = await sdk.session
511+
.messages({ sessionID, limit: 20 })
512+
.then((x) => x.data ?? [])
513+
.catch(() => [])
514+
return items.some((item) => item.info.role === "user")
515+
},
516+
{ timeout },
517+
)
518+
.toBe(true)
519+
}
520+
389521
export function sessionIDFromUrl(url: string) {
390522
const match = /\/session\/([^/?#]+)/.exec(url)
391523
return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
797929
}
798930

799931
export async function openProjectMenu(page: Page, projectSlug: string) {
932+
await openSidebar(page)
933+
const item = page.locator(projectSwitchSelector(projectSlug)).first()
934+
await expect(item).toBeVisible()
935+
await item.hover()
936+
800937
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
801938
await expect(trigger).toHaveCount(1)
939+
await expect(trigger).toBeVisible()
802940

803941
const menu = page
804942
.locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
807945
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
808946

809947
const clicked = await trigger
810-
.click({ timeout: 1500 })
948+
.click({ force: true, timeout: 1500 })
811949
.then(() => true)
812950
.catch(() => false)
813951

packages/app/e2e/fixtures.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { test as base, expect, type Page } from "@playwright/test"
22
import type { E2EWindow } from "../src/testing/terminal"
3-
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
4-
import { promptSelector } from "./selectors"
3+
import {
4+
healthPhase,
5+
cleanupSession,
6+
cleanupTestProject,
7+
createTestProject,
8+
setHealthPhase,
9+
seedProjects,
10+
sessionIDFromUrl,
11+
waitSlug,
12+
waitSession,
13+
} from "./actions"
514
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
615

716
export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
2736
}
2837

2938
export const test = base.extend<TestFixtures, WorkerFixtures>({
39+
page: async ({ page }, use) => {
40+
let boundary: string | undefined
41+
setHealthPhase(page, "test")
42+
const consoleHandler = (msg: { text(): string }) => {
43+
const text = msg.text()
44+
if (!text.includes("[e2e:error-boundary]")) return
45+
if (healthPhase(page) === "cleanup") {
46+
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
47+
return
48+
}
49+
boundary ||= text
50+
console.log(text)
51+
}
52+
const pageErrorHandler = (err: Error) => {
53+
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
54+
}
55+
page.on("console", consoleHandler)
56+
page.on("pageerror", pageErrorHandler)
57+
await use(page)
58+
page.off("console", consoleHandler)
59+
page.off("pageerror", pageErrorHandler)
60+
if (boundary) throw new Error(boundary)
61+
},
3062
directory: [
3163
async ({}, use) => {
3264
const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
4880

4981
const gotoSession = async (sessionID?: string) => {
5082
await page.goto(sessionPath(directory, sessionID))
51-
await expect(page.locator(promptSelector)).toBeVisible()
83+
await waitSession(page, { directory, sessionID })
5284
}
5385
await use(gotoSession)
5486
},
5587
withProject: async ({ page }, use) => {
5688
await use(async (callback, options) => {
5789
const root = await createTestProject()
58-
const slug = dirSlug(root)
5990
const sessions = new Map<string, string>()
6091
const dirs = new Set<string>()
6192
await seedStorage(page, { directory: root, extra: options?.extra })
6293

6394
const gotoSession = async (sessionID?: string) => {
6495
await page.goto(sessionPath(root, sessionID))
65-
await expect(page.locator(promptSelector)).toBeVisible()
96+
await waitSession(page, { directory: root, sessionID })
6697
const current = sessionIDFromUrl(page.url())
6798
if (current) trackSession(current)
6899
}
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
77108

78109
try {
79110
await gotoSession()
111+
const slug = await waitSlug(page)
80112
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
81113
} finally {
114+
setHealthPhase(page, "cleanup")
82115
await Promise.allSettled(
83116
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
84117
)
85118
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
86119
await cleanupTestProject(root)
120+
setHealthPhase(page, "test")
87121
}
88122
})
89123
},

0 commit comments

Comments
 (0)