157 lines
9.8 KiB
JavaScript
157 lines
9.8 KiB
JavaScript
// 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 });
|
|
}
|