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

549 lines
24 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
const CONTROL_URL = '/control/'
const SESSION_KEY = 'agent:main:webchat:e2eshare'
const SYSTEM_ONLY_SESSION_KEY = 'agent:main:webchat:e2esharesysonly'
const SEEDED_MODEL = 'deepseek-v4-flash-20260423'
const SEEDED_COST = '$0.000646'
type SeededMessage = {
role: 'user' | 'assistant' | 'system'
text: string
id: string
timestamp: number
model?: string
usage?: {
model: string
input_tokens: number
output_tokens: number
cost_usd: number
}
}
interface ShareStageProbe {
metrics: {
contentWidth: number
exportScale: number
bottomSafeArea: number
userBubbleRightOffset: number
}
stageWidth: number
clones: number
roles: string[]
costEls: number
modelEls: number
finalVisibleContentRole: string | null
finalVisibleContentSelector: string | null
finalVisibleContentBottomGap: number | null
finalVisibleContentRightGap: number | null
finalCloneRightGap: number | null
interMessageGap: number | null
text: string
}
interface SeedHistoryOptions {
includeAssistantMeta?: boolean
trailingUser?: boolean
}
// Seed a settled turn through the real WS pipeline: the page talks to the
// real gateway, but chat.history responses are rewritten in flight so a
// user + assistant exchange renders without a live agent run. With
// withMessages=false the thread holds a single system message: the header
// renders but no bubble is shareable.
async function seedHistory(page: Page, withMessages: boolean, options: SeedHistoryOptions = {}) {
await page.routeWebSocket(/\/ws$/, ws => {
const server = ws.connectToServer()
const historyIds = new Set<string>()
ws.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.type === 'req' && frame.method === 'chat.history') {
historyIds.add(String(frame.id))
}
} catch {}
server.send(message)
})
server.onMessage(message => {
try {
const frame = JSON.parse(String(message))
if (frame?.type === 'res' && frame.id !== undefined && historyIds.has(String(frame.id))) {
historyIds.delete(String(frame.id))
frame.ok = true
delete frame.error
const now = Math.floor(Date.now() / 1000)
const messages: SeededMessage[] = withMessages
? [
{
role: 'user',
text: 'Summarize the launch checklist.',
id: 'msg-share-user',
timestamp: now - 180,
},
{
role: 'assistant',
text: 'The checklist has three open items.',
id: 'msg-share-assistant',
timestamp: now - 120,
...(options.includeAssistantMeta
? {
model: SEEDED_MODEL,
usage: {
model: SEEDED_MODEL,
input_tokens: 321,
output_tokens: 45,
cost_usd: 0.000646,
},
}
: {}),
},
]
: [
{
role: 'system',
text: 'Session restored.',
id: 'msg-share-system',
timestamp: now - 60,
},
]
if (withMessages && options.trailingUser) {
messages.push({
role: 'user',
text: 'Ship it when the checklist is green.',
id: 'msg-share-user-trailing',
timestamp: now - 60,
})
}
frame.payload = {
messages,
has_more: false,
}
ws.send(JSON.stringify(frame))
return
}
} catch {}
ws.send(message)
})
})
}
async function openSeededSession(
page: Page,
key: string,
withMessages: boolean,
options: SeedHistoryOptions = {},
) {
await seedHistory(page, withMessages, options)
await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(key))
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await page.waitForSelector('.chat-header', { timeout: 10000 })
}
async function installShareStageProbe(page: Page) {
await page.addInitScript(() => {
const w = window as unknown as { __shareStageProbe?: ShareStageProbe }
w.__shareStageProbe = undefined
const cloneRole = (clone: HTMLElement | null) => {
if (!clone) return null
if (clone.classList.contains('msg-user')) return 'user'
if (clone.classList.contains('msg-ai')) return 'assistant'
return 'unknown'
}
const finalContent = (clone: HTMLElement | null) => {
if (!clone) return { element: null, selector: null }
if (clone.classList.contains('msg-user')) {
return {
element: clone.querySelector<HTMLElement>('.msg-user-bubble') || clone,
selector: clone.querySelector('.msg-user-bubble') ? '.msg-user-bubble' : '.msg-user',
}
}
if (clone.classList.contains('msg-ai')) {
const meta = clone.querySelector<HTMLElement>('.msg-ai-meta')
if (meta) return { element: meta, selector: '.msg-ai-meta' }
const ending = clone.querySelector<HTMLElement>('.msg-ai-ending')
if (ending) return { element: ending, selector: '.msg-ai-ending' }
return {
element: clone.querySelector<HTMLElement>('.msg-ai-text') || clone,
selector: clone.querySelector('.msg-ai-text') ? '.msg-ai-text' : '.msg-ai',
}
}
return { element: clone, selector: null }
}
new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (!(node instanceof HTMLElement) || node.id !== 'opensquilla-share-export-stage') return
requestAnimationFrame(() => {
const clones = Array.from(node.querySelectorAll<HTMLElement>('[data-share-message-id]'))
const lastClone = clones[clones.length - 1] || null
const firstBox = clones[0]?.getBoundingClientRect()
const secondBox = clones[1]?.getBoundingClientRect()
const stageBox = node.getBoundingClientRect()
const lastBox = lastClone?.getBoundingClientRect()
const final = finalContent(lastClone)
const finalBox = final.element?.getBoundingClientRect()
w.__shareStageProbe = {
metrics: JSON.parse(node.dataset.shareTemplateMetrics || '{}'),
stageWidth: stageBox.width,
clones: clones.length,
roles: clones.map(cloneRole).filter(Boolean) as string[],
costEls: node.querySelectorAll('.msg-meta__cost').length,
modelEls: node.querySelectorAll('.msg-meta__model').length,
finalVisibleContentRole: cloneRole(lastClone),
finalVisibleContentSelector: final.selector,
finalVisibleContentBottomGap: finalBox ? stageBox.bottom - finalBox.bottom : null,
finalVisibleContentRightGap: finalBox ? stageBox.right - finalBox.right : null,
finalCloneRightGap: lastBox ? stageBox.right - lastBox.right : null,
interMessageGap: firstBox && secondBox ? secondBox.top - firstBox.bottom : null,
text: node.innerText,
}
})
})
}
}).observe(document, { childList: true, subtree: true })
})
}
async function enterShareMode(page: Page) {
await expect(page.locator('.msg-ai-main').last()).toBeVisible({ timeout: 10000 })
await page.getByRole('button', { name: 'Share' }).click()
await expect(page.getByTestId('share-banner')).toBeVisible()
}
function expectBottomSafeArea(probe: ShareStageProbe) {
expect(probe.metrics.bottomSafeArea).toBeGreaterThan(0)
expect(probe.finalVisibleContentBottomGap).not.toBeNull()
expect(probe.finalVisibleContentBottomGap ?? 0)
.toBeGreaterThanOrEqual(probe.metrics.bottomSafeArea)
}
function expectNoExtraInterMessageGap(probe: ShareStageProbe) {
expect(probe.interMessageGap).not.toBeNull()
expect(probe.interMessageGap ?? 0).toBeLessThanOrEqual(1)
}
function expectUserBubbleRightOffset(probe: ShareStageProbe) {
expect(probe.metrics.userBubbleRightOffset).toBeGreaterThan(0)
expect(probe.finalVisibleContentRightGap).not.toBeNull()
expect(probe.finalCloneRightGap).not.toBeNull()
const offset = (probe.finalVisibleContentRightGap ?? 0) - (probe.finalCloneRightGap ?? 0)
expect(offset).toBeGreaterThanOrEqual(probe.metrics.userBubbleRightOffset - 0.5)
}
test.describe('Share mode interaction shell', () => {
test('entering share mode opens the banner; the header keeps no share controls', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
const banner = page.getByTestId('share-banner')
await expect(banner).toContainText('Select bubbles to share')
await expect(banner).toContainText('0 selected')
await expect(banner.locator('[role="status"]')).toHaveAttribute('aria-live', 'polite')
// The banner owns the mode: save/cancel live there, not in the header.
await expect(banner.getByRole('button', { name: 'Save PNG' })).toBeVisible()
await expect(banner.getByRole('button', { name: 'Cancel' })).toBeVisible()
const header = page.locator('.chat-header')
await expect(header.getByRole('button', { name: 'Save PNG' })).toHaveCount(0)
await expect(header.getByRole('button', { name: 'Cancel' })).toHaveCount(0)
await expect(header.getByRole('button', { name: 'Share' })).toHaveCount(0)
// The banner sits below the header band, clear of the floating topbar.
const headerBox = await header.boundingBox()
const bannerBox = await banner.boundingBox()
expect(bannerBox!.y).toBeGreaterThanOrEqual(headerBox!.y + headerBox!.height - 1)
// Entering share mode moves focus to the banner.
const bannerFocused = await page.evaluate(() => {
const el = document.querySelector('[data-testid="share-banner"]')
return !!el && !!document.activeElement && el.contains(document.activeElement)
})
expect(bannerFocused).toBe(true)
})
test('checkbox indicators are always visible and selection drives the count', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
const pickers = page.locator('.chat-share-picker')
await expect(pickers).toHaveCount(2)
await expect(pickers.first()).toBeVisible()
await expect(pickers.last()).toBeVisible()
await expect(pickers.first()).toHaveAttribute('aria-pressed', 'false')
// Clicking anywhere on a bubble toggles it; selecting two updates the count.
await page.locator('.msg-user-bubble').first().click()
await expect(page.getByTestId('share-banner')).toContainText('1 selected')
await pickers.last().click()
await expect(page.getByTestId('share-banner')).toContainText('2 selected')
await expect(page.locator('.msg-user--share-selected')).toHaveCount(1)
await expect(page.locator('.msg-ai--share-selected')).toHaveCount(1)
await expect(pickers.first()).toHaveAttribute('aria-pressed', 'true')
await expect(pickers.last()).toHaveAttribute('aria-pressed', 'true')
// Keyboard path: the indicator is a real button, Enter toggles it off.
await pickers.first().focus()
await page.keyboard.press('Enter')
await expect(page.getByTestId('share-banner')).toContainText('1 selected')
await expect(pickers.first()).toHaveAttribute('aria-pressed', 'false')
})
test('share mode hides message action controls while keeping bubble selection active', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await expect(page.locator('.msg-user-actions')).toHaveCount(1)
await expect(page.locator('.msg-ai-actions')).toHaveCount(1)
await enterShareMode(page)
await expect(page.locator('.msg-user-actions')).toHaveCount(0)
await expect(page.locator('.msg-ai-actions')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Edit' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Regenerate' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Fork conversation' })).toHaveCount(0)
await page.locator('.msg-user-bubble').click()
await expect(page.getByTestId('share-banner')).toContainText('1 selected')
await expect(page.locator('.msg-user--share-selected')).toHaveCount(1)
})
test('user-only share export keeps bottom breathing room', async ({ page }) => {
await installShareStageProbe(page)
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
await page.locator('.msg-user-bubble').click()
await expect(page.getByTestId('share-banner')).toContainText('1 selected')
await page.getByTestId('share-banner').getByRole('button', { name: 'Save PNG' }).click()
await expect(page.getByRole('dialog', { name: 'Share preview' })).toBeVisible({ timeout: 15000 })
const probe = await page.evaluate(
() => (window as unknown as { __shareStageProbe?: ShareStageProbe }).__shareStageProbe,
)
expect(probe, 'export stage was never observed').toBeTruthy()
expect(probe!.clones).toBe(1)
expect(probe!.roles).toEqual(['user'])
expect(probe!.finalVisibleContentRole).toBe('user')
expect(probe!.finalVisibleContentSelector).toBe('.msg-user-bubble')
expect(probe!.text).toContain('Summarize the launch checklist.')
expectBottomSafeArea(probe!)
expectUserBubbleRightOffset(probe!)
})
test('mixed share export keeps the shared bottom safe area when the final message is user', async ({ page }) => {
await installShareStageProbe(page)
await openSeededSession(page, `${SESSION_KEY}-trailing-user`, true, {
includeAssistantMeta: true,
trailingUser: true,
})
await enterShareMode(page)
await page.locator('.chat-share-picker').nth(1).click()
await page.locator('.msg-user-bubble').last().click()
await expect(page.getByTestId('share-banner')).toContainText('2 selected')
await page.getByTestId('share-banner').getByRole('button', { name: 'Save PNG' }).click()
await expect(page.getByRole('dialog', { name: 'Share preview' })).toBeVisible({ timeout: 15000 })
const probe = await page.evaluate(
() => (window as unknown as { __shareStageProbe?: ShareStageProbe }).__shareStageProbe,
)
expect(probe, 'export stage was never observed').toBeTruthy()
expect(probe!.clones).toBe(2)
expect(probe!.roles).toEqual(['assistant', 'user'])
expect(probe!.finalVisibleContentRole).toBe('user')
expect(probe!.finalVisibleContentSelector).toBe('.msg-user-bubble')
expect(probe!.text).toContain('The checklist has three open items.')
expect(probe!.text).toContain('Ship it when the checklist is green.')
expectBottomSafeArea(probe!)
expectUserBubbleRightOffset(probe!)
expectNoExtraInterMessageGap(probe!)
})
test('save is visibly disabled at zero selected and Escape exits the mode', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
const save = page.getByTestId('share-banner').getByRole('button', { name: 'Save PNG' })
await expect(save).toBeDisabled()
const saveOpacity = await save.evaluate(el => parseFloat(getComputedStyle(el).opacity))
expect(saveOpacity).toBeGreaterThanOrEqual(0.6)
await page.keyboard.press('Escape')
await expect(page.getByTestId('share-banner')).toHaveCount(0)
await expect(page.locator('.chat-share-picker')).toHaveCount(0)
await expect(page.locator('.chat-header').getByRole('button', { name: 'Share' })).toBeVisible()
})
test('banner Cancel ends the mode', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
await page.getByTestId('share-banner').getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByTestId('share-banner')).toHaveCount(0)
await expect(page.locator('.chat-header').getByRole('button', { name: 'Share' })).toBeVisible()
})
test('at 700px the entry button collapses to an icon that stays visible', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 })
await openSeededSession(page, SESSION_KEY, true)
await expect(page.locator('.msg-ai-main').last()).toBeVisible({ timeout: 10000 })
const entry = page.locator('.chat-header').getByRole('button', { name: 'Share' })
await expect(entry).toBeVisible()
await expect(entry.locator('.chat-share-btn__label')).toBeHidden()
const iconBox = await entry.locator('svg').boundingBox()
expect(iconBox).not.toBeNull()
expect(iconBox!.width).toBeGreaterThan(0)
expect(iconBox!.height).toBeGreaterThan(0)
// Icon-only buttons keep a mobile-adequate tap target.
const entryBox = await entry.boundingBox()
expect(entryBox!.width).toBeGreaterThanOrEqual(43)
expect(entryBox!.height).toBeGreaterThanOrEqual(43)
})
test('at 375px the entry stays clear of the floating topbar cluster and opens share mode', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await openSeededSession(page, SESSION_KEY, true)
await expect(page.locator('.msg-ai-main').last()).toBeVisible({ timeout: 10000 })
const entry = page.locator('.chat-header').getByRole('button', { name: 'Share' })
await expect(entry).toBeVisible()
// Reproduce the occlusion probe: a tap at the button center must land on
// the button, not on the floating conn pill that overlays the header band.
const probe = await page.evaluate(() => {
const btn = document.querySelector<HTMLElement>('.chat-header .chat-share-btn[aria-label="Share"]')
const pill = document.querySelector<HTMLElement>('.conn-pill')
if (!btn || !pill) return null
const r = btn.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
// Worst-case cluster intrusion: the fixed chrome right of the pill plus
// the pill rendered with its longest state label. The pill text varies
// (CONNECTED/CONNECTING/DISCONNECTED), so asserting against the current
// state alone would under-test.
const cs = getComputedStyle(pill)
const ctx = document.createElement('canvas').getContext('2d')!
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
const text = 'DISCONNECTED'
const letterSpacing = parseFloat(cs.letterSpacing) || 0
const chrome = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)
+ parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)
const worstPill = ctx.measureText(text).width + letterSpacing * text.length + chrome
const worstIntrusion = (window.innerWidth - pill.getBoundingClientRect().right) + worstPill
return {
hitIsButton: hit === btn || btn.contains(hit),
btnRight: r.right,
clearance: window.innerWidth - worstIntrusion - r.right,
}
})
expect(probe).not.toBeNull()
expect(probe!.hitIsButton).toBe(true)
// Clear of the cluster even in its widest (disconnected) state.
expect(probe!.clearance).toBeGreaterThanOrEqual(0)
await entry.click()
await expect(page.getByTestId('share-banner')).toBeVisible()
})
test('a thread without shareable bubbles keeps the entry visible-disabled with an explanation', async ({ page }) => {
await openSeededSession(page, SYSTEM_ONLY_SESSION_KEY, false)
await expect(page.locator('.msg-system')).toBeVisible({ timeout: 10000 })
const entry = page.locator('.chat-header').getByRole('button', { name: 'Send a message first to share' })
await expect(entry).toBeVisible()
await expect(entry).toBeDisabled()
await expect(entry).toHaveAttribute('title', 'Send a message first to share')
const opacity = await entry.evaluate(el => parseFloat(getComputedStyle(el).opacity))
expect(opacity).toBeGreaterThanOrEqual(0.6)
})
test('Save opens the preview modal; Escape closes only the modal and keeps share mode', async ({ page }) => {
await installShareStageProbe(page)
await openSeededSession(page, SESSION_KEY, true, { includeAssistantMeta: true })
await enterShareMode(page)
await expect(page.locator('.chat-thread .msg-meta__model')).toContainText(SEEDED_MODEL)
await expect(page.locator('.chat-thread .msg-meta__cost')).toContainText(SEEDED_COST)
// Select both bubbles, then Save renders the PNG and opens the preview.
await page.locator('.msg-user-bubble').first().click()
await page.locator('.chat-share-picker').last().click()
await expect(page.getByTestId('share-banner')).toContainText('2 selected')
await page.getByTestId('share-banner').getByRole('button', { name: 'Save PNG' }).click()
const dialog = page.getByRole('dialog', { name: 'Share preview' })
await expect(dialog).toBeVisible({ timeout: 15000 })
await expect(dialog).toHaveAttribute('aria-modal', 'true')
const previewImg = dialog.getByRole('img', { name: 'Share preview' })
await expect(previewImg).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Download image' })).toBeVisible()
await expect.poll(async () => previewImg.evaluate((img: HTMLImageElement) => img.naturalWidth))
.toBeGreaterThan(0)
const imageMetrics = await previewImg.evaluate((img: HTMLImageElement) => {
const box = img.getBoundingClientRect()
return {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
displayedWidth: box.width,
displayedHeight: box.height,
}
})
expect(imageMetrics.naturalWidth).toBeGreaterThanOrEqual(1520)
expect(imageMetrics.naturalHeight).toBeGreaterThan(imageMetrics.displayedHeight)
expect(imageMetrics.displayedWidth).toBeLessThanOrEqual(imageMetrics.naturalWidth)
const probe = await page.evaluate(
() => (window as unknown as { __shareStageProbe?: ShareStageProbe }).__shareStageProbe,
)
expect(probe, 'export stage was never observed').toBeTruthy()
expect(probe!.clones).toBe(2)
expect(probe!.metrics.exportScale).toBeGreaterThanOrEqual(2)
expect(probe!.stageWidth).toBe(probe!.metrics.contentWidth)
expect(probe!.roles).toEqual(['user', 'assistant'])
expect(probe!.finalVisibleContentRole).toBe('assistant')
expect(probe!.finalVisibleContentSelector).toBe('.msg-ai-meta')
expectBottomSafeArea(probe!)
expectNoExtraInterMessageGap(probe!)
expect(probe!.costEls).toBe(0)
expect(probe!.modelEls).toBeGreaterThan(0)
expect(probe!.text).toContain(SEEDED_MODEL)
expect(probe!.text).not.toContain(SEEDED_COST)
expect(probe!.text).not.toMatch(/\$\d/)
// Escape closes the preview but leaves share mode active (the banner stays),
// and focus returns to the share banner — the header Share button is
// unmounted while share mode is on, so the banner is the mode's anchor.
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(page.getByTestId('share-banner')).toBeVisible()
const bannerFocusedAfterEscape = await page.evaluate(() => {
const el = document.querySelector('[data-testid="share-banner"]')
return !!el && document.activeElement === el
})
expect(bannerFocusedAfterEscape).toBe(true)
})
test('the modal close button dismisses the preview and returns focus to the Share entry', async ({ page }) => {
await openSeededSession(page, SESSION_KEY, true)
await enterShareMode(page)
await page.locator('.msg-user-bubble').first().click()
await page.locator('.chat-share-picker').last().click()
await page.getByTestId('share-banner').getByRole('button', { name: 'Save PNG' }).click()
const dialog = page.getByRole('dialog', { name: 'Share preview' })
await expect(dialog).toBeVisible({ timeout: 15000 })
await dialog.getByRole('button', { name: 'Close' }).click()
await expect(dialog).toHaveCount(0)
await expect(page.getByTestId('share-banner')).toBeVisible()
const bannerFocusedAfterClose = await page.evaluate(() => {
const el = document.querySelector('[data-testid="share-banner"]')
return !!el && document.activeElement === el
})
expect(bannerFocusedAfterClose).toBe(true)
})
})