Files
jiapu/tests/browser-click-smoke.mjs
T
2026-08-29 19:06:07 +08:00

1421 lines
91 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { extname, join, resolve, sep } from 'node:path';
const projectRoot = resolve(import.meta.dirname, '..');
const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const genealogyId = '2062179707935264701';
const personId = '2062179707935264702';
const albumId = '2062179707935264730';
const ceremonyId = '2062179707935264740';
const feedId = '2062179707935264750';
const articleId = '2062179707935264770';
const videoId = '2062179707935264771';
const growthRecordId = '2062179707935264772';
const relativeId = '2062179707935264773';
const memoId = '2062179707935264774';
const meritId = '2062179707935264775';
const applyId = '2062179707935264760';
const feedbackId = '2062179707935264761';
const navigationAuditDir = resolve(projectRoot, 'artifacts', 'navigation-consistency-audit-2026-08-28');
const fullProjectAuditDir = resolve(projectRoot, 'artifacts', 'full-project-audit-2026-08-28');
const profileUxAuditDir = resolve(projectRoot, 'artifacts', 'profile-ux-audit-2026-08-28');
const shouldCaptureNavigationAudit = process.argv.includes('--capture-navigation-audit') ||
process.env.JIAPU_CAPTURE_NAVIGATION_AUDIT === '1';
const shouldRunFullProjectAudit = process.argv.includes('--full-project-audit');
const shouldRunProfileUxAudit = process.argv.includes('--profile-ux-audit');
const profileOperationAuditRoundArg = process.argv.find((arg) => arg.startsWith('--profile-operation-audit-round='));
const profileOperationAuditRound = profileOperationAuditRoundArg
? Number(profileOperationAuditRoundArg.split('=')[1])
: 0;
const profileOperationAuditAttemptArg = process.argv.find((arg) => arg.startsWith('--profile-operation-audit-attempt='));
const profileOperationAuditAttempt = profileOperationAuditAttemptArg
? Number(profileOperationAuditAttemptArg.split('=')[1])
: 1;
const shouldRunProfileOperationAudit = Number.isInteger(profileOperationAuditRound) &&
profileOperationAuditRound >= 1 && profileOperationAuditRound <= 9;
const createFormQuotaDelayArg = process.argv.find((arg) => arg.startsWith('--create-form-quota-delay='));
const createFormQuotaDelayMs = createFormQuotaDelayArg
? Number(createFormQuotaDelayArg.split('=')[1])
: 0;
const profileOperationAuditRoot = resolve(projectRoot, 'artifacts', 'profile-operation-audit-2026-08-29');
const familyWorkspacePages = [
'profile-family-home.html', 'profile-tree.html', 'profile-generation.html',
'profile-family-admin.html', 'profile-admin-permissions.html', 'profile-join-review.html',
'profile-content.html', 'profile-article.html', 'profile-article-edit.html',
'profile-album.html', `profile-album-detail.html?albumId=${albumId}`, 'profile-album-edit.html',
'profile-video.html', 'profile-video-edit.html', 'profile-feed.html',
`profile-feed-detail.html?feedId=${feedId}`, 'profile-feed-edit.html', 'profile-ceremony.html',
`profile-ceremony-detail.html?ceremonyId=${ceremonyId}`, 'profile-gift-edit.html', 'profile-gift.html',
'profile-growth.html', 'profile-growth-edit.html', 'profile-merit.html',
'profile-merit-edit.html', 'profile-memo.html', 'profile-memo-edit.html',
'profile-relative.html', 'profile-relative-edit.html', 'profile-invite.html',
'profile-data-reminders.html', 'profile-documents.html', 'profile-family-settings.html'
];
const accountWorkspacePages = [
'profile-families.html', 'profile-create-family.html', 'join-genealogy.html', 'profile-join-family.html',
'profile-data.html', 'profile-security.html', 'profile-services.html',
'profile-feedback.html', 'my-tickets.html', 'submit-ticket.html', 'ticket-detail.html',
'profile-earnings.html', 'profile-share.html', 'profile-messages.html'
];
let mockGenealogyRole = 'owner';
let failingApiPath = '';
const contentTypes = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml'
};
function localImage(fileName) {
return {
fileId: '2062179707935264790',
ossId: '2062179707935264791',
fileName,
mediaType: fileName.endsWith('.jpg') ? 'image/jpeg' : 'image/png',
fileSize: 1024,
accessUrl: `http://127.0.0.1:${port}/public/images/${fileName}`,
expiresAt: '2026-08-28T23:59:59+08:00'
};
}
function startStaticServer() {
const server = createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
const target = resolve(projectRoot, pathname === '/' ? 'index.html' : pathname.slice(1));
if (target !== projectRoot && !target.startsWith(projectRoot + sep)) throw new Error('invalid path');
const info = await stat(target);
const file = info.isDirectory() ? join(target, 'index.html') : target;
response.writeHead(200, { 'Content-Type': contentTypes[extname(file)] || 'application/octet-stream' });
response.end(await readFile(file));
} catch (error) {
response.writeHead(404); response.end('Not found');
}
});
return new Promise((resolveReady) => server.listen(0, '127.0.0.1', () => resolveReady(server)));
}
function reserveDebugPort() {
const probe = createServer();
return new Promise((resolvePort, reject) => {
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const availablePort = probe.address().port;
probe.close((error) => error ? reject(error) : resolvePort(availablePort));
});
});
}
function responseFor(url, method) {
const path = new URL(url).pathname;
if (failingApiPath && path === failingApiPath) {
return { code: 500, msg: '服务暂时不可用,请稍后重试', data: null };
}
const canManage = mockGenealogyRole === 'owner' || mockGenealogyRole === 'admin';
const genealogy = {
genealogyId, genealogyName: '叶氏家谱', surname: '叶', status: '0',
memberCount: 3, personCount: 5, roleType: mockGenealogyRole, canManage, canEditContent: canManage
};
const order = {
orderId: '2062179707935264710', orderNo: 'VIP20260827001', packageId: '2062179707935264709',
packageName: '年度会员', genealogyId, genealogyName: '叶氏家谱', orderAmount: '99.00',
payAmount: '99.00', payType: 'WECHAT', payStatus: '0', status: '0',
transactionId: '2062179707935264711', canRefreshPayment: true, canClose: true
};
const article = {
articleId, genealogyId, genealogyName: '叶氏家谱', surname: '叶', articleTitle: '叶氏源流',
articleSummary: '记录家族迁徙与传承脉络', articleContent: '<p>先祖迁徙与家族传承记录。</p>',
authorName: '叶宗亲', publishTime: '2026-08-28 09:00:00', viewCount: 18,
sortOrder: 1, status: '0', coverFile: localImage('family-archive-cover.jpg')
};
const video = {
videoId, genealogyId, genealogyName: '叶氏家谱', surname: '叶', videoTitle: '清明祭祖影像',
videoDesc: '记录宗亲共同祭祖的过程', videoFile: {
...localImage('family-ceremony-cover.jpg'), fileName: 'family-video.mp4', mediaType: 'video/mp4'
},
coverFile: localImage('family-ceremony-cover.jpg'), durationSeconds: 180,
publisherUserId: '2062179707935264700', publisherNickName: '叶用户',
publishTime: '2026-08-28 09:00:00', viewCount: 12, sortOrder: 1, status: '0'
};
const growthRecord = {
recordId: growthRecordId, genealogyId, genealogyName: '叶氏家谱', surname: '叶',
appUserId: '2062179707935264700', appUserNickName: '叶用户', lineagePersonId: personId,
lineagePersonName: '叶一', recordType: '入学', recordTitle: '小学入学',
recordContent: '第一天上学留念。', recordDate: '2026-09-01 00:00:00',
sortOrder: 1, status: '0', mediaFiles: [localImage('family-memory-cover.jpg')]
};
const relativeRecord = {
relativeId, genealogyId, genealogyName: '叶氏家谱', surname: '叶',
appUserId: '2062179707935264700', appUserNickName: '叶用户', relativeName: '王叔',
relationName: '长辈', eventName: '寿宴', eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50', recordContent: '宗亲往来记录。', sortOrder: 1, status: '0',
mediaFiles: [localImage('family-memory-cover.jpg')]
};
const memo = {
memoId, genealogyId, genealogyName: '叶氏家谱', surname: '叶',
appUserId: '2062179707935264700', appUserNickName: '叶用户', memoTitle: '祭祖准备',
memoContent: '准备供品和宗亲签到册。', remindTime: '2026-09-10 08:30:00',
completed: '0', sortOrder: 1, status: '0', mediaFiles: [localImage('family-archive-cover.jpg')]
};
const meritRecord = {
meritId, genealogyId, genealogyName: '叶氏家谱', surname: '叶',
appUserId: '2062179707935264700', appUserNickName: '叶用户', donorName: '叶明',
meritType: 'repair', meritTitle: '修缮宗祠', meritContent: '参与宗祠修缮。',
amount: '500.50', meritTime: '2026-07-26 10:00:00', sortOrder: 1, status: '0',
mediaFiles: [localImage('family-ceremony-cover.jpg')]
};
const ceremonyRecord = {
ceremonyId, genealogyId, sponsorUserId: '2062179707935264700', sponsorNickName: '叶用户', ceremonyType: '祭祖',
ceremonyTitle: '清明祭祖', ceremonyTime: '2026-04-04 09:00:00', coverFile: localImage('family-ceremony-cover.jpg'), status: '0'
};
const feedRecord = {
feedId, genealogyId, publisherUserId: '2062179707935264700', publisherNickName: '叶用户', feedType: 'text',
feedContent: '今日修谱。', mediaFiles: [localImage('family-memory-cover.jpg')], likedByMe: false, likeCount: 0, commentCount: 0, status: '0'
};
let data = null;
if (path === '/genealogy/pc/genealogies/mine' && method === 'GET') data = [genealogy, { ...genealogy, genealogyId: '2062179707935264712', genealogyName: '叶氏支谱' }];
else if (path === '/genealogy/pc/genealogies/options' && method === 'GET') data = [genealogy];
else if (path === '/genealogy/pc/genealogies/mine/order') data = null;
else if (path === '/genealogy/pc/genealogies/quota') data = {
createCount: 2, createLimit: 5, createRemaining: 3, canCreate: true,
joinCount: 2, joinLimit: 5, joinRemaining: 3, canJoin: true
};
else if (path === '/genealogy/pc/auth/profile') data = {
userId: '2062179707935264700', nickName: '叶用户', avatarFile: localImage('logo-mark.png')
};
else if (path === '/genealogy/pc/region/children') data = [];
else if (path === '/genealogy/pc/promotions') data = [{
promotionId: '2062179707935264780', promotionTitle: '家谱移动端', promotionDesc: '随时查看家谱',
targetUrl: 'https://example.com/app', status: '0', coverFile: localImage('app-home.png')
}];
else if (path.endsWith('/lineage/persons/options')) data = [{ personId, name: '叶一' }];
else if (path.endsWith('/lineage/persons/page')) return { code: 200, rows: [], total: 0 };
else if (path.endsWith('/lineage/persons')) data = [];
else if (path.endsWith('/lineage/tree')) data = [{
personId, genealogyId, name: '叶一', sex: '0', generation: 1, generationName: '德',
personStatus: '0', status: '0', avatarFile: localImage('logo-mark.png')
}];
else if (path.endsWith('/generation-poems') || path.endsWith('/generation-poems/management')) data = [];
else if (path.endsWith('/members/options')) data = [];
else if (path.endsWith('/members') && method === 'GET') data = [
{ memberId: '2062179707935264720', genealogyId, appUserId: '2062179707935264700', memberName: '叶谱主', roleType: 'owner', status: '0' },
{ memberId: '2062179707935264721', genealogyId, appUserId: '2062179707935264722', memberName: '叶成员', roleType: 'member', status: '0' }
];
else if (path.endsWith(`/articles/${articleId}`) && method === 'GET') data = article;
else if (path.endsWith('/articles') && method === 'GET') data = [article];
else if (path.endsWith(`/videos/${videoId}`) && method === 'GET') data = video;
else if (path.endsWith('/videos') && method === 'GET') data = [video];
else if (path.endsWith(`/growth-records/${growthRecordId}`) && method === 'GET') data = growthRecord;
else if (path.endsWith('/growth-records') && method === 'GET') data = [growthRecord];
else if (path.endsWith(`/relative-records/${relativeId}`) && method === 'GET') data = relativeRecord;
else if (path.endsWith('/relative-records') && method === 'GET') data = [relativeRecord];
else if (path.endsWith(`/memos/${memoId}`) && method === 'GET') data = memo;
else if (path.endsWith('/memos') && method === 'GET') data = [memo];
else if (path.endsWith(`/merit-records/${meritId}`) && method === 'GET') data = meritRecord;
else if (path.endsWith('/merit-records') && method === 'GET') data = [meritRecord];
else if (path.endsWith('/albums') && method === 'GET') data = [{
albumId, genealogyId, genealogyName: '叶氏家谱', albumName: '祠堂旧影', albumDesc: '家族旧照片',
coverFile: localImage('family-memory-cover.jpg'), photoCount: 1, sortOrder: 1, status: '0'
}];
else if (path.endsWith(`/albums/${albumId}/photos`) && method === 'GET') data = [{
photoId: '2062179707935264731', genealogyId, albumId, albumName: '祠堂旧影', photoTitle: '宗亲合影',
photoFile: localImage('ancestral-hall.png'), status: '0'
}];
else if (path.endsWith(`/ceremonies/${ceremonyId}`) && method === 'GET') data = ceremonyRecord;
else if (path.endsWith(`/ceremonies/${ceremonyId}/gifts`) && method === 'GET') data = [];
else if (path.endsWith(`/ceremonies/${ceremonyId}/invitations`) && method === 'GET') data = [];
else if (path.endsWith(`/feeds/${feedId}`) && method === 'GET') data = feedRecord;
else if (path.endsWith(`/feeds/${feedId}/comments/page`) && method === 'GET') return { code: 200, rows: [], total: 0 };
else if (path.endsWith('/join-applies/pending') && method === 'GET') data = [{
applyId, genealogyId, genealogyName: '叶氏家谱', applicantName: '叶申请人', phone: '138****0000', relationDesc: '族亲', applyReason: '申请加入', status: '0'
}];
else if (path === '/genealogy/pc/genealogies/join-applies/mine' && method === 'GET') data = [];
else if (path === '/genealogy/pc/feedback' && method === 'GET') data = [{
feedbackId, feedbackType: 'suggestion', feedbackTypeLabel: '功能建议',
feedbackTypeOptionState: 'ACTIVE', feedbackContent: '希望相册支持按年份筛选。',
handleStatus: '0', status: '0'
}];
else if (path === '/genealogy/pc/notifications' && method === 'GET') data = [];
else if (path === '/genealogy/pc/notifications/unread-count' && method === 'GET') data = 0;
else if (path === '/genealogy/pc/help-articles' && method === 'GET') data = [];
else if (path === '/genealogy/pc/dictionaries/gen_feedback_type' && method === 'GET') data = [
{ value: 'suggestion', label: '功能建议', enabled: true }
];
else if (path.endsWith('/feeds/page') && method === 'GET') return { code: 200, rows: [feedRecord], total: 1 };
else if (path === `/genealogy/pc/genealogies/${genealogyId}/ceremonies` && method === 'GET') data = [ceremonyRecord];
else if (path === '/genealogy/pc/genealogies/ceremony-invitations/mine' && method === 'GET') data = [];
else if (path === '/genealogy/pc/genealogies/invitations/mine') data = [];
else if (path === '/genealogy/pc/genealogies/invitations/preview') data = {
inviteId: '2062179707935264703', genealogyId, genealogyName: '叶氏家谱', status: 'ACTIVE',
expiresAt: '2026-09-01T00:00:00+08:00'
};
else if (path === '/genealogy/pc/genealogies/invitations/redeem') data = {
inviteId: '2062179707935264703', genealogyId, genealogyName: '叶氏家谱', status: 'REDEEMED'
};
else if (path.endsWith('/invitations') && method === 'POST') data = {
inviteId: '2062179707935264703', genealogyId, genealogyName: '叶氏家谱', status: 'ACTIVE',
token: 'invite-token', expiresAt: '2026-09-01T00:00:00+08:00'
};
else if (path.endsWith('/profile-reminders')) data = [{ personId, displayName: '叶一', missingField: 'birthDate', missingFieldLabel: '出生日期' }];
else if (path.endsWith('/permanent-deletion/capability')) data = { canDeletePermanently: true, verifiedMobileMasked: '138****0000', disabledReasons: [] };
else if (path === `/genealogy/pc/genealogies/${genealogyId}/overview`) data = {
genealogyId, genealogyNo: 'G20260828001', genealogyName: '叶氏家谱', surname: '叶',
ancestralHall: '南阳堂', originPlace: '四川成都', regionFullName: '四川省 成都市',
memberCount: 3, personCount: 5, status: '0', roleType: 'owner', canManage: true, canEditContent: true,
coverFile: localImage('ancestral-hall.png')
};
else if (path === `/genealogy/pc/genealogies/${genealogyId}`) data = genealogy;
else if (path === '/genealogy/pc/dictionaries/gen_person_document_type') data = [{ value: 'id_card', label: '身份证', enabled: true }];
else if (path.endsWith('/person-documents')) data = [];
else if (path === '/genealogy/pc/earnings/summary') data = { availableAmount: '100.00', frozenAmount: '0.00', minimumWithdrawal: '10.00', withdrawalEnabled: true, currency: 'CNY' };
else if (path === '/genealogy/pc/earnings/ledger') return { code: 200, rows: [], total: 0 };
else if (path === '/genealogy/pc/earnings/withdrawals') return { code: 200, rows: [], total: 0 };
else if (path === '/genealogy/pc/vip/capability') data = { enabled: true, paymentMethods: [{ paymentMethod: 'WECHAT', enabled: true }] };
else if (path === '/genealogy/pc/vip/packages') data = [{
packageId: '2062179707935264709', packageName: '年度会员', packageType: 'vip', price: '99.00',
originalPrice: '129.00', durationValue: 1, durationUnit: 'year', status: '0'
}];
else if (path === '/genealogy/pc/vip/orders' && method === 'GET') data = [order];
else if (path === '/genealogy/pc/vip/orders' && method === 'POST') data = {
order, transactionId: order.transactionId, outTradeNo: 'WX20260827001', tradeType: 'NATIVE',
paymentMethod: 'WECHAT', expiresAt: '2026-08-27T12:30:00+08:00', codeUrl: 'weixin://wxpay/test', completed: false
};
else return { code: 404, msg: `browser mock missing: ${method} ${path}`, data: null };
return { code: 200, msg: '操作成功', data };
}
class CdpClient {
constructor(url) {
this.id = 0; this.pending = new Map(); this.listeners = new Map();
this.socket = new WebSocket(url);
this.socket.addEventListener('close', () => {
const error = new Error('Chrome DevTools connection closed unexpectedly');
this.pending.forEach((request) => request.reject(error));
this.pending.clear();
});
}
async ready() {
if (this.socket.readyState === WebSocket.OPEN) return;
await new Promise((resolveReady, reject) => {
this.socket.addEventListener('open', resolveReady, { once: true });
this.socket.addEventListener('error', reject, { once: true });
});
this.socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id && this.pending.has(message.id)) {
const item = this.pending.get(message.id); this.pending.delete(message.id);
if (message.error) item.reject(new Error(message.error.message)); else item.resolve(message.result);
return;
}
(this.listeners.get(message.method) || []).forEach((listener) => listener(message.params));
});
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolveSend, reject) => {
this.pending.set(id, { resolve: resolveSend, reject });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
on(method, listener) {
const list = this.listeners.get(method) || []; list.push(listener); this.listeners.set(method, list);
}
close() { this.socket.close(); }
}
async function waitFor(expression, timeoutMs = 5000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await expression()) return;
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
throw new Error('browser condition timed out');
}
const server = await startStaticServer();
const port = server.address().port;
const debugPort = await reserveDebugPort();
const profileDir = await mkdtemp(join(tmpdir(), 'jiapu-chrome-'));
const chrome = spawn(chromePath, [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--disable-breakpad', '--disable-crash-reporter',
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profileDir}`, 'about:blank'
], { stdio: 'ignore', windowsHide: true });
let client;
try {
let version;
await waitFor(async () => {
try { version = await (await fetch(`http://127.0.0.1:${debugPort}/json/version`)).json(); return true; }
catch (error) { return false; }
});
const target = await (await fetch(`http://127.0.0.1:${debugPort}/json/new?http://127.0.0.1:${port}/`, { method: 'PUT' })).json();
client = new CdpClient(target.webSocketDebuggerUrl);
await client.ready();
await client.send('Page.enable');
await client.send('Runtime.enable');
await client.send('Fetch.enable', { patterns: [{ urlPattern: 'https://backend-api.ddxcjp.cn/*' }] });
const consoleErrors = [];
const interceptionErrors = [];
const interceptedWrites = [];
client.on('Runtime.exceptionThrown', (event) => consoleErrors.push(event.exceptionDetails.text));
client.on('Fetch.requestPaused', async (event) => {
try {
if (['POST', 'PUT', 'DELETE'].includes(event.request.method)) {
interceptedWrites.push({ method: event.request.method, url: event.request.url });
}
const body = event.request.method === 'OPTIONS'
? null
: responseFor(event.request.url, event.request.method);
if (createFormQuotaDelayMs > 0 && event.request.method === 'GET' &&
new URL(event.request.url).pathname === '/genealogy/pc/genealogies/quota') {
await new Promise((resolveDelay) => setTimeout(resolveDelay, createFormQuotaDelayMs));
}
await client.send('Fetch.fulfillRequest', {
requestId: event.requestId, responseCode: event.request.method === 'OPTIONS' ? 204 : 200,
responseHeaders: [
{ name: 'Content-Type', value: 'application/json; charset=utf-8' },
{ name: 'Access-Control-Allow-Origin', value: `http://127.0.0.1:${port}` },
{ name: 'Access-Control-Allow-Headers', value: '*' },
{ name: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }
],
body: body ? Buffer.from(JSON.stringify(body)).toString('base64') : ''
});
} catch (error) {
// A rapid page change can cancel a paused request before Chrome receives the mock response.
// That request no longer belongs to the visible page and is safe to discard.
if (error && error.message === 'Invalid InterceptionId.') return;
interceptionErrors.push(error);
}
});
await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `
const browserSmokeParams = new URLSearchParams(location.search);
if (browserSmokeParams.has('unauthenticated')) {
localStorage.removeItem('genealogy_auth_token');
} else if (!['/login.html', '/register.html', '/forgot-password.html'].includes(location.pathname)) {
localStorage.setItem('genealogy_auth_token', 'browser-smoke-token');
}
sessionStorage.setItem('genealogy_current_context_v1', JSON.stringify({ genealogyId: '${genealogyId}', genealogyName: '叶氏家谱' }));
window.confirm = () => false;
` });
async function evaluate(expression) {
const result = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text);
return result.result.value;
}
async function navigate(page) {
await client.send('Page.navigate', { url: `http://127.0.0.1:${port}/${page}` });
await waitFor(async () => (await evaluate('document.readyState')) === 'complete');
if (interceptionErrors.length) throw interceptionErrors.shift();
}
async function waitText(selector, text) {
try {
await waitFor(async () => evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent.includes(${JSON.stringify(text)})`));
} catch (error) {
const snapshot = await evaluate(`({ href: location.href, title: document.title, text: document.body.innerText.slice(0, 1200) })`);
throw new Error(`waitText failed for ${selector} -> ${text}: ${JSON.stringify(snapshot)}; console=${consoleErrors.join(' | ')}`);
}
}
async function assertEmptyFormBlocked({ page, form, status, action, ready }) {
console.log(`BROWSER: checking empty form ${page} ${form}`);
await navigate(`${page}${page.includes('?') ? '&' : '?'}genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector(${JSON.stringify(form)}) && !document.querySelector(${JSON.stringify(form)}).hidden`), 2500);
if (ready) {
try {
await waitFor(async () => evaluate(ready), 6000);
} catch (error) {
const readinessSnapshot = await evaluate(`({
href: location.href,
status: document.querySelector(${JSON.stringify(status)})?.textContent.trim() || '',
disabled: document.querySelector(${JSON.stringify(form)})?.querySelector('button[type="submit"]')?.disabled,
genealogyCreateBound: document.querySelector(${JSON.stringify(form)})?.__genealogyCreateBound,
consoleErrors: ${JSON.stringify(consoleErrors)}
})`);
throw new Error(`form readiness timed out for ${page}: ${JSON.stringify(readinessSnapshot)}`);
}
}
const writesBefore = interceptedWrites.length;
const statusBefore = await evaluate(`document.querySelector(${JSON.stringify(form)})?.querySelector(${JSON.stringify(status)})?.textContent.trim() || ''`);
if (action) {
await evaluate(`document.querySelector(${JSON.stringify(action)}).click(); document.querySelector(${JSON.stringify(action)}).click()`);
} else {
await evaluate(`document.querySelector(${JSON.stringify(form)}).requestSubmit(); document.querySelector(${JSON.stringify(form)}).requestSubmit()`);
}
await waitFor(async () => evaluate(`(document.querySelector(${JSON.stringify(form)})?.querySelector(${JSON.stringify(status)})?.textContent.trim() || '') !== ${JSON.stringify(statusBefore)}`), 2500);
assert.equal(interceptedWrites.length, writesBefore, `${page} 的空表单不应发送写请求`);
}
async function assertEmptyAuthFormBlocked({ page, form }) {
await navigate(page);
const writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector(${JSON.stringify(form)}).requestSubmit(); document.querySelector(${JSON.stringify(form)}).requestSubmit()`);
await waitFor(async () => evaluate(`Boolean(document.querySelector(${JSON.stringify(form)} + ' [data-auth-form-status]')?.textContent.trim())`));
assert.equal(interceptedWrites.length, writesBefore, `${page} 的空认证表单不应发送写请求`);
}
async function workspaceNavigationSnapshot() {
return evaluate(`(() => {
const sidebar = document.querySelector('.module-nav');
const rect = sidebar?.getBoundingClientRect();
return {
scope: sidebar?.getAttribute('data-workspace-navigation') || '',
links: Array.from(sidebar?.querySelectorAll('a') || []).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname),
headerLinks: Array.from(document.querySelectorAll('.site-header .nav-links a')).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname),
currentCount: sidebar?.querySelectorAll('a[aria-current="page"]').length || 0,
top: rect ? Math.round(rect.top) : -1,
left: rect ? Math.round(rect.left) : -1,
width: rect ? Math.round(rect.width) : -1,
hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth
};
})()`);
}
async function captureNavigationAudit(name) {
if (!shouldCaptureNavigationAudit) return;
await mkdir(navigationAuditDir, { recursive: true });
const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true });
await writeFile(resolve(navigationAuditDir, name), Buffer.from(screenshot.data, 'base64'));
}
function fullAuditPageUrl(page) {
const params = new URLSearchParams();
if (page.startsWith('profile-')) params.set('genealogyId', genealogyId);
if (page === 'family-detail.html') params.set('genealogyId', genealogyId);
if (page === 'surname-detail.html') params.set('surname', '叶');
if (page === 'search-result.html') params.set('keyword', '叶氏家谱');
if (page === 'article-detail.html' || page === 'notice-detail.html') params.set('articleId', '2062179707935264788');
if (page === 'ticket-detail.html') params.set('feedbackId', feedbackId);
if (page.startsWith('profile-album-detail') || page.startsWith('profile-album-edit')) params.set('albumId', albumId);
if (page === 'profile-ceremony-detail.html' || page === 'profile-gift-edit.html') params.set('ceremonyId', ceremonyId);
if (page === 'profile-feed-detail.html' || page === 'profile-feed-edit.html') params.set('feedId', feedId);
if (page === 'profile-article.html' || page === 'profile-article-edit.html') params.set('articleId', articleId);
if (page === 'profile-video.html' || page === 'profile-video-edit.html') params.set('videoId', videoId);
if (page === 'profile-growth.html' || page === 'profile-growth-edit.html') params.set('recordId', growthRecordId);
if (page === 'profile-relative.html' || page === 'profile-relative-edit.html') params.set('relativeId', relativeId);
if (page === 'profile-memo.html' || page === 'profile-memo-edit.html') params.set('memoId', memoId);
if (page === 'profile-merit.html' || page === 'profile-merit-edit.html') params.set('meritId', meritId);
return page + (params.size ? `?${params}` : '');
}
async function settlePageForAudit() {
await new Promise((resolveWait) => setTimeout(resolveWait, 180));
await evaluate(`(async () => {
const scrollingElement = document.scrollingElement;
const step = Math.max(320, Math.floor(innerHeight * .75));
for (let top = 0; top < scrollingElement.scrollHeight; top += step) {
scrollingElement.scrollTop = top;
await new Promise((resolve) => setTimeout(resolve, 16));
}
scrollingElement.scrollTop = 0;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
})()`);
}
async function settlePageForOperationAudit() {
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
await evaluate(`new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))`);
}
async function inspectCurrentPageForAudit(expectedPage) {
return evaluate(`(() => {
const isVisible = (node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
};
const viewportWidth = document.documentElement.clientWidth;
const brokenImages = Array.from(document.querySelectorAll('img[src]'))
.filter((image) => image.complete && image.naturalWidth === 0)
.map((image) => ({ src: image.getAttribute('src'), alt: image.getAttribute('alt') || '' }));
const outOfBoundsControls = Array.from(document.querySelectorAll('a, button, input, select, textarea, summary'))
.filter(isVisible)
.filter((node) => {
const rect = node.getBoundingClientRect();
return rect.left < -1 || rect.right > viewportWidth + 1;
})
.map((node) => ({ tag: node.tagName, text: (node.textContent || node.value || '').trim().slice(0, 40) }));
const unnamedControls = Array.from(document.querySelectorAll('a, button'))
.filter(isVisible)
.filter((node) => !(node.textContent || '').trim() && !node.getAttribute('aria-label') &&
!node.getAttribute('title') && !node.querySelector('img[alt]:not([alt=""])'))
.map((node) => ({ tag: node.tagName, className: node.className || '' }));
const bodyText = document.body.innerText;
return {
expectedPage: ${JSON.stringify(expectedPage)},
actualPage: location.pathname.split('/').pop(),
title: document.title.trim(),
bodyTextLength: bodyText.trim().length,
mockTransportErrors: bodyText.split(String.fromCharCode(10))
.filter((line) => line.toLowerCase().includes('browser mock missing:')),
hasHorizontalOverflow: document.documentElement.scrollWidth > viewportWidth + 1,
brokenImages,
outOfBoundsControls,
unnamedControls,
leakedValues: bodyText.match(/\\bundefined\\b|\\[object Object\\]|\\bNaN\\b/g) || [],
openDialogs: Array.from(document.querySelectorAll('dialog[open], [role="dialog"]'))
.filter(isVisible).map((node) => node.getAttribute('aria-label') || node.getAttribute('aria-labelledby') || node.className || node.tagName)
};
})()`);
}
async function captureFullProjectAudit(directory, name) {
const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true });
await writeFile(resolve(directory, name), Buffer.from(screenshot.data, 'base64'));
}
async function prepareOperationAuditState(scope) {
await evaluate(`(() => {
document.querySelectorAll('details').forEach((details) => { details.open = true; });
const mobileMenu = document.querySelector('[data-profile-mobile-menu]');
if (mobileMenu) mobileMenu.hidden = ${JSON.stringify(false)};
if (${JSON.stringify(scope)} !== 'menu' && mobileMenu) mobileMenu.hidden = true;
window.scrollTo(0, 0);
})()`);
await new Promise((resolveWait) => setTimeout(resolveWait, 80));
}
async function operationInventory(scope) {
return evaluate(`(() => {
const mobileMenu = document.querySelector('[data-profile-mobile-menu]');
const isVisible = (node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' &&
style.pointerEvents !== 'none' && rect.width > 0 && rect.height > 0;
};
const isInMobileMenu = (node) => Boolean(mobileMenu && mobileMenu.contains(node));
const candidates = Array.from(document.querySelectorAll(
'a[href], button, summary, input:not([type="hidden"]), select, textarea, label.upload-control'
)).filter((node) => !node.disabled && isVisible(node) && !(
node.matches('input[type="file"]') && node.id &&
document.querySelector('label.upload-control[for="' + CSS.escape(node.id) + '"]')
)).filter((node) =>
${JSON.stringify(scope)} === 'menu' ? isInMobileMenu(node) : !isInMobileMenu(node)
);
return candidates.map((node, index) => {
const tag = node.tagName.toLowerCase();
const type = (node.getAttribute('type') || '').toLowerCase();
const descendantImage = node.querySelector && node.querySelector('img[alt]:not([alt=""])');
const text = (node.textContent || node.value || node.getAttribute('aria-label') ||
node.getAttribute('title') || node.getAttribute('placeholder') ||
(descendantImage && descendantImage.getAttribute('alt')) || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
return {
index,
scope: ${JSON.stringify(scope)},
tag,
type,
text,
id: node.id || '',
name: node.getAttribute('name') || '',
href: tag === 'a' ? new URL(node.href, location.href).href : '',
disabled: Boolean(node.disabled),
checked: 'checked' in node ? Boolean(node.checked) : null,
value: 'value' in node ? String(node.value || '').slice(0, 120) : '',
hasAccessibleName: Boolean(text || node.getAttribute('aria-labelledby') ||
(node.id && document.querySelector('label[for="' + CSS.escape(node.id) + '"]'))),
rect: (() => { const rect = node.getBoundingClientRect(); return {
left: Math.round(rect.left), top: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height)
}; })()
};
});
})()`);
}
async function operationState() {
return evaluate(`(() => {
const visible = (node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
};
const active = document.activeElement;
const statusText = Array.from(document.querySelectorAll(
'[role="status"], [role="alert"], .form-status, .api-state, .upload-status'
)).filter(visible).map((node) => node.textContent.trim()).filter(Boolean).join(' | ').slice(0, 600);
return {
pathname: location.pathname,
search: location.search,
title: document.title,
activeText: active ? (active.textContent || active.value || active.getAttribute('aria-label') || '').trim().slice(0, 120) : '',
activeTag: active ? active.tagName.toLowerCase() : '',
statusText,
dialogs: Array.from(document.querySelectorAll('dialog[open], [role="dialog"], .layui-layer'))
.filter(visible).map((node) => (node.textContent || node.getAttribute('aria-label') || '').trim().replace(/\\s+/g, ' ').slice(0, 180)),
hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
bodyTextLength: document.body.innerText.trim().length
};
})()`);
}
async function operationVisualState(scope, operationIndex) {
return evaluate(`(() => {
const mobileMenu = document.querySelector('[data-profile-mobile-menu]');
const isVisible = (node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' &&
style.pointerEvents !== 'none' && rect.width > 0 && rect.height > 0;
};
const inMenu = (node) => Boolean(mobileMenu && mobileMenu.contains(node));
const nodes = Array.from(document.querySelectorAll(
'a[href], button, summary, input:not([type="hidden"]), select, textarea, label.upload-control'
)).filter((node) => !node.disabled && isVisible(node) && !(
node.matches('input[type="file"]') && node.id &&
document.querySelector('label.upload-control[for="' + CSS.escape(node.id) + '"]')
)).filter((node) =>
${JSON.stringify(scope)} === 'menu' ? inMenu(node) : !inMenu(node)
);
const node = nodes[${operationIndex}];
if (!node) return { found: false };
const rect = node.getBoundingClientRect();
const visibleWidth = Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0));
const visibleHeight = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0));
const centerX = Math.max(0, Math.min(innerWidth - 1, rect.left + rect.width / 2));
const centerY = Math.max(0, Math.min(innerHeight - 1, rect.top + rect.height / 2));
const hit = document.elementFromPoint(centerX, centerY);
return {
found: true,
visibleRatio: Math.round((visibleWidth * visibleHeight / (rect.width * rect.height)) * 1000) / 1000,
centerIsReachable: Boolean(hit && (hit === node || node.contains(hit))),
rect: {
left: Math.round(rect.left), top: Math.round(rect.top),
right: Math.round(rect.right), bottom: Math.round(rect.bottom),
width: Math.round(rect.width), height: Math.round(rect.height)
}
};
})()`);
}
async function captureOperationEvidence(directory, name) {
const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true });
const bytes = Buffer.from(screenshot.data, 'base64');
assert.ok(bytes.length > 5000, `${name} 截图内容异常`);
await writeFile(resolve(directory, name), bytes);
return screenshot.data;
}
async function activateAuditedOperation(operation) {
return evaluate(`(async () => {
const mobileMenu = document.querySelector('[data-profile-mobile-menu]');
const isVisible = (node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' &&
style.pointerEvents !== 'none' && rect.width > 0 && rect.height > 0;
};
const isInMobileMenu = (node) => Boolean(mobileMenu && mobileMenu.contains(node));
const candidates = Array.from(document.querySelectorAll(
'a[href], button, summary, input:not([type="hidden"]), select, textarea, label.upload-control'
)).filter((node) => !node.disabled && isVisible(node) && !(
node.matches('input[type="file"]') && node.id &&
document.querySelector('label.upload-control[for="' + CSS.escape(node.id) + '"]')
)).filter((node) =>
${JSON.stringify(operation.scope)} === 'menu' ? isInMobileMenu(node) : !isInMobileMenu(node)
);
const node = candidates[${operation.index}];
if (!node) return { activated: false, reason: 'operation-not-found' };
node.scrollIntoView({ block: 'center', inline: 'nearest' });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const tag = node.tagName.toLowerCase();
const type = (node.getAttribute('type') || '').toLowerCase();
if (tag === 'select') {
node.focus();
const option = Array.from(node.options).find((candidate) => !candidate.disabled && candidate.value !== node.value);
if (option) {
node.value = option.value;
node.dispatchEvent(new Event('change', { bubbles: true }));
}
return { activated: true, action: option ? 'select-change' : 'select-focus' };
}
if (tag === 'textarea' || (tag === 'input' && !['button', 'submit', 'reset', 'checkbox', 'radio', 'file'].includes(type))) {
node.focus();
return { activated: true, action: 'field-focus' };
}
if (tag === 'input' && type === 'file') {
node.focus();
return { activated: true, action: 'file-control-focus', limitation: 'native-file-picker-not-captured' };
}
node.click();
return { activated: true, action: 'click' };
})()`);
}
async function runProfileOperationAudit() {
const roundBaseName = `round-${String(profileOperationAuditRound).padStart(2, '0')}`;
const roundName = profileOperationAuditAttempt > 1
? `${roundBaseName}-recheck-${String(profileOperationAuditAttempt - 1).padStart(2, '0')}`
: roundBaseName;
const roundDirectory = resolve(profileOperationAuditRoot, roundName);
const profilePages = (await readdir(projectRoot)).filter((name) => name.startsWith('profile') && name.endsWith('.html')).sort();
const viewports = [
{ name: 'desktop', width: 1440, height: 900, mobile: false, scopes: ['main'] },
{ name: 'mobile', width: 375, height: 844, mobile: true, scopes: ['main', 'menu'] }
];
const records = [];
let evidenceNumber = 0;
await mkdir(roundDirectory, { recursive: true });
for (const viewport of viewports) {
const viewportDirectory = resolve(roundDirectory, viewport.name);
await mkdir(viewportDirectory, { recursive: true });
await client.send('Emulation.setDeviceMetricsOverride', {
width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: viewport.mobile
});
for (const page of profilePages) {
const pageUrl = fullAuditPageUrl(page);
for (const scope of viewport.scopes) {
await navigate(pageUrl);
await settlePageForOperationAudit();
await prepareOperationAuditState(scope);
const inventory = await operationInventory(scope);
for (const operation of inventory) {
await navigate(pageUrl);
await settlePageForOperationAudit();
await prepareOperationAuditState(scope);
const currentInventory = await operationInventory(scope);
const currentOperation = currentInventory[operation.index];
assert.ok(currentOperation, `${page} 的操作 ${scope}:${operation.index} 在重载后消失`);
await evaluate(`(() => {
const mobileMenu = document.querySelector('[data-profile-mobile-menu]');
const isVisible = (node) => { const style = getComputedStyle(node); const rect = node.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && style.pointerEvents !== 'none' && rect.width > 0 && rect.height > 0; };
const inMenu = (node) => Boolean(mobileMenu && mobileMenu.contains(node));
const nodes = Array.from(document.querySelectorAll('a[href], button, summary, input:not([type="hidden"]), select, textarea, label.upload-control')).filter((node) => !node.disabled && isVisible(node) && !(node.matches('input[type="file"]') && node.id && document.querySelector('label.upload-control[for="' + CSS.escape(node.id) + '"]'))).filter((node) => ${JSON.stringify(scope)} === 'menu' ? inMenu(node) : !inMenu(node));
nodes[${operation.index}]?.scrollIntoView({ block: 'center', inline: 'nearest' });
})()`);
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
evidenceNumber += 1;
const prefix = String(evidenceNumber).padStart(4, '0');
const beforeName = `${prefix}-before.png`;
const afterName = `${prefix}-after.png`;
const beforeState = await operationState();
const beforeVisualState = await operationVisualState(scope, operation.index);
const beforeImage = await captureOperationEvidence(viewportDirectory, beforeName);
const consoleErrorCountBefore = consoleErrors.length;
let activation;
try {
activation = await activateAuditedOperation(operation);
} catch (error) {
activation = { activated: false, reason: error.message };
}
await new Promise((resolveWait) => setTimeout(resolveWait, 120));
try {
await waitFor(async () => (await evaluate('document.readyState')) === 'complete', 1800);
} catch (error) {
// Some controls intentionally keep the current document active without navigation.
}
const afterState = await operationState();
const afterImage = await captureOperationEvidence(viewportDirectory, afterName);
const navigationChanged = beforeState.pathname !== afterState.pathname || beforeState.search !== afterState.search;
const dialogChanged = JSON.stringify(beforeState.dialogs) !== JSON.stringify(afterState.dialogs);
const statusChanged = beforeState.statusText !== afterState.statusText;
const focusChanged = beforeState.activeTag !== afterState.activeTag || beforeState.activeText !== afterState.activeText;
const visualChanged = beforeImage !== afterImage;
const newConsoleErrors = consoleErrors.slice(consoleErrorCountBefore);
const issues = [];
if (!operation.hasAccessibleName) issues.push('missing-accessible-name');
if (!beforeVisualState.found || beforeVisualState.visibleRatio < 0.85) issues.push('operation-not-visible-in-before-screenshot');
if (beforeVisualState.found && !beforeVisualState.centerIsReachable) issues.push('operation-obscured-in-before-screenshot');
if (afterState.hasHorizontalOverflow) issues.push('horizontal-overflow-after-operation');
if (!afterState.bodyTextLength) issues.push('empty-page-after-operation');
if (!activation?.activated) issues.push('operation-not-activated');
if (newConsoleErrors.length) issues.push('console-error-after-operation');
records.push({
round: profileOperationAuditRound,
viewport: viewport.name,
page,
operation: currentOperation,
activation,
evidence: { before: `${viewport.name}/${beforeName}`, after: `${viewport.name}/${afterName}` },
beforeState,
beforeVisualState,
afterState,
observedChange: { navigationChanged, dialogChanged, statusChanged, focusChanged, visualChanged },
issues,
health: issues.length ? '需要处理' : '正常'
});
}
}
}
}
const issueRecords = records.filter((record) => record.issues.length);
await writeFile(resolve(roundDirectory, 'operation-audit.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
round: profileOperationAuditRound,
operationCount: records.length,
screenshotCount: records.length * 2,
issueCount: issueRecords.length,
records
}, null, 2));
const reportLines = [
`# 个人中心逐操作审查 ${roundName}`,
'',
`- 操作数:${records.length}`,
`- 截图数:${records.length * 2}`,
`- 自动检查问题数:${issueRecords.length}`,
'',
'| # | 视口 | 页面 | 操作 | 前图 | 后图 | 变化 | 健康度 |',
'|---:|---|---|---|---|---|---|---|',
...records.map((record, index) => {
const changes = Object.entries(record.observedChange).filter(([, changed]) => changed).map(([name]) => name).join('、') || '无可见变化';
const label = (record.operation.text || `${record.operation.tag}:${record.operation.type || 'control'}`).replace(/\\|/g, '/');
return `| ${index + 1} | ${record.viewport} | ${record.page} | ${label} | [前](${record.evidence.before}) | [后](${record.evidence.after}) | ${changes} | ${record.health} |`;
})
];
await writeFile(resolve(roundDirectory, 'operation-audit.md'), reportLines.join('\n'));
assert.deepEqual(issueRecords, [], `profile operation audit round ${profileOperationAuditRound} found ${issueRecords.length} issue(s)`);
console.log(`BROWSER: profile operation audit ${roundName} captured ${records.length} operations and ${records.length * 2} screenshots`);
}
let writesBefore;
await assertEmptyAuthFormBlocked({ page: 'login.html', form: '#login-password-form' });
await evaluate(`document.querySelectorAll('.login-mode-tab')[1].click()`);
assert.equal(await evaluate(`document.querySelector('#login-sms-form').hidden`), false);
writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector('#login-sms-form').requestSubmit(); document.querySelector('#login-sms-form').requestSubmit()`);
await waitFor(async () => evaluate(`Boolean(document.querySelector('#login-sms-form [data-auth-form-status]')?.textContent.trim())`));
assert.equal(interceptedWrites.length, writesBefore, '短信登录空表单不应发出写请求');
await assertEmptyAuthFormBlocked({ page: 'register.html', form: '#register-form' });
await assertEmptyAuthFormBlocked({ page: 'forgot-password.html', form: '#password-reset-form' });
console.log('BROWSER: authentication validation and login-mode navigation passed');
await navigate('profile-families.html');
await waitText('[data-genealogy-list="mine"]', '叶氏家谱');
assert.equal(await evaluate(`document.querySelector('[data-genealogy-sort-down]').click(); document.querySelector('[data-genealogy-sort-save]').click(); true`), true);
await waitText('[data-genealogy-entry-status]', '顺序已保存');
console.log('BROWSER: families sorting passed');
await navigate(`profile-invite.html?genealogyId=${genealogyId}`);
await waitText('[data-invite-status]', '可生成');
await evaluate(`document.querySelector('[data-invite-form]').requestSubmit()`);
await waitText('[data-invite-status]', '邀请已生成');
assert.match(await evaluate(`document.querySelector('[data-invite-link]').value`), /token=invite-token/);
await navigate('profile-invite.html?token=invite-token');
await waitText('[data-invite-redeem]', '接受“叶氏家谱”邀请');
await evaluate(`document.querySelector('[data-invite-redeem-submit]').click()`);
await waitText('[data-invite-status]', '邀请已接受');
console.log('BROWSER: invitation passed');
await navigate(`profile-data-reminders.html?genealogyId=${genealogyId}`);
await waitText('[data-profile-reminder-list]', '出生日期');
assert.equal(await evaluate(`document.querySelector('[data-profile-reminder-list] a').getAttribute('href')`), `profile-tree.html?genealogyId=${genealogyId}`);
console.log('BROWSER: reminders passed');
await navigate(`profile-family-settings.html?genealogyId=${genealogyId}`);
await waitText('[data-lifecycle-status]', '138****0000');
assert.equal(await evaluate(`document.querySelector('[data-genealogy-delete-submit]').disabled`), false);
writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector('[data-genealogy-delete-form]').requestSubmit(); document.querySelector('[data-genealogy-delete-form]').requestSubmit()`);
await waitText('[data-lifecycle-status]', '请填写家谱全名和短信验证码');
assert.equal(interceptedWrites.length, writesBefore, '永久删除空表单不应发出写请求');
console.log('BROWSER: lifecycle passed');
await navigate(`profile-documents.html?genealogyId=${genealogyId}`);
await waitText('[data-document-status]', '已加载');
await evaluate(`document.querySelector('[data-document-form]').requestSubmit()`);
await waitText('[data-document-status]', '请选择关联人物');
console.log('BROWSER: documents passed');
await navigate('profile-earnings.html');
await waitText('[data-earning-status]', '已加载');
await evaluate(`document.querySelector('[data-withdrawal-form]').requestSubmit()`);
await waitText('[data-earning-status]', '请输入有效提现金额');
console.log('BROWSER: earnings passed');
await navigate('profile-services.html');
await waitText('[data-vip-order-status]', '请选择会员套餐');
await evaluate(`document.querySelector('[data-vip-package-id]').click(); document.querySelector('[data-vip-order-form]').requestSubmit()`);
await waitText('[data-vip-order-status]', '请使用微信扫码支付');
assert.equal(await evaluate(`document.querySelector('[data-vip-payment-qr] canvas, [data-vip-payment-qr] img, [data-vip-payment-qr] svg') !== null`), true);
console.log('BROWSER: VIP payment passed');
const emptyEditorFlows = [
{ page: 'profile-article-edit.html', form: '[data-article-form]', status: '[data-article-form-status]' },
{ page: 'profile-album-edit.html', form: '[data-album-form]', status: '[data-album-form-status]' },
{ page: 'profile-feed-edit.html', form: '[data-feed-form]', status: '[data-feed-form-status]' },
{ page: 'profile-gift-edit.html', form: '[data-ceremony-form]', status: '[data-ceremony-form-status]' },
{ page: 'profile-growth-edit.html', form: '[data-growth-form]', status: '[data-growth-form-status]' },
{ page: 'profile-memo-edit.html', form: '[data-memo-form]', status: '[data-memo-form-status]' },
{ page: 'profile-merit-edit.html', form: '[data-merit-form]', status: '[data-merit-form-status]' },
{ page: 'profile-relative-edit.html', form: '[data-relative-form]', status: '[data-relative-form-status]' },
{ page: 'profile-video-edit.html', form: '[data-video-form]', status: '[data-video-form-status]' }
];
for (const flow of emptyEditorFlows) await assertEmptyFormBlocked(flow);
console.log(`BROWSER: ${emptyEditorFlows.length} empty editor validations passed`);
const emptyManagementFlows = [
{
page: 'create-genealogy.html',
form: '[data-genealogy-create-form]',
status: '[data-genealogy-create-status]',
ready: `document.querySelector('[data-genealogy-create-form]')?.__genealogyCreateBound === true`
},
{
page: 'profile-create-family.html',
form: '[data-genealogy-create-form]',
status: '[data-genealogy-create-status]',
ready: `document.querySelector('[data-genealogy-create-form]')?.__genealogyCreateBound === true`
},
{
page: 'join-genealogy.html',
form: '[data-join-apply-form]',
status: '[data-join-apply-status]',
ready: `document.querySelector('[data-join-genealogy-id]') !== null`
},
{ page: 'profile-feedback.html', form: '[data-feedback-form]', status: '[data-feedback-status]' },
{ page: 'submit-ticket.html', form: '[data-feedback-form]', status: '[data-feedback-status]' },
{ page: 'profile-security.html', form: '[data-security-form="password"]', status: '[data-security-status]' },
{ page: 'profile-security.html', form: '[data-security-form="phone"]', status: '[data-security-status]' },
{ page: 'profile-security.html', form: '[data-security-form="deactivate"]', status: '[data-security-status]' },
{ page: 'profile-generation.html', form: '[data-generation-batch-form]', status: '[data-generation-batch-status]', action: '[data-generation-batch-action="preview"]' },
{ page: 'profile-generation.html', form: '[data-generation-form]', status: '[data-generation-form-status]' },
{ page: 'profile-tree.html', form: '[data-lineage-form]', status: '[data-lineage-form-status]' },
{ page: 'profile-family-admin.html', form: '[data-owner-transfer-form]', status: '[data-owner-transfer-status]' },
{ page: `profile-album-detail.html?albumId=${albumId}`, form: '[data-album-photo-form]', status: '[data-album-photo-status]' },
{ page: `profile-ceremony-detail.html?ceremonyId=${ceremonyId}`, form: '[data-ceremony-gift-form]', status: '[data-ceremony-gift-status]' },
{ page: `profile-ceremony-detail.html?ceremonyId=${ceremonyId}`, form: '[data-ceremony-invitee-form]', status: '[data-ceremony-invitee-status]' },
{ page: `profile-feed-detail.html?feedId=${feedId}`, form: '[data-feed-comment-form]', status: '[data-feed-comment-form-status]' }
];
for (const flow of emptyManagementFlows) await assertEmptyFormBlocked(flow);
console.log(`BROWSER: ${emptyManagementFlows.length} empty management validations passed`);
await navigate(`profile-family-admin.html?genealogyId=${genealogyId}`);
await waitText('[data-member-list]', '叶成员');
await evaluate(`document.querySelector('[data-member-edit-id]').click()`);
await waitFor(async () => evaluate(`document.querySelector('[data-member-form]')?.hidden === false`));
writesBefore = interceptedWrites.length;
await evaluate(`
document.querySelector('[data-member-form] [name="memberName"]').value = '超'.repeat(51);
document.querySelector('[data-member-form]').requestSubmit();
document.querySelector('[data-member-form]').requestSubmit();
`);
await waitText('[data-member-form-status]', '成员名称不能超过 50 个字符');
assert.equal(interceptedWrites.length, writesBefore, '超长成员名称不应发出更新请求');
console.log('BROWSER: member editing validation passed');
await navigate(`profile-join-review.html?genealogyId=${genealogyId}`);
await waitText('[data-join-apply-list="pending"]', '叶申请人');
await evaluate(`document.querySelector('[data-join-audit-status="2"]').click()`);
await waitFor(async () => evaluate(`document.querySelector('[data-join-review-editor]')?.hidden === false`));
writesBefore = interceptedWrites.length;
await evaluate(`
document.querySelector('[data-join-review-form] [name="auditRemark"]').value = '因'.repeat(501);
document.querySelector('[data-join-review-form]').requestSubmit();
document.querySelector('[data-join-review-form]').requestSubmit();
`);
await waitText('[data-join-review-form-status]', '审核说明不能超过 500 个字符');
assert.equal(interceptedWrites.length, writesBefore, '超长审核说明不应发出审核请求');
console.log('BROWSER: join review validation passed');
await navigate('join-genealogy.html');
writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector('[data-join-genealogy-search]').requestSubmit(); document.querySelector('[data-join-genealogy-search]').requestSubmit()`);
await waitText('[data-join-genealogy-options]', '叶氏家谱');
assert.equal(interceptedWrites.length, writesBefore, '空家谱搜索只允许读取,不应发出写请求');
console.log('BROWSER: genealogy search validation passed');
await navigate('profile-data.html');
await waitFor(async () => evaluate(`document.querySelector('[data-profile-form] [name="email"]') !== null`));
writesBefore = interceptedWrites.length;
await evaluate(`
document.querySelector('[data-profile-form] [name="email"]').value = 'invalid-email';
document.querySelector('[data-profile-form]').requestSubmit();
document.querySelector('[data-profile-form]').requestSubmit();
`);
await waitText('[data-profile-form] [data-profile-status]', '请检查资料填写是否正确');
assert.equal(interceptedWrites.length, writesBefore, '无效邮箱不应发出资料更新请求');
writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector('[data-region-search-form]').requestSubmit(); document.querySelector('[data-region-search-form]').requestSubmit()`);
await waitText('[data-region-search-status]', '请输入地区名称或编码');
assert.equal(interceptedWrites.length, writesBefore, '空地区搜索不应发出请求');
console.log('BROWSER: profile and region validation passed');
await navigate(`profile-tree.html?genealogyId=${genealogyId}`);
const treeSidebarSignature = await evaluate(`Array.from(document.querySelectorAll('.module-nav a')).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname)`);
const treeHeaderSignature = await evaluate(`Array.from(document.querySelectorAll('.site-header .nav-links a')).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname)`);
assert.equal(await evaluate(`document.querySelector('.module-nav a[aria-current="page"]')?.textContent.trim()`), '世系图');
assert.equal(await evaluate(`document.querySelector('.module-nav a[aria-current="page"]')?.closest('[data-workspace-nav-group]')?.classList.contains('is-open')`), true);
await evaluate(`Array.from(document.querySelectorAll('[data-genealogy-context-link]')).find((link) => new URL(link.href).pathname === '/profile-family-home.html').click()`);
await waitFor(async () => (await evaluate('location.pathname')) === '/profile-family-home.html');
await waitFor(async () => evaluate(`document.readyState === 'complete' && document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
assert.equal(await evaluate(`new URLSearchParams(location.search).get('genealogyId')`), genealogyId);
const familyHomeSidebarSignature = await evaluate(`Array.from(document.querySelectorAll('.module-nav a')).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname)`);
const familyHomeHeaderSignature = await evaluate(`Array.from(document.querySelectorAll('.site-header .nav-links a')).map((link) => link.textContent.trim() + '|' + new URL(link.href).pathname)`);
assert.deepEqual(treeSidebarSignature, familyHomeSidebarSignature);
assert.deepEqual(treeHeaderSignature, familyHomeHeaderSignature);
console.log('BROWSER: contextual back navigation passed');
await navigate('profile.html');
await evaluate(`document.querySelector('.logout-link[data-logout-open]').click()`);
await waitFor(async () => evaluate(`document.querySelector('#logoutModal').getAttribute('aria-hidden') === 'false' || Boolean(document.querySelector('.layui-layer'))`));
await evaluate(`
const layerCancel = document.querySelector('.layui-layer .layui-layer-btn1');
if (layerCancel) layerCancel.click();
else document.querySelector('#logoutModal [data-logout-close]').click();
`);
await waitFor(async () => evaluate(`document.querySelector('#logoutModal').getAttribute('aria-hidden') === 'true' && !document.querySelector('.layui-layer')`));
assert.equal(await evaluate(`location.pathname`), '/profile.html');
console.log('BROWSER: logout confirmation cancellation passed');
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
const mediaChecks = [
['profile.html', '[data-profile-avatar] img', '07-profile-avatar.png'],
[`profile-family-home.html?genealogyId=${genealogyId}`, '.family-cover-media img', '08-family-cover.png'],
[`profile-album-detail.html?albumId=${albumId}&genealogyId=${genealogyId}`, '.album-photo-media img', '09-album-photo.png'],
[`profile-ceremony-detail.html?ceremonyId=${ceremonyId}&genealogyId=${genealogyId}`, '.ceremony-cover-media img', '10-ceremony-cover.png'],
[`profile-feed-detail.html?feedId=${feedId}&genealogyId=${genealogyId}`, '.feed-media-gallery img', '11-feed-media.png'],
['app.html', '.promotion-cover-media img', '12-app-promotion.png'],
[`profile-article.html?genealogyId=${genealogyId}&articleId=${articleId}`, '.article-cover-media img', '13-article-cover.png'],
[`profile-growth.html?genealogyId=${genealogyId}&recordId=${growthRecordId}`, '.media-gallery img', '14-growth-media.png'],
[`profile-relative.html?genealogyId=${genealogyId}&relativeId=${relativeId}`, '.media-gallery img', '15-relative-media.png'],
[`profile-memo.html?genealogyId=${genealogyId}&memoId=${memoId}`, '.media-gallery img', '16-memo-media.png'],
[`profile-merit.html?genealogyId=${genealogyId}&meritId=${meritId}`, '.media-gallery img', '17-merit-media.png'],
[`profile-tree.html?genealogyId=${genealogyId}`, '.lineage-node-avatar img', '18-lineage-avatar.png']
];
for (const [page, selector, screenshotName] of mediaChecks) {
console.log(`BROWSER: checking media ${page} ${selector}`);
await navigate(page);
await waitFor(async () => evaluate(`document.querySelector(${JSON.stringify(selector)})?.complete === true`));
assert.equal(await evaluate(`document.querySelector(${JSON.stringify(selector)})?.naturalWidth > 0`), true, `${page} 的业务图片应成功显示`);
await captureNavigationAudit(screenshotName);
}
console.log(`BROWSER: ${mediaChecks.length} authorized media displays passed`);
const contentDetailJourneys = [
[`profile-feed.html?genealogyId=${genealogyId}`, '.feed-row a[href*="profile-feed-detail.html"]', '/profile-feed-detail.html', 'feedId', feedId],
[`profile-ceremony.html?genealogyId=${genealogyId}`, '.ceremony-row a[href*="profile-ceremony-detail.html"]', '/profile-ceremony-detail.html', 'ceremonyId', ceremonyId],
['my-tickets.html', '[data-feedback-list] a[href*="ticket-detail.html"]', '/ticket-detail.html', 'feedbackId', feedbackId]
];
for (const [page, linkSelector, expectedPath, queryName, expectedId] of contentDetailJourneys) {
await navigate(page);
await waitFor(async () => evaluate(`Boolean(document.querySelector(${JSON.stringify(linkSelector)}))`));
await evaluate(`document.querySelector(${JSON.stringify(linkSelector)}).click()`);
await waitFor(async () => (await evaluate('location.pathname')) === expectedPath);
assert.equal(await evaluate(`new URLSearchParams(location.search).get(${JSON.stringify(queryName)})`), expectedId);
}
console.log(`BROWSER: ${contentDetailJourneys.length} list-to-detail journeys passed`);
const inlineDetailJourneys = [
['profile-growth.html', '[data-growth-detail-id]', '[data-growth-detail-heading]', 'recordId', growthRecordId],
['profile-memo.html', '[data-memo-detail-id]', '[data-memo-detail-heading]', 'memoId', memoId],
['profile-merit.html', '[data-merit-detail-id]', '[data-merit-detail-heading]', 'meritId', meritId],
['profile-relative.html', '[data-relative-detail-id]', '[data-relative-detail-heading]', 'relativeId', relativeId]
];
for (const [page, triggerSelector, headingSelector, queryName, expectedId] of inlineDetailJourneys) {
await navigate(`${page}?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`Boolean(document.querySelector(${JSON.stringify(triggerSelector)}))`));
await evaluate(`
const trigger = document.querySelector(${JSON.stringify(triggerSelector)});
trigger.click();
trigger.click();
`);
await waitFor(async () => evaluate(`document.activeElement === document.querySelector(${JSON.stringify(headingSelector)})`));
assert.equal(await evaluate(`new URLSearchParams(location.search).get(${JSON.stringify(queryName)})`), expectedId);
await client.send('Page.reload', { ignoreCache: true });
await waitFor(async () => evaluate(`new URLSearchParams(location.search).get(${JSON.stringify(queryName)}) === ${JSON.stringify(expectedId)}`));
await waitFor(async () => evaluate(`!document.querySelector(${JSON.stringify(headingSelector)})?.nextElementSibling?.querySelector('[data-api-state="loading"]')`));
}
console.log(`BROWSER: ${inlineDetailJourneys.length} repeated inline detail journeys reveal, refresh and persist selection`);
await client.send('Emulation.setDeviceMetricsOverride', { width: 375, height: 844, deviceScaleFactor: 1, mobile: true });
const mobileRowActionPages = [
'profile-album.html', 'profile-article.html', 'profile-ceremony.html', 'profile-feed.html',
'profile-growth.html', 'profile-memo.html', 'profile-merit.html', 'profile-relative.html', 'profile-video.html'
];
for (const page of mobileRowActionPages) {
await navigate(`${page}?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelectorAll('.row-actions .pill').length > 0`));
assert.deepEqual(
await evaluate(`Array.from(document.querySelectorAll('.row-actions .pill')).filter((control) => control.getBoundingClientRect().height < 44).map((control) => control.textContent.trim())`),
[],
`${page} 的手机列表操作点击高度不得低于 44px`
);
}
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
console.log(`BROWSER: ${mobileRowActionPages.length} mobile list action groups meet touch target size`);
await navigate(`profile-feed-edit.html?genealogyId=${genealogyId}`);
assert.equal(await evaluate(`document.querySelector('[data-feed-editor-title]').textContent.trim()`), '发布动态');
assert.equal(await evaluate(`document.querySelector('[data-feed-editor-action]').textContent.trim()`), '发布动态');
await navigate(`profile-feed-edit.html?genealogyId=${genealogyId}&feedId=${feedId}`);
assert.equal(await evaluate(`document.querySelector('[data-feed-editor-title]').textContent.trim()`), '编辑动态');
assert.equal(await evaluate(`document.querySelector('[data-feed-editor-action]').textContent.trim()`), '保存修改');
console.log('BROWSER: feed create and edit copy stays consistent');
await navigate(`profile-video.html?genealogyId=${genealogyId}&videoId=${videoId}`);
await waitFor(async () => evaluate(`Boolean(document.querySelector('.video-detail-media video')?.poster)`));
assert.equal(await evaluate(`new Promise((resolve) => {
const image = new Image();
image.onload = () => resolve(image.naturalWidth > 0);
image.onerror = () => resolve(false);
image.src = document.querySelector('.video-detail-media video').poster;
})`), true, '视频封面应成功显示');
await captureNavigationAudit('19-video-poster.png');
await navigate(`profile-article.html?genealogyId=${genealogyId}&articleId=${articleId}`);
await waitFor(async () => evaluate(`Boolean(document.querySelector('[data-media-preview-url]'))`));
await evaluate(`document.querySelector('[data-media-preview-url]').click()`);
await waitFor(async () => evaluate(`document.querySelector('[data-media-preview-dialog]')?.open === true`));
assert.equal(await evaluate(`document.querySelector('[data-media-preview-dialog] [data-media-dialog-image]')?.naturalWidth > 0`), true);
await captureNavigationAudit('20-image-preview-dialog.png');
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await waitFor(async () => evaluate(`document.querySelector('[data-media-preview-dialog]')?.open === false`));
writesBefore = interceptedWrites.length;
await evaluate(`document.querySelector('[data-article-delete-id]').click()`);
await waitFor(async () => evaluate(`Boolean(document.querySelector('.layui-layer.profile-layer-confirm, .layui-layer .profile-layer-confirm'))`));
await captureNavigationAudit('21-delete-confirm-dialog.png');
await evaluate(`document.querySelector('.layui-layer .layui-layer-btn1').click()`);
await waitFor(async () => evaluate(`!document.querySelector('.layui-layer')`));
assert.equal(interceptedWrites.length, writesBefore, '取消删除谱文不应发送删除请求');
assert.equal(await evaluate(`location.pathname`), '/profile-article.html');
console.log('BROWSER: image preview keyboard close and destructive confirmation cancellation passed');
await navigate('profile.html?unauthenticated=1');
await waitFor(async () => (await evaluate('location.pathname')) === '/login.html');
console.log('BROWSER: unauthenticated redirect passed');
mockGenealogyRole = 'member';
await navigate(`profile-ceremony-detail.html?ceremonyId=${ceremonyId}&genealogyId=${genealogyId}`);
await waitText('[data-ceremony-invitee-options]', '当前账号无权查看或维护受邀名单');
assert.equal(await evaluate(`document.querySelector('[data-ceremony-invitee-form]').hidden`), true);
mockGenealogyRole = 'owner';
console.log('BROWSER: member permission boundary passed');
failingApiPath = `/genealogy/pc/genealogies/${genealogyId}/ceremonies/${ceremonyId}`;
await navigate(`profile-ceremony-detail.html?ceremonyId=${ceremonyId}&genealogyId=${genealogyId}`);
await waitText('[data-ceremony-detail]', '服务暂时不可用,请稍后重试');
failingApiPath = '';
console.log('BROWSER: API failure state passed');
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
let familyNavigationReference;
for (const page of familyWorkspacePages) {
await navigate(`${page}${page.includes('?') ? '&' : '?'}genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
const snapshot = await workspaceNavigationSnapshot();
familyNavigationReference ||= snapshot;
assert.deepEqual(snapshot.links, familyNavigationReference.links, `${page} 的家谱侧栏项目或顺序发生变化`);
assert.deepEqual(snapshot.headerLinks, familyNavigationReference.headerLinks, `${page} 的顶部导航项目或顺序发生变化`);
assert.equal(snapshot.currentCount, 1, `${page} 必须准确标记一个当前菜单项`);
assert.equal(snapshot.top, familyNavigationReference.top, `${page} 的侧栏纵向位置发生变化`);
assert.equal(snapshot.left, familyNavigationReference.left, `${page} 的侧栏横向位置发生变化`);
assert.equal(snapshot.width, familyNavigationReference.width, `${page} 的侧栏宽度发生变化`);
assert.equal(snapshot.hasHorizontalOverflow, false, `${page} 不应产生横向滚动`);
}
console.log(`BROWSER: ${familyWorkspacePages.length} family workspace navigation layouts passed`);
let accountNavigationReference;
for (const page of accountWorkspacePages) {
await navigate(page);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
const snapshot = await workspaceNavigationSnapshot();
accountNavigationReference ||= snapshot;
assert.deepEqual(snapshot.links, accountNavigationReference.links, `${page} 的个人中心侧栏项目或顺序发生变化`);
assert.deepEqual(snapshot.headerLinks, familyNavigationReference.headerLinks, `${page} 的顶部导航项目或顺序发生变化`);
assert.equal(snapshot.currentCount, 1, `${page} 必须准确标记一个当前菜单项`);
assert.equal(snapshot.top, familyNavigationReference.top, `${page} 的侧栏纵向位置发生变化`);
assert.equal(snapshot.left, accountNavigationReference.left, `${page} 的侧栏横向位置发生变化`);
assert.equal(snapshot.width, accountNavigationReference.width, `${page} 的侧栏宽度发生变化`);
assert.equal(snapshot.hasHorizontalOverflow, false, `${page} 不应产生横向滚动`);
}
console.log(`BROWSER: ${accountWorkspacePages.length} account workspace navigation layouts passed`);
await navigate('profile-data.html');
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
await captureNavigationAudit('03-account-navigation.png');
await navigate('join-genealogy.html');
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
await captureNavigationAudit('05-join-navigation.png');
await navigate('submit-ticket.html');
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
await captureNavigationAudit('06-ticket-navigation.png');
await navigate(`profile-family-home.html?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
const familyNavigationTargets = await evaluate(`Array.from(document.querySelectorAll('.module-nav a')).map((link) => ({ pathname: new URL(link.href).pathname, carriesContext: link.hasAttribute('data-genealogy-context-link') }))`);
for (const target of familyNavigationTargets) {
await navigate(`profile-family-home.html?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
await evaluate(`(() => {
const link = Array.from(document.querySelectorAll('.module-nav a')).find((candidate) => new URL(candidate.href).pathname === ${JSON.stringify(target.pathname)});
const group = link.closest('details');
if (group && !group.open) group.querySelector('summary').click();
link.click();
})()`);
await waitFor(async () => (await evaluate('location.pathname')) === target.pathname);
await waitFor(async () => evaluate(`document.readyState === 'complete'`));
if (target.carriesContext) {
assert.equal(await evaluate(`new URLSearchParams(location.search).get('genealogyId')`), genealogyId, `${target.pathname} 丢失当前家谱上下文`);
}
}
console.log(`BROWSER: ${familyNavigationTargets.length} family workspace menu clicks passed`);
await navigate('profile-families.html');
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
const accountNavigationTargets = await evaluate(`Array.from(document.querySelectorAll('.module-nav a')).map((link) => new URL(link.href).pathname)`);
for (const targetPath of accountNavigationTargets) {
await navigate('profile-families.html');
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'account'`));
await evaluate(`(() => {
const link = Array.from(document.querySelectorAll('.module-nav a')).find((candidate) => new URL(candidate.href).pathname === ${JSON.stringify(targetPath)});
const group = link.closest('details');
if (group && !group.open) group.querySelector('summary').click();
link.click();
})()`);
await waitFor(async () => (await evaluate('location.pathname')) === targetPath);
}
console.log(`BROWSER: ${accountNavigationTargets.length} account workspace menu clicks passed`);
await navigate(`profile-family-home.html?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
await captureNavigationAudit('01-family-home-navigation.png');
await navigate(`profile-tree.html?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav a[aria-current="page"]')?.textContent.trim() === '世系图'`));
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.nav-links a:not(.active)')).color`), 'rgb(255, 253, 248)');
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.nav-actions > a:not(.btn)')).color`), 'rgb(255, 253, 248)');
await captureNavigationAudit('02-lineage-navigation.png');
await navigate(`profile-family-home.html?genealogyId=${genealogyId}`);
await waitFor(async () => evaluate(`document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
await evaluate(`window.scrollTo(0, 700)`);
await waitFor(async () => evaluate(`window.scrollY > 0`));
assert.equal(await evaluate(`Math.round(document.querySelector('.module-nav').getBoundingClientRect().top)`), 88);
assert.equal(await evaluate(`document.querySelector('.module-nav').getBoundingClientRect().height <= window.innerHeight - 108`), true);
console.log('BROWSER: desktop sticky navigation position passed');
await navigate('index.html');
assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('.nav-links')).display`), 'none');
assert.equal(await evaluate(`document.documentElement.scrollWidth <= document.documentElement.clientWidth`), true);
console.log('BROWSER: public desktop layout passed');
await client.send('Emulation.setDeviceMetricsOverride', { width: 1024, height: 800, deviceScaleFactor: 1, mobile: false });
await navigate('index.html');
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.nav-links')).display`), 'none');
assert.equal(await evaluate(`document.querySelector('[data-site-menu-toggle]') !== null`), true);
await evaluate(`document.querySelector('[data-site-menu-toggle]').click()`);
assert.equal(await evaluate(`document.querySelector('[data-site-mobile-menu]').hidden`), false);
await evaluate(`Array.from(document.querySelectorAll('[data-site-mobile-menu] a')).find((link) => new URL(link.href).pathname === '/plaza.html').click()`);
await waitFor(async () => (await evaluate('location.pathname')) === '/plaza.html');
console.log('BROWSER: public compact navigation passed');
await client.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
await navigate('index.html');
assert.equal(await evaluate(`document.documentElement.scrollWidth <= document.documentElement.clientWidth`), true);
assert.equal(await evaluate(`document.querySelector('[data-site-menu-toggle]') !== null`), true);
await evaluate(`document.querySelector('[data-site-menu-toggle]').click()`);
assert.equal(await evaluate(`document.querySelector('[data-site-mobile-menu]').hidden`), false);
await navigate('profile.html');
assert.equal(await evaluate(`document.documentElement.scrollWidth <= document.documentElement.clientWidth`), true);
assert.equal(await evaluate(`Boolean(document.querySelector('main'))`), true);
await navigate(`profile-tree.html?genealogyId=${genealogyId}`);
assert.equal(await evaluate(`document.querySelector('[data-lineage-person-options]').getBoundingClientRect().top < document.querySelector('[data-lineage-relation="parents"]').getBoundingClientRect().top`), true);
assert.equal(await evaluate(`document.querySelector('.module-title-row a[href^="profile-generation.html"]')?.textContent.trim()`), '管理字辈');
await evaluate(`document.querySelector('[data-profile-mobile-menu-toggle]').click()`);
assert.equal(await evaluate(`document.querySelector('[data-profile-mobile-menu]').hidden`), false);
assert.equal(await evaluate(`document.querySelectorAll('.profile-mobile-workspace a').length`), familyNavigationTargets.length);
assert.equal(await evaluate(`document.querySelector('.profile-mobile-workspace a[aria-current="page"]')?.textContent.trim()`), '世系图');
assert.equal(await evaluate(`document.querySelector('.profile-mobile-workspace a[aria-current="page"]')?.closest('details')?.open`), true);
await captureNavigationAudit('04-mobile-lineage-menu.png');
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await waitFor(async () => evaluate(`document.querySelector('[data-profile-mobile-menu]').hidden`));
assert.equal(await evaluate(`document.activeElement === document.querySelector('[data-profile-mobile-menu-toggle]')`), true);
await evaluate(`document.querySelector('[data-profile-mobile-menu-toggle]').click()`);
await evaluate(`Array.from(document.querySelectorAll('.profile-mobile-workspace a')).find((link) => new URL(link.href).pathname === '/profile-generation.html').click()`);
await waitFor(async () => (await evaluate('location.pathname')) === '/profile-generation.html');
await waitFor(async () => evaluate(`document.readyState === 'complete' && document.querySelector('.module-nav')?.getAttribute('data-workspace-navigation') === 'family'`));
assert.equal(await evaluate(`new URLSearchParams(location.search).get('genealogyId')`), genealogyId);
const mobileGenerationOverflow = await evaluate(`Array.from(document.querySelectorAll('body *')).filter((node) => {
const rect = node.getBoundingClientRect();
return rect.right > document.documentElement.clientWidth + 1 || rect.left < -1;
}).map((node) => ({ tag: node.tagName, className: node.className, text: node.textContent.trim().slice(0, 40), left: Math.round(node.getBoundingClientRect().left), right: Math.round(node.getBoundingClientRect().right) })).slice(0, 20)`);
assert.deepEqual(mobileGenerationOverflow, [], '字辈页在手机菜单跳转后不应横向溢出');
console.log('BROWSER: mobile public, profile and nested workspace navigation passed');
for (const width of [320, 375, 414, 768]) {
await client.send('Emulation.setDeviceMetricsOverride', { width, height: 844, deviceScaleFactor: 1, mobile: width < 768 });
for (const page of ['index.html', 'profile.html', `profile-family-home.html?genealogyId=${genealogyId}`, `profile-tree.html?genealogyId=${genealogyId}`]) {
await navigate(page);
assert.equal(await evaluate(`document.documentElement.scrollWidth <= document.documentElement.clientWidth`), true, `${page}${width}px 不应横向溢出`);
const outOfBoundsControls = await evaluate(`Array.from(document.querySelectorAll('main button, main a, main input, main select, main textarea')).filter((node) => { const rect = node.getBoundingClientRect(); return rect.right > document.documentElement.clientWidth + 1 || rect.left < -1; }).map((node) => ({ tag: node.tagName, text: node.textContent.trim().slice(0, 30), className: node.className, left: Math.round(node.getBoundingClientRect().left), right: Math.round(node.getBoundingClientRect().right) }))`);
assert.deepEqual(outOfBoundsControls, [], `${page}${width}px 的主要控件不应越界`);
}
}
console.log('BROWSER: 320/375/414/768 responsive layout checks passed');
if (shouldRunFullProjectAudit || shouldRunProfileUxAudit) {
const auditDirectory = shouldRunProfileUxAudit ? profileUxAuditDir : fullProjectAuditDir;
const htmlPages = (await readdir(projectRoot))
.filter((name) => name.endsWith('.html') && (!shouldRunProfileUxAudit || name.startsWith('profile')))
.sort();
const auditResults = [];
const auditIssues = [];
const auditViewport = async (width, height, captureScreenshots, screenshotLabel) => {
await client.send('Emulation.setDeviceMetricsOverride', {
width, height, deviceScaleFactor: 1, mobile: width < 768
});
for (let index = 0; index < htmlPages.length; index += 1) {
const page = htmlPages[index];
await navigate(fullAuditPageUrl(page));
await settlePageForAudit();
const snapshot = await inspectCurrentPageForAudit(page);
const result = { viewport: `${width}x${height}`, ...snapshot };
auditResults.push(result);
if (captureScreenshots) {
const order = String(index + 1).padStart(2, '0');
const suffix = screenshotLabel ? `-${screenshotLabel}` : '';
await captureFullProjectAudit(auditDirectory, `${order}-${page.replace('.html', '')}${suffix}.png`);
}
if (snapshot.actualPage !== page) auditIssues.push({ page, width, type: 'unexpected-navigation', value: snapshot.actualPage });
if (!snapshot.title) auditIssues.push({ page, width, type: 'missing-title' });
if (!snapshot.bodyTextLength) auditIssues.push({ page, width, type: 'empty-page' });
if (snapshot.hasHorizontalOverflow) auditIssues.push({ page, width, type: 'horizontal-overflow' });
if (snapshot.brokenImages.length) auditIssues.push({ page, width, type: 'broken-images', value: snapshot.brokenImages });
if (snapshot.mockTransportErrors.length) auditIssues.push({ page, width, type: 'browser-mock-missing', value: snapshot.mockTransportErrors });
if (snapshot.outOfBoundsControls.length) auditIssues.push({ page, width, type: 'out-of-bounds-controls', value: snapshot.outOfBoundsControls });
if (snapshot.unnamedControls.length) auditIssues.push({ page, width, type: 'unnamed-controls', value: snapshot.unnamedControls });
if (snapshot.leakedValues.length) auditIssues.push({ page, width, type: 'leaked-runtime-values', value: snapshot.leakedValues });
if (snapshot.openDialogs.length) auditIssues.push({ page, width, type: 'dialog-open-on-load', value: snapshot.openDialogs });
}
};
await mkdir(auditDirectory, { recursive: true });
await auditViewport(1440, 900, true, shouldRunProfileUxAudit ? 'desktop' : '');
await auditViewport(375, 844, shouldRunProfileUxAudit, shouldRunProfileUxAudit ? 'mobile' : '');
await writeFile(resolve(auditDirectory, 'audit-results.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
pageCount: htmlPages.length,
viewports: ['1440x900', '375x844'],
issues: auditIssues,
pages: auditResults
}, null, 2));
assert.deepEqual(auditIssues, [], `full project audit found ${auditIssues.length} issue(s): ${JSON.stringify(auditIssues.slice(0, 12))}`);
console.log(`BROWSER: ${htmlPages.length} pages passed ${shouldRunProfileUxAudit ? 'profile UX' : 'full'} desktop and mobile audit`);
}
if (shouldRunProfileOperationAudit) {
await runProfileOperationAudit();
}
assert.deepEqual(consoleErrors, []);
console.log(`BROWSER CLICK SMOKE: ${28 + contentDetailJourneys.length + inlineDetailJourneys.length + mobileRowActionPages.length + emptyEditorFlows.length + emptyManagementFlows.length + familyWorkspacePages.length + accountWorkspacePages.length + familyNavigationTargets.length + accountNavigationTargets.length} flows passed`);
} finally {
if (client) client.close();
const chromeExited = chrome.exitCode === null
? new Promise((resolveExit) => chrome.once('exit', resolveExit))
: Promise.resolve();
chrome.kill();
await Promise.race([chromeExited, new Promise((resolveWait) => setTimeout(resolveWait, 3000))]);
if (server.closeAllConnections) server.closeAllConnections();
await new Promise((resolveClose) => server.close(resolveClose));
await rm(profileDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}