// 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();}