Files
2026-07-13 13:12:33 +08:00

131 lines
4.4 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
const CONTROL_URL = '/control/'
const SESSION_A = 'agent:main:webchat:e2e-race-a'
const SESSION_B = 'agent:main:webchat:e2e-race-b'
const SESSION_A_TITLE = 'Race Session A'
const SESSION_B_TITLE = 'Race Session B'
function wsResponse(id: string, payload: unknown) {
return JSON.stringify({ type: 'res', id, ok: true, payload })
}
async function readSessionDiag(page: Page): Promise<Array<{ source?: string; to?: string }>> {
return page.evaluate(() => {
const helper = (window as unknown as {
OpenSquillaSessionDiag?: { read?: () => Array<{ source?: string; to?: string }> }
}).OpenSquillaSessionDiag
return helper?.read?.() ?? []
})
}
test('late chat.send response cannot navigate away from the current session', async ({ page }) => {
let delayedSend: { id: string; sendResponse: (payload: unknown) => void } | null = null
await page.route('**/api/approvals', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ pending: [] }),
}))
await page.routeWebSocket(/\/ws$/, ws => {
ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }))
ws.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.type !== 'req') return
if (frame.method === 'connect') {
ws.send(JSON.stringify({ protocol: 3, policy: { tick_interval_ms: 30000 } }))
return
}
if (frame.method === 'chat.send') {
const id = String(frame.id)
delayedSend = {
id,
sendResponse: payload => ws.send(wsResponse(id, payload)),
}
return
}
const payloads: Record<string, unknown> = {
'agents.list': { agents: [] },
'chat.history': { messages: [], has_more: false },
'commands.list_for_surface': { commands: [] },
'config.get': {
squilla_router: { enabled: false, rollout_phase: 'observe', tiers: {} },
permissions: {},
skills: {},
},
'sessions.list': {
sessions: [
{
key: SESSION_B,
title: SESSION_B_TITLE,
sessionKind: 'chat',
surface: 'webchat',
conversationKind: 'direct',
effectiveAgentId: 'main',
updatedAt: 200,
messageCount: 0,
status: 'ok',
runStatus: 'idle',
},
{
key: SESSION_A,
title: SESSION_A_TITLE,
sessionKind: 'chat',
surface: 'webchat',
conversationKind: 'direct',
effectiveAgentId: 'main',
updatedAt: 100,
messageCount: 0,
status: 'ok',
runStatus: 'idle',
},
],
has_more: false,
},
'sessions.messages.subscribe': {
subscribed: true,
replay_complete: true,
current_stream_seq: 0,
run_status: 'idle',
},
'usage.status': { sessions: [] },
}
ws.send(wsResponse(String(frame.id), payloads[String(frame.method)] ?? {}))
} catch {}
})
})
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(SESSION_A))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.chat-textarea')).toBeVisible()
await page.locator('.chat-textarea').fill('hello from A')
await page.locator('.chat-send-btn[aria-label="Send"]').click()
await expect.poll(() => delayedSend?.id ?? '').not.toBe('')
await page
.locator('.sidebar-history-row[data-family="chats"]')
.filter({ hasText: SESSION_B_TITLE })
.locator('.sidebar-history-item')
.click()
await expect.poll(() => new URL(page.url()).searchParams.get('session')).toBe(SESSION_B)
const send = delayedSend
if (!send) throw new Error('chat.send was not captured')
send.sendResponse({
ok: true,
sessionKey: SESSION_A,
status: 'accepted',
})
await expect.poll(() => new URL(page.url()).searchParams.get('session')).toBe(SESSION_B)
await expect.poll(async () => {
const diag = await readSessionDiag(page)
return diag.some(entry => entry.source === 'send.response.stale')
}).toBe(true)
})