test(web): cover row fork in the real app

This commit is contained in:
imccyu
2026-07-30 13:27:02 +08:00
parent 36f1489398
commit 7d27c92851
4 changed files with 48 additions and 6 deletions
+29 -1
View File
@@ -87,13 +87,41 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}) })
it.skipIf(MODE === 'record')('forks the session through the settled user-message action', async () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
await page.getByRole('button', { name: '在新对话中分支' }).first().click() await page.getByRole('button', { name: '在新对话中分支' }).first().click()
await expect.poll( await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
{ timeout: 15_000 }, { timeout: 15_000 },
).toBeDefined() ).toBeDefined()
await expect.poll(
() => page.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBe(3)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
// The row action owns a distinct ui-workspace injection from the message
// action above, so exercise both through the loaded app before capture.
const sourceRow = page.locator('[role="treeitem"][aria-expanded="true"]').last()
const rowBox = await sourceRow.boundingBox()
if (rowBox === null) throw new Error('fork source row has no layout box')
const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]')
await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } })
await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true)
const buttonBox = await actionButton.boundingBox()
if (buttonBox === null) throw new Error('fork source row action has no layout box')
await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2)
await page.getByRole('menuitem', { name: 'Fork session' }).click()
await expect.poll(
() => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length,
{ timeout: 15_000 },
).toBe(2)
await expect.poll(
() => page.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBe(4)
await expect.poll( await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(), () => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 }, { timeout: 10_000 },
@@ -1,9 +1,10 @@
- tree "Sessions": - tree "Sessions":
- treeitem "Ungrouped 2 sessions" [expanded]: - treeitem "Ungrouped 3 sessions" [expanded]:
- img - img
- text: Ungrouped 2 sessions - text: Ungrouped 3 sessions
- treeitem "Collapse Use the read tool twice 1min" [expanded]: - treeitem "Collapse Use the read tool twice 1min" [expanded]:
- button "Collapse": - button "Collapse":
- img - img
- text: Use the read tool twice 1min - text: Use the read tool twice 1min
- treeitem "Use the read tool twice now" [selected] - treeitem "Use the read tool twice now" [selected]
- treeitem "Use the read tool twice now"
@@ -31,7 +31,7 @@
- img - img
- button "在新对话中分支": - button "在新对话中分支":
- img - img
- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps - text: {{clock}}
- textbox "Message the agent" - textbox "Message the agent"
- button "Add attachment": - button "Add attachment":
- img - img
@@ -41,3 +41,4 @@
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img
- button "Send message" [disabled] - button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
@@ -82,7 +82,9 @@ describe('sessions.fork', () => {
expect(response.result.ok).toBe(true) expect(response.result.ok).toBe(true)
if (!response.result.ok) return if (!response.result.ok) return
const child = ctx.sessions.get(response.result.value.sessionId) const child = ctx.sessions.get(response.result.value.sessionId)
expect(child?.events.length).toBe(3) expect(child?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
expect(child?.header.parentSession).toBe(source.id) expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj') expect(child?.header.cwd).toBe('/proj')
await ctx.fiber.dispose() await ctx.fiber.dispose()
@@ -92,13 +94,23 @@ describe('sessions.fork', () => {
const ctx = await composed() const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, true) const source = liveAgent(ctx, 'session-tail', 2, true)
const proxy = api(ctx) const proxy = api(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
]
const omitted = await proxy.sessions.fork(request({ sessionId: source.id })) const omitted = await proxy.sessions.fork(request({ sessionId: source.id }))
expect(omitted.result.ok).toBe(true) expect(omitted.result.ok).toBe(true)
if (omitted.result.ok) { if (omitted.result.ok) {
expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.length).toBe(6) expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
} }
const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 })) const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 }))
expect(pastEnd.result.ok).toBe(true) expect(pastEnd.result.ok).toBe(true)
if (pastEnd.result.ok) {
expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })