Skip to content

Commit 0b7a5b1

Browse files
authored
test(app): abort sessions and wait for idle before e2e cleanup (anomalyco#16439)
1 parent 28bb16c commit 0b7a5b1

11 files changed

Lines changed: 131 additions & 89 deletions

packages/app/e2e/AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
7171
- `closeDialog(page, dialog)` - Close any dialog
7272
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
7373
- `withSession(sdk, title, callback)` - Create temp session
74+
- `withProject(...)` - Create temp project/workspace
75+
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
76+
- `trackDirectory(directory)` - Register directory for fixture cleanup
7477
- `clickListItem(container, filter)` - Click list item by key/text
7578

7679
**Selectors** (`selectors.ts`):
@@ -109,7 +112,7 @@ import { test, expect } from "@playwright/test"
109112

110113
### Error Handling
111114

112-
Tests should clean up after themselves:
115+
Tests should clean up after themselves. Prefer fixture-managed cleanup:
113116

114117
```typescript
115118
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -120,6 +123,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
120123
})
121124
```
122125

126+
- Prefer `withSession(...)` for temp sessions
127+
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
128+
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
129+
- Avoid calling `sdk.session.delete(...)` directly
130+
123131
### Timeouts
124132

125133
Default: 60s per test, 10s per assertion. Override when needed:

packages/app/e2e/actions.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,57 @@ export async function clickListItem(
306306
return item
307307
}
308308

309+
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
310+
const data = await sdk.session
311+
.status()
312+
.then((x) => x.data ?? {})
313+
.catch(() => undefined)
314+
return data?.[sessionID]
315+
}
316+
317+
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
318+
let prev = ""
319+
await expect
320+
.poll(
321+
async () => {
322+
const info = await sdk.session
323+
.get({ sessionID })
324+
.then((x) => x.data)
325+
.catch(() => undefined)
326+
if (!info) return true
327+
const next = `${info.title}:${info.time.updated ?? info.time.created}`
328+
if (next !== prev) {
329+
prev = next
330+
return false
331+
}
332+
return true
333+
},
334+
{ timeout },
335+
)
336+
.toBe(true)
337+
}
338+
339+
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
340+
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
341+
}
342+
343+
export async function cleanupSession(input: {
344+
sessionID: string
345+
directory?: string
346+
sdk?: ReturnType<typeof createSdk>
347+
}) {
348+
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
349+
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
350+
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
351+
const current = await status(sdk, input.sessionID).catch(() => undefined)
352+
if (current && current.type !== "idle") {
353+
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
354+
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
355+
}
356+
await stable(sdk, input.sessionID).catch(() => undefined)
357+
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
358+
}
359+
309360
export async function withSession<T>(
310361
sdk: ReturnType<typeof createSdk>,
311362
title: string,
@@ -317,7 +368,7 @@ export async function withSession<T>(
317368
try {
318369
return await callback(session)
319370
} finally {
320-
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
371+
await cleanupSession({ sdk, sessionID: session.id })
321372
}
322373
}
323374

packages/app/e2e/fixtures.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test as base, expect, type Page } from "@playwright/test"
2-
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
2+
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
33
import { promptSelector } from "./selectors"
44
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
55

