import { test, expect, type Page } from '@playwright/test'; import { createServer, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; let server: ViteDevServer; let baseUrl: string; test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); test.beforeAll(async () => { server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-preview-layout', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'isolated-preview-layout', enforce: 'pre', load(id) { if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }'; if (id.endsWith('/src/lib/host-api.ts')) return ` const browser = { browserId: 'fixture', projectId: 'layout-fixture', state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', visible: true, bounds: null, eventCursor: 0 }; export async function hostApiFetch(path) { return path.includes('ensure-work') ? { status: 'ready', browser } : { success: true, browser }; } `; }, configureServer(vite) { vite.middlewares.use((req, res, next) => { if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, }], }); await server.listen(); baseUrl = server.resolvedUrls!.local[0]; }); test.afterAll(async () => { await server?.close(); }); for (const width of [375, 768, 1280]) { test(`all teacher introductions fit at ${width}px and keep the native preview clear`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await page.goto(baseUrl + '?agents=3&welcomes'); const group = page.getByTestId('teacher-welcomes'); await expect(group.locator('.teacher-welcome-card')).toHaveCount(3); await expect(group).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); await expect(group).toHaveCSS('box-shadow', 'none'); await expect(group.getByText('来认识一下')).toHaveCount(0); await expect(group.getByRole('button', { name: '先自己试试' })).toHaveCount(0); await expect(group).toBeInViewport({ ratio: 1 }); for (const card of await group.locator('.teacher-welcome-card').all()) { await expect(card).toBeInViewport({ ratio: 1 }); expect(await card.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); } await expect.poll(async () => { const [bubble, viewport] = await Promise.all([group.boundingBox(), page.getByTestId('agent-browser-viewport').boundingBox()]); return !!bubble && !!viewport && viewport.y >= bubble.y + bubble.height; }).toBe(true); await page.screenshot({ path: test.info().outputPath(`teacher-introductions-${width}.png`) }); await group.getByRole('button', { name: '收起智能体2的介绍' }).click(); await expect(group.locator('.teacher-welcome-card')).toHaveCount(2); await expect(page.locator('#coding-consultation-dock')).toHaveCount(0); await group.getByRole('button', { name: '和智能体3聊聊' }).click({ position: { x: 8, y: 8 } }); await expect(page.getByRole('button', { name: '与智能体3聊天' })).toHaveAttribute('aria-pressed', 'true'); await expect(group).toHaveCount(0); await page.getByRole('button', { name: '关闭智能体栏' }).click(); await expect(group.locator('.teacher-welcome-card')).toHaveCount(1); while (await group.count()) await group.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); await expect(group).toHaveCount(0); }); } test('many teacher introductions remain scrollable and honor reduced motion', async ({ page }) => { await page.setViewportSize({ width: 520, height: 600 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(baseUrl + '?agents=8&welcomes'); const group = page.getByTestId('teacher-welcomes'); await expect(group).toBeInViewport({ ratio: 1 }); await expect(group.locator('.teacher-welcome-card')).toHaveCount(8); await expect(group.locator('.teacher-welcome-card').first()).toHaveCSS('animation-name', 'none'); const last = group.getByRole('button', { name: '和智能体8聊聊' }); await last.scrollIntoViewIfNeeded(); await expect(last).toBeInViewport({ ratio: 1 }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(520); await last.focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: '与智能体8聊天' })).toHaveAttribute('aria-pressed', 'true'); await page.getByRole('button', { name: '关闭智能体栏' }).click(); await expect(group.locator('.teacher-welcome-card')).toHaveCount(7); while (await group.count()) await group.getByRole('button', { name: /^收起.*的介绍$/ }).first().click(); await expect(group).toHaveCount(0); }); for (const width of [520, 1000]) { test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await page.goto(baseUrl + '?agents=8'); const tabs = page.getByRole('navigation', { name: '已下发智能体' }); const refresh = page.getByRole('button', { name: '刷新智能体', exact: true }); await expect(tabs.getByRole('button')).toHaveCount(8); await expect(refresh).toBeVisible(); await expect(tabs.getByRole('button', { name: '刷新智能体', exact: true })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '新话题使用的智能体' })).toHaveCount(0); const last = tabs.getByRole('button', { name: '与智能体8聊天' }); await last.focus(); await last.press('Enter'); await expect(last).toHaveAttribute('aria-pressed', 'true'); const bounds = await page.evaluate(() => { const strip = document.querySelector('.consultation-agent-tabs')!; const refresh = document.querySelector('button[aria-label="刷新智能体"]')!; const controls = document.querySelector('[data-testid="window-controls"]')!; return { right: strip.getBoundingClientRect().right, controlsLeft: controls.getBoundingClientRect().left, refreshLeft: refresh.getBoundingClientRect().left, refreshRight: refresh.getBoundingClientRect().right, scroll: strip.scrollLeft, documentWidth: document.documentElement.scrollWidth }; }); expect(bounds.right).toBeLessThanOrEqual(bounds.refreshLeft); expect(bounds.refreshRight).toBeLessThanOrEqual(bounds.controlsLeft); expect(bounds.documentWidth).toBeLessThanOrEqual(width); expect(bounds.scroll).toBeGreaterThan(0); await refresh.click(); await expect(tabs.getByRole('button')).toHaveCount(9); await expect(page.getByRole('status').filter({ hasText: '已更新' })).toBeVisible(); await expect(last).toHaveAttribute('aria-pressed', 'true'); await expect(refresh).toBeInViewport(); await expect(refresh).toBeEnabled(); await page.screenshot({ path: test.info().outputPath('distributed-agents-titlebar.png') }); }); } async function geometry(page: Page) { return page.evaluate(() => { const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); const bubble = box('[data-testid="teacher-presence"]'); const viewport = box('[data-testid="agent-browser-viewport"]'); const panel = box('[data-testid="agent-browser-panel"]'); const work = box('[data-testid="work-view"]'); return { gap: panel.top - work.top, clearance: Number.parseFloat((document.querySelector('[data-testid="agent-browser-panel"]') as HTMLElement).style.marginTop) || 0, nativeGap: viewport.top - bubble.bottom, sideBySide: bubble.left >= viewport.right || bubble.right <= viewport.left }; }); } for (const width of [520, 1000]) { test(`teacher introduction floats outside the scrolling strip and clears the work at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await page.goto(`${baseUrl}?agents=8&quiet=1`); const first = page.getByRole('button', { name: '与智能体1聊天', exact: true }); const card = page.getByTestId('teacher-introduction'); await expect(first).not.toHaveAttribute('title'); await first.hover(); await expect(card).toBeVisible(); await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1'); await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover(); await expect(card).toHaveCount(1); await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体2'); await first.hover(); await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体1'); await expect.poll(async () => page.evaluate(() => { const card = document.querySelector('[data-testid="teacher-introduction"]')!.getBoundingClientRect(); const viewport = document.querySelector('[data-testid="agent-browser-viewport"]')!.getBoundingClientRect(); return card.left >= 0 && card.right <= innerWidth && card.bottom <= innerHeight && (card.left >= viewport.right || card.right <= viewport.left || viewport.top - card.bottom >= 8); })).toBe(true); await card.hover(); await expect(card).toBeVisible(); await page.screenshot({ path: test.info().outputPath('teacher-hover-introduction.png') }); const cardBounds = (await card.boundingBox())!; await page.screenshot({ path: test.info().outputPath('teacher-introduction-card.png'), clip: { x: Math.max(0, cardBounds.x - 12), y: cardBounds.y - 12, width: cardBounds.width + 24, height: cardBounds.height + 30, } }); await page.mouse.move(0, 780); await expect(card).toHaveCount(0); await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px'); const last = page.getByRole('button', { name: '与智能体8聊天', exact: true }); await last.focus(); await expect(card).toBeVisible(); await expect(card.locator('.teacher-introduction-description')).toContainText('我是智能体8'); await last.press('Escape'); await expect(card).toHaveCount(0); await expect(last).toBeFocused(); await last.hover(); await expect(card).toBeVisible(); await last.click(); await expect(card).toHaveCount(0); await expect(page.locator('#coding-consultation-dock')).toBeVisible(); const lastBounds = (await last.boundingBox())!; await page.mouse.move(lastBounds.x + lastBounds.width / 2 + 2, lastBounds.y + lastBounds.height / 2); await page.waitForTimeout(300); await expect(card).toHaveCount(0); await page.mouse.move(0, 780); await last.hover(); await expect(card).toBeVisible(); await last.click(); await expect(page.locator('#coding-consultation-dock')).toHaveCount(0); }); } test('closing an introduction preserves the unread speech and its native preview clearance', async ({ page }) => { await page.setViewportSize({ width: 1180, height: 720 }); await page.goto(`${baseUrl}?agents=2`); await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8); const original = (await geometry(page)).clearance; await page.getByRole('button', { name: '与智能体2聊天', exact: true }).hover(); await expect(page.getByTestId('teacher-introduction')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByTestId('teacher-introduction')).toHaveCount(0); await expect(page.getByTestId('teacher-invitation')).toBeVisible(); await expect.poll(async () => (await geometry(page)).clearance).toBe(original); }); test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => { await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({ contentType: 'image/svg+xml', body: '', })); await page.route('https://yuxi.example.test/api/storage/public/images/agent-1.png', route => route.fulfill({ status: 404 })); await page.goto(`${baseUrl}?agents=2&icons=1`); const custom = page.getByRole('button', { name: '与智能体1聊天' }).locator('img'); const fallback = page.getByRole('button', { name: '与智能体2聊天' }).locator('img'); const speaker = page.getByRole('button', { name: '打开这条智能体消息' }).locator('img'); await expect(custom).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png'); await expect(custom).toHaveCSS('image-rendering', 'auto'); await expect(speaker).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png'); await expect(fallback).toHaveAttribute('src', /avatar-06\.png/); for (const img of [custom, fallback, speaker]) { await expect.poll(() => img.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); } await page.screenshot({ path: test.info().outputPath('distributed-icons.png') }); }); for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 }, { width: 1000, height: 640 }]) { test(`preview only clears overlapping speech at ${size.width}x${size.height}`, async ({ page }) => { await page.setViewportSize(size); const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await page.goto(baseUrl); await expect(page.getByTestId('teacher-invitation')).toBeVisible(); await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8); expect((await geometry(page)).gap).toBeLessThan(65); await page.getByRole('button', { name: '智能体', exact: true }).click(); await expect.poll(async () => (await geometry(page)).sideBySide).toBe(true); await expect.poll(async () => (await geometry(page)).gap).toBe(20); // A narrow dock can overlap again; increasing its width must give the // preview all its space back without hiding the unread message. await page.getByRole('slider', { name: '智能体栏宽度' }).press('Home'); await expect.poll(async () => { const g = await geometry(page); return g.sideBySide || g.nativeGap >= 8; }).toBe(true); await page.getByRole('slider', { name: '智能体栏宽度' }).press('End'); await expect.poll(async () => (await geometry(page)).gap).toBe(20); await page.screenshot({ path: test.info().outputPath('work-with-teacher-no-gap.png') }); await page.getByRole('button', { name: '关闭智能体栏' }).click(); await expect.poll(async () => (await geometry(page)).nativeGap).toBeGreaterThanOrEqual(8); await page.getByRole('button', { name: '切换消息长度' }).click(); await expect.poll(async () => (await geometry(page)).gap).toBe(20); await page.getByRole('button', { name: '等会儿聊' }).click(); await expect(page.getByTestId('teacher-invitation')).toHaveCount(0); await expect(page.getByTestId('agent-browser-panel')).toHaveCSS('margin-top', '0px'); expect(errors).toEqual([]); }); }