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([]);
});