@@ -13,6 +13,8 @@ type TestFixtures = {
1313
directory: string
1414
slug: string
1515
gotoSession: (sessionID?: string) => Promise<void>
16+
trackSession: (sessionID: string, directory?: string) => void
17+
trackDirectory: (directory: string) => void
1618
}) => Promise<T>,
1719
options?: { extra?: string[] },
1820
) => Promise<T>
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
5153
},
5254
withProject: async ({ page }, use) => {
5355
await use(async (callback, options) => {
54-
const directory = await createTestProject()
55-
const slug = dirSlug(directory)
56-
await seedStorage(page, { directory, extra: options?.extra })
56+
const root = await createTestProject()
57+
const slug = dirSlug(root)
58+
const sessions = new Map<string, string>()
59+
const dirs = new Set<string>()
60+
await seedStorage(page, { directory: root, extra: options?.extra })
5761

5862
const gotoSession = async (sessionID?: string) => {
59-
await page.goto(sessionPath(directory, sessionID))
63+
await page.goto(sessionPath(root, sessionID))
6064
await expect(page.locator(promptSelector)).toBeVisible()
65+
const current = sessionIDFromUrl(page.url())
66+
if (current) trackSession(current)
67+
}
68+
69+
const trackSession = (sessionID: string, directory?: string) => {
70+
sessions.set(sessionID, directory ?? root)
71+
}
72+
73+
const trackDirectory = (directory: string) => {
74+
if (directory !== root) dirs.add(directory)
6175
}
6276

6377
try {
6478
await gotoSession()
65-
return await callback({ directory, slug, gotoSession })
79+
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
6680
} finally {
67-
await cleanupTestProject(directory)
81+
await Promise.allSettled(
82+
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
83+
)
84+
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
85+
await cleanupTestProject(root)
6886
}
6987
})
7088
},

packages/app/e2e/projects/projects-switch.spec.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
33
import { test, expect } from "../fixtures"
44
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
55
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
6-
import { createSdk, dirSlug, sessionPath } from "../utils"
6+
import { dirSlug } from "../utils"
77

