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