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 sourceProjectRoot = resolve(import.meta.dirname, '..'); const projectRootArg = process.argv.find((arg) => arg.startsWith('--project-root=')); const projectRoot = projectRootArg ? resolve(sourceProjectRoot, projectRootArg.slice('--project-root='.length)) : sourceProjectRoot; 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 shouldRunNavigationStructureSmoke = process.argv.includes('--navigation-structure-smoke'); const shouldRunProfileSpacingSmoke = process.argv.includes('--profile-spacing-smoke'); const shouldRunSiteSpacingSmoke = process.argv.includes('--site-spacing-smoke'); 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: '

先祖迁徙与家族传承记录。

', 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'); if (browserSmokeParams.has('no-user-cache')) { localStorage.removeItem('genealogy_public_user_v1'); } else { localStorage.setItem('genealogy_public_user_v1', JSON.stringify({ nickName: '叶用户', avatarUrl: 'http://127.0.0.1:${port}/public/images/logo-mark.png' })); } } 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`); } if (shouldRunProfileSpacingSmoke || shouldRunSiteSpacingSmoke) { const spacingPages = []; const spacingPagePattern = shouldRunSiteSpacingSmoke ? /\.html$/ : /^profile-.*\.html$/; for (const file of (await readdir(projectRoot)).filter((name) => spacingPagePattern.test(name))) { const source = await readFile(resolve(projectRoot, file), 'utf8'); if (/class="[^"]*(?:actions|toolbar|cta|auth-links)/.test(source)) spacingPages.push(file); } const spacingViolations = []; for (const viewport of [{ width: 1440, height: 900, mobile: false }, { width: 390, height: 844, mobile: true }]) { await client.send('Emulation.setDeviceMetricsOverride', { ...viewport, deviceScaleFactor: 1 }); for (const page of spacingPages) { await navigate(fullAuditPageUrl(page)); await settlePageForOperationAudit(); const pageViolations = await evaluate(`(() => Array.from(document.querySelectorAll('.row-actions, .bottom-actions, .profile-form-actions, .toolbar, .cta, .auth-links, [class$="-actions"], [class*="-actions "]')).flatMap((actions) => { if (!actions.getClientRects().length) return []; let previous = actions.previousElementSibling; while (previous && !previous.getClientRects().length) previous = previous.previousElementSibling; if (!previous) return []; const previousRect = previous.getBoundingClientRect(); const actionsRect = actions.getBoundingClientRect(); const gap = Math.round(actionsRect.top - previousRect.bottom); const parentStyle = getComputedStyle(actions.parentElement); if (actions.parentElement.matches('.editor-field') || parentStyle.display !== 'block' || gap < -1 || gap >= 12) return []; return [{ className: actions.className, parentClass: actions.parentElement.className, parentDisplay: parentStyle.display, parentGap: parentStyle.rowGap, previousClass: previous.className, previousTag: previous.tagName.toLowerCase(), gap }]; }))()`); pageViolations.forEach((violation) => spacingViolations.push({ viewport: viewport.width, page, ...violation })); } } assert.deepEqual(spacingViolations, [], `页面操作区间距不足:${JSON.stringify(spacingViolations)}`); console.log(`BROWSER: ${spacingPages.length} ${shouldRunSiteSpacingSmoke ? 'site' : 'profile'} pages action spacing passed at desktop and mobile widths`); } else if (shouldRunNavigationStructureSmoke) { await navigate('login.html'); const authNavigation = await evaluate(`(() => ({ brandHref: document.querySelector('.brand')?.getAttribute('href'), hasRedundantControls: Boolean(document.querySelector('[data-page-navigation-controls], [data-navigation-back]')) }))()`); assert.deepEqual(authNavigation, { brandHref: 'index.html', hasRedundantControls: false }); await client.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false }); await navigate('index.html?no-user-cache=1'); await waitFor(async () => evaluate(`document.querySelector('.site-header .nav-user-name')?.textContent.trim() === '叶用户'`)); const publicNavigation = await evaluate(`(() => ({ brandHref: document.querySelector('.site-header .brand')?.getAttribute('href'), hasHome: Array.from(document.querySelectorAll('.site-header .nav-links a')).some((link) => link.textContent.trim() === '首页'), userName: document.querySelector('.site-header .nav-user-name')?.textContent.trim(), userHref: document.querySelector('.site-header .nav-user-entry')?.getAttribute('href'), hasAvatar: Boolean(document.querySelector('.site-header .nav-user-avatar img')), hasLegacyAccountLinks: Array.from(document.querySelectorAll('.site-header .nav-actions a')).some((link) => ['个人中心', '账户资料'].includes(link.textContent.trim())), hasRedundantControls: Boolean(document.querySelector('[data-page-navigation-controls], [data-navigation-back]')), hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth }))()`); assert.deepEqual(publicNavigation, { brandHref: 'index.html', hasHome: true, userName: '叶用户', userHref: 'profile.html', hasAvatar: true, hasLegacyAccountLinks: false, hasRedundantControls: false, hasHorizontalOverflow: false }); await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }); await navigate('profile.html'); const profileNavigation = await evaluate(`(() => ({ brandHref: document.querySelector('.site-header .brand')?.getAttribute('href'), hasHome: Array.from(document.querySelectorAll('.site-header .nav-links a')).some((link) => link.textContent.trim() === '首页'), userName: document.querySelector('.site-header .nav-user-name')?.textContent.trim(), userHref: document.querySelector('.site-header .nav-user-entry')?.getAttribute('href'), hasAvatar: Boolean(document.querySelector('.site-header .nav-user-avatar img')), hasLegacyAccountLinks: Array.from(document.querySelectorAll('.site-header .nav-actions a')).some((link) => ['个人中心', '账户资料'].includes(link.textContent.trim())), hasRedundantControls: Boolean(document.querySelector('[data-page-navigation-controls], [data-navigation-back]')), hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth }))()`); assert.deepEqual(profileNavigation, { brandHref: 'index.html', hasHome: true, userName: '叶用户', userHref: 'profile.html', hasAvatar: true, hasLegacyAccountLinks: false, hasRedundantControls: false, hasHorizontalOverflow: false }); console.log('BROWSER: home logo and primary navigation structure passed'); } else { 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(); } 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`); } assert.deepEqual(consoleErrors, []); } 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 }); }