feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Real login/router/API clients; synthetic delivery and business responses.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
const require=createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM??import.meta.url);
|
||||
const {chromium}=require('playwright');
|
||||
const {createServer}=await import(pathToFileURL(resolve('web/node_modules/vite/dist/node/index.js')));
|
||||
const server=await createServer({configFile:false,root:resolve('web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5325,strictPort:true}});
|
||||
await server.listen();const output=resolve('.design-evidence/operator-phone');await mkdir(output,{recursive:true});
|
||||
let browser;const checks=[],errors=[];
|
||||
try {
|
||||
browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH??'C:/Program Files/Google/Chrome/Application/chrome.exe'});
|
||||
const page=await browser.newPage({viewport:{width:1280,height:1000}});
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
let customer=true,operator=false,recentSMS=false,captcha=0,opReads=0,writes=0,sends=0;
|
||||
const tenant='10000000-0000-0000-0000-000000000011';
|
||||
await page.route('**/*',async route=>{
|
||||
const r=route.request(),path=new URL(r.url()).pathname;
|
||||
if(!path.startsWith('/api/')&&!path.startsWith('/health/'))return route.continue();
|
||||
const body=r.method()==='POST'?r.postDataJSON():undefined;
|
||||
let status=200,data={},code='OK',message='';
|
||||
if(path.startsWith('/health/'))data={status:path.endsWith('/live')?'alive':'ready',service:'api',checks:{database:'ready'}};
|
||||
else if(path==='/api/v1/version')data={version:'test'};
|
||||
else if(path==='/api/v1/web/operator/account/session'){if(operator)data={authenticated:true,csrf_token:'operator-csrf'};else{status=401;code='LOGIN_REQUIRED';}}
|
||||
else if(path==='/api/v1/web/account/session'){assert(customer);data={authenticated:true,csrf_token:'customer-csrf'};}
|
||||
else if(path==='/api/v1/web/operator/login/session')data={sms_enabled:true,password_enabled:true,signup_enabled:false,csrf_token:'operator-login-csrf'};
|
||||
else if(path==='/api/v1/web/operator/login/captcha'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');captcha++;
|
||||
data={captcha_id:`image-${captcha}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000};
|
||||
} else if(path==='/api/v1/web/operator/login/password'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.phone_e164,'+8618941657635');assert.equal(body.captcha_id,`image-${captcha}`);
|
||||
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
||||
else{assert.equal(body.password,'OperatorOnly123!');operator=true;recentSMS=false;data={csrf_token:'operator-csrf'};}
|
||||
} else if(path==='/api/v1/web/operator/login/sms/send'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.captcha_id,`image-${captcha}`);assert.equal(body.captcha_code,'654321');sends++;
|
||||
data={sms_challenge_id:'sms-op',expires_at_ms:Date.now()+300000,resend_after_ms:60000};
|
||||
} else if(path==='/api/v1/web/operator/login/sms/verify'){assert.equal(body.code,'654321');operator=true;recentSMS=true;data={csrf_token:'operator-csrf'};}
|
||||
else if(path==='/api/v1/me'){
|
||||
const op=r.headers()['x-login-portal']==='operator';
|
||||
if(op&&!operator){status=401;code='LOGIN_REQUIRED';}
|
||||
else data={identity:{subject:op?'admin':'owner',display_name:op?'测试管理员':'测试家庭用户'},accounts:op?[{tenant_id:tenant,tenant_name:'测试组织',user_id:'admin',platform_role:'ops_admin',account_kind:'operator',household_memberships:[]}]:[]};
|
||||
} else if(path==='/api/v1/web/operator/account/password'){
|
||||
assert(operator);if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');assert.equal(body.current_password,'OperatorOnly123!');writes++;}
|
||||
data={available:true,phone_masked:'+86189****7635',password_configured:true,sms_verified_recently:recentSMS};
|
||||
} else if(path==='/api/v1/web/account/password')data={available:false};
|
||||
else if(path==='/api/v1/web/operator/auth/logout'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');operator=false;}
|
||||
else if(path.startsWith('/api/v1/ops/')){
|
||||
assert(operator);assert.equal(r.headers()['x-login-portal'],'operator');assert.equal(r.headers().authorization,undefined);assert.equal(r.headers()['x-tenant-id'],tenant);
|
||||
if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');writes++;}else opReads++;
|
||||
data={items:[]};
|
||||
} else if(['/api/v1/households','/api/v1/devices'].includes(path))data={items:[]};
|
||||
else throw new Error('Unexpected API '+path);
|
||||
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message,data})});
|
||||
});
|
||||
const origin='http://127.0.0.1:5325',target='/maintenance?area=devices&record=123#detail';
|
||||
await page.goto(origin+target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||||
assert.equal(opReads,0);assert.equal(await page.locator('.maintenance-layout').count(),0);
|
||||
const phone=page.getByLabel('手机号',{exact:true});await phone.fill('18941657635');
|
||||
assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());
|
||||
const bounds=await page.locator('.login-panel').boundingBox();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
assert.deepEqual(await page.locator('.login-panel').boundingBox(),bounds);assert.equal(await phone.inputValue(),'18941657635');
|
||||
checks.push('customer session cannot enter management; both admin login modes share one card');
|
||||
await page.getByLabel('密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('图片验证码',{exact:true}).fill('000000');await page.locator('form.sms-login-form button[type=submit]').click();
|
||||
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert.equal(page.url(),origin+target);
|
||||
await page.reload();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert(opReads>0);
|
||||
checks.push('operator password/captcha login, API reads and reload retain original destination');
|
||||
await page.evaluate(async tenant=>{const {authenticatedJSON}=await import('/src/api/client.ts');await authenticatedJSON('POST','/api/v1/ops/devices','',undefined,{}, {'X-Tenant-Id':tenant});const {onboardingRequest}=await import('/src/api/onboarding.ts');await onboardingRequest('/api/v1/ops/device-inventory',{}, {tenant,token:''});},tenant);
|
||||
assert.equal(writes,2);checks.push('both operator API clients send operator CSRF and tenant without an empty Bearer');
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'登录与安全'}).click();await page.getByText('手机号:+86189****7635').waitFor();
|
||||
await page.getByLabel('当前密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('新密码',{exact:true}).fill('ChangedOperator123!');await page.getByLabel('确认新密码',{exact:true}).fill('ChangedOperator123!');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新').waitFor();
|
||||
await page.locator('.ant-drawer-close').click();
|
||||
checks.push('management account menu offers isolated password settings');
|
||||
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||||
await page.evaluate(async()=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo('/?section=profile');});await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
await page.evaluate(async target=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo(target);},target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||||
checks.push('operator logout preserves customer session; switching portals reloads the correct identity');
|
||||
await phone.fill('18941657635');await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByText(/验证码已发送/).waitFor();assert.equal(sends,1);
|
||||
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();
|
||||
checks.push('operator SMS login requires image verification and returns to management');
|
||||
operator=false;await page.evaluate(async()=>{const {platformApi}=await import('/src/api/client.ts');await platformApi.currentUser('').catch(()=>{});});await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||||
for(const width of [375,320]){await page.setViewportSize({width,height:920});await page.waitForFunction(()=>!document.querySelector('#customer-phone').disabled);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
||||
await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true});checks.push('expired management session returns to its login page; 320px and 375px layouts fit');
|
||||
assert.deepEqual(errors,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,errors},null,2));console.log(JSON.stringify({checks,errors}));
|
||||
}finally{await browser?.close();await server.close();}
|
||||
Reference in New Issue
Block a user