526 lines
33 KiB
TypeScript
526 lines
33 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
import { createServer, type ViteDevServer } from 'vite';
|
||
import { resolve } from 'node:path';
|
||
import type { CloudAttachment, 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 attachment composer 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 { useState } from 'react';
|
||
import { CloudChat } from '/src/pages/CloudAgents/CloudChat.tsx';
|
||
import { CloudAvatar } from '/src/pages/CloudAgents/CloudAvatar.tsx';
|
||
import avatar from '/src/assets/agent-avatars/avatar-05.png';
|
||
import '/src/styles/globals.css';
|
||
const examples = ['我想一起想一个有趣的故事。', '请帮我把今天的新发现整理一下。'];
|
||
const name = new URLSearchParams(location.search).has('longName') ? '我的故事搭档'.repeat(16) : '我的故事搭档';
|
||
function Harness() {
|
||
const [header, setHeader] = useState(null);
|
||
return <main className="flex h-screen min-h-0 min-w-0 flex-col">
|
||
<header className="agent-workspace-header agent-chat-workspace-header">
|
||
<CloudAvatar src={avatar} name={name} className="agent-chat-header-avatar" />
|
||
<h2 className="agent-chat-header-name" title={name}>{name}</h2>
|
||
<div ref={setHeader} className="agent-chat-header-files" />
|
||
</header>
|
||
<CloudChat slug="ml-chat-layout-fixture" assistantName="我的故事搭档" assistantAvatarUrl={avatar}
|
||
examples={examples} attachmentHeader={header} />
|
||
</main>;
|
||
}
|
||
createRoot(document.getElementById('root')).render(<Harness />);
|
||
`;
|
||
},
|
||
configureServer(vite) {
|
||
vite.middlewares.use((req, res, next) => {
|
||
if (req.url?.split('?')[0] !== '/') 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 = [
|
||
'## 一起把小发现变成故事',
|
||
'你可以先选一个最喜欢的发现,再让角色去试一试。',
|
||
'[下载故事笔记](sandbox:/故事笔记.md)',
|
||
`这个很长的名字也应该完整保留:${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 FixtureStream = EventTarget & { readyState: number; url: string };
|
||
type FixtureState = FixtureSeed & { calls: FixtureCall[]; attachments: CloudAttachment[]; streams: FixtureStream[] };
|
||
type FixtureWindow = Window & { __cloudChatFixture: FixtureState };
|
||
type FixtureKind = 'empty' | 'history' | 'running' | 'approval' | 'thinking' | 'delivery';
|
||
|
||
async function installFixture(page: Page, kind: FixtureKind) {
|
||
const isRunning = kind === 'running' || kind === 'thinking';
|
||
const first = makeThread('story-first', '小狐狸的故事', isRunning ? '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 = isRunning ? makeRun(first, 'running') : kind === 'approval' ? makeRun(first, 'interrupted', interrupt) : null;
|
||
const seed: FixtureSeed = kind === 'empty' ? { threads: [], histories: {} } : {
|
||
threads: [first, second], histories: {
|
||
[first.thread_id]: kind === 'thinking' ? { ...makeHistory(first, false, run), messages: [] }
|
||
: kind === 'delivery' ? { ...makeHistory(first), messages: [
|
||
{ id: 1, role: 'user', content: '[用户提到的文件](sandbox:/user.txt)', request_id: 'delivery-user', run_id: null, created_at: date },
|
||
{ id: 2, role: 'assistant', request_id: 'delivery-answer', run_id: null, created_at: date,
|
||
content: '[下载完成的小程序](sandbox:/deliverable.py)\n\n准备时用到了 /internal.py、/call.txt、/cache.json 和 /plain.txt。\n\n`[行内示例](sandbox:/inline.py)`\n\n```markdown\n[代码示例](sandbox:/example.py)\n```',
|
||
reasoning_content: '[思考过程](sandbox:/thought.txt)',
|
||
},
|
||
] } : 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: [], attachments: [{ file_id: 'attachment-story', file_name: '我的故事笔记与新发现.txt', file_size: 4096 }], streams: [] };
|
||
const state = scope.__cloudChatFixture;
|
||
const persisted = sessionStorage.getItem('cloud-chat-fixture-durable');
|
||
if (persisted) {
|
||
const { history, thread } = JSON.parse(persisted) as { history: CloudHistory; thread: CloudThread };
|
||
state.histories[thread.thread_id] = history;
|
||
state.threads[0] = thread;
|
||
}
|
||
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;
|
||
url: string;
|
||
onopen: ((event: Event) => void) | null = null;
|
||
onerror: ((event: Event) => void) | null = null;
|
||
constructor(url: string) { super(); this.url = url; state.streams.push(this); }
|
||
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/attachments/pick') return result({ object_name: 'temporary/狐狸照片.png', file_name: '狐狸照片.png', file_type: 'png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
|
||
if (path === '/api/cloud-agents/files/save') 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: state.attachments });
|
||
case 'confirmAttachment': {
|
||
const attachment = { file_id: 'attachment-photo', file_name: '狐狸照片.png', file_size: 8192 };
|
||
if (!state.attachments.some(item => item.file_id === attachment.file_id)) state.attachments.push(attachment);
|
||
return result({ attachments: [attachment] });
|
||
}
|
||
case 'files': return result({ files: ['故事笔记.md', 'deliverable.py', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']
|
||
.map(name => ({ name, is_dir: false, path: '/' + name, directory_path: '/', size: 1024 })) });
|
||
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: FixtureKind, width: number, height: number, longName = false) {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', error => errors.push(error.message));
|
||
await page.setViewportSize({ width, height });
|
||
await installFixture(page, kind);
|
||
await page.goto(baseUrl + (longName ? '?longName=1' : ''));
|
||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||
await expect(page.getByLabel('消息', { exact: true })).toBeEnabled();
|
||
await expect(page.getByText('正在读取对话…', { exact: true })).toHaveCount(0);
|
||
await expect(page.locator('.agent-chat-toolbar')).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: /^(已保存|已应用|调整智能体|查看历史对话|新对话|客户端对话|微信对话)$/ })).toHaveCount(0);
|
||
await expect(page.getByRole('navigation', { name: '对话来源', exact: true })).toHaveCount(0);
|
||
await expect(page.getByText(/由创建者支付词元点数|和助手聊一聊/)).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' });
|
||
}
|
||
|
||
async function emitRunEvent(page: Page, type: 'messages' | 'end', id: string, envelope: Record<string, unknown>) {
|
||
await page.evaluate(({ type, id, envelope }) => {
|
||
const stream = (window as unknown as FixtureWindow).__cloudChatFixture.streams.filter(item => item.readyState === 1).at(-1);
|
||
if (!stream) throw new Error('Missing active fixture stream');
|
||
stream.dispatchEvent(new MessageEvent(type, { lastEventId: id, data: JSON.stringify(envelope) }));
|
||
}, { type, id, envelope });
|
||
}
|
||
|
||
test('long Agent identity and attachment entry fit on a short narrow window', async ({ page }) => {
|
||
const errors = await openChat(page, 'history', 375, 540, true);
|
||
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
|
||
await expect(entry).toBeVisible();
|
||
const header = page.locator('.agent-chat-workspace-header');
|
||
expect((await header.boundingBox())!.height).toBeLessThanOrEqual(104);
|
||
await expect(header.locator('.agent-chat-header-name')).toHaveAttribute('title', '我的故事搭档'.repeat(16));
|
||
await expect(header.locator('.agent-attachment-entry')).toHaveCount(1);
|
||
await expectComposerBounds(page);
|
||
await entry.click();
|
||
await expect(page.getByRole('dialog', { name: '本次对话的附件' })).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await expect(entry).toBeFocused();
|
||
await expectComposerBounds(page);
|
||
await screenshot(page, 'agent-chat-long-name-attachment-header');
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
test('the file Sheet counts only uploads and files explicitly delivered in the assistant answer', async ({ page }) => {
|
||
const errors = await openChat(page, 'delivery', 375, 640);
|
||
const entry = page.getByRole('button', { name: '附件,共 2 个文件', exact: true });
|
||
await expect(entry).toBeVisible();
|
||
await entry.click();
|
||
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
|
||
const generated = panel.getByRole('region', { name: '助手生成的' });
|
||
await expect(generated.getByRole('button', { name: /^保存 / })).toHaveCount(1);
|
||
await expect(generated.getByRole('button', { name: '保存 deliverable.py', exact: true })).toBeVisible();
|
||
for (const name of ['故事笔记.md', 'internal.py', 'call.txt', 'cache.json', 'plain.txt', 'inline.py', 'example.py', 'user.txt', 'thought.txt']) {
|
||
await expect(generated.getByText(name, { exact: true })).toHaveCount(0);
|
||
}
|
||
await generated.getByRole('button', { name: '保存 deliverable.py', exact: true }).click();
|
||
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
|
||
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/deliverable.py' }]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(entry).toBeFocused();
|
||
await expectComposerBounds(page);
|
||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume', 'deleteAttachment'].includes(String(item?.operation)))).toBe(false);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
|
||
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: 768, height: 760 }, { width: 1024, height: 760 }, { 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);
|
||
const userBubble = transcript.locator('.agent-chat-message[data-role="user"]').first();
|
||
const bubbleStyle = await userBubble.evaluate(element => {
|
||
const style = getComputedStyle(element);
|
||
return { background: style.backgroundColor, radius: Number.parseFloat(style.borderTopLeftRadius) };
|
||
});
|
||
expect(bubbleStyle.background).not.toBe('rgba(0, 0, 0, 0)');
|
||
expect(bubbleStyle.radius).toBeGreaterThan(0);
|
||
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 expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
|
||
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
|
||
await expect(transcript.getByRole('article')).toHaveCount(16);
|
||
await input.fill('');
|
||
const entry = page.getByRole('button', { name: /^附件,共 2 个文件$/ });
|
||
await expect(composer.getByRole('button', { name: /附件,共/ })).toHaveCount(0);
|
||
await entry.click();
|
||
const panel = page.getByRole('dialog', { name: '本次对话的附件' });
|
||
await expect(panel).toBeVisible();
|
||
const bounds = await panel.boundingBox();
|
||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width + 1);
|
||
expect(Math.abs(bounds!.x + bounds!.width - viewport.width)).toBeLessThanOrEqual(1);
|
||
expect(await panel.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||
await expect(page.getByRole('button', { name: '保存 故事笔记.md', exact: true })).toBeVisible();
|
||
await page.getByRole('button', { name: '保存 故事笔记.md', exact: true }).click();
|
||
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.calls
|
||
.filter(call => call.path === '/api/cloud-agents/files/save').map(call => call.input))).toEqual([{ thread_id: 'story-first', path: '/故事笔记.md' }]);
|
||
const chosen = page.getByRole('checkbox', { name: '选择附件 我的故事笔记与新发现.txt', exact: true });
|
||
await chosen.check();
|
||
await screenshot(page, `agent-chat-attachment-panel-${viewport.width}`);
|
||
await page.getByRole('button', { name: /返回聊天/ }).click();
|
||
await expect(panel).toHaveCount(0);
|
||
await expect(composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true })).toBeVisible();
|
||
await composer.getByRole('button', { name: '移除附件 我的故事笔记与新发现.txt', exact: true }).click();
|
||
await entry.click();
|
||
await expect(chosen).not.toBeChecked();
|
||
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
|
||
expect((await operations(page)).some(item => item?.operation === 'deleteAttachment')).toBe(false);
|
||
await page.keyboard.press('Escape');
|
||
await expect(panel).toHaveCount(0);
|
||
await expect(entry).toBeFocused();
|
||
await expectComposerBounds(page);
|
||
expect((await operations(page)).some(item => ['submit', 'preview', 'resume'].includes(String(item?.operation)))).toBe(false);
|
||
expect(errors).toEqual([]);
|
||
});
|
||
}
|
||
|
||
for (const width of [375, 1440]) {
|
||
test(`a picked image is added inline and can be deselected without deleting it at ${width}px`, async ({ page }) => {
|
||
const errors = await openChat(page, 'history', width, width === 375 ? 640 : 900);
|
||
const composer = page.getByTestId('cloud-chat-composer');
|
||
const send = composer.getByRole('button', { name: '发送', exact: true });
|
||
await expect(send).toBeDisabled();
|
||
await composer.getByRole('button', { name: '添加文件或图片', exact: true }).click();
|
||
await page.getByRole('menuitem', { name: '添加文件或图片', exact: true }).click();
|
||
const remove = composer.getByRole('button', { name: '移除附件 狐狸照片.png', exact: true });
|
||
await expect(remove).toBeVisible();
|
||
await expect(composer.locator('.agent-attachment-card[data-kind="image"]')).toHaveCount(1);
|
||
await expect(composer.locator('.agent-attachment-card')).toHaveText(/狐狸照片\.png/);
|
||
await expect(send).toBeEnabled();
|
||
const confirms = (await operations(page)).filter(item => item?.operation === 'confirmAttachment');
|
||
expect(confirms).toHaveLength(1);
|
||
expect(confirms[0]?.input).toEqual({ thread_id: 'story-first', attachments: [{ object_name: 'temporary/狐狸照片.png', file_type: 'png' }] });
|
||
expect((await operations(page)).some(item => item?.operation === 'parseAttachment')).toBe(false);
|
||
await expectComposerBounds(page);
|
||
await screenshot(page, `agent-chat-inline-image-${width}`);
|
||
await remove.click();
|
||
await expect(composer.locator('.agent-attachment-selected')).toHaveCount(0);
|
||
await expect(send).toBeDisabled();
|
||
await page.getByRole('button', { name: /^附件,共 3 个文件$/ }).click();
|
||
await expect(page.getByRole('checkbox', { name: '选择附件 狐狸照片.png', exact: true })).not.toBeChecked();
|
||
await page.getByRole('button', { name: '关闭附件面板' }).click();
|
||
expect((await operations(page)).some(item => ['deleteAttachment', 'submit', 'preview'].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([]);
|
||
});
|
||
|
||
for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 }]) {
|
||
test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => {
|
||
const errors = await openChat(page, 'thinking', viewport.width, viewport.height);
|
||
const transcript = page.getByTestId('cloud-chat-messages');
|
||
const thinking = page.getByTestId('cloud-chat-reasoning');
|
||
const envelope = { thread_id: 'story-first', run_id: 'run-story-first' };
|
||
const prefix = '先观察故事里的小发现。\n';
|
||
const reasoning = prefix + Array.from({ length: 40 }, (_, index) =>
|
||
`第 ${index + 1} 个线索:${longWord},再想一想角色会怎样解决。`).join('\n');
|
||
const answer = '小狐狸发现了会发光的故事笔记,决定邀请伙伴一起探索。';
|
||
const delta = (fields: Record<string, unknown>) => ({ stream_event: {
|
||
type: 'message_delta', message_id: 'assistant-story-first', ...fields,
|
||
} });
|
||
await expect.poll(() => page.evaluate(() => (window as unknown as FixtureWindow).__cloudChatFixture.streams.length)).toBe(1);
|
||
await expect(thinking).toHaveCount(0);
|
||
|
||
// Child Agent and unrelated run events never become visible in this chat.
|
||
for (const wrong of [{ thread_id: 'child-story', run_id: envelope.run_id }, { ...envelope, run_id: 'run-other' }]) {
|
||
await emitRunEvent(page, 'messages', '1000-0', { ...wrong, payload: { items: [delta({ reasoning_content: '不属于当前对话的思考' })] } });
|
||
}
|
||
await expect(thinking).toHaveCount(0);
|
||
|
||
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: prefix })] } });
|
||
await expect(thinking).toHaveAttribute('open');
|
||
await expect(thinking).toContainText(prefix.trim());
|
||
await expect(page.locator('.agent-chat-welcome')).toHaveCount(0);
|
||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(0);
|
||
|
||
// Replayed SSE IDs must not duplicate the thought or replace the answer.
|
||
await emitRunEvent(page, 'messages', '1001-0', { ...envelope, payload: { items: [delta({ reasoning_content: '重复事件不应显示' })] } });
|
||
await emitRunEvent(page, 'messages', '1002-0', { ...envelope, payload: { chunk: delta({
|
||
additional_reasoning_content: reasoning.slice(prefix.length),
|
||
}) } });
|
||
await emitRunEvent(page, 'messages', '1003-0', { ...envelope, payload: { items: [delta({ content: answer })] } });
|
||
await expect(thinking).toContainText(reasoning);
|
||
await expect(transcript).not.toContainText('重复事件不应显示');
|
||
await expect(transcript).not.toContainText('不属于当前对话的思考');
|
||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||
expect(await thinking.locator('pre, table, a').count()).toBe(0);
|
||
await expectComposerBounds(page);
|
||
await screenshot(page, `agent-chat-reasoning-live-${viewport.width}`);
|
||
|
||
const durable = await page.evaluate(({ reasoning, answer, date }) => {
|
||
const state = (window as unknown as FixtureWindow).__cloudChatFixture;
|
||
const history = state.histories['story-first'];
|
||
if (!history.run) throw new Error('Missing reasoning fixture run');
|
||
history.run = { ...history.run, status: 'completed', output: answer };
|
||
history.messages = [{ id: 1, role: 'assistant', content: answer, reasoning_content: reasoning,
|
||
request_id: history.run.request_id, run_id: history.run.agent_run_id, created_at: date }];
|
||
state.threads[0] = { ...state.threads[0], status: 'completed' };
|
||
return { history, thread: state.threads[0] };
|
||
}, { reasoning, answer, date });
|
||
// Native terminal events can reuse the last message ID and still finish a run.
|
||
await emitRunEvent(page, 'end', '1003-0', { ...envelope, payload: { chunk: { status: 'completed' } } });
|
||
await expect(page.getByRole('button', { name: '停止回答', exact: true })).toHaveCount(0);
|
||
await expect(thinking).toHaveCount(1);
|
||
await expect(thinking).not.toHaveAttribute('open');
|
||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||
await thinking.locator('summary').scrollIntoViewIfNeeded();
|
||
await thinking.locator('summary').focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(thinking).toHaveAttribute('open');
|
||
await expect(thinking).toContainText(reasoning);
|
||
await expectComposerBounds(page);
|
||
await screenshot(page, `agent-chat-reasoning-history-${viewport.width}`);
|
||
await thinking.locator('summary').click();
|
||
await expect(thinking).not.toHaveAttribute('open');
|
||
|
||
// Reopening restores the durable conversation without bringing back history controls.
|
||
await page.evaluate(saved => sessionStorage.setItem('cloud-chat-fixture-durable', JSON.stringify(saved)), durable);
|
||
await page.reload();
|
||
await expect(thinking).toHaveCount(1);
|
||
await expect(thinking).not.toHaveAttribute('open');
|
||
await expect(page.getByRole('button', { name: '查看历史对话', exact: true })).toHaveCount(0);
|
||
await expect(page.getByLabel('历史对话', { exact: true })).toHaveCount(0);
|
||
await thinking.locator('summary').click();
|
||
await expect(thinking).toContainText(reasoning);
|
||
await expect(transcript.getByText(answer, { exact: true })).toHaveCount(1);
|
||
await expect(transcript.getByRole('article')).toHaveCount(1);
|
||
await expectComposerBounds(page);
|
||
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([]);
|
||
});
|