256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
import fs from 'node:fs'
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
const CONTROL_URL = '/control/'
|
|
const LIVE = process.env.OPENSQUILLA_E2E_LIVE === '1'
|
|
|
|
interface StageProbe {
|
|
metrics: {
|
|
width: number
|
|
contentWidth: number
|
|
exportScale: number
|
|
bottomSafeArea: number
|
|
top: number
|
|
brandHeight: number
|
|
brandGap: number
|
|
footerHeight: number
|
|
qrSize: number
|
|
caption: string
|
|
}
|
|
stageWidth: number
|
|
stageHeight: number
|
|
clones: number
|
|
roles: string[]
|
|
thinkingFolds: number
|
|
costEls: number
|
|
metaMore: number
|
|
actionRows: number
|
|
selectionChrome: number
|
|
modelEls: number
|
|
savedEls: number
|
|
finalVisibleContentRole: string | null
|
|
finalVisibleContentSelector: string | null
|
|
finalVisibleContentBottomGap: number | null
|
|
interMessageGap: number | null
|
|
text: string
|
|
}
|
|
|
|
test.describe('Share export', () => {
|
|
test('live share export saves a content-first PNG with a clean filename', async ({ page }) => {
|
|
test.skip(!LIVE, 'Live gateway test; set OPENSQUILLA_E2E_LIVE=1 to run.')
|
|
test.setTimeout(300000)
|
|
|
|
// Capture the offscreen export stage (the template DOM) before the
|
|
// composable rasterizes and removes it.
|
|
await page.addInitScript(() => {
|
|
const w = window as unknown as { __shareStageProbe?: StageProbe }
|
|
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 final = finalContent(lastClone)
|
|
const finalBox = final.element?.getBoundingClientRect()
|
|
w.__shareStageProbe = {
|
|
metrics: JSON.parse(node.dataset.shareTemplateMetrics || '{}'),
|
|
stageWidth: stageBox.width,
|
|
stageHeight: Math.max(node.scrollHeight, node.offsetHeight),
|
|
clones: clones.length,
|
|
roles: clones.map(cloneRole).filter(Boolean) as string[],
|
|
thinkingFolds: node.querySelectorAll('.thinking-fold').length,
|
|
costEls: node.querySelectorAll('.msg-meta__cost').length,
|
|
metaMore: node.querySelectorAll('.msg-meta__more').length,
|
|
actionRows: node.querySelectorAll('.msg-ai-actions, .msg-user-actions').length,
|
|
selectionChrome: node.querySelectorAll([
|
|
'.chat-share-picker',
|
|
'.share-select-check',
|
|
'.share-select-checkbox',
|
|
'.chat-share-checkbox',
|
|
'[data-share-checkbox]',
|
|
'[data-share-control]',
|
|
].join(',')).length,
|
|
modelEls: node.querySelectorAll('.msg-meta__model').length,
|
|
savedEls: node.querySelectorAll('.savings-indicator').length,
|
|
finalVisibleContentRole: cloneRole(lastClone),
|
|
finalVisibleContentSelector: final.selector,
|
|
finalVisibleContentBottomGap: finalBox ? stageBox.bottom - finalBox.bottom : null,
|
|
interMessageGap: firstBox && secondBox ? secondBox.top - firstBox.bottom : null,
|
|
text: node.innerText,
|
|
}
|
|
})
|
|
})
|
|
}
|
|
// documentElement may not exist yet when init scripts run; the
|
|
// Document node itself is always observable.
|
|
}).observe(document, { childList: true, subtree: true })
|
|
})
|
|
|
|
await page.goto(CONTROL_URL + 'chat/new')
|
|
await page.waitForSelector('.conn-pill', { timeout: 10000 })
|
|
|
|
// One real turn so there are two bubbles to share. The prompt is CJK on
|
|
// purpose: the title (and thus the filename slug) derives from the first
|
|
// user message, and this asserts non-ASCII titles survive the view→export
|
|
// wiring (a pre-slug mangler in the view once stripped them to "chat").
|
|
const prompt = '用一个词回答我:好'
|
|
await page.locator('.chat-textarea').fill(prompt)
|
|
await page.locator('.chat-send-btn[aria-label="Send"]').click()
|
|
await expect(page.locator('.msg-ai').first()).toBeVisible({ timeout: 120000 })
|
|
await expect(page.locator('.work-card')).toHaveCount(0, { timeout: 120000 })
|
|
|
|
const liveModelCount = await page.locator('.chat-thread .msg-meta__model').count()
|
|
const liveSavedCount = await page.locator('.chat-thread .savings-indicator').count()
|
|
|
|
// Enter share mode via the keyboard path: this spec owns the export
|
|
// pipeline, not the header layout, so it must not gate on pointer
|
|
// reachability of the floating topbar cluster.
|
|
await page.getByRole('button', { name: /^Share/ }).first().focus()
|
|
await page.keyboard.press('Enter')
|
|
const pickers = page.getByRole('button', { name: 'Add to share image' })
|
|
await expect(pickers.first()).toBeVisible()
|
|
expect(await pickers.count()).toBeGreaterThanOrEqual(2)
|
|
await pickers.first().focus()
|
|
await page.keyboard.press('Enter')
|
|
// The toggled picker's accessible name flips to "Remove from share
|
|
// image", so first() resolves to the next unselected bubble.
|
|
await pickers.first().focus()
|
|
await page.keyboard.press('Enter')
|
|
await expect(page.locator('.chat-share-banner__count')).toHaveText(/2 selected/)
|
|
|
|
// Save no longer downloads blind: it renders the PNG and opens the preview
|
|
// modal. The download fires only when the user commits via Download image.
|
|
await page.getByRole('button', { name: /Save PNG/ }).focus()
|
|
await page.keyboard.press('Enter')
|
|
const dialog = page.getByRole('dialog', { name: 'Share preview' })
|
|
await expect(dialog).toBeVisible()
|
|
const previewImg = dialog.getByRole('img', { name: 'Share preview' })
|
|
await expect(previewImg).toBeVisible()
|
|
await expect.poll(async () => previewImg.evaluate((img: HTMLImageElement) => img.naturalWidth))
|
|
.toBeGreaterThan(0)
|
|
|
|
// The export defaults to a light theme; the segmented toggle re-renders the
|
|
// preview on demand. Exercise Dark then return to Light (the asset the rest
|
|
// of this test reasons about) and confirm each switch produces an image.
|
|
const themeGroup = dialog.getByRole('group', { name: 'Export theme' })
|
|
const lightBtn = themeGroup.getByRole('button', { name: 'Light' })
|
|
const darkBtn = themeGroup.getByRole('button', { name: 'Dark' })
|
|
await expect(lightBtn).toHaveAttribute('aria-pressed', 'true')
|
|
await expect(darkBtn).toHaveAttribute('aria-pressed', 'false')
|
|
await darkBtn.click()
|
|
await expect(darkBtn).toHaveAttribute('aria-pressed', 'true')
|
|
await expect(dialog.locator('[aria-busy="false"] img')).toBeVisible()
|
|
await lightBtn.click()
|
|
await expect(lightBtn).toHaveAttribute('aria-pressed', 'true')
|
|
await expect(dialog.locator('[aria-busy="false"] img')).toBeVisible()
|
|
|
|
// Copy image is offered when the browser supports clipboard images
|
|
// (Chromium does); the live runner asserts presence, not the OS clipboard.
|
|
await expect(dialog.getByRole('button', { name: 'Copy image' })).toBeVisible()
|
|
|
|
const downloadPromise = page.waitForEvent('download')
|
|
await dialog.getByRole('button', { name: 'Download image' }).click()
|
|
const download = await downloadPromise
|
|
|
|
// Filename contract: opensquilla-{slug}-{YYYY-MM-DD}.png, no duplicated
|
|
// adjacent segments (the old template emitted "opensquilla-chat-chat-…").
|
|
const filename = download.suggestedFilename()
|
|
expect(filename).toMatch(/^opensquilla-[^/\\]+-\d{4}-\d{2}-\d{2}\.png$/)
|
|
expect(filename).not.toContain('chat-chat')
|
|
const slugPart = filename.replace(/^opensquilla-/, '').replace(/-\d{4}-\d{2}-\d{2}\.png$/, '')
|
|
expect(slugPart.length).toBeGreaterThan(0)
|
|
expect(slugPart).not.toMatch(/(^|-)([^-]+)-\2(-|$)/)
|
|
// The CJK title must survive into the slug, not collapse to the fallback.
|
|
expect(slugPart).toMatch(/[一-鿿]/)
|
|
expect(slugPart).not.toBe('chat')
|
|
|
|
// Blob is a real PNG of meaningful size.
|
|
const filePath = await download.path()
|
|
const buffer = fs.readFileSync(filePath)
|
|
expect(buffer.length).toBeGreaterThan(10 * 1024)
|
|
expect(Array.from(buffer.subarray(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
const pngWidth = buffer.readUInt32BE(16)
|
|
const pngHeight = buffer.readUInt32BE(20)
|
|
|
|
const probe = await page.evaluate(
|
|
() => (window as unknown as { __shareStageProbe?: unknown }).__shareStageProbe,
|
|
) as StageProbe | undefined
|
|
expect(probe, 'export stage was never observed').toBeTruthy()
|
|
const { metrics } = probe!
|
|
|
|
// Content-first template: small brand row, one-line footer, small QR,
|
|
// no marketing taglines.
|
|
expect(metrics.qrSize).toBeLessThanOrEqual(80)
|
|
expect(metrics.brandHeight).toBeLessThanOrEqual(32)
|
|
expect(metrics.caption).toContain('opensquilla.ai')
|
|
expect(metrics.caption).not.toMatch(/Token-Efficient|Meta-Skills|AI Agent|Scan the QR/i)
|
|
const chrome = metrics.top + metrics.brandHeight + metrics.brandGap + metrics.footerHeight
|
|
expect(chrome).toBeLessThanOrEqual(170)
|
|
|
|
// PNG dimensions follow the template: fixed width, height = chrome +
|
|
// content scaled to the content column.
|
|
const scale = pngWidth / metrics.width
|
|
expect(scale).toBeGreaterThanOrEqual(2)
|
|
expect(scale).toBe(metrics.exportScale)
|
|
expect(probe!.stageWidth).toBe(metrics.contentWidth)
|
|
const contentDrawn = Math.ceil((probe!.stageHeight * metrics.contentWidth) / probe!.stageWidth)
|
|
expect(Math.abs(pngHeight / scale - (chrome + contentDrawn))).toBeLessThanOrEqual(12)
|
|
|
|
// The cloned conversation keeps content and drops interactive remnants.
|
|
expect(probe!.clones).toBe(2)
|
|
expect(probe!.text).toContain(prompt)
|
|
expect(probe!.thinkingFolds).toBe(0)
|
|
expect(probe!.metaMore).toBe(0)
|
|
expect(probe!.actionRows).toBe(0)
|
|
expect(probe!.selectionChrome).toBe(0)
|
|
expect(probe!.costEls).toBe(0)
|
|
expect(probe!.text).not.toMatch(/\$\d/)
|
|
expect(probe!.text).not.toMatch(/Token-Efficient|Meta-Skills/i)
|
|
expect(metrics.bottomSafeArea).toBeGreaterThan(0)
|
|
expect(probe!.roles).toEqual(['user', 'assistant'])
|
|
expect(probe!.finalVisibleContentRole).toBe('assistant')
|
|
expect(probe!.finalVisibleContentSelector).toBe('.msg-ai-meta')
|
|
expect(probe!.finalVisibleContentBottomGap).not.toBeNull()
|
|
expect(probe!.finalVisibleContentBottomGap ?? 0).toBeGreaterThanOrEqual(metrics.bottomSafeArea)
|
|
expect(probe!.interMessageGap).not.toBeNull()
|
|
expect(probe!.interMessageGap ?? 0).toBeLessThanOrEqual(1)
|
|
if (liveModelCount > 0) expect(probe!.modelEls).toBeGreaterThan(0)
|
|
if (liveSavedCount > 0) expect(probe!.savedEls).toBeGreaterThan(0)
|
|
|
|
// Download closes the preview and exits share mode.
|
|
await expect(page.getByRole('dialog', { name: 'Share preview' })).toHaveCount(0)
|
|
await expect(page.getByTestId('share-banner')).toHaveCount(0)
|
|
})
|
|
})
|