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