Files
MailUI4Agents/client/electron/test/manual/responsive-verify.mjs

222 lines
9.6 KiB
JavaScript

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);