88
function slugFromUrl(url: string) {
99
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -76,14 +76,10 @@ test("switching back to a project opens the latest workspace session", async ({
7676

7777
const other = await createTestProject()
7878
const otherSlug = dirSlug(other)
79-
let rootDir: string | undefined
8079
let workspaceDir: string | undefined
81-
let sessionID: string | undefined
82-
8380
try {
8481
await withProject(
85-
async ({ directory, slug }) => {
86-
rootDir = directory
82+
async ({ directory, slug, trackSession, trackDirectory }) => {
8783
await defocus(page)
8884
await workspaces(page, directory, true)
8985
await page.reload()
@@ -108,6 +104,7 @@ test("switching back to a project opens the latest workspace session", async ({
108104
const workspaceSlug = slugFromUrl(page.url())
109105
workspaceDir = base64Decode(workspaceSlug)
110106
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
107+
trackDirectory(workspaceDir)
111108
await openSidebar(page)
112109

113110
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
@@ -131,7 +128,7 @@ test("switching back to a project opens the latest workspace session", async ({
131128

132129
const created = sessionIDFromUrl(page.url())
133130
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
134-
sessionID = created
131+
trackSession(created, workspaceDir)
135132

136133
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
137134

@@ -152,20 +149,6 @@ test("switching back to a project opens the latest workspace session", async ({
152149
{ extra: [other] },
153150
)
154151
} finally {
155-
if (sessionID) {
156-
const id = sessionID
157-
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
158-
await Promise.all(
159-
dirs.map((directory) =>
160-
createSdk(directory)
161-
.session.delete({ sessionID: id })
162-
.catch(() => undefined),
163-
),
164-
)
165-
}
166-
if (workspaceDir) {
167-
await cleanupTestProject(workspaceDir)
168-
}
169152
await cleanupTestProject(other)
170153
}
171154
})

packages/app/e2e/projects/workspace-new-session.spec.ts

Lines changed: 25 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { base64Decode } from "@opencode-ai/util/encode"
22
import type { Page } from "@playwright/test"
33
import { test, expect } from "../fixtures"
4-
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
4+
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
55
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
66
import { createSdk } from "../utils"
77

@@ -105,48 +105,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
105105
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
106106
await page.setViewportSize({ width: 1400, height: 800 })
107107

108-
await withProject(async ({ directory, slug: root }) => {
109-
const workspaces = [] as { slug: string; directory: string }[]
110-
const sessions = [] as string[]
111-
112-
try {
113-
await openSidebar(page)
114-
await setWorkspacesEnabled(page, root, true)
115-
116-
const first = await createWorkspace(page, root, [])
117-
workspaces.push(first)
118-
await waitWorkspaceReady(page, first.slug)
119-
120-
const second = await createWorkspace(page, root, [first.slug])
121-
workspaces.push(second)
122-
await waitWorkspaceReady(page, second.slug)
123-
124-
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
125-
sessions.push(firstSession.sessionID)
126-
127-
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
128-
sessions.push(secondSession.sessionID)
129-
130-
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
131-
sessions.push(thirdSession.sessionID)
132-
133-
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
134-
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
135-
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
136-
} finally {
137-
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
138-
await Promise.all(
139-
sessions.map((sessionID) =>
140-
Promise.all(
141-
dirs.map((dir) =>
142-
createSdk(dir)
143-
.session.delete({ sessionID })
144-
.catch(() => undefined),
145-
),
146-
),
147-
),
148-
)
149-
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
150-
}
108+
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
109+
await openSidebar(page)
110+
await setWorkspacesEnabled(page, root, true)
111+
112+
const first = await createWorkspace(page, root, [])
113+
trackDirectory(first.directory)
114+
await waitWorkspaceReady(page, first.slug)
115+
116+
const second = await createWorkspace(page, root, [first.slug])
117+
trackDirectory(second.directory)
118+
await waitWorkspaceReady(page, second.slug)
119+
120+
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
121+
trackSession(firstSession.sessionID, first.directory)
122+
123+
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
124+
trackSession(secondSession.sessionID, second.directory)
125+
126+
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
127+
trackSession(thirdSession.sessionID, first.directory)
128+
129+
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
130+
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
131+
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
151132
})
152133
})

packages/app/e2e/prompt/prompt-async.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect } from "../fixtures"
22
import { promptSelector } from "../selectors"
3-
import { sessionIDFromUrl, withSession } from "../actions"
3+
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
44

55
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
66

@@ -40,7 +40,7 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
4040
)
4141
.toContain(token)
4242
} finally {
43-
await sdk.session.delete({ sessionID }).catch(() => undefined)
43+
await cleanupSession({ sdk, sessionID })
4444
}
4545
})
4646

packages/app/e2e/prompt/prompt-shell.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const isBash = (part: unknown): part is ToolPart => {
1414
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
1515
test.setTimeout(120_000)
1616

17-
await withProject(async ({ directory, gotoSession }) => {
17+
await withProject(async ({ directory, gotoSession, trackSession }) => {
1818
const sdk = createSdk(directory)
1919
const prompt = page.locator(promptSelector)
2020
const cmd = process.platform === "win32" ? "dir" : "ls"
@@ -31,6 +31,7 @@ test("shell mode runs a command in the project directory", async ({ page, withPr
3131

3232
const id = sessionIDFromUrl(page.url())
3333
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
34+
trackSession(id, directory)
3435

3536
await expect
3637
.poll(

packages/app/e2e/prompt/prompt.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect } from "../fixtures"
22
import { promptSelector } from "../selectors"
3-
import { sessionIDFromUrl, withSession } from "../actions"
3+
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
44

55
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
66
test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
4646
.toContain(token)
4747
} finally {
4848
page.off("pageerror", onPageError)
49-
await sdk.session.delete({ sessionID }).catch(() => undefined)
49+
await cleanupSession({ sdk, sessionID })
5050
}
5151

5252
if (pageErrors.length > 0) {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from "../fixtures"
2-
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
2+
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
33
import {
44
permissionDockSelector,
55
promptSelector,
@@ -26,7 +26,7 @@ async function withDockSession<T>(
2626
try {
2727
return await fn(session)
2828
} finally {
29-
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
29+
await cleanupSession({ sdk, sessionID: session.id })
3030
}
3131
}
3232

@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
311311
await expect(page.locator(promptSelector)).toBeVisible()
312312
})
313313
} finally {
314-
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
314+
await cleanupSession({ sdk, sessionID: child.id })
315315
}
316316
})
317317
})
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
358358
},
359359
)
360360
} finally {
361-
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
361+
await cleanupSession({ sdk, sessionID: child.id })
362362
}
363363
})
364364
})

0 commit comments

Comments
 (0)