Skip to content

Commit 96b1d8f

Browse files
authored
fix(app): stabilize todo dock e2e with composer probe (anomalyco#17267)
1 parent dcb17c6 commit 96b1d8f

6 files changed

Lines changed: 353 additions & 77 deletions

File tree

packages/app/e2e/AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,25 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
176176
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
177177
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
178178

179+
### Wait on state
180+
181+
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
182+
- Avoid race-prone flows that assume work is finished after an action
183+
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
184+
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
185+
186+
### Add hooks
187+
188+
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
189+
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
190+
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
191+
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
192+
193+
### Prefer helpers
194+
195+
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
196+
- Use direct locators when the interaction is simple and a helper would not add clarity
197+
179198
## Writing New Tests
180199

181200
1. Choose appropriate folder or create new one

packages/app/e2e/session/session-composer-dock.spec.ts

Lines changed: 171 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { test, expect } from "../fixtures"
2-
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
2+
import { composerEvent, type ComposerDriverState, type ComposerProbeState, type ComposerWindow } from "../../src/testing/session-composer"
3+
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
34
import {
45
permissionDockSelector,
56
promptSelector,
67
questionDockSelector,
78
sessionComposerDockSelector,
8-
sessionTodoDockSelector,
9-
sessionTodoListSelector,
109
sessionTodoToggleButtonSelector,
1110
} from "../selectors"
1211

@@ -42,12 +41,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
4241

4342
async function clearPermissionDock(page: any, label: RegExp) {
4443
const dock = page.locator(permissionDockSelector)
45-
for (let i = 0; i < 3; i++) {
46-
const count = await dock.count()
47-
if (count === 0) return
48-
await dock.getByRole("button", { name: label }).click()
49-
await page.waitForTimeout(150)
50-
}
44+
await expect(dock).toBeVisible()
45+
await dock.getByRole("button", { name: label }).click()
5146
}
5247

5348
async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,6 +54,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
5954
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
6055
}
6156

