Files
makelore/tests/e2e/cloud-agent-chat-layout.spec.ts
T

295 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<main className="flex h-screen min-h-0 min-w-0 flex-col">
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
examples={examples} headerAction={<Button variant="outline" data-testid="fixture-adjust"
onClick={() => window.__cloudChatFixture.adjustments++}>调整智能体</Button>} />
</main>
);
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/') return next();
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-chat-layout-harness.tsx"></script></body></html>');
});
},
}],
});
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<string, unknown> | null };
type FixtureSeed = { threads: CloudThread[]; histories: Record<string, CloudHistory> };
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<string, unknown> : 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<string, unknown> | 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<string, unknown>).decision).toEqual({ decisions: [{ type: 'reject', message: '用户拒绝执行' }] });
await expectComposerBounds(page);
expect(errors).toEqual([]);
});