| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import { chromium, devices } from 'playwright';
- import { mkdir, writeFile, readdir } from 'node:fs/promises';
- import { resolve, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- import assert from 'node:assert/strict';
- const root = dirname(fileURLToPath(import.meta.url));
- const out = resolve(root, 'test-results');
- await mkdir(out, { recursive: true });
- const launchOptions = { headless: true };
- if (process.env.DEMO_CHROME) launchOptions.executablePath = process.env.DEMO_CHROME;
- else if (process.platform === 'win32') {
- const cache = resolve(process.env.LOCALAPPDATA, 'ms-playwright');
- const installed = (await readdir(cache)).filter(x => /^chromium-\d+$/.test(x)).sort().reverse();
- if (installed.length) launchOptions.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe');
- }
- const browser = await chromium.launch(launchOptions);
- const base = process.env.DEMO_URL || 'http://localhost:4300';
- const results = [];
- const errors = [];
- const requests = [];
- const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
- const mobile = await browser.newContext({ ...devices['iPhone 13'], viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' });
- const pc = await desktop.newPage();
- const phone = await mobile.newPage();
- for (const page of [pc, phone]) {
- page.on('pageerror', error => errors.push(error.message));
- page.on('requestfailed', request => requests.push(`${request.url()} ${request.failure()?.errorText}`));
- }
- async function test(name, action) {
- try { await action(); results.push({ name, passed: true }); process.stdout.write(`PASS ${name}\n`); }
- catch (error) { results.push({ name, passed: false, error: error.message }); process.stdout.write(`FAIL ${name}: ${error.message}\n`); }
- }
- async function go(page, path) {
- await page.goto(base + path);
- await page.locator('#app').waitFor();
- }
- async function click(page, action, value) {
- await page.locator(`[data-action="${action}"]${value !== undefined ? `[data-value="${value}"]` : ''}`).first().click();
- }
- async function checkLayout(page) {
- const layout = await page.evaluate(() => ({
- windowWidth: innerWidth, documentWidth: document.documentElement.scrollWidth,
- mainWidth: document.querySelector('#main')?.clientWidth,
- mainScrollWidth: document.querySelector('#main')?.scrollWidth,
- missingNames: [...document.querySelectorAll('button')].filter(b => b.getBoundingClientRect().width && !b.textContent.trim() && !b.getAttribute('aria-label')).length,
- }));
- assert.ok(layout.documentWidth <= layout.windowWidth + 1, JSON.stringify(layout));
- assert.ok(!layout.mainWidth || layout.mainScrollWidth <= layout.mainWidth + 1, JSON.stringify(layout));
- assert.equal(layout.missingNames, 0);
- }
- const routes = ['chat', 'contacts', 'projects', 'projects/autumn', 'events', 'workbench', 'tasks', 'tasks/T-106', 'deliverables', 'agent/zhixia', 'agent/mobai', 'mine'];
- for (const [name, page, prefix, paths] of [['desktop', pc, '/desktop/', routes], ['mobile', phone, '/mobile/', ['home', 'messages', ...routes.map(x => x === 'chat' ? 'chat/zhixia' : x)]]]) {
- for (const route of paths) {
- await test(`${name} route ${route}`, async () => {
- await go(page, prefix + route);
- assert.ok((await page.locator('#main').innerText()).length > 30);
- await checkLayout(page);
- if (['chat', 'chat/zhixia', 'home', 'workbench', 'tasks/T-106', 'deliverables'].includes(route)) {
- await page.screenshot({ path: resolve(out, `${name}-${route.replaceAll('/', '-')}.png`) });
- }
- });
- }
- }
- await test('desktop four-column baseline', async () => {
- await go(pc, '/desktop/chat');
- assert.ok(await pc.locator('.result-rail').isVisible());
- const widths = await pc.locator('.conversation-layout').evaluate(el => getComputedStyle(el).gridTemplateColumns);
- assert.equal(widths.split(' ').length, 3);
- });
- for (const width of [1280, 1150, 1149, 940, 939, 768]) {
- await test(`desktop breakpoint ${width}`, async () => {
- await pc.setViewportSize({ width, height: 900 });
- await go(pc, '/desktop/chat');
- await checkLayout(pc);
- assert.equal(await pc.locator('.result-rail').isVisible(), width >= 1150);
- });
- }
- await pc.setViewportSize({ width: 1440, height: 900 });
- await test('mobile bottom tabs and home counts', async () => {
- await go(phone, '/mobile/home');
- assert.equal(await phone.locator('.mobile-nav a').count(), 5);
- for (const section of await phone.locator('.home-section').all()) {
- const count = Number(await section.locator('.section-count').innerText());
- assert.equal(await section.locator('.artifact-row, .chat-row').count(), count);
- }
- });
- await test('contacts tabs search and unconnected agent routing', async () => {
- await go(pc, '/desktop/contacts');
- await click(pc, 'contacts-tab', 'ai');
- await pc.getByRole('searchbox').fill('墨');
- assert.equal(await pc.locator('.contact-row').count(), 1);
- await click(pc, 'agent-open', 'mobai');
- assert.match(pc.url(), /agent\/mobai$/);
- assert.ok(await pc.getByRole('button', { name: '尚未接入会话' }).isDisabled());
- });
- await test('message send and simulated reply', async () => {
- await go(pc, '/desktop/chat/zhixia');
- await pc.locator('#message-input').fill('请补充渠道执行节奏。');
- await pc.locator('#message-input').press('Enter');
- await pc.getByText('这是演示回复,未调用真实数字生命。', { exact: false }).waitFor();
- assert.equal(await pc.locator('#message-input').inputValue(), '');
- });
- await test('message failure and retry', async () => {
- await click(pc, 'lab');
- await click(pc, 'fail-next');
- await pc.locator('#message-input').fill('失败重试测试');
- await pc.locator('#message-input').press('Enter');
- await pc.locator('.send-error').waitFor();
- await click(pc, 'retry-message');
- assert.equal(await pc.locator('.send-error').count(), 0);
- });
- await test('composer Shift+Enter and auto height', async () => {
- const input = pc.locator('#message-input');
- await input.fill('第一行');
- const before = await input.evaluate(el => el.clientHeight);
- for (let i = 0; i < 5; i++) { await input.press('Shift+Enter'); await input.press('a'); }
- assert.ok((await input.inputValue()).includes('\n'));
- assert.ok(await input.evaluate(el => el.clientHeight) > before);
- });
- await test('file upload progress and card', async () => {
- await pc.locator('#file-input').setInputFiles({ name: 'demo-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Demo attachment') });
- await pc.locator('progress').waitFor();
- await pc.getByText('demo-notes.txt', { exact: true }).waitFor();
- assert.ok(await pc.getByText('本地文件,不上传', { exact: false }).isVisible());
- });
- await test('preview download and Escape focus return', async () => {
- await go(pc, '/desktop/deliverables');
- const item = pc.locator('[data-action="preview"]').first();
- await item.click();
- await pc.getByRole('dialog').waitFor();
- const downloadPromise = pc.waitForEvent('download');
- await click(pc, 'download');
- const download = await downloadPromise;
- assert.ok(download.suggestedFilename().endsWith('.md'));
- await pc.keyboard.press('Escape');
- assert.equal(await pc.getByRole('dialog').count(), 0);
- assert.equal(await item.evaluate(el => el === document.activeElement), true);
- });
- await test('results project and timeline toggle', async () => {
- await click(pc, 'result-view', 'timeline');
- assert.equal(await pc.locator('[data-testid="results-timeline"]').count(), 1);
- assert.equal(await pc.locator('[data-testid="results-project"]').count(), 0);
- });
- await test('task status and agent scoping, read-only detail', async () => {
- await go(pc, '/desktop/tasks');
- await pc.locator('#task-agent').selectOption('qiyun');
- await click(pc, 'task-filter', 'blocked');
- assert.equal(await pc.locator('.task-row').count(), 1);
- assert.match(await pc.locator('.task-detail').innerText(), /已阻塞 4 小时/);
- assert.equal(await pc.getByRole('button', { name: /派发|发布评论|提交评论|新建任务/ }).count(), 0);
- await pc.screenshot({ path: resolve(out, 'desktop-tasks.png') });
- });
- await test('event category filtering', async () => {
- await go(pc, '/desktop/events');
- await click(pc, 'event-filter', 'alert');
- const cats = await pc.locator('[data-category]').evaluateAll(els => els.map(el => el.dataset.category));
- assert.deepEqual(cats, ['alert']);
- });
- await test('page error has retry and not empty copy', async () => {
- await go(pc, '/desktop/workbench');
- await click(pc, 'lab'); await click(pc, 'scenario', 'error');
- assert.ok(await pc.locator('[data-state="error"]').isVisible());
- assert.equal(await pc.locator('[data-state="empty"]').count(), 0);
- await click(pc, 'retry-state');
- assert.equal(await pc.locator('.agent-card').count(), 5);
- });
- await test('mobile independent homepage states', async () => {
- await go(phone, '/mobile/home');
- await click(phone, 'lab');
- await phone.locator('[data-home-state="home-results"]').selectOption('error');
- await phone.keyboard.press('Escape');
- assert.equal(await phone.locator('.home-section').first().locator('[data-state="error"]').count(), 1);
- assert.equal(await phone.locator('.home-section').last().locator('.chat-row').count(), 4);
- await click(phone, 'retry-state', 'home-results');
- assert.equal(await phone.locator('.home-section').first().locator('.artifact-row').count(), 3);
- });
- await test('mobile results drawer and modal keyboard trap', async () => {
- await go(phone, '/mobile/chat/zhixia');
- await click(phone, 'results-drawer', 'zhixia');
- assert.ok(await phone.locator('.drawer').isVisible());
- for (let i = 0; i < 12; i++) await phone.keyboard.press('Tab');
- assert.ok(await phone.evaluate(() => !!document.activeElement.closest('.dialog')));
- await phone.screenshot({ path: resolve(out, 'mobile-results-drawer.png') });
- await phone.keyboard.press('Escape');
- });
- await test('four complete palettes switch, contrast, and persist', async () => {
- await go(pc, '/desktop/mine');
- const paletteIds = ['platinum', 'ice', 'clay', 'slate'];
- assert.equal(await pc.locator('[data-action="palette"]').count(), paletteIds.length);
- assert.equal(await pc.locator('.palette-thumbnail').count(), paletteIds.length);
- const contrast = value => {
- const rawChannels = value.startsWith('#')
- ? value.slice(1).match(/.{2}/g).map(channel => parseInt(channel, 16))
- : value.match(/\d+/g).slice(0, 3).map(Number);
- const channels = rawChannels.map(channel => {
- const normalized = channel / 255;
- return normalized <= .04045 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4;
- });
- return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
- };
- for (const theme of ['light', 'dark']) {
- await click(pc, 'theme', theme);
- for (const palette of paletteIds) {
- await click(pc, 'palette', palette);
- const values = await pc.evaluate(() => {
- const style = getComputedStyle(document.documentElement);
- return Object.fromEntries(['--bg', '--surface', '--nav-bg', '--ink', '--ink-muted', '--accent', '--on-accent', '--nav-text', '--nav-muted', '--success', '--warning', '--danger'].map(token => [token, style.getPropertyValue(token).trim()]));
- });
- assert.equal(await pc.locator('html').getAttribute('data-palette'), palette);
- assert.equal(await pc.locator('html').getAttribute('data-theme'), theme);
- assert.notEqual(values['--bg'], values['--surface']);
- assert.notEqual(values['--nav-bg'], values['--surface']);
- const ratio = (a, b) => (Math.max(contrast(a), contrast(b)) + .05) / (Math.min(contrast(a), contrast(b)) + .05);
- assert.ok(ratio(values['--accent'], values['--on-accent']) >= 4.5, `${palette}/${theme} primary action contrast`);
- assert.ok(ratio(values['--nav-text'], values['--nav-bg']) >= 4.5, `${palette}/${theme} navigation contrast`);
- assert.ok(ratio(values['--nav-muted'], values['--nav-bg']) >= 4.5, `${palette}/${theme} secondary navigation contrast`);
- assert.ok(ratio(values['--ink-muted'], values['--bg']) >= 4.5, `${palette}/${theme} secondary text contrast`);
- assert.equal(values['--success'], theme === 'dark' ? '#8ed2ac' : '#207550');
- assert.equal(values['--warning'], theme === 'dark' ? '#e8bf77' : '#91600e');
- assert.equal(values['--danger'], theme === 'dark' ? '#f2a0a5' : '#c33f42');
- }
- }
- await click(pc, 'palette', 'slate');
- await go(pc, '/desktop/chat');
- await pc.reload();
- assert.equal(await pc.locator('html').getAttribute('data-palette'), 'slate');
- assert.equal(await pc.locator('html').getAttribute('data-theme'), 'dark');
- await pc.screenshot({ path: resolve(out, 'desktop-slate-dark.png') });
- await go(pc, '/desktop/mine');
- await click(pc, 'theme', 'light');
- await click(pc, 'palette', 'clay');
- await pc.screenshot({ path: resolve(out, 'desktop-clay-light.png') });
- await go(phone, '/mobile/mine');
- await click(phone, 'palette', 'ice');
- assert.equal(await phone.locator('html').getAttribute('data-palette'), 'ice');
- await phone.reload();
- assert.equal(await phone.locator('html').getAttribute('data-palette'), 'ice');
- await checkLayout(phone);
- });
- await test('logout confirmation and login validation', async () => {
- await click(pc, 'logout');
- await click(pc, 'close');
- assert.ok(pc.url().includes('/mine'));
- await click(pc, 'logout'); await click(pc, 'confirm-logout');
- assert.ok(pc.url().endsWith('/login'));
- await pc.locator('#phone').fill('123');
- await pc.locator('#login-form [type="submit"]').click();
- assert.match(await pc.locator('#login-error').innerText(), /有效/);
- await pc.locator('#phone').fill('13800138000');
- await pc.locator('#code').fill('000000');
- await pc.locator('#login-form [type="submit"]').click();
- assert.match(await pc.locator('#login-error').innerText(), /过期/);
- await pc.locator('#code').fill('123456');
- await pc.locator('#login-form [type="submit"]').click();
- await pc.waitForURL(/desktop\/chat$/);
- });
- await test('login hash token cleaned and invalid token visible', async () => {
- await go(pc, '/login#token=demo-fmode');
- assert.ok(!pc.url().includes('token'));
- assert.ok(pc.url().includes('/desktop/chat'));
- await go(pc, '/login?token=invalid');
- assert.ok(!pc.url().includes('token'));
- assert.match(await pc.locator('#login-error').innerText(), /无效/);
- await pc.screenshot({ path: resolve(out, 'desktop-login.png') });
- });
- await test('legacy route mapping and retired call notice', async () => {
- await go(pc, '/call/zhixia');
- assert.ok(pc.url().includes('/desktop/agent/zhixia'));
- await pc.getByRole('status').filter({ hasText: '语音通话功能暂未开放' }).waitFor();
- });
- await test('root UA routing', async () => {
- await go(phone, '/');
- assert.ok(phone.url().includes('/mobile/home'));
- await go(pc, '/');
- assert.ok(pc.url().includes('/desktop/chat'));
- });
- await test('all visible touch targets at least 44px', async () => {
- const violations = [];
- for (const path of ['/mobile/home', '/mobile/contacts', '/mobile/chat/zhixia', '/mobile/tasks', '/mobile/mine']) {
- await go(phone, path);
- const tiny = await phone.evaluate(() => [...document.querySelectorAll('button, a, select')].flatMap(el => {
- const r = el.getBoundingClientRect();
- if (!r.width || !r.height || r.bottom < 0 || r.top > innerHeight || el.classList.contains('skip-link')) return [];
- return r.width < 43.5 || r.height < 43.5 ? [{ text: el.textContent.trim().slice(0, 25) || el.getAttribute('aria-label'), width: r.width, height: r.height }] : [];
- }));
- violations.push(...tiny.map(x => ({ path, ...x })));
- }
- assert.deepEqual(violations, []);
- });
- await test('no browser runtime errors or failed resources', async () => {
- assert.deepEqual(errors, []); assert.deepEqual(requests, []);
- });
- await writeFile(resolve(out, 'report.json'), JSON.stringify({ date: new Date().toISOString(), passed: results.filter(r => r.passed).length, total: results.length, results, errors, requests }, null, 2));
- await browser.close();
- process.stdout.write(`\n${results.filter(r => r.passed).length}/${results.length} passed. Screenshots: ${out}\n`);
- if (results.some(r => !r.passed)) process.exitCode = 1;
|