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;