Files
makelore/tests/e2e/cloud-agent-platform-avatars.spec.ts
T

317 lines
22 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 { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft } from '../../shared/cloud-agents';
let server: ViteDevServer;
let baseUrl: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
// Render the real workspace and keep all service calls at the Main IPC boundary.
// This suite never starts Electron, opens a native file picker or calls a live service.
test.beforeAll(async () => {
const entry = resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx');
server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite-cloud-agent-platform-avatars',
resolve: { alias: { '@': resolve('src') } },
esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-cloud-agent-platform-avatars', enforce: 'pre',
resolveId(id) { if (id === '/__cloud-agent-platform-avatars-harness.tsx') return entry; },
load(id) {
if (id === entry) return `
import { createRoot } from 'react-dom/client';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CloudAgents from '/src/pages/CloudAgents/index.tsx';
import '/src/styles/globals.css';
const router = createMemoryRouter([{ path: '/cloud-agents', element: <CloudAgents /> }], { initialEntries: ['/cloud-agents?view=mine'] });
createRoot(document.getElementById('root')).render(<div className="h-screen min-w-0"><RouterProvider router={router} /></div>);
`;
if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "avatar-fixture" } }); }';
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) 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-platform-avatars-harness.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
const makeAgent = (character: string, name: string, published: boolean, extra: Partial<CloudAgentDraft> = {}): CloudAgentDraft => ({
slug: 'ml-' + character.repeat(32), name, purpose: `让${name}按照我的要求帮助我。`,
system_prompt: '用简单清楚的中文回答,先理解我的问题。',
draft_revision: 2, updated_at: '2026-10-12T00:00:00Z',
configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' },
published_version: published ? 1 : null, enabled: true, ...extra,
});
const readyAgent = makeAgent('a', '我的故事搭档', true);
const draftAgent = makeAgent('b', '我的英语陪练', false);
const disabledAgent = makeAgent('c', '暂停使用的助手', true, { enabled: false });
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
type AvatarFixture = { agents: CloudAgentDraft[]; calls: FixtureCall[]; failPreset: boolean; failSave: boolean };
type FixtureWindow = Window & { __agentAvatarFixture: AvatarFixture };
async function installFixture(page: Page, agents: CloudAgentDraft[]) {
page.on('pageerror', error => { throw error; });
await page.addInitScript(({ agents, configuration }) => {
const scope = window as unknown as FixtureWindow;
scope.__agentAvatarFixture = { agents, calls: [], failPreset: false, failSave: false };
const state = scope.__agentAvatarFixture;
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } });
const failure = (message: string) => ({ ok: true, data: { status: 503, ok: false, json: { error: message } } });
const avatarUrl = (icon: string | null | undefined) => icon ? '/src/assets/agent-avatars/' + icon.split('preset-')[1] : null;
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => {
if (channel === 'hostapi:token') return 'fixture';
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/avatars/preset') {
if (state.failPreset) return failure('平台头像暂时不可用');
const id = String(input?.id);
if (!/^avatar-(0[1-9]|1[0-6])$/.test(id)) throw new Error('Unexpected preset ' + id);
return result({ icon: '/api/storage/public/images/preset-' + id + '.png', icon_url: '/src/assets/agent-avatars/' + id + '.png' });
}
if (path.startsWith('/api/cloud-agents/agents')) {
const url = new URL(path, 'http://fixture.invalid');
if (url.pathname === '/api/cloud-agents/agents') {
if (method === 'POST') {
const created = { ...configuration, ...input, slug: 'ml-' + 'd'.repeat(32),
system_prompt: '', draft_revision: 1, enabled: true, published_version: null,
icon_url: avatarUrl(input?.icon as string | null), updated_at: '2026-10-12T00:00:00Z' };
state.agents.unshift(created as CloudAgentDraft);
return result(created);
}
return result({ agents: state.agents, next_cursor: null });
}
const agent = state.agents.find(item => item.slug === url.pathname.split('/')[4]);
if (!agent) throw new Error('Unknown fixture Agent ' + url.pathname);
if (method === 'PATCH') {
if (state.failSave) return failure('保存头像失败,请重试');
Object.assign(agent, input, { draft_revision: agent.draft_revision + 1, icon_url: avatarUrl(input?.icon as string | null) });
}
return result(agent);
}
if (path === '/api/cloud-agents/actions') {
const args = input?.input as { slug?: string } | undefined;
const agent = state.agents.find(item => item.slug === args?.slug) ?? state.agents[0];
switch (input?.operation) {
case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null });
case 'resources': return result({ mcps: [], skills: [], subagents: [] });
case 'knowledge': return result({ databases: [], models: [] });
case 'budget': return result({ agent_slug: agent?.slug, request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00', timezone: 'Asia/Shanghai', unit: '词元点数', resets_at: '2026-10-13T00:00:00+08:00' });
case 'access': return result({ published_version: agent?.published_version, enabled: agent?.enabled,
share_url: 'niancode://agents/' + agent?.slug, api_url: 'http://fixture.invalid', grants: [], applications: [],
versions: agent?.published_version ? [{ version: 1, draft_revision: agent.draft_revision, created_at: '2026-10-12T00:00:00Z' }] : [] });
case 'threads': return result({ threads: [], next_offset: null });
default: throw new Error('Unexpected fixture operation ' + String(input?.operation));
}
}
throw new Error('Unexpected fixture request ' + path);
},
} } });
}, { agents, configuration: { configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' } } });
}
async function expectNoOverflow(page: Page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
async function expectImagesLoaded(page: Page, selector = 'img') {
await expect.poll(() => page.locator(selector).evaluateAll(images => images.every(image => {
const img = image as HTMLImageElement;
return img.complete && img.naturalWidth > 0;
}))).toBe(true);
}
async function expectConfigurationBounds(page: Page) {
await expectNoOverflow(page);
await expect.poll(() => page.getByTestId('cloud-agent-editor').getByLabel('名称', { exact: true })
.evaluate(element => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(96);
// Document width alone misses controls clipped by the editor's overflow container.
const clipped = await page.getByTestId('cloud-agent-editor').evaluate(editor => {
const scroll = editor.querySelector('.agent-config-scroll')!;
const scrollBounds = scroll.getBoundingClientRect();
return [...editor.querySelectorAll('button, input, textarea, select, h2, summary, .agent-config-card')]
.filter(element => element.getClientRects().length > 0 && !element.classList.contains('sr-only'))
.flatMap(element => {
const bounds = element.getBoundingClientRect();
const insideScroll = scroll.contains(element);
const left = insideScroll ? scrollBounds.left : 0;
const right = insideScroll ? scrollBounds.right : innerWidth;
return bounds.left < left - 1 || bounds.right > right + 1
? [{ element: element.tagName, label: element.getAttribute('aria-label') ?? element.textContent?.trim(), left: bounds.left, right: bounds.right }]
: [];
});
});
expect(clipped).toEqual([]);
}
for (const width of [375, 768, 1024, 1440]) {
test(`introduction cards and one-page advanced settings fit and scroll at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 850 });
await installFixture(page, [draftAgent]);
await page.goto(baseUrl);
await page.getByRole('button', { name: '继续制作', exact: true }).click();
const editor = page.getByTestId('cloud-agent-editor');
const tabs = editor.getByRole('tablist', { name: '编辑设置', exact: true });
await expect(tabs.getByRole('tab')).toHaveCount(2);
await expect(editor.getByRole('heading', { name: '一句话介绍', exact: true })).toBeVisible();
await expect(editor.getByRole('heading', { name: '提示词', exact: true })).toBeVisible();
await expect(editor.locator('section.agent-config-card[data-tone="blue"]')).toHaveCSS('background-color', 'rgb(241, 246, 255)');
await expect(editor.locator('.agent-config-prompt[data-tone="lilac"]')).toHaveCSS('background-color', 'rgb(246, 242, 255)');
await editor.getByLabel('一句话介绍', { exact: true }).focus();
await expect(editor.getByLabel('一句话介绍', { exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(editor.getByLabel('提示词', { exact: true })).toBeFocused();
await expectConfigurationBounds(page);
if (width === 375 || width === 1440) {
await page.screenshot({ path: test.info().outputPath(`agent-introduction-prompt-${width}.png`), animations: 'disabled' });
}
await tabs.getByRole('tab', { name: '高级设置', exact: true }).click();
const advanced = editor.locator('.agent-advanced-settings');
await expect(tabs.getByRole('tab', { name: '高级设置', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(advanced.getByRole('navigation')).toHaveCount(0);
await expect(advanced.getByRole('tablist')).toHaveCount(0);
const headings = ['模型', '工具箱(Tool)', '做事手册(Skill)', 'MCP 服务', '子智能体', '知识库', '费用上限', '运行限制'];
for (const name of headings) await expect(advanced.getByRole('heading', { name, exact: true })).toBeVisible();
await expect(advanced.getByRole('heading').first()).toHaveText('模型');
await expect(advanced.getByLabel('模型', { exact: true })).toHaveAttribute('required', '');
await expect(advanced.getByText('选它做事时能用的工具。', { exact: true })).toBeVisible();
await expect(advanced.getByText('给它一本手册,照着方法做。', { exact: true })).toBeVisible();
await expect(advanced.getByLabel('模型', { exact: true })).toBeInViewport();
await expectConfigurationBounds(page);
if (width === 375 || width === 1440) {
await page.screenshot({ path: test.info().outputPath(`agent-advanced-upper-${width}.png`), animations: 'disabled' });
}
await advanced.getByLabel('单次运行时限(秒)', { exact: true }).scrollIntoViewIfNeeded();
await expect(advanced.getByLabel('单次运行时限(秒)', { exact: true })).toBeInViewport();
await expect(advanced.getByLabel('每日上限(词元点数)', { exact: true })).toBeVisible();
await expectConfigurationBounds(page);
if (width === 375 || width === 1440) {
await page.screenshot({ path: test.info().outputPath(`agent-advanced-lower-${width}.png`), animations: 'disabled' });
}
// Returning from the bottom resets the view to the introduction, without changing a draft.
await tabs.getByRole('tab', { name: '介绍与提示词', exact: true }).click();
await expect(editor.getByLabel('一句话介绍', { exact: true })).toBeInViewport();
await expect(editor.getByLabel('提示词', { exact: true })).toHaveValue(draftAgent.system_prompt);
const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__agentAvatarFixture.calls);
expect(calls.filter(call => call.method === 'PATCH' || call.path === '/api/cloud-agents/avatars/preset'
|| (call.method === 'POST' && call.path === '/api/cloud-agents/agents'))).toEqual([]);
expect(calls.filter(call => call.path === '/api/cloud-agents/actions')
.every(call => ['catalog', 'resources', 'knowledge', 'budget', 'access'].includes(String(call.input?.operation)))).toBe(true);
});
}
for (const width of [375, 1366]) {
test(`pastel Agent cards and explicit chat/adjust controls remain usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 850 });
await installFixture(page, [readyAgent, draftAgent, disabledAgent]);
await page.goto(baseUrl);
const cards = page.getByRole('article');
await expect(cards).toHaveCount(3);
const tones = { '0': 'rgb(241, 246, 255)', '1': 'rgb(246, 242, 255)', '2': 'rgb(255, 245, 233)' };
for (const [tone, color] of Object.entries(tones)) await expect(page.locator(`article[data-tone="${tone}"]`)).toHaveCSS('background-color', color);
const ready = page.getByRole('article', { name: readyAgent.name, exact: true });
await expect(ready.getByRole('button', { name: '和它聊聊', exact: true })).toBeVisible();
await expect(ready.getByRole('button', { name: '调整', exact: true })).toBeVisible();
await expect(page.getByRole('article', { name: draftAgent.name, exact: true }).getByRole('button', { name: '继续制作', exact: true })).toBeVisible();
await expect(page.getByRole('article', { name: disabledAgent.name, exact: true }).getByRole('button', { name: '查看与调整', exact: true })).toBeVisible();
await expectImagesLoaded(page);
await expectNoOverflow(page);
await page.screenshot({ path: test.info().outputPath(`agent-cards-${width}.png`), fullPage: true, animations: 'disabled' });
await ready.getByRole('button', { name: '和它聊聊', exact: true }).click();
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0);
await page.getByRole('button', { name: '我的智能体', exact: true }).click();
await ready.getByRole('button', { name: '调整', exact: true }).click();
await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByLabel('提示词', { exact: true })).toHaveValue(readyAgent.system_prompt);
await expectNoOverflow(page);
});
test(`sixteen platform portraits preview, create and reopen at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 850 });
await installFixture(page, []);
await page.goto(baseUrl);
await page.getByRole('button', { name: '创建我的第一个智能体', exact: true }).click();
await page.getByLabel('名称', { exact: true }).fill('像素伙伴助手');
const presets = page.getByRole('group', { name: '平台像素头像', exact: true });
await expect(presets.getByRole('button')).toHaveCount(16);
await expectImagesLoaded(page);
await expectNoOverflow(page);
await presets.getByRole('button', { name: '像素伙伴 7', exact: true }).click();
await expect(presets.getByRole('button', { name: '像素伙伴 7', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(presets.getByRole('button', { pressed: true })).toHaveCount(1);
await expect(page.getByRole('img', { name: '像素伙伴助手头像', exact: true })).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-07.png');
await page.screenshot({ path: test.info().outputPath(`platform-avatar-picker-${width}.png`), fullPage: true, animations: 'disabled' });
await page.getByRole('button', { name: '下一步', exact: true }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__agentAvatarFixture.calls);
const created = calls.find(call => call.path === '/api/cloud-agents/agents' && call.method === 'POST')!;
expect(created.input?.icon).toBe('/api/storage/public/images/preset-avatar-07.png');
expect(created.input).not.toHaveProperty('icon_url');
await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-07.png');
await expectNoOverflow(page);
await page.getByRole('button', { name: '我的智能体', exact: true }).click();
const card = page.getByRole('article', { name: '像素伙伴助手', exact: true });
await expect(card.getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-07.png');
await card.getByRole('button', { name: '继续制作', exact: true }).click();
await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-07.png');
});
}
test('preset and save failures retain the existing avatar; successful save reopens persisted portrait', async ({ page }) => {
await page.setViewportSize({ width: 1366, height: 850 });
const icon = '/api/storage/public/images/preset-avatar-03.png';
await installFixture(page, [{ ...draftAgent, icon, icon_url: '/src/assets/agent-avatars/avatar-03.png' }]);
await page.goto(baseUrl);
await page.getByRole('button', { name: '继续制作', exact: true }).click();
await page.getByRole('button', { name: '修改头像', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '修改头像', exact: true });
await expect(dialog.getByRole('group', { name: '平台像素头像', exact: true }).getByRole('button')).toHaveCount(16);
await page.evaluate(() => { (window as unknown as FixtureWindow).__agentAvatarFixture.failPreset = true; });
await dialog.getByRole('button', { name: '像素伙伴 4', exact: true }).click();
await expect(dialog.getByRole('alert')).toContainText('平台头像暂时不可用');
await expect(dialog.getByRole('img', { name: draftAgent.name + '头像', exact: true })).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-03.png');
await expect(page.getByRole('button', { name: '已保存', exact: true, includeHidden: true })).toBeDisabled();
await page.evaluate(() => { (window as unknown as FixtureWindow).__agentAvatarFixture.failPreset = false; });
await dialog.getByRole('button', { name: '像素伙伴 4', exact: true }).click();
await expect(dialog.getByRole('img', { name: draftAgent.name + '头像', exact: true })).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-04.png');
await expectImagesLoaded(page);
await page.screenshot({ path: test.info().outputPath('existing-agent-platform-avatar-dialog.png'), animations: 'disabled' });
await dialog.getByRole('button', { name: '完成', exact: true }).click();
await page.evaluate(() => { (window as unknown as FixtureWindow).__agentAvatarFixture.failSave = true; });
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('保存头像失败,请重试');
await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-04.png');
expect(await page.evaluate(() => (window as unknown as FixtureWindow).__agentAvatarFixture.agents[0].icon)).toBe(icon);
await page.evaluate(() => { (window as unknown as FixtureWindow).__agentAvatarFixture.failSave = false; });
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeDisabled();
const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__agentAvatarFixture.calls);
const saves = calls.filter(call => call.method === 'PATCH');
expect(saves).toHaveLength(2);
expect(saves[1].input?.icon).toBe('/api/storage/public/images/preset-avatar-04.png');
expect(saves[1].input).not.toHaveProperty('icon_url');
await page.getByRole('button', { name: '我的智能体', exact: true }).click();
const card = page.getByRole('article', { name: draftAgent.name, exact: true });
await expect(card.getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-04.png');
await card.getByRole('button', { name: '继续制作', exact: true }).click();
await expect(page.getByRole('button', { name: '修改头像', exact: true }).getByRole('img')).toHaveAttribute('src', '/src/assets/agent-avatars/avatar-04.png');
});