feat(auth): clarify login identity and speed up mobile entry
This commit is contained in:
@@ -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"]');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user