feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+57
View File
@@ -0,0 +1,57 @@
// Synthetic customer data for layout checks; all API traffic is intercepted.
export const id = (n) => `10000000-0000-0000-0000-${String(n).padStart(12, "0")}`;
const now = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86400000).toISOString();
const capabilities = ["household.read", "household.manage", "member.manage", "subject.read", "subject.write", "consent.manage", "device.read", "device.manage", "conversation.read", "story.review", "privacy.read", "privacy.manage", "privacy.request", "recording.access.manage", "sharing.manage"];
export const households = ["林家的安心陪伴空间", "陈家的家庭空间", "外婆的家庭空间"].map((name, i) => ({ id: id(200 + i), tenant_id: id(1), name, status: "active", role: "owner", capabilities, version: 1, created_at: now, updated_at: now }));
export const currentUser = { identity: { issuer: "http://localhost:58180/realms/voice-local", subject: "mobile-fixture", display_name: "林家的家庭管理员", preferred_username: "mobile-fixture" }, accounts: [{ user_id: id(100), tenant_id: id(1), tenant_name: "家庭空间", policy_version: "2026-09", platform_role: "customer", account_kind: "customer", display_name: "林家的家庭管理员", household_memberships: households.map((h) => ({ household_id: h.id, household_name: h.name, role: h.role, capabilities })) }] };
export const subjects = ["林秀兰", "陈建国", "王素梅"].map((preferred_name, i) => ({ id: id(400 + i), household_id: id(200), preferred_name, language: "zh-CN", timezone: "Asia/Shanghai", profile_data: { background: "退休后住在杭州,喜欢和家人分享以前的工作与生活经历。", interests: ["养花", "旅行", "听戏曲"], relationship: "妈妈", birth_year: 1948, city: "杭州" }, conversation_preferences: { conversation_goal: "轻松聊聊当天的生活与年轻时的工作", pace: "slow", preferred_topics: ["家乡", "老朋友"], avoided_topics: ["疾病"] }, status: "active", version: 1, created_at: now, updated_at: now }));
export const devices = subjects.map((s, i) => ({ id: id(500 + i), binding_id: id(510 + i), household_id: id(200), subject_id: s.id, subject_name: s.preferred_name, model: i ? "安心陪伴音箱" : "Companion-Speaker-Pro-2026-Long-Model-Name", status: "registered", firmware_version: "1.2.0", desired_revision: 1, reported_revision: 1, last_reported_at: now, bound_at: now, registered_at: now, version: 1 }));
export const members = ["林家的家庭管理员", "陈建国的家人"].map((display_name, i) => ({ id: id(300 + i), user_id: id(100 + i), display_name, role: i ? "editor" : "owner", status: "active", joined_at: now, version: 1, is_owner: !i }));
export const conversations = Array.from({ length: 7 }, (_, i) => ({ id: id(600 + i), subject_id: id(400), device_id: id(500), status: "COMPLETED", runtime_type: "legacy_voicechat", engine_mode: "s2s_hybrid", retention_mode: "transcript_and_audio", audio_retention_enabled: true, started_at: new Date(Date.now() - i * 86400000 - 1200000).toISOString(), ended_at: new Date(Date.now() - i * 86400000).toISOString(), duration_ms: 1200000, created_at: now, updated_at: now }));
export const stories = ["年轻时第一次独自乘火车去杭州的故事", "和老朋友一起种花的春天", "留给家人的童年回忆"].map((title, i) => ({ id: id(700 + i), subject_id: id(400), conversation_id: id(600), title, content: "那时候的火车站很热闹,第一次独自出门的时候,家人送我到了站台。后来结识了新朋友,我们一起工作、生活,留下许多珍贵的回忆。".repeat(5), status: "CANDIDATE", sensitivity: "normal", confidence: 0.92, generation_version: 1, revision_no: 1, author_type: "model", version: 1, created_at: now, updated_at: now, sources: [], facts: [{ id: id(750 + i), fact_type: "life_event", content: "年轻时在杭州工作", sensitivity: "normal", source_turn_ids: [id(650)], status: "CANDIDATE", version: 1 }] }));
const memories = [{ id: id(800), subject_id: id(400), content: "喜欢养花,和家人在杭州生活,每周都会和老朋友见面。", source_refs: [], status: "ACTIVE", sensitivity: "normal", version_no: 1, version: 1, confirmed_by: id(100), confirmed_at: now, index_status: "ACTIVE", provider_index_status: "DISABLED", created_at: now, updated_at: now }];
const consents = ["transcript_storage", "raw_audio_storage", "story_extraction", "personalized_memory", "family_sharing"].map((type, i) => ({ id: id(900 + i), subject_id: id(400), type, scope: {}, effective: true, version: 1, granted_at: now, policy_version: "2026-09", notice_digest: "synthetic-fixture", actor_user_id: id(100) }));
export async function installCustomerFixture(context, { empty = false, failure = false, authenticated = true } = {}) {
const unexpected = [];
if (authenticated) await context.addInitScript(({ currentUser }) => {
sessionStorage.setItem("oidc.user:http://localhost:58180/realms/voice-local:voice-platform-web", JSON.stringify({ access_token: "synthetic-mobile-layout-token", token_type: "Bearer", profile: { sub: "mobile-fixture", ...currentUser.identity }, expires_at: Math.floor(Date.now() / 1000) + 3600 }));
}, { currentUser });
const list = (items) => ({ items: empty ? [] : items });
await context.route("**/api/v1/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace("/api/v1", "");
if (request.method() !== "GET") { unexpected.push(`${request.method()} ${path}`); return route.abort(); }
if (path === "/events") return route.fulfill({ contentType: "text/event-stream", body: ": mobile layout fixture\n\n" });
if (failure && path === "/households") return route.fulfill({ status: 503, json: { error: { code: "unavailable", message: "家庭资料暂时无法读取,请稍后重试。" } } });
let data;
if (path === "/me") data = currentUser;
else if (path === "/households") data = list(households);
else if (path === "/devices") data = list(devices);
else if (/\/households\/[^/]+\/subjects$/.test(path)) data = list(subjects);
else if (/\/members$/.test(path)) data = list(members);
else if (/\/invitations$/.test(path)) data = list([{ id: id(350), household_id: id(200), invitee_hint: "邀请一起照顾长者的家人", role: "viewer", status: "pending", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/facts$/.test(path)) data = list([{ id: id(950), subject_id: id(400), fact_type: "background", content: "年轻时在杭州工作,喜欢种花和听戏曲。", source_type: "family_entry", confirmation_status: "pending", sensitivity: "normal", version: 1, created_at: now, updated_at: now }]);
else if (/\/consents$/.test(path)) data = list(consents);
else if (/\/conversation-readiness$/.test(path)) data = { subject_id: id(400), ready: true, evaluated_at: now, checks: [{ code: "PROFILE_BACKGROUND", status: "ready", blocking: false, title: "陪伴资料已完善", detail: "已填写背景与交流偏好" }], devices, start_time_checks: [] };
else if (/\/subjects\/[^/]+\/conversations$/.test(path)) data = list(conversations);
else if (/\/conversations\/[^/]+$/.test(path)) data = { ...(conversations.find((c) => path.endsWith(c.id)) || conversations[0]), household_id: id(200), snapshots: { consent_snapshot_id: id(901), consent_snapshot_version: 1, context_snapshot_id: id(902), profile_version: 1, prompt_bundle_version: "mobile-qa", context_preparation_status: "READY" }, history: [{ from_status: "ACTIVE", to_status: "COMPLETED", reason: "user_hangup", source: "device", occurred_at: now }] };
else if (/\/turns$/.test(path)) data = list(Array.from({ length: 6 }, (_, i) => ({ id: id(650 + i), sequence_no: i + 1, speaker: i % 2 ? "agent" : "subject", start_at: now, end_at: now, final_text: i % 2 ? "能和我分享一下当时的经历吗?" : "那时候我第一次独自乘火车出门,到杭州后认识了好多新朋友。".repeat(3), interrupted: false, source_type: "device" })));
else if (/\/summaries$/.test(path)) data = list([{ id: id(660), type: "conversation_summary", content: "长者聊起在杭州工作与养花的经历,心情轻松。关键词:杭州、工作、养花。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }, { id: id(661), type: "open_topic", content: "下次可以继续聊聊老朋友与家乡的春天。", source_turn_ids: [id(650)], model_ref: "mobile-layout-fixture", prompt_version: "v1", generation_version: 1, status: "READY" }]);
else if (/\/usage$/.test(path)) data = { conversation_id: id(600), currency: "CNY", cost_micros: 120000, lines: [] };
else if (/\/media$/.test(path)) data = { conversation_id: id(600), segments: ["near", "far"].map((track, i) => ({ id: id(680 + i), track, sequence_no: 1, duration_ms: 1200000, expected_size: 38400000, status: "VERIFIED" })), can_play: true, can_download: true };
else if (/\/subjects\/[^/]+\/stories$/.test(path)) data = list(stories);
else if (/\/stories\/[^/]+$/.test(path)) data = stories.find((s) => path.endsWith(s.id));
else if (/\/memories$/.test(path)) data = list(memories);
else if (/\/(privacy-requests|exports|recording-access-grants|content-models)$/.test(path)) data = list([]);
else if (path === "/sharing-grants") data = list([{ id: id(980), subject_id: id(400), resource_type: "STORY", resource_id: id(700), permission: "VIEW_TEXT", purpose: "与家人分享珍贵的童年回忆", grantee_user_id: id(101), login_required: true, status: "ACTIVE", expires_at: tomorrow, created_at: now, version: 1 }]);
else if (/\/presence$/.test(path)) data = { device_id: id(500), online: true, status: "online", last_seen_at: now, last_heartbeat_at: now };
else if (/\/twin$/.test(path)) data = { device_id: id(500), desired_revision: 1, reported_revision: 1, desired_config: { speaker_volume: 60 }, reported_config: { speaker_volume: 60 }, updated_at: now };
else if (/\/usage-overview$/.test(path)) data = { from: url.searchParams.get("from"), to: url.searchParams.get("to"), timezone: "Asia/Shanghai", scope: { household_name: households[0].name }, total_duration_ms: 8400000, today_duration_ms: 1200000, average_duration_ms: 1200000, conversation_count: 7, active_days: 7, current_streak_days: 7, longest_streak_days: 7, cost_micros: 1200000, currency: "CNY", cost_available: true, daily: empty ? [] : conversations.map((c) => ({ date: c.started_at.slice(0, 10), duration_ms: c.duration_ms, conversation_count: 1, cost_micros: 120000 })), devices: empty ? [] : devices.map((d) => ({ id: d.id, name: d.model, conversation_count: 7, duration_ms: 8400000, cost_micros: 1200000 })), recent_conversations: empty ? [] : conversations.map((c) => ({ ...c, subject_name: subjects[0].preferred_name, device_name: devices[0].model, cost_micros: 120000 })) };
else { unexpected.push(`${request.method()} ${path}`); return route.fulfill({ status: 404, json: { error: { message: `Missing layout fixture: ${path}` } } }); }
return route.fulfill({ json: { request_id: "mobile-layout-fixture", data } });
});
return unexpected;
}
+60
View File
@@ -0,0 +1,60 @@
// Synthetic browser tests: all business APIs are intercepted; no real SMS or
// provider calls. Backend transactions are covered by Go integration tests.
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {mkdir,writeFile} from 'node:fs/promises';
import {resolve} from 'node:path';
const require=createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM??import.meta.url);
const {chromium}=require('playwright');
const base=process.env.ONBOARDING_WEB_URL??'http://127.0.0.1:5319';
const output=resolve('.design-evidence/onboarding');await mkdir(output,{recursive:true});
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
const failures=[],checks=[];const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`;
async function shot(page,name){await page.evaluate(()=>document.fonts.ready);await page.waitForTimeout(250);const overflow=await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth);assert(overflow<=2,`${name} overflow ${overflow}`);await page.screenshot({path:resolve(output,name+'.png'),fullPage:true});checks.push(name);}
try{
for(const width of (process.env.OPS_ONLY?[]:[375,768])){
const context=await browser.newContext({viewport:{width,height:900}});const page=await context.newPage();page.on('pageerror',e=>failures.push(e.message));
let exchange=0,claims=0,acks=0;
const state={device_id:'KP01-AABBCCDDEEFF',platform_device_id:id(500),device_revision:3,binding_generation:0,binding_state:'UNBOUND',activation_state:'NOT_STARTED',state_hash:'a'.repeat(64),next_action:'OPEN_SETUP'};
const status={setup_id:id(600),state,authenticated:false,closed:false,sms_enabled:true,device_saved:false,expires_at_ms:Date.now()+900000};
const requests=[];
await page.route('**/api/v1/**',async route=>{
const r=route.request(),path=new URL(r.url()).pathname;let data={};let body;
if(r.method()==='POST'){body=r.postDataJSON();requests.push({path,body});assert(r.headers()['x-request-id']);if(!path.endsWith('/exchange'))assert(r.headers()['x-csrf-token']);}
if(path.endsWith('/handoffs/exchange')){exchange++;assert.equal(body.token,'once-token');assert.equal(body.verifier,'once-verifier');data={csrf_token:'test-csrf',data:status};}
else if(path.endsWith('/setup/session'))data={csrf_token:'test-csrf',data:status};
else if(path.endsWith('/setup/status'))data=status;
else if(path.endsWith('/auth/sms/send')){assert.equal(body.phone_e164,'+8613800000000');data={sms_challenge_id:id(700),resend_after_ms:60000};}
else if(path.endsWith('/auth/sms/verify')){assert.equal(body.code,'123456');status.authenticated=true;data={csrf_token:'test-csrf',data:status};}
else if(path.endsWith('/profiles'))data={items:[{household_id:id(200),household_name:'测试家庭',elder_id:id(400),preferred_name:'奶奶'}]};
else if(path.endsWith('/claims')){claims++;assert.equal(body.elder_id,id(400));state.binding_state='CLAIM_PENDING';status.claim_id=id(800);state.device_revision++;data={claim_id:status.claim_id,state};}
else if(path.endsWith('/claims/cancel')){state.binding_state='UNBOUND';status.claim_id='';state.device_revision++;data={state};}
else if(path.endsWith('/setup/ack')){acks++;assert.equal(body.binding_generation,1);data={accepted:true};}
else if(path.endsWith('/onboarding/devices'))data={items:[{...state,household_name:'测试家庭',preferred_name:'奶奶'}]};
else {failures.push('Unexpected mobile API '+path);await route.abort();return;}
await route.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,code:'OK',data})});
});
await page.goto(base+'/onboarding/entry#token=once-token&verifier=once-verifier');
await page.getByRole('button',{name:'获取验证码',exact:true}).waitFor();assert.equal(exchange,1);assert(!page.url().includes('#'));await shot(page,`${width}-login`);
await page.getByLabel('手机号码(含国家区号)').fill('+8613800000000');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByRole('button',{name:/秒后可重发/}).waitFor();
await page.getByLabel('短信验证码').fill('123456');await page.locator('button[type=submit]').click();
await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).waitFor();await shot(page,`${width}-profiles`);
await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).click();await page.getByRole('button',{name:/取消/}).waitFor();await shot(page,`${width}-pending`);
await page.getByRole('button',{name:/取消/}).click();await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).waitFor();await page.getByRole('button',{name:'下一步,到设备确认',exact:true}).click();assert.equal(claims,2);
state.binding_state='BOUND';state.binding_generation=1;state.device_revision++;state.activation_state='RECONCILE_REQUIRED';status.binding_id=id(900);
await page.getByText('绑定已完成',{exact:true}).waitFor();await page.getByText('服务开通待核对',{exact:true}).waitFor();await shot(page,`${width}-bound-uncertain`);assert(acks>=1);
await page.getByRole('link',{name:'完成,查看我的设备'}).click();await page.getByText('奶奶',{exact:true}).waitFor();await shot(page,`${width}-devices`);
await context.close();
}
// Actual maintenance portal with an operator OIDC session and scoped APIs.
const context=await browser.newContext({viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('pageerror',e=>failures.push(e.message));let inventoryBody;
const principal={identity:{issuer:'http://localhost:58180/realms/voice-local',subject:'ops-fixture',display_name:'测试运维'},accounts:[{user_id:id(100),tenant_id:id(1),tenant_name:'测试组织',account_kind:'operator',platform_role:'ops_admin',household_memberships:[]}]};
await page.addInitScript(({principal})=>sessionStorage.setItem('oidc.user:http://localhost:58180/realms/voice-local:voice-platform-web',JSON.stringify({access_token:'test-ops',token_type:'Bearer',scope:'openid profile',expires_at:Math.floor(Date.now()/1000)+3600,profile:{iss:principal.identity.issuer,sub:principal.identity.subject}})),{principal});
await page.route('**/api/v1/**',async route=>{const r=route.request(),p=new URL(r.url()).pathname;let data={};if(p==='/api/v1/me')data=principal;else if(p==='/api/v1/ops/device-inventory'){assert.equal(r.headers()['x-tenant-id'],id(1));if(r.method()==='POST'){inventoryBody=r.postDataJSON();data={device_id:'KP01-AABBCCDDEEFF',platform_device_id:id(500),enrollment_job_id:id(600),released:false};}else data={items:[],limit:200};}else if(p==='/api/v1/version')data={version:'test',commit:'test'};else{failures.push('Unexpected ops API '+p);}await route.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,code:'OK',data})});});
await page.route('**/health/**',r=>r.fulfill({contentType:'application/json',body:JSON.stringify({ok:true,data:{status:'ok',dependencies:[]}})}));
await page.goto(base+'/maintenance?area=onboarding');await page.getByRole('button',{name:'登记生产设备',exact:true}).waitFor();await shot(page,'1440-ops');
await page.getByRole('button',{name:'登记生产设备',exact:true}).click();await page.getByRole('dialog').waitFor();await shot(page,'1440-inventory-dialog');
await page.getByLabel('出厂 MAC').fill('AABBCCDDEEFF');await page.getByLabel('硬件版本').fill('A1');await page.getByLabel('生产批次').fill('QA');await page.getByRole('button',{name:'确 定',exact:true}).click();
await page.getByRole('dialog').waitFor({state:'hidden',timeout:10000});assert.deepEqual(inventoryBody,{factory_mac:'AABBCCDDEEFF',hardware_model:'KP01',hardware_revision:'A1',batch_no:'QA'});
await context.close();assert.deepEqual(failures,[]);await writeFile(resolve(output,'results.json'),JSON.stringify({checks,failures},null,2));console.log(JSON.stringify({checks,failures}));
}finally{await browser.close();}
+212
View File
@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { installCustomerFixture } from "./fixtures/customer-mobile.mjs";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173";
const output = resolve(".design-evidence/customer-mobile");
const widths = (process.env.MOBILE_WIDTHS ?? "320,375,390,430,768,900,1024,1440").split(",").map(Number);
const sections = ["home", "conversations", "stories", "subjects", "messages", "members", "devices", "sharing", "privacy", "billing", "profile"];
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const results = [];
const errors = [];
async function inspect(page, name) {
await page.evaluate(() => document.fonts.ready);
const layout = await page.evaluate(() => {
const viewport = document.documentElement.clientWidth;
const overflows = [...document.querySelectorAll(".customer-page *, .customer-mobile-menu *")].filter((el) => {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height || getComputedStyle(el).visibility === "hidden") return false;
// Only these intentionally scrollable regions may exceed their own viewport.
if (el.closest(".usage-heatmap-scroll, .permission-matrix-wrap, .household-tabs, .elder-switcher, .journal-weekly__chart")) return false;
return rect.right > viewport + 2 || rect.left < -2 || (el.scrollWidth > el.clientWidth + 2 && getComputedStyle(el).overflowX === "visible" && !["SPAN", "SVG"].includes(el.tagName));
}).slice(0, 16).map((el) => ({ tag: el.tagName, class: String(el.className), width: el.clientWidth, scroll: el.scrollWidth, text: el.textContent?.slice(0, 45) }));
return { viewport, pageOverflow: document.documentElement.scrollWidth - viewport, overflows };
});
results.push({ name, ...layout });
if (layout.pageOverflow > 2 || layout.overflows.length) console.log(JSON.stringify({ name, ...layout }));
await page.screenshot({ path: resolve(output, `${name}.png`), fullPage: true });
}
async function openSection(page, section) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(200);
}
async function checkDialogs(page, width) {
const cases = [
["members", "new-household", () => page.getByRole("button", { name: "新增家庭", exact: true })],
["members", "edit-household", () => page.getByRole("button", { name: "编辑家庭名称" })],
["members", "accept-invitation", () => page.locator(".family-accept-button")],
["members", "invite-member", () => page.locator(".family-member-card--invite")],
["subjects", "new-subject", () => page.locator(".subject-add-button")],
["subjects", "edit-subject", () => page.locator("button.subject-profile-card").first()],
["subjects", "subject-operations", () => page.getByRole("button", { name: "资料与授权", exact: true })],
["devices", "bind-device", () => page.locator(".workspace__actions button").filter({ hasText: "绑定设备" })],
["devices", "device-settings", () => page.locator(".device-card__manage").first()],
["stories", "story-review", () => page.locator(".story-candidate-card").first()],
["stories", "memory-review", () => page.locator(".confirmed-memory-card > button").first()],
["sharing", "new-share", () => page.locator(".subject-list-toolbar > button").filter({ hasText: "新建分享" })],
["privacy", "correction", () => page.getByRole("button", { name: "资料更正", exact: true })],
["privacy", "delete-confirmation", () => page.getByRole("button", { name: /申请.*删除|永久删除|删除长者数据|删除全部数据|主体删除/ }).first()],
["privacy", "access-confirmation", () => page.getByRole("button", { name: "提交全量盘点", exact: true })],
["privacy", "withdraw-consents", () => page.getByRole("button", { name: "批量撤回授权", exact: true })],
["privacy", "export-confirmation", () => page.getByRole("button", { name: "生成私有导出", exact: true })],
];
for (const [section, name, trigger] of cases) {
await openSection(page, section);
await trigger().click();
const dialog = page.locator(".workspace-dialog:visible, .subject-create-modal:visible, .subject-detail-modal:visible, .subject-management-operations[open]").last();
await dialog.waitFor();
await page.waitForTimeout(100);
await inspect(page, `${width}-dialog-${name}`);
const bounds = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: window.innerHeight }; });
assert.ok(bounds.top >= -1 && bounds.bottom <= bounds.height + 1, `${name}: dialog must fit vertically`);
const controls = dialog.locator("button:visible");
await controls.last().scrollIntoViewIfNeeded();
const last = await controls.last().boundingBox();
assert.ok(last && last.y >= 0 && last.y + last.height <= page.viewportSize().height + 1, `${name}: last action must be reachable`);
const close = dialog.getByRole("button", { name: /^(关闭.*|取消)$/ }).first();
await close.click();
await dialog.waitFor({ state: "hidden" });
}
await openSection(page, "conversations");
await page.locator(".conversation-row").first().click();
await page.getByRole("button", { name: "查看完整聊天", exact: true }).click();
await inspect(page, `${width}-dialog-conversation`);
const conversation = page.getByRole("dialog");
await conversation.getByRole("button", { name: "关闭", exact: true }).click();
if (width <= 600) {
await page.getByRole("button", { name: "返回聊天列表" }).click();
await page.getByRole("button", { name: "更多筛选" }).click();
await page.getByLabel("开始日期", { exact: true }).fill("2026-08-01");
await inspect(page, `${width}-expanded-conversation-filters`);
}
}
async function checkStates() {
for (const [name, options, expected] of [
["login", { authenticated: false }, "登录家庭陪伴中心"],
["empty", { empty: true }, "还没有可访问的家庭"],
["error", { failure: true }, "家庭数据载入失败"],
]) {
const context = await browser.newContext({ viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true });
const unexpected = await installCustomerFixture(context, options);
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(webURL);
await page.getByText(expected, { exact: true }).waitFor();
await inspect(page, `320-state-${name}`);
errors.push(...unexpected);
await context.close();
}
const context = await browser.newContext({ viewport: { width: 844, height: 390 }, isMobile: true, hasTouch: true });
const unexpected = await installCustomerFixture(context);
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(String(e)));
for (const section of sections) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(150);
await inspect(page, `landscape-${section}`);
}
await openSection(page, "sharing");
await page.locator(".subject-list-toolbar > button").click();
await page.setViewportSize({ width: 390, height: 400 });
const dialog = page.getByRole("dialog");
await dialog.getByLabel("分享用途").fill("和家人一起回顾长者的珍贵记忆");
await dialog.getByRole("button", { name: "下一步确认" }).scrollIntoViewIfNeeded();
await inspect(page, "390-short-viewport-form");
const action = await dialog.getByRole("button", { name: "下一步确认" }).boundingBox();
assert.ok(action && action.y >= 0 && action.y + action.height <= 400, "Form action remains reachable in a short viewport");
await page.goto(`${webURL}/?designPreview=1`);
await inspect(page, "390-design-preview");
errors.push(...unexpected);
await context.close();
}
async function checkNavigation(page, width) {
await openSection(page, "home");
const toggle = page.getByRole("button", { name: "打开导航菜单" });
assert.equal(await toggle.getAttribute("aria-expanded"), "false");
await toggle.click();
const drawer = page.locator(".customer-mobile-menu");
await drawer.getByRole("navigation").waitFor();
await page.waitForFunction(() => {
const panel = document.querySelector(".customer-mobile-menu .ant-drawer-content-wrapper");
return panel && Math.abs(panel.getBoundingClientRect().left) < 1;
});
await inspect(page, `${width}-navigation`);
assert.equal(await drawer.getByRole("menuitem").count(), 12);
await page.keyboard.press("Escape");
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
assert.equal(await toggle.evaluate((el) => el === document.activeElement), true, "Drawer restores focus");
await toggle.click();
await drawer.getByRole("menuitem", { name: /数据与授权$/ }).click();
await page.locator(".customer-page--privacy").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await page.goBack();
await page.locator(".customer-page--home").waitFor();
await toggle.click();
const search = drawer.getByPlaceholder("搜索长者、设备、通话");
await search.fill("设备");
await search.press("Enter");
await page.locator(".customer-page--devices").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await toggle.click();
await drawer.locator(".ant-drawer-mask").click({ position: { x: width - 5, y: 100 }, force: true });
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await toggle.click();
await page.setViewportSize({ width: 1280, height: 900 });
await page.locator(".customer-sider").waitFor();
await drawer.getByRole("navigation").waitFor({ state: "hidden" });
await page.setViewportSize({ width, height: 844 });
assert.equal(await toggle.getAttribute("aria-expanded"), "false", "Resize leaves the mobile menu closed");
}
try {
for (const width of widths) {
const context = await browser.newContext({ viewport: { width, height: width < 600 ? 844 : 1024 }, isMobile: width < 600, hasTouch: width <= 900, reducedMotion: "reduce" });
const unexpected = await installCustomerFixture(context);
const page = await context.newPage();
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => errors.push(String(e)));
for (const section of process.env.MOBILE_INTERACTIONS_ONLY ? [] : process.env.MOBILE_SECTIONS?.split(",") ?? sections) {
await page.goto(`${webURL}/?section=${section}`);
await page.locator(".customer-account-button").waitFor();
if (section !== "profile") await page.locator(".household-tabs button").first().waitFor();
await page.waitForTimeout(200);
if (section === "conversations") {
await page.locator(".conversation-row").first().click();
await page.locator(".memory-summary-panel").waitFor();
await page.waitForTimeout(150);
}
if (section === "billing" && width <= 600) {
const scroll = await page.locator(".usage-heatmap-scroll").evaluate((el) => ({ left: el.scrollLeft, width: el.clientWidth, full: el.scrollWidth }));
assert.ok(scroll.left + scroll.width >= scroll.full - 2, "Mobile calendar opens at the latest dates");
await page.locator(".usage-heatmap button").last().click();
assert.equal(await page.locator(".usage-heatmap button.selected").count(), 1);
}
await inspect(page, `${width}-${section}`);
}
if (!process.env.MOBILE_SECTIONS && [320, 390, 768].includes(width)) await checkDialogs(page, width);
if (!process.env.MOBILE_SECTIONS && width === 390) await checkNavigation(page, width);
errors.push(...unexpected);
await context.close();
console.log(`Checked ${width}px`);
}
if (!process.env.MOBILE_INTERACTIONS_ONLY && !process.env.MOBILE_SECTIONS) await checkStates();
} finally {
await browser.close();
const failures = results.filter((r) => r.pageOverflow > 2 || r.overflows.length);
await writeFile(resolve(output, "report.json"), JSON.stringify({ results, errors, failures }, null, 2));
console.log(JSON.stringify({ checks: results.length, failures: failures.length, errors }));
}
assert.equal(errors.length, 0, "Browser errors or missing fixtures");
assert.equal(results.filter((r) => r.pageOverflow > 2 || r.overflows.length).length, 0, "Customer layouts must fit the viewport");