317 lines
22 KiB
TypeScript
317 lines
22 KiB
TypeScript
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');
|
||
});
|