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

216 lines
8.3 KiB
TypeScript

import { test, expect } from '@playwright/test'
const CONTROL_URL = '/control/'
const LIVE = process.env.OPENSQUILLA_E2E_LIVE === '1'
test.describe('Chat Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto(CONTROL_URL)
// Wait for WebSocket connection
await page.waitForSelector('.conn-pill', { timeout: 10000 })
})
test('page loads with correct title', async ({ page }) => {
await expect(page).toHaveTitle(/OpenSquilla/)
})
test('sidebar core shows fixed rows with the Console fold', async ({ page }) => {
const core = page.locator('.sidebar-core')
// The old grouped nav (WORK/OPERATE/OBSERVE) and the Chat row are gone;
// Approvals only appears while requests are pending (count badge).
await expect(page.locator('.sidebar-nav-group-label')).toHaveCount(0)
await expect(core.getByText('Chat', { exact: true })).toHaveCount(0)
const hasPendingApprovals = (await page.locator('.approval-inline').count()) > 0
await expect(core.locator('> .sidebar-fn-item .sidebar-fn-label')).toHaveText(
hasPendingApprovals ? ['Sessions', 'Approvals', 'Console'] : ['Sessions', 'Console'],
)
// Console row toggles the fold without navigating.
const urlBefore = page.url()
const consoleRow = core.getByRole('button', { name: 'Console' })
await expect(consoleRow).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('#sidebar-console-list')).toHaveCount(0)
await consoleRow.click()
await expect(consoleRow).toHaveAttribute('aria-expanded', 'true')
// Health folded into Overview, so the console fold holds seven pages.
await expect(page.locator('#sidebar-console-list .sidebar-fn-label')).toHaveText([
'Agents', 'Channels', 'Cron', 'Skills',
'Overview', 'Usage', 'Logs',
])
expect(page.url()).toBe(urlBefore)
await consoleRow.click()
await expect(consoleRow).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('#sidebar-console-list')).toHaveCount(0)
})
test('can navigate between views', async ({ page }) => {
const core = page.locator('.sidebar-core')
await core.getByRole('button', { name: 'Console' }).click()
await core.getByText('Overview', { exact: true }).click()
await expect(page).toHaveURL(/\/overview/)
// The fold stays open while moving between console pages.
await core.getByText('Logs', { exact: true }).click()
await expect(page).toHaveURL(/\/logs/)
await core.getByText('Sessions', { exact: true }).click()
await expect(page).toHaveURL(/\/sessions/)
// New chat is instant (no modal): the primary button drops straight to a
// draft. `exact` matches the New-chat button precisely.
await page.getByRole('button', { name: 'New chat', exact: true }).click()
await expect(page.getByRole('dialog', { name: 'New chat' })).toHaveCount(0)
await expect(page).toHaveURL(/\/chat\/new\?agent=[a-z0-9_-]+$/i)
})
test('sidebar conversation list is free of raw identifiers', async ({ page }) => {
// The family filter chips were retired with the Recents-only sidebar.
await expect(page.locator('.sidebar-filter-chip')).toHaveCount(0)
// Let the session list settle before inspecting sidebar text.
await page.waitForSelector('.conn-pill.connected', { timeout: 10000 }).catch(() => {})
await page.waitForTimeout(800)
await expect(page.locator('.sidebar-history-list, .sidebar-onboarding, .sidebar-history-empty').first()).toBeVisible()
const sidebarText = await page.locator('.sidebar').innerText()
expect(sidebarText).not.toMatch(/agent:[a-z0-9_-]+:[a-z0-9_-]+:/i)
expect(sidebarText).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
// Contract-gap chips stay hidden unless the debug feature flag is on.
await expect(page.locator('.sidebar-history-gap')).toHaveCount(0)
})
test('theme menu picks a mode directly', async ({ page }) => {
const html = page.locator('html')
await page.evaluate(() => localStorage.setItem('opensquilla-theme', 'light'))
await page.reload()
await page.waitForSelector('.topbar .conn-pill', { timeout: 10000 })
await expect(html).toHaveAttribute('data-theme', 'light')
const themeButton = page.getByRole('button', { name: 'Theme', exact: true })
await themeButton.click()
const menu = page.getByRole('menu', { name: 'Theme' })
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitemradio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
await menu.getByRole('menuitemradio', { name: 'Dark' }).click()
await expect(html).toHaveAttribute('data-theme', 'dark')
await expect(menu).toHaveCount(0)
// Escape closes without changing the mode.
await themeButton.click()
await page.keyboard.press('Escape')
await expect(page.getByRole('menu', { name: 'Theme' })).toHaveCount(0)
await expect(html).toHaveAttribute('data-theme', 'dark')
})
test('chat input area is visible', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat')
await expect(page.locator('.chat-textarea')).toBeVisible()
await expect(page.locator('.chat-composer')).toBeVisible()
})
test('connection status shows connected', async ({ page }) => {
const connPill = page.locator('.topbar .conn-pill')
await expect(connPill).toBeVisible()
const text = await connPill.textContent()
expect(text?.toLowerCase()).toMatch(/connected|connecting/)
})
test('no console errors on load', async ({ page }) => {
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
await page.goto(CONTROL_URL)
await page.waitForLoadState('networkidle')
// Filter out non-critical errors
const criticalErrors = errors.filter(e =>
!e.includes('Source map') &&
!e.includes('favicon') &&
!e.includes('net::ERR_BLOCKED_BY_CLIENT')
)
expect(criticalErrors).toHaveLength(0)
})
})
test.describe('Chat Interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto(CONTROL_URL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
})
test('can type in chat input', async ({ page }) => {
const textarea = page.locator('.chat-textarea')
await textarea.fill('Hello, this is a test message')
await expect(textarea).toHaveValue('Hello, this is a test message')
})
test('sidebar toggle works on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
// Mobile collapses the sidebar to an overlay; the topbar toggle reopens it.
const sidebar = page.locator('.sidebar')
await expect(sidebar).not.toHaveClass(/docked/)
await page.click('.topbar-toggle')
await expect(sidebar).toHaveClass(/docked/)
await page.click('.sidebar-brand .sidebar-dock-toggle')
await expect(sidebar).not.toHaveClass(/docked/)
})
})
test.describe('Visual Regression', () => {
// Live runs seed real sessions into the sidebar, so the pixel baselines
// only hold against a clean instance (the default, non-live suite).
test.skip(LIVE, 'Visual baselines assume a clean sidebar; skipped in live runs.')
// Live data and wall-clock content are masked so the baseline pins the
// chrome: sidebar core/footer, composer, and chat surface.
const dynamicRegions = (page: import('@playwright/test').Page) => [
page.locator('.sidebar-history'),
page.locator('.empty-state__greeting'),
]
test('chat page screenshot matches baseline', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await page.waitForTimeout(500) // Let animations settle
await expect(page).toHaveScreenshot('chat-page.png', {
maxDiffPixels: 100,
mask: dynamicRegions(page),
})
})
test('dark mode screenshot', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
// Force dark theme
await page.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark')
localStorage.setItem('opensquilla-theme', 'dark')
})
await page.waitForTimeout(300)
await expect(page).toHaveScreenshot('chat-page-dark.png', {
maxDiffPixels: 100,
mask: dynamicRegions(page),
})
})
})