import { readFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs'; const { chromium } = await import(PLAYWRIGHT); const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222'; const APP = 'http://127.0.0.1:8180'; const DIST = process.env.AGENTMAIL_DIST || '/home/program/agentmail/client/electron/dist'; const types = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; const user = { user_id: 'u-test', username: 'tester', display_name: '测试用户', role: 'admin', status: 'active', allowed_agents: [], allowed_paths: [], created_at: '2025-01-01T00:00:00Z' }; const mail = { mail_id: 'm-test', session_id: 's-test', parent_mail_id: null, from_name: 'deepseekharness', from_workspace: 'deepseekharness', to_name: 'tester', to_workspace: '', cc_list: [], subject: '响应式布局测试邮件', body: '## 正文\n\n用于浏览器布局验证。', body_preview: '用于浏览器布局验证。', mail_type: 'normal', permission_options: null, permission_result: null, status: 'unread', created_at: '2025-01-02T03:04:00Z', session_alias: 'layout-test', session_workspace: '/program/test', attachments: [], from_human: false, to_human: true }; const contact = { session_id: 's-test', agent_name: 'deepseekharness', path: '/program/test', session_alias: 'layout-test', address: 'deepseekharness@/program/test.layout-test', status: 'active', mail_count: 1, unread_count: 1, last_activity: '2025-01-02T03:04:00Z', subject: '响应式布局测试邮件', max_rounds: 0, used_rounds: 0, last_from: 'deepseekharness', last_preview: '用于浏览器布局验证。' }; function json(route, body, status = 200) { return route.fulfill({ status, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) }); } async function mockApi(route, url) { const p = url.pathname.replace(/^\/api\/v1/, ''); if (p === '/auth/me') return json(route, { user }); if (p === '/setup/status') return json(route, { needs_setup: false }); if (p === '/me/mail/inbox') return json(route, { mails: [mail], total: 1 }); if (p === '/me/mail/sent') return json(route, { mails: [] }); if (p === '/me/sessions') return json(route, { sessions: [] }); if (p === '/contacts') return json(route, { contacts: [contact] }); if (p === '/contacts/suggest') { return json(route, { kind: 'name', suggestions: ['deepseekharness', 'pi', 'opencode'], candidates: [ { alias: 'deepseekharness', source: 'mail', title: '布局测试', unread: 1 }, { alias: 'pi', source: 'platform', title: 'Pi' }, { alias: 'opencode', source: 'platform', title: 'OpenCode' } ] }); } if (p === '/agents') return json(route, { agents: [] }); if (p === '/me/keys') return json(route, { keys: [] }); if (p === '/admin/users') return json(route, { users: [user] }); if (p === '/admin/scopes') return json(route, { agents: [], paths: [] }); if (p === '/admin/quotas') return json(route, { quotas: [] }); if (p === '/calendar/events') return json(route, { events: [] }); if (p === '/events/stream') { return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'event: connected\ndata: {}\n\n' }); } if (p === '/mail/m-test') return json(route, mail); if (p === '/mail/m-test/read') return json(route, { status: 'ok' }); if (p === '/mail/m-test/thread') { return json(route, { anchor_mail_id: 'm-test', root_mail_id: 'm-test', anchor_depth: 0, nodes: [], total: 0, hidden: 0, has_more: false, next_offset: 0 }); } return json(route, { error: `unmocked ${p}` }, 404); } async function installRoutes(page) { await page.route(`${APP}/**`, async route => { const url = new URL(route.request().url()); if (url.pathname.startsWith('/api/v1/')) return mockApi(route, url); const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, ''); const file = join(DIST, rel); try { const body = await readFile(file); return route.fulfill({ status: 200, body, contentType: types[extname(file)] || 'application/octet-stream' }); } catch { return route.fulfill({ status: 404, body: 'not found' }); } }); } const browser = await chromium.connectOverCDP(CDP); const context = browser.contexts()[0]; const failures = []; let passed = 0; function check(name, ok, detail = '') { console.log(` ${ok ? '通过' : '失败'} ${name}${detail ? ` — ${detail}` : ''}`); if (ok) passed++; else failures.push(name); } async function open(viewport) { const page = await context.newPage(); await page.setViewportSize(viewport); await installRoutes(page); await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('text=收件箱'); await page.waitForTimeout(250); return page; } async function openCompose(page, narrow) { if (narrow) await page.locator('nav button').filter({ hasText: '新建' }).click(); else await page.locator('button[title="新建邮件"]').click(); await page.waitForSelector('textarea'); } async function measureCompose(page, name, narrow) { await openCompose(page, narrow); const m = await page.evaluate(() => { const ta = document.querySelector('textarea'); const attach = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('添加附件')); const send = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === '发送'); const root = ta?.closest('.overflow-y-auto, .lg\\:overflow-hidden'); attach?.scrollIntoView({ block: 'center' }); const tr = ta?.getBoundingClientRect(); const ar = attach?.getBoundingClientRect(); const sr = send?.parentElement?.getBoundingClientRect(); return { textareaH: tr?.height || 0, textareaBottom: tr?.bottom || 0, attachmentTop: ar?.top || 0, attachmentBottom: ar?.bottom || 0, footerTop: sr?.top || 0, separated: !!tr && !!ar && ar.top >= tr.bottom - 1, footerSeparated: !!ar && !!sr && sr.top >= ar.bottom - 1, docW: document.documentElement.clientWidth, scrollW: document.documentElement.scrollWidth, composeScrollH: root?.scrollHeight || 0, composeClientH: root?.clientHeight || 0 }; }); check( narrow ? `${name} 正文不少于 12rem` : `${name} 桌面正文占据剩余高度`, m.textareaH >= (narrow ? 191 : 120), `${Math.round(m.textareaH)}px` ); check( `${name} 附件不遮挡正文`, m.separated, `正文底=${Math.round(m.textareaBottom)} 附件顶=${Math.round(m.attachmentTop)}` ); check( `${name} 操作栏不遮挡附件`, m.footerSeparated, `附件底=${Math.round(m.attachmentBottom)} 操作栏顶=${Math.round(m.footerTop)}` ); check(`${name} 无横向溢出`, m.scrollW <= m.docW, `${m.docW}/${m.scrollW}`); return m; } for (const viewport of [ { width: 320, height: 568 }, { width: 390, height: 844 }, { width: 768, height: 900 }, { width: 820, height: 980 } ]) { const page = await open(viewport); const label = `${viewport.width}x${viewport.height}`; check(`${label} 使用单栏底部导航`, await page.locator('nav.narrow-nav').isVisible()); await measureCompose(page, label, true); await page.close(); } { const page = await open({ width: 390, height: 420 }); const m = await measureCompose(page, '390x420 键盘态', true); check('键盘态写信页可纵向滚动', m.composeScrollH > m.composeClientH, `${m.composeClientH}/${m.composeScrollH}`); await page.locator('textarea').focus(); await page.waitForTimeout(50); const navHidden = await page.locator('nav.narrow-nav').evaluate(el => getComputedStyle(el).display === 'none'); check('输入时隐藏底部导航', navHidden); const to = page.locator('input[placeholder*="deepseekharness"]').first(); await to.fill('d'); await page.waitForTimeout(250); const menu = page.locator('div.absolute.z-20').first(); const bounds = await menu.evaluate(el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, vh: window.visualViewport?.height || innerHeight }; }); check('地址补全不越出可视视口', bounds.top >= 0 && bounds.bottom <= bounds.vh + 1, JSON.stringify(bounds)); await page.close(); } for (const viewport of [{ width: 1024, height: 768 }, { width: 1280, height: 800 }]) { const page = await open(viewport); const label = `${viewport.width}x${viewport.height}`; check(`${label} 使用桌面侧栏`, await page.locator('button[title="新建邮件"]').isVisible()); await measureCompose(page, label, false); await page.close(); } { const page = await open({ width: 1280, height: 800 }); for (const mode of ['light', 'dark']) { await page.evaluate(mode => { localStorage.setItem('agentmail.theme', mode); document.documentElement.classList.toggle('dark', mode === 'dark'); document.documentElement.style.colorScheme = mode; }, mode); const colors = await page.evaluate(() => ({ body: getComputedStyle(document.body).backgroundColor, card: getComputedStyle(document.querySelector('.bg-white')).backgroundColor, text: getComputedStyle(document.querySelector('.text-gray-900') || document.body).color })); check(`${mode} 主题颜色均已解析`, !Object.values(colors).some(c => c === 'rgba(0, 0, 0, 0)'), JSON.stringify(colors)); } await page.close(); } console.log(`\n响应式浏览器验收:${passed} 通过,${failures.length} 失败`); if (failures.length) console.log(failures.join('\n')); await browser.close(); process.exit(failures.length ? 1 : 0);