222 lines
9.6 KiB
JavaScript
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);
|