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

304 lines
13 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
const CONTROL_URL = '/control/'
const LIVE = process.env.OPENSQUILLA_E2E_LIVE === '1'
const RESEARCH_SESSION_KEY = 'agent:main:webchat:e2eresearchsources'
async function seedSearchHistory(page: Page, toolName: string) {
await page.routeWebSocket(/\/ws$/, ws => {
ws.onMessage(message => {
let frame: Record<string, unknown>
try {
frame = JSON.parse(String(message)) as Record<string, unknown>
} catch {
return
}
if (frame?.type !== 'req' || frame.id === undefined) return
if (frame.method === 'connect') {
ws.send(JSON.stringify({ protocol: 3, policy: {} }))
return
}
if (frame?.type === 'req' && frame.method === 'chat.history') {
ws.send(JSON.stringify({
type: 'res',
id: frame.id,
ok: true,
payload: {
messages: [
{
role: 'assistant',
text: 'The answer cites the research result.',
id: 'msg-e2e-research-source',
timestamp: Math.floor(Date.now() / 1000) - 60,
tool_calls: [
{
tool_use_id: 'tool-e2e-research-source',
name: toolName,
input: { query: 'OpenSquilla web search' },
result: JSON.stringify({
ok: true,
results: [
{
title: 'OpenSquilla Search Notes',
url: 'https://example.com/opensquilla-search',
domain: 'example.com',
provider: 'tavily',
excerpt: 'Compact citation-ready excerpt.',
fetched: true,
},
],
}),
status: 'success',
},
],
},
],
has_more: false,
},
}))
return
}
ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} }))
})
ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }))
})
}
async function seedPersistedSearchSourcesHistory(page: Page) {
await page.routeWebSocket(/\/ws$/, ws => {
ws.onMessage(message => {
let frame: Record<string, unknown>
try {
frame = JSON.parse(String(message)) as Record<string, unknown>
} catch {
return
}
if (frame?.type !== 'req' || frame.id === undefined) return
if (frame.method === 'connect') {
ws.send(JSON.stringify({ protocol: 3, policy: {} }))
return
}
if (frame?.type === 'req' && frame.method === 'chat.history') {
ws.send(JSON.stringify({
type: 'res',
id: frame.id,
ok: true,
payload: {
messages: [
{
role: 'assistant',
text: 'The answer cites the persisted source.',
id: 'msg-e2e-persisted-source',
timestamp: Math.floor(Date.now() / 1000) - 60,
tool_calls: [
{
tool_use_id: 'tool-e2e-persisted-source',
name: 'web_search',
input: { query: 'OpenSquilla persisted source' },
sources: [
{
title: 'OpenSquilla Persisted Source',
url: 'https://example.com/opensquilla-persisted-source',
domain: 'example.com',
provider: 'duckduckgo',
fetched: true,
},
],
result: JSON.stringify({
ok: true,
results: [
{
title: 'Truncated fallback source',
url: 'https://example.com/opensquilla-persisted-sour…',
domain: 'example.com',
provider: 'duckduckgo',
excerpt: 'Compacted persisted result with a dead-link URL.',
fetched: true,
},
],
}),
status: 'success',
},
],
},
],
has_more: false,
},
}))
return
}
ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} }))
})
ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }))
})
}
test.describe('Sources row and thinking disclosure', () => {
test('idle chat renders no sources row or thinking disclosure', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.sources-row')).toHaveCount(0)
await expect(page.locator('.thinking-fold')).toHaveCount(0)
})
test('replayed web_search results render a sources row with links', async ({ page }) => {
await seedSearchHistory(page, 'web_search')
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.msg-ai .tool-row[data-op="web.search"]').first()).toBeVisible({ timeout: 10000 })
const sourcesRow = page.locator('.msg-ai .sources-row').first()
await expect(sourcesRow).toBeVisible({ timeout: 10000 })
await expect(sourcesRow.locator('.sources-row__count')).toHaveText('1')
await sourcesRow.locator('.sources-row__toggle').click()
const link = sourcesRow.locator('.sources-row__link')
await expect(link).toHaveAttribute('href', 'https://example.com/opensquilla-search')
await expect(sourcesRow.locator('.sources-row__title')).toHaveText('OpenSquilla Search Notes')
})
test('replayed web_search prefers persisted sources over truncated result URLs', async ({ page }) => {
await seedPersistedSearchSourcesHistory(page)
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.msg-ai .tool-row[data-op="web.search"]').first()).toBeVisible({ timeout: 10000 })
const sourcesRow = page.locator('.msg-ai .sources-row').first()
await expect(sourcesRow).toBeVisible({ timeout: 10000 })
await expect(sourcesRow.locator('.sources-row__count')).toHaveText('1')
await sourcesRow.locator('.sources-row__toggle').click()
const link = sourcesRow.locator('.sources-row__link')
await expect(link).toHaveAttribute('href', 'https://example.com/opensquilla-persisted-source')
await expect(sourcesRow.locator('.sources-row__title')).toHaveText('OpenSquilla Persisted Source')
})
test('replayed web_discover results render as discovery without sources row', async ({ page }) => {
await seedSearchHistory(page, 'web_discover')
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.msg-ai .tool-row[data-op="web.discover"]').first()).toBeVisible({ timeout: 10000 })
await expect(page.locator('.msg-ai .sources-row')).toHaveCount(0)
})
test('live search turn renders a sources row with real links', async ({ page }) => {
test.skip(!LIVE, 'Live gateway test; set OPENSQUILLA_E2E_LIVE=1 to run.')
test.setTimeout(240000)
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
const textarea = page.locator('.chat-textarea')
await textarea.fill('Use your web search tool to find one recent headline about renewable energy, then answer in one sentence.')
await page.locator('.chat-send-btn[aria-label="Send"]').click()
// The turn runs and completes.
const ribbon = page.locator('.stream-activity')
await expect(ribbon).toBeVisible({ timeout: 30000 })
await expect(ribbon).toHaveCount(0, { timeout: 180000 })
// Sources row appears on the finished assistant turn, collapsed.
const sourcesRow = page.locator('.msg-ai .sources-row').first()
await expect(sourcesRow).toBeVisible({ timeout: 30000 })
const toggle = sourcesRow.locator('.sources-row__toggle')
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await expect(toggle.locator('.sources-row__count')).toHaveText(/^[1-9]\d*$/)
// Expanding reveals real external links.
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
const links = sourcesRow.locator('.sources-row__link')
expect(await links.count()).toBeGreaterThan(0)
for (const href of await links.evaluateAll(nodes => nodes.map(node => node.getAttribute('href') || ''))) {
expect(href).toMatch(/^https?:\/\//)
// Compacted tool results truncate long strings with a '…' suffix; such
// URLs are dead links and must never be rendered as hrefs.
expect(href).not.toMatch(/…|%E2%80%A6/i)
}
const resolvedHrefs = await links.evaluateAll(nodes =>
nodes.map(node => (node as HTMLAnchorElement).href),
)
for (const href of resolvedHrefs) {
expect(href).not.toMatch(/…|%E2%80%A6/i)
}
const rels = await links.evaluateAll(nodes => nodes.map(node => node.getAttribute('rel') || ''))
for (const rel of rels) {
expect(rel).toContain('noopener')
expect(rel).toContain('noreferrer')
}
// Thinking disclosure only renders when the routed model emitted
// reasoning; when present it must default to collapsed.
const folds = page.locator('.thinking-fold')
if (await folds.count() > 0) {
for (const isOpen of await folds.evaluateAll(nodes => nodes.map(node => node.hasAttribute('open')))) {
expect(isOpen).toBe(false)
}
}
// The row survives a reload: tool results replay through chat.history.
await page.reload()
await page.waitForSelector('.conn-pill', { timeout: 10000 })
const replayedRow = page.locator('.msg-ai .sources-row').first()
await expect(replayedRow).toBeVisible({ timeout: 30000 })
await expect(replayedRow.locator('.sources-row__toggle')).toHaveAttribute('aria-expanded', 'false')
})
test('measured reasoning time survives the post-turn history sync', async ({ page }) => {
test.skip(!LIVE, 'Live gateway test; set OPENSQUILLA_E2E_LIVE=1 to run.')
test.setTimeout(240000)
// Force the incremental merge ON regardless of the rollout default, before
// any page script reads the flag. The merge keeps a finished turn's measured
// reasoning.seconds when the next history snapshot lands; without it the
// snapshot maps seconds back to 0 and the disclosure loses its elapsed.
await page.addInitScript(() => {
try { window.localStorage.setItem('opensquilla.chat.historyMerge', '1') } catch { /* storage blocked */ }
})
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
// A multi-step reasoning prompt so the measured think time is comfortably
// over a second; a trivial one-word answer can resolve in under 1s, which
// renders as "Thought process" (seconds < 1) and cannot exercise the
// seconds-preservation contract this test guards.
const textarea = page.locator('.chat-textarea')
await textarea.fill(
'Reason thoroughly and at length, step by step, before answering. '
+ 'Logic puzzle: Alice, Bob, and Carol each own a different pet '
+ '(cat, dog, fish) and like a different color (red, green, blue). '
+ 'Clue 1: Alice does not own the cat. Clue 2: the dog owner likes blue. '
+ 'Clue 3: Carol likes red. Clue 4: Bob does not like green. Work through '
+ 'every deduction explicitly, then give the full solution in one sentence.',
)
await page.locator('.chat-send-btn[aria-label="Send"]').click()
// The turn runs and completes.
const ribbon = page.locator('.stream-activity')
await expect(ribbon).toBeVisible({ timeout: 30000 })
await expect(ribbon).toHaveCount(0, { timeout: 180000 })
// The finished assistant turn's reasoning disclosure shows the measured
// elapsed. ReasoningPart renders the summary as "Thought for Ns" when
// seconds >= 1 and degrades to "Thought process" when seconds is 0 — the
// latter is what a snapshot that drops the measured seconds would produce.
// Bind the real summary element, not the illustrative .__elapsed node.
const summary = page.locator('.msg-ai .thinking-fold .thinking-fold__summary').first()
await expect(summary).toBeVisible({ timeout: 30000 })
await expect(summary).toHaveText(/Thought for \d+/)
await expect(summary).not.toHaveText(/Thought process/)
// Wait past the ~50ms history-sync debounce plus a generous margin, then
// re-assert: a sync that clobbered the row would have reset the summary to
// "Thought process" here; the merge keeps the measured seconds.
await page.waitForTimeout(500)
await expect(summary).toHaveText(/Thought for \d+/)
await expect(summary).not.toHaveText(/Thought process/)
})
})