57+
async function expectQuestionBlocked(page: any) {
58+
await expect(page.locator(questionDockSelector)).toBeVisible()
59+
await expect(page.locator(promptSelector)).toHaveCount(0)
60+
}
61+
62+
async function expectQuestionOpen(page: any) {
63+
await expect(page.locator(questionDockSelector)).toHaveCount(0)
64+
await expect(page.locator(promptSelector)).toBeVisible()
65+
}
66+
67+
async function expectPermissionBlocked(page: any) {
68+
await expect(page.locator(permissionDockSelector)).toBeVisible()
69+
await expect(page.locator(promptSelector)).toHaveCount(0)
70+
}
71+
72+
async function expectPermissionOpen(page: any) {
73+
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
74+
await expect(page.locator(promptSelector)).toBeVisible()
75+
}
76+
77+
async function todoDock(page: any, sessionID: string) {
78+
await page.addInitScript(() => {
79+
const win = window as ComposerWindow
80+
win.__opencode_e2e = {
81+
...win.__opencode_e2e,
82+
composer: {
83+
enabled: true,
84+
sessions: {},
85+
},
86+
}
87+
})
88+
89+
const write = async (driver: ComposerDriverState | undefined) => {
90+
await page.evaluate(
91+
(input) => {
92+
const win = window as ComposerWindow
93+
const composer = win.__opencode_e2e?.composer
94+
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
95+
composer.sessions ??= {}
96+
const prev = composer.sessions[input.sessionID] ?? {}
97+
if (!input.driver) {
98+
if (!prev.probe) {
99+
delete composer.sessions[input.sessionID]
100+
} else {
101+
composer.sessions[input.sessionID] = { probe: prev.probe }
102+
}
103+
} else {
104+
composer.sessions[input.sessionID] = {
105+
...prev,
106+
driver: input.driver,
107+
}
108+
}
109+
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
110+
},
111+
{ event: composerEvent, sessionID, driver },
112+
)
113+
}
114+
115+
const read = () =>
116+
page.evaluate((sessionID) => {
117+
const win = window as ComposerWindow
118+
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
119+
}, sessionID) as Promise<ComposerProbeState | null>
120+
121+
const api = {
122+
async clear() {
123+
await write(undefined)
124+
return api
125+
},
126+
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
127+
await write({ live: true, todos })
128+
return api
129+
},
130+
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
131+
await write({ live: false, todos })
132+
return api
133+
},
134+
async expectOpen(states: ComposerProbeState["states"]) {
135+
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
136+
mounted: true,
137+
collapsed: false,
138+
hidden: false,
139+
count: states.length,
140+
states,
141+
})
142+
return api
143+
},
144+
async expectCollapsed(states: ComposerProbeState["states"]) {
145+
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
146+
mounted: true,
147+
collapsed: true,
148+
hidden: true,
149+
count: states.length,
150+
states,
151+
})
152+
return api
153+
},
154+
async expectClosed() {
155+
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
156+
return api
157+
},
158+
async collapse() {
159+
await page.locator(sessionTodoToggleButtonSelector).click()
160+
return api
161+
},
162+
async expand() {
163+
await page.locator(sessionTodoToggleButtonSelector).click()
164+
return api
165+
},
166+
}
167+
168+
return api
169+
}
170+
62171
async function withMockPermission<T>(
63172
page: any,
64173
request: {
@@ -70,7 +179,7 @@ async function withMockPermission<T>(
70179
always?: string[]
71180
},
72181
opts: { child?: any } | undefined,
73-
fn: () => Promise<T>,
182+
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
74183
) {
75184
let pending = [
76185
{
@@ -119,8 +228,14 @@ async function withMockPermission<T>(
119228

120229
if (sessionList) await page.route("**/session?*", sessionList)
121230

231+
const state = {
232+
async resolved() {
233+
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
234+
},
235+
}
236+
122237
try {
123-
return await fn()
238+
return await fn(state)
124239
} finally {
125240
await page.unroute("**/permission", list)
126241
await page.unroute("**/session/*/permissions/*", reply)
@@ -173,14 +288,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
173288
})
174289

175290
const dock = page.locator(questionDockSelector)
176-
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
177-
await expect(page.locator(promptSelector)).toHaveCount(0)
291+
await expectQuestionBlocked(page)
178292

179293
await dock.locator('[data-slot="question-option"]').first().click()
180294
await dock.getByRole("button", { name: /submit/i }).click()
181295

182-
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
183-
await expect(page.locator(promptSelector)).toBeVisible()
296+
await expectQuestionOpen(page)
184297
})
185298
})
186299
})
@@ -199,15 +312,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
199312
metadata: { description: "Need permission for command" },
200313
},
201314
undefined,
202-
async () => {
315+
async (state) => {
203316
await page.goto(page.url())
204-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
205-
await expect(page.locator(promptSelector)).toHaveCount(0)
317+
await expectPermissionBlocked(page)
206318

207319
await clearPermissionDock(page, /allow once/i)
320+
await state.resolved()
208321
await page.goto(page.url())
209-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
210-
await expect(page.locator(promptSelector)).toBeVisible()
322+
await expectPermissionOpen(page)
211323
},
212324
)
213325
})
@@ -226,15 +338,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
226338
patterns: ["/tmp/opencode-e2e-perm-reject"],
227339
},
228340
undefined,
229-
async () => {
341+
async (state) => {
230342
await page.goto(page.url())
231-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
232-
await expect(page.locator(promptSelector)).toHaveCount(0)
343+
await expectPermissionBlocked(page)
233344

234345
await clearPermissionDock(page, /deny/i)
346+
await state.resolved()
235347
await page.goto(page.url())
236-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
237-
await expect(page.locator(promptSelector)).toBeVisible()
348+
await expectPermissionOpen(page)
238349
},
239350
)
240351
})
@@ -254,15 +365,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
254365
metadata: { description: "Need permission for command" },
255366
},
256367
undefined,
257-
async () => {
368+
async (state) => {
258369
await page.goto(page.url())
259-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
260-
await expect(page.locator(promptSelector)).toHaveCount(0)
370+
await expectPermissionBlocked(page)
261371

262372
await clearPermissionDock(page, /allow always/i)
373+
await state.resolved()
263374
await page.goto(page.url())
264-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
265-
await expect(page.locator(promptSelector)).toBeVisible()
375+
await expectPermissionOpen(page)
266376
},
267377
)
268378
})
@@ -301,14 +411,12 @@ test("child session question request blocks parent dock and unblocks after submi
301411
})
302412

