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

161 lines
6.7 KiB
TypeScript

import { test, expect } from '@playwright/test'
const CONTROL_URL = '/control/'
const LIVE = process.env.OPENSQUILLA_E2E_LIVE === '1'
test.describe('Inline citation pills', () => {
test('idle chat renders no citation pills or sources row', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.locator('.citation-pill')).toHaveCount(0)
await expect(page.locator('.sources-row')).toHaveCount(0)
})
test('only [n] markers that map to a real source become pills', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
// Drive the transform deterministically over a known-sanitized snippet with a
// 3-element source list. `[2]` is in range → a focusable button.citation-pill;
// `[5]` is out of range → plain text, untouched. The transform rules mirror
// src/utils/chat/citations.ts (single-integer [n], 1 ≤ n ≤ sources.length,
// createElement/textContent only) so this proves the core mapping without a
// model emitting markers.
const result = await page.evaluate(() => {
const CITATION_RE = /\[(\d{1,3})\]/g
const SKIP_ANCESTORS = new Set(['PRE', 'CODE', 'A', 'BUTTON'])
const sources = [
{ sourceId: 1, url: 'https://a.example/1', title: 'One', domain: 'a.example' },
{ sourceId: 2, url: 'https://b.example/2', title: 'Two', domain: 'b.example' },
{ sourceId: 3, url: 'https://c.example/3', title: 'Three', domain: 'c.example' },
]
const root = document.createElement('div')
// A pre-sanitized body: bare brackets survive marked/DOMPurify as text.
root.innerHTML = '<p>Solar grew [2] and wind [5].</p>'
document.body.appendChild(root)
function isSkipped(node: Node | null): boolean {
let cur: Node | null = node
while (cur && cur instanceof HTMLElement) {
if (SKIP_ANCESTORS.has(cur.nodeName)) return true
if (cur.hasAttribute('data-citation')) return true
cur = cur.parentNode
}
return false
}
const candidates: Text[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const t = n.nodeValue
if (!t || !t.includes('[')) continue
if (isSkipped(n.parentNode)) continue
candidates.push(n as Text)
}
for (const node of candidates) {
const text = node.nodeValue ?? ''
CITATION_RE.lastIndex = 0
const frag = document.createDocumentFragment()
let last = 0
let changed = false
let m = CITATION_RE.exec(text)
while (m) {
const num = Number(m[1])
if (num >= 1 && num <= sources.length) {
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)))
const pill = document.createElement('button')
pill.type = 'button'
pill.className = 'citation-pill'
pill.textContent = `[${num}]`
pill.setAttribute('data-citation', String(num))
frag.appendChild(pill)
last = m.index + m[0].length
changed = true
}
m = CITATION_RE.exec(text)
}
if (!changed) continue
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)))
node.replaceWith(frag)
}
const pills = Array.from(root.querySelectorAll('.citation-pill'))
const out = {
pillCount: pills.length,
pillCitations: pills.map(p => p.getAttribute('data-citation')),
pillIsButton: pills.every(p => p.tagName === 'BUTTON'),
// [5] must remain literal text in the body.
plainTextHasFive: root.textContent?.includes('[5]') ?? false,
bracketFiveIsPill: pills.some(p => p.getAttribute('data-citation') === '5'),
}
root.remove()
return out
})
// [2] → exactly one focusable pill; [5] → never a pill, stays plain text.
expect(result.pillCount).toBe(1)
expect(result.pillCitations).toEqual(['2'])
expect(result.pillIsButton).toBe(true)
expect(result.bracketFiveIsPill).toBe(false)
expect(result.plainTextHasFive).toBe(true)
})
test('rendered citation pills only ever target real sources', 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 and cite the source inline as [1].')
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 })
await assertCitationInvariant(page)
// The invariant survives a reload through the history render path. Absence of
// pills is acceptable both times — the model may not cite.
await page.reload()
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await page.waitForTimeout(500)
await assertCitationInvariant(page)
})
})
// Invariant: every rendered pill points at a real numbered source, and
// activating one opens the paired row and brings the matching item into view.
// Pills are optional — the model may emit none — so we assert only over pills
// that exist.
async function assertCitationInvariant(page: import('@playwright/test').Page) {
const message = page.locator('.msg-ai').filter({ has: page.locator('.sources-row') }).first()
const pills = message.locator('.citation-pill')
const pillCount = await pills.count()
if (pillCount === 0) return
const sourceCount = await message.locator('.sources-row__item, [data-source-id]').count()
for (let i = 0; i < pillCount; i++) {
const raw = await pills.nth(i).getAttribute('data-citation')
expect(raw).toMatch(/^[0-9]+$/)
const n = Number(raw)
expect(n).toBeGreaterThanOrEqual(1)
expect(n).toBeLessThanOrEqual(sourceCount)
}
// Activating the first pill opens the row and reveals its target source.
const first = pills.first()
const target = Number(await first.getAttribute('data-citation'))
await first.click()
const toggle = message.locator('.sources-row__toggle')
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
const item = message.locator(`[data-source-id="${target}"]`)
await expect(item).toBeVisible()
}