feat(auth): clarify login identity and speed up mobile entry

This commit is contained in:
xsl
2026-10-12 11:01:49 +08:00
parent 85790ef985
commit d2931a41e4
28 changed files with 1903 additions and 1046 deletions
+3
View File
@@ -22,6 +22,7 @@ await page.addInitScript(() => {
});
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: base + 'vendor/faceapi/face-api.js' });
// Real models from production paths, actual landmark inference on the sample image.
const detected = await page.evaluate(async data => {
await Promise.all([
@@ -35,11 +36,13 @@ try {
assert(detected && detected.landmarks === 68 && detected.score > 0.3);
console.log('PASS: real production models load and detect sample face with 68 landmarks');
// Keep subsequent error-flow uploads independent of angle validation.
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
await page.evaluate(() => { window.faceapi = undefined; });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
+1
View File
@@ -119,6 +119,7 @@ try {
await page.locator('#login-consent').check();
await page.locator('[data-action="login-browser"]').click();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
+156
View File
@@ -0,0 +1,156 @@
// Isolated server/data; no production account, report or paid API call.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import zlib from 'node:zlib';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-login-perf-'));
const child = spawn(process.execPath, ['server.js'], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
env: { ...process.env, PORT: '18091', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' },
stdio: ['ignore', 'pipe', 'pipe']
});
const base = 'http://127.0.0.1:18091/';
let browser;
async function raw(route, headers = {}) {
return new Promise((resolve, reject) => {
http.get(base + route, { headers }, response => {
const chunks = [];
response.on('data', data => chunks.push(data));
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }));
}).on('error', reject);
});
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(new Error('server exited: ' + code)); });
});
for (const route of ['index.html', 'js/app.js', 'css/ui-20261009.css', 'vendor/faceapi/face-api.js']) {
const plain = await raw(route);
const compressed = await raw(route, { 'Accept-Encoding': 'gzip' });
assert.equal(compressed.status, 200);
assert.equal(compressed.headers['content-encoding'], 'gzip');
assert.equal(compressed.headers.vary, 'Accept-Encoding');
assert.equal(compressed.headers['cache-control'], 'no-cache');
assert.deepEqual(zlib.gunzipSync(compressed.body), plain.body);
assert(compressed.body.length < plain.body.length * .5, route);
const cached = await raw(route, { 'Accept-Encoding': 'gzip', 'If-None-Match': compressed.headers.etag });
assert.equal(cached.status, 304); assert.equal(cached.body.length, 0);
assert.equal((await raw(route, { 'Accept-Encoding': 'gzip;q=0' })).headers['content-encoding'], undefined);
}
const session = await raw('api/auth/session', { 'Accept-Encoding': 'gzip' });
assert.equal(session.headers['cache-control'], 'no-store');
assert.equal(session.headers.etag, undefined);
assert.equal(session.headers['content-encoding'], undefined);
assert.equal((await raw('data/accounts/identities.json')).status, 403);
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [], requests = [];
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => requests.push(new URL(request.url()).pathname));
// If the old eager script is reintroduced, it will fail instead of silently
// passing on a fast developer machine. Login must not depend on this request.
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150,
downloadThroughput: 250000, uploadThroughput: 125000 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
const start = Date.now();
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
const readyMs = Date.now() - start;
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /faceapi|models|\.woff2$|bg-splash\.jpg$/.test(p)), JSON.stringify(requests));
const resourceBytes = await page.evaluate(() => performance.getEntriesByType('resource').reduce((sum, r) => sum + r.encodedBodySize, 0));
assert(resourceBytes < 350000, 'login resource budget exceeded: ' + resourceBytes);
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
const artifacts = process.env.H5_ARTIFACT_DIR;
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, 'browser-login-success.png') });
}
await page.keyboard.press('Escape');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
for (const width of [320, 375, 390, 480]) {
await page.setViewportSize({ width, height: 726 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
const badge = await page.locator('.account-status-home').boundingBox();
const slogan = await page.locator('#s-splash .splash-slogan').boundingBox();
assert(badge.y + badge.height <= slogan.y, 'account badge overlaps home slogan at ' + width);
}
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /faceapi|models|\.woff2$/.test(p)), JSON.stringify(requests));
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '本机浏览器已登录');
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
await page.click('#s-history [data-action="switch-login"]');
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.deepEqual(errors, []);
console.log('PASS: gzip/304/revalidation, no-store private API, login without fonts/face engine, success dialog/Escape/status/switching/layout; throttled cold login=' + readyMs + 'ms, resources=' + resourceBytes + ' bytes');
// Lazy engine loading must still support real models and retry after failure.
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
await page.goBack();
await page.goBack();
await page.waitForSelector('#s-splash.active');
await page.evaluate(() => { navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('test denial', 'NotAllowedError')); });
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector);
await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.waitForTimeout(400);
}
await page.click('[data-action="submit-profile"]');
await page.click('[data-action="start-camera"]');
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('系统相机'));
await page.waitForTimeout(300);
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 1, 'engine must load only when entering camera');
await page.goBack();
await page.waitForSelector('#s-profile.active');
await page.unroute('**/vendor/faceapi/face-api.js');
await page.click('[data-action="submit-profile"]');
await page.click('[data-action="start-camera"]');
await page.waitForFunction(() => window.faceapi && faceapi.nets.tinyFaceDetector.isLoaded && faceapi.nets.faceLandmark68Net.isLoaded);
assert.equal(requests.filter(p => p.endsWith('/face-api.js')).length, 2, 'failed lazy engine should be retried');
const sample = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/C3_180.jpg', import.meta.url)));
const face = await page.evaluate(async data => {
const image = await faceapi.fetchImage(data);
const found = await faceapi.detectSingleFace(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
return found ? found.landmarks.positions.length : 0;
}, 'data:image/jpeg;base64,' + sample.toString('base64'));
assert.equal(face, 68, 'lazy-loaded engine must perform real landmark inference');
console.log('PASS: no engine at login; camera loads engine/models; failed script retries; real 68-point inference works');
// A stalled session request must leave a recoverable message, not spin forever
// or discard the saved browser credential. Only this test shortens the timeout.
const token = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
await page.addInitScript(() => {
const original = window.setTimeout;
window.setTimeout = (fn, ms, ...args) => original(fn, ms === 12000 ? 100 : ms, ...args);
});
await page.route('**/api/auth/session', () => {});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('网络响应超时'));
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), token);
assert.equal(await page.locator('#p-login-success.show').count(), 0);
console.log('PASS: stalled session timeout is visible and preserves browser identity');
} finally {
if (browser) await browser.close();
const stopped = once(child, 'exit'); child.kill(); await stopped;
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+1
View File
@@ -37,6 +37,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = context.request;
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
+1
View File
@@ -42,6 +42,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = page.context().request;
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 60, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
+4
View File
@@ -24,6 +24,10 @@ await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.waitForSelector('#p-login-success.show');
await page.screenshot({ path: new URL('02-login-success.png', root).pathname });
await page.click('[data-action="login-confirm"]');
await page.waitForTimeout(400);
await page.waitForFunction(() => !document.querySelector('#toast').classList.contains('show'));
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('02-home.png', root).pathname });
+24
View File
@@ -56,6 +56,23 @@ try {
const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm,
presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null],
payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } };
async function confirmLogin(page, kind) {
await page.waitForSelector('#p-login-success.show');
assert.equal(await page.locator('#login-success-title').textContent(), '登录成功');
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:本机浏览器登录'));
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '本机浏览器已登录');
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await page.waitForLoadState('networkidle');
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, kind + '-login-success.png'), animations: 'disabled' });
}
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'login-confirm');
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash').evaluate(e => e.inert), false);
}
for (let device = 0; device < 2; device++) {
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
const page = await context.newPage();
@@ -76,6 +93,7 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
browserId = await page.evaluate(() => AccountClient.user().id);
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
@@ -89,12 +107,14 @@ try {
await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
await page.waitForFunction(() => !location.search.includes('login_error'));
upstreamError = false;
}
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
await page.waitForFunction(() => !location.search.includes('login=success'));
const user = await page.evaluate(() => AccountClient.user());
assert.equal(user.kind, 'wechat');
@@ -104,6 +124,7 @@ try {
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '微信已登录');
await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0);
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved)));
@@ -117,12 +138,14 @@ try {
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
await page.click('[data-action="history"]');
await page.click('[data-action="switch-login"]');
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
@@ -137,6 +160,7 @@ try {
await page.reload(); // Ordinary browser restore is still supported.
await page.waitForSelector('#s-splash.active');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
}
assert.deepEqual(errors, []);
await context.close();