import { test, expect, type Page } from '@playwright/test'; import { createServer, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import type { CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents'; let server: ViteDevServer; let baseUrl: string; test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); // The real CloudChat, Markdown renderer and CloudFiles are used. Main IPC and // the event stream are isolated fixtures; this suite never starts a native app // or contacts an account, cloud service or model. test.beforeAll(async () => { const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx'); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-chat-layout', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'isolated-cloud-agent-chat-layout', enforce: 'pre', resolveId(id) { if (id === '/__cloud-agent-chat-layout-harness.tsx') return entry; }, load(id) { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx'; import { Button } from '/src/components/ui/button.tsx'; import avatar from '/src/assets/agent-avatars/avatar-05.png'; import '/src/styles/globals.css'; const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。']; createRoot(document.getElementById('root')).render(
window.__cloudChatFixture.adjustments++}>调整智能体} />
); `; }, configureServer(vite) { vite.middlewares.use((req, res, next) => { if (req.url !== '/') return next(); res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, }], }); await server.listen(); baseUrl = server.resolvedUrls!.local[0]; }); test.afterAll(async () => { await server?.close(); }); const slug = 'ml-chat-layout-fixture'; const date = '2026-10-12T12:00:00Z'; const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。']; const longWord = 'an-unbroken-story-notebook-'.repeat(24); const richReply = [ '## 一起把小发现变成故事', '你可以先选一个最喜欢的发现,再让角色去试一试。', `这个很长的名字也应该完整保留:${longWord}`, `[查看完整故事线索](https://example.invalid/story/${longWord})`, ['| 今天的线索 | 可以接着想什么 |', '| --- | --- |', ...Array.from({ length: 8 }, (_, index) => `| 第 ${index + 1} 个小发现 | ${longWord.slice(0, 120)} |`), ].join('\n'), ['```javascript', `const completeStoryTitle = "${longWord}";`, ...Array.from({ length: 6 }, (_, index) => `console.log("第 ${index + 1} 个新想法");`), '```', ].join('\n'), ].join('\n\n'); function makeThread(id: string, title: string, status = 'completed'): CloudThread { return { thread_id: id, client_thread_id: id, agent_slug: slug, title, mode: 'published', updated_at: date, run_id: null, status, unread: false }; } function makeRun(thread: CloudThread, status: string, interrupt?: CloudInterrupt): CloudRun { return { agent_run_id: 'run-' + thread.thread_id, request_id: 'request-' + thread.thread_id, thread_id: thread.thread_id, agent_slug: slug, status, output: '', version: '1', interrupt }; } function makeHistory(thread: CloudThread, dense = false, run: CloudRun | null = null): CloudHistory { return { thread_id: thread.thread_id, next_offset: null, queued_requests: [], run, messages: dense ? Array.from({ length: 16 }, (_, index) => ({ id: index + 1, role: index % 2 ? 'assistant' as const : 'user' as const, content: index === 15 ? richReply : index % 2 ? `第 ${index + 1} 次回答:我们一起想一想,角色还可以试着做什么?` : `第 ${index + 1} 个想法:我想写一只会寻找新发现的小狐狸。`, request_id: 'fixture-request-' + index, run_id: null, created_at: date, })) : [{ id: 1, role: 'assistant', content: '这是另一段保留下来的故事对话。', request_id: 'fixture-request-short', run_id: null, created_at: date }], }; } type FixtureCall = { path: string; method: string; input: Record | null }; type FixtureSeed = { threads: CloudThread[]; histories: Record }; type FixtureState = FixtureSeed & { calls: FixtureCall[]; adjustments: number }; type FixtureWindow = Window & { __cloudChatFixture: FixtureState }; async function installFixture(page: Page, kind: 'empty' | 'history' | 'running' | 'approval') { const first = makeThread('story-first', '小狐狸的故事', kind === 'running' ? 'running' : kind === 'approval' ? 'interrupted' : 'completed'); const second = makeThread('story-second', '另一段故事'); const interrupt: CloudInterrupt = { approval: { action_requests: [{ name: 'execute', description: '把刚才确认的故事保存到我的故事笔记', args: { path: '/stories/' + longWord, content: '保留我们的故事想法。' } }] } }; const run = kind === 'running' ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null; const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : { threads: [first, second], histories: { [first.thread_id]: makeHistory(first, kind !== 'approval', run), [second.thread_id]: makeHistory(second), }, }; await page.addInitScript(({ seed }) => { const scope = window as unknown as FixtureWindow; scope.__cloudChatFixture = { ...seed, calls: [], adjustments: 0 }; const state = scope.__cloudChatFixture; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); // Keep durable run polling real, but prevent EventSource from reaching a network. class FixtureEventSource extends EventTarget { readyState = 1; onopen: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; close() { this.readyState = 2; } } Object.defineProperty(window, 'EventSource', { value: FixtureEventSource }); Object.defineProperty(window, 'electron', { value: { ipcRenderer: { invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { if (channel === 'hostapi:token') return 'fixture-token'; if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC: ' + channel); const path = request?.path ?? ''; const method = request?.method ?? 'GET'; const input = request?.body ? JSON.parse(request.body) as Record : null; state.calls.push({ path, method, input }); if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] }); if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path: ' + path); const operation = String(input?.operation); const args = input?.input as Record | undefined; const thread = state.threads.find(item => item.thread_id === args?.thread_id) ?? state.threads[0]; const history = thread ? state.histories[thread.thread_id] : null; switch (operation) { case 'threads': return result({ threads: state.threads, next_offset: null }); case 'history': return result(history); case 'viewed': return result({ viewed: true }); case 'attachments': return result({ attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }] }); case 'files': return result({ files: [{ name: '故事笔记.md', is_dir: false, path: '/故事笔记.md' }] }); case 'run': return result(Object.values(state.histories).find(item => item.run?.agent_run_id === args?.run_id)?.run); case 'cancelRun': return result({ status: 'cancel_requested' }); case 'resume': { if (!history?.run) throw new Error('Missing approval fixture run'); history.run = { ...history.run, status: 'completed', interrupt: undefined }; return result({ run_id: history.run.agent_run_id, status: 'completed' }); } default: throw new Error('Unexpected fixture operation: ' + operation); } }, } } }); }, { seed }); await page.route('**/*', route => { const url = new URL(route.request().url()); if (url.hostname === '127.0.0.1') return route.continue(); return route.abort('blockedbyclient'); }); } async function openChat(page: Page, kind: 'empty' | 'history' | 'running' | 'approval', width: number, height: number) { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await page.setViewportSize({ width, height }); await installFixture(page, kind); await page.goto(baseUrl); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByLabel('消息', { exact: true })).toBeEnabled(); await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0); return errors; } async function expectComposerBounds(page: Page) { const composer = page.getByTestId('cloud-chat-composer'); await expect(composer).toBeInViewport({ ratio: 1 }); const overflow = await page.evaluate(() => ({ document: document.documentElement.scrollWidth - innerWidth, body: document.body.scrollWidth - innerWidth, elements: [...document.querySelectorAll('.agent-chat, [data-testid="cloud-chat-messages"], [data-testid="cloud-chat-composer"]')] .map(element => ({ width: element.scrollWidth - element.clientWidth, name: element.getAttribute('data-testid') ?? element.className })), composer: (() => { const bounds = document.querySelector('[data-testid="cloud-chat-composer"]')!.getBoundingClientRect(); return { left: bounds.left, right: bounds.right, bottom: bounds.bottom }; })(), })); expect(overflow.document).toBeLessThanOrEqual(1); expect(overflow.body).toBeLessThanOrEqual(1); expect(overflow.elements.filter(item => item.width > 1)).toEqual([]); expect(overflow.composer.left).toBeGreaterThanOrEqual(0); expect(overflow.composer.right).toBeLessThanOrEqual(page.viewportSize()!.width); expect(overflow.composer.bottom).toBeLessThanOrEqual(page.viewportSize()!.height); } async function operations(page: Page) { return page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls .filter(call => call.path === '/api/cloud-agents/actions').map(call => call.input)); } async function screenshot(page: Page, name: string) { const path = test.info().outputPath(name + '.png'); await page.screenshot({ path, animations: 'disabled' }); await test.info().attach(name, { path, contentType: 'image/png' }); } for (const width of [375, 768, 1024, 1440]) { test(`friendly empty conversation keeps the composer reachable at ${width}px`, async ({ page }) => { const errors = await openChat(page, 'empty', width, width === 375 ? 640 : 760); await expect(page.locator('.agent-chat-welcome')).toBeVisible(); await expect(page.locator('img').first()).toBeVisible(); await expect.poll(() => page.locator('img').evaluateAll(images => images.every(image => { const element = image as HTMLImageElement; return element.complete && element.naturalWidth > 0; }))).toBe(true); await expectComposerBounds(page); await screenshot(page, `agent-chat-empty-${width}`); await page.getByRole('button', { name: examples[0], exact: true }).click(); await expect(page.getByLabel('消息', { exact: true })).toHaveValue(examples[0]); await expect(page.getByRole('button', { name: '发送', exact: true })).toBeEnabled(); expect((await operations(page)).some(item => ['submit', 'preview', 'createThread', 'resume'].includes(String(item?.operation)))).toBe(false); await expectComposerBounds(page); expect(errors).toEqual([]); }); } for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) { test(`long Markdown history scrolls independently at ${viewport.width}×${viewport.height}`, async ({ page }) => { const errors = await openChat(page, 'history', viewport.width, viewport.height); const transcript = page.getByTestId('cloud-chat-messages'); const composer = page.getByTestId('cloud-chat-composer'); await expect(transcript.getByRole('article')).toHaveCount(16); await expect(transcript.locator('pre')).toHaveCount(1); await expect(transcript.locator('table')).toHaveCount(1); await expectComposerBounds(page); expect(await transcript.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); const before = await composer.boundingBox(); await transcript.evaluate(element => { element.scrollTop = 0; }); await expect.poll(() => transcript.evaluate(element => element.scrollTop)).toBe(0); await screenshot(page, `agent-chat-message-roles-${viewport.width}`); const after = await composer.boundingBox(); expect(Math.abs(before!.y - after!.y)).toBeLessThanOrEqual(1); expect(await page.evaluate(() => scrollY)).toBe(0); await transcript.evaluate(element => { element.scrollTop = element.scrollHeight; }); await screenshot(page, `agent-chat-long-history-${viewport.width}`); const input = page.getByLabel('消息', { exact: true }); await input.fill(Array.from({ length: 6 }, (_, index) => `第 ${index + 1} 行未发送的故事想法`).join('\n')); await expectComposerBounds(page); await page.getByRole('button', { name: '查看历史对话', exact: true }).click(); const history = page.getByLabel('历史对话', { exact: true }); await expect(history).toBeDisabled(); await input.fill(''); await expect(history).toBeEnabled(); await history.selectOption('story-second'); await expect(transcript.getByText('这是另一段保留下来的故事对话。', { exact: true })).toBeVisible(); await expect(transcript.getByRole('article')).toHaveCount(1); await page.locator('.agent-chat-files summary').click(); await expect(page.getByRole('button', { name: '添加附件', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible(); await expectComposerBounds(page); expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false); expect(errors).toEqual([]); }); } test('running conversation keeps Stop reachable on a short narrow screen', async ({ page }) => { const errors = await openChat(page, 'running', 375, 540); const stop = page.getByRole('button', { name: '停止', exact: true }); await expect(stop).toBeInViewport({ ratio: 1 }); await expectComposerBounds(page); await screenshot(page, 'agent-chat-running-375'); await stop.click(); await expect.poll(async () => (await operations(page)).filter(item => item?.operation === 'cancelRun').length).toBe(1); expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false); expect(errors).toEqual([]); }); test('approval details and decisions remain reachable without covering the composer', async ({ page }) => { const errors = await openChat(page, 'approval', 768, 480); const approval = page.getByRole('heading', { name: '需要你的确认', exact: true }); await expect(approval).toBeVisible(); const reject = page.getByRole('button', { name: '拒绝', exact: true }); await reject.scrollIntoViewIfNeeded(); await expect(reject).toBeInViewport({ ratio: 1 }); await expect(page.getByRole('button', { name: '允许执行', exact: true })).toBeInViewport({ ratio: 1 }); await expectComposerBounds(page); await screenshot(page, 'agent-chat-approval-768'); expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false); await reject.click(); await expect(approval).toHaveCount(0); const resumes = (await operations(page)).filter(item => item?.operation === 'resume'); expect(resumes).toHaveLength(1); expect((resumes[0]?.input as Record).decision).toEqual({ decisions: [{ type: 'reject', message: '用户拒绝执行' }] }); await expectComposerBounds(page); expect(errors).toEqual([]); });