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 UI/router with synthetic HTTP responses. Backend checks run separately against PostgreSQL/Redis.
|
||||
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:5324,strictPort:true}});
|
||||
const output=resolve('.design-evidence/phone-password');await mkdir(output,{recursive:true});
|
||||
await server.listen();let browser;
|
||||
const checks=[],errors=[],sentPurposes=[];
|
||||
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:900}});
|
||||
page.on('pageerror',e=>errors.push(e.message));await page.clock.install();
|
||||
let signedIn=false,smsEnabled=true,captchaCount=0,sendCount=0,passwordAttempts=0,configured=true,recentSMS=false,saveCount=0,trustedDevice=false;
|
||||
await page.route('**/api/v1/**',async route=>{
|
||||
const r=route.request(),path=new URL(r.url()).pathname,body=r.method()==='POST'?r.postDataJSON():undefined;
|
||||
let status=200,code='OK',message='',data={};
|
||||
if(path==='/api/v1/web/account/session'){if(signedIn)data={authenticated:true,csrf_token:'account-csrf'};else{status=401;code='LOGIN_REQUIRED';}}
|
||||
else if(path==='/api/v1/web/login/session')data={sms_enabled:smsEnabled,password_enabled:true,csrf_token:'login-csrf'};
|
||||
else if(path==='/api/v1/web/login/captcha'){
|
||||
assert.equal(r.headers()['x-csrf-token'],'login-csrf');captchaCount++;
|
||||
data={captcha_id:`captcha-${captchaCount}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:await page.evaluate(()=>Date.now()+120000)};
|
||||
} else if(path==='/api/v1/web/login/password'){
|
||||
passwordAttempts++;assert.equal(r.headers()['x-csrf-token'],'login-csrf');assert.equal(body.phone_e164,'+8613800000000');assert.equal(body.captcha_id,`captcha-${captchaCount}`);
|
||||
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
||||
else if(body.password!=='LocalOnly123!'){status=401;code='PASSWORD_INVALID';message='手机号或密码不正确';}
|
||||
else if(!trustedDevice){status=403;code='NEW_DEVICE_SMS_REQUIRED';message='首次在此设备登录,请使用手机验证码完成验证';}
|
||||
else{signedIn=true;recentSMS=false;data={csrf_token:'account-csrf'};}
|
||||
} else if(path==='/api/v1/web/login/sms/send'){assert.equal(body.captcha_code,'654321');assert.equal(body.captcha_id,`captcha-${captchaCount}`);sentPurposes.push(body.purpose);sendCount++;data={sms_challenge_id:'sms-challenge',expires_at_ms:await page.evaluate(()=>Date.now()+300000),resend_after_ms:60000};}
|
||||
else if(path==='/api/v1/web/login/sms/verify'){signedIn=true;trustedDevice=true;recentSMS=true;data={csrf_token:'account-csrf'};}
|
||||
else if(path==='/api/v1/web/account/password'){
|
||||
if(r.method()==='GET')data={available:true,phone_masked:'+86138****0000',password_configured:configured,sms_verified_recently:recentSMS};
|
||||
else {assert(signedIn);assert.equal(r.headers()['x-csrf-token'],'account-csrf');if(!recentSMS)assert.equal(body.current_password,'LocalOnly123!');assert.equal(body.password,'abc123');saveCount++;configured=true;recentSMS=false;}
|
||||
} else if(path==='/api/v1/me'){assert(signedIn);data={identity:{display_name:'手机号测试用户'},accounts:[]};}
|
||||
else if(path==='/api/v1/web/auth/logout'){signedIn=false;}
|
||||
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:5324',target='/?section=profile&record=123#details';
|
||||
await page.goto(origin+target);await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
||||
const phone=page.getByLabel('手机号',{exact:true}),submit=page.locator('form.sms-login-form').getByRole('button',{name:/^登\s*录$/});
|
||||
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
||||
await page.getByText(/验证码已发送/).waitFor();const smsBounds=await page.locator('.login-panel').boundingBox();const loginURL=page.url();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
assert.equal(page.url(),loginURL);assert.equal(await phone.inputValue(),'13800000000');
|
||||
const passwordBounds=await page.locator('.login-panel').boundingBox();assert.deepEqual(passwordBounds,smsBounds);
|
||||
assert.equal(await page.getByRole('button',{name:'账号登录',exact:true}).count(),0);
|
||||
checks.push('same card position and size, phone preserved, no external login or username field');
|
||||
await page.getByRole('button',{name:'验证码登录',exact:true}).click();assert.equal(await phone.inputValue(),'13800000000');assert.equal(sendCount,1);assert(await page.getByRole('button',{name:/秒后重发/}).isDisabled());
|
||||
checks.push('switching back preserves SMS challenge and resend countdown');
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByLabel('密码',{exact:true}).fill('LocalOnly123!');
|
||||
const beforeRejected=captchaCount;assert(await submit.isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('000000');await submit.click();
|
||||
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#customer-image-code').value==='');
|
||||
assert.equal(passwordAttempts,1);assert.equal(captchaCount,beforeRejected+1);
|
||||
checks.push('captcha is required and refreshed after a rejected login');
|
||||
await page.getByRole('button',{name:'换一张',exact:true}).click();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);assert.equal(captchaCount,beforeRejected+2);
|
||||
await page.clock.fastForward(121000);await page.getByText('图片验证码已过期,请换一张',{exact:true}).waitFor();
|
||||
assert(await submit.isDisabled());await page.getByRole('button',{name:'换一张',exact:true}).click();
|
||||
checks.push('manual refresh and captcha expiry prevent stale submissions');
|
||||
for(const width of [375,320]){await page.setViewportSize({width,height:850});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
||||
checks.push('password and captcha fit 320px and 375px screens');
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await submit.click();
|
||||
await page.getByText('首次在此浏览器登录,请完成手机验证码验证。验证后记住此浏览器 30 天。',{exact:true}).waitFor();
|
||||
assert.equal(signedIn,false);assert.equal(await phone.inputValue(),'13800000000');assert.equal(await page.getByLabel('密码',{exact:true}).count(),0);
|
||||
await page.screenshot({path:resolve(output,'new-device-sms-mobile.png')});
|
||||
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
||||
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并登录'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||||
checks.push('new device redirects to existing LOGIN SMS verification without granting a password-only session');
|
||||
assert.equal(page.url(),origin+target);await page.reload();await page.getByText('手机号:+86138****0000').waitFor();
|
||||
checks.push('password login and reload restore the original path, query and fragment');
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc12');await page.getByLabel('确认新密码',{exact:true}).fill('abc12');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码须为 6–64 个字符').waitFor();assert.equal(saveCount,0);
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新',{exact:true}).waitFor();assert.equal(saveCount,1);
|
||||
checks.push('profile allows password changes with current password and account CSRF');
|
||||
await page.getByRole('button',{name:'打开账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
||||
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('button',{name:'忘记密码'}).click();
|
||||
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并重置密码'}).click();
|
||||
await page.getByRole('dialog',{name:'重置密码'}).waitFor();assert.equal(await page.getByLabel('当前密码',{exact:true}).count(),0);
|
||||
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('mismatch');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('两次输入的密码不一致').waitFor();assert.equal(saveCount,1);
|
||||
await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();assert.equal(saveCount,2);assert.equal(page.url(),origin+target);
|
||||
assert.deepEqual(sentPurposes,['LOGIN','LOGIN','CHANGE_PASSWORD']);
|
||||
checks.push('forgot password uses the password-change template purpose, verifies SMS, confirms the new password, and preserves destination');
|
||||
signedIn=false;smsEnabled=false;await page.goto(origin+'/login?returnTo='+encodeURIComponent(target));await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||||
checks.push('password login remains available when SMS delivery is not configured');
|
||||
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