303413
const dock = page.locator(questionDockSelector)
304-
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
305-
await expect(page.locator(promptSelector)).toHaveCount(0)
414+
await expectQuestionBlocked(page)
306415

307416
await dock.locator('[data-slot="question-option"]').first().click()
308417
await dock.getByRole("button", { name: /submit/i }).click()
309418

310-
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
311-
await expect(page.locator(promptSelector)).toBeVisible()
419+
await expectQuestionOpen(page)
312420
})
313421
} finally {
314422
await cleanupSession({ sdk, sessionID: child.id })
@@ -344,17 +452,15 @@ test("child session permission request blocks parent dock and supports allow onc
344452
metadata: { description: "Need child permission" },
345453
},
346454
{ child },
347-
async () => {
455+
async (state) => {
348456
await page.goto(page.url())
349-
const dock = page.locator(permissionDockSelector)
350-
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
351-
await expect(page.locator(promptSelector)).toHaveCount(0)
457+
await expectPermissionBlocked(page)
352458

353459
await clearPermissionDock(page, /allow once/i)
460+
await state.resolved()
354461
await page.goto(page.url())
355462

356-
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
357-
await expect(page.locator(promptSelector)).toBeVisible()
463+
await expectPermissionOpen(page)
358464
},
359465
)
360466
} finally {
@@ -365,36 +471,31 @@ test("child session permission request blocks parent dock and supports allow onc
365471

366472
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
367473
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
368-
await withDockSeed(sdk, session.id, async () => {
369-
await gotoSession(session.id)
370-
371-
await seedSessionTodos(sdk, {
372-
sessionID: session.id,
373-
todos: [
374-
{ content: "first task", status: "pending", priority: "high" },
375-
{ content: "second task", status: "in_progress", priority: "medium" },
376-
],
377-
})
378-
379-
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
380-
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
381-
382-
await page.locator(sessionTodoToggleButtonSelector).click()
383-
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
384-
385-
await page.locator(sessionTodoToggleButtonSelector).click()
386-
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
387-
388-
await seedSessionTodos(sdk, {
389-
sessionID: session.id,
390-
todos: [
391-
{ content: "first task", status: "completed", priority: "high" },
392-
{ content: "second task", status: "cancelled", priority: "medium" },
393-
],
394-
})
474+
const dock = await todoDock(page, session.id)
475+
await gotoSession(session.id)
476+
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
395477

396-
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
397-
})
478+
try {
479+
await dock.open([
480+
{ content: "first task", status: "pending", priority: "high" },
481+
{ content: "second task", status: "in_progress", priority: "medium" },
482+
])
483+
await dock.expectOpen(["pending", "in_progress"])
484+
485+
await dock.collapse()
486+
await dock.expectCollapsed(["pending", "in_progress"])
487+
488+
await dock.expand()
489+
await dock.expectOpen(["pending", "in_progress"])
490+
491+
await dock.finish([
492+
{ content: "first task", status: "completed", priority: "high" },
493+
{ content: "second task", status: "cancelled", priority: "medium" },
494+
])
495+
await dock.expectClosed()
496+
} finally {
497+
await dock.clear()
498+
}
398499
})
399500
})
400501

@@ -414,8 +515,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
414515
],
415516
})
416517

417-
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
418-
await expect(page.locator(promptSelector)).toHaveCount(0)
518+
await expectQuestionBlocked(page)
419519

420520
await page.locator("main").click({ position: { x: 5, y: 5 } })
421521
await page.keyboard.type("abc")

0 commit comments

Comments
 (0)