Files
juzi/h5/test/guest-privacy.mjs
T

141 lines
9.9 KiB
JavaScript

// Isolated backend and fixture-backed analysis. No production data or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-guest-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {}; vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'),vm=require('vm'),w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE; let calls=0;
global.fetch=async(url,options)=>({ok:true,status:200,json:async()=>options.body.has('left_side_image_file')&&++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
{request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}});
require('./server.js');
`], { cwd: h5, env: { ...process.env, NODE_ENV: 'test', PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
function diskSnapshot(dir = dataRoot, prefix = '') {
const files = {};
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const name = prefix + entry.name;
if (entry.isDirectory()) Object.assign(files, diskSnapshot(path.join(dir, entry.name), name + '/'));
else files[name] = fs.readFileSync(path.join(dir, entry.name)).toString('base64');
}
return files;
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
const base = 'http://127.0.0.1:18115/';
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = []; page.on('pageerror', e => errors.push(e.message));
const guestRequests = [];
page.on('request', r => { if (r.url().endsWith('/api/auth/browser')) guestRequests.push(r.url()); });
await page.addInitScript(() => {
localStorage.setItem('juzi.identity.v1', 'old-guest-token');
localStorage.setItem('juzi.reports.v1', '[{"id":"old-report"}]');
localStorage.setItem('juzi.profile.v1.browser.old', JSON.stringify({ gender: '男' }));
});
const baseline = diskSnapshot();
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'old guest token cannot silently restore login');
const artifacts = process.env.H5_ARTIFACT_DIR;
async function screenshot(name) {
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' });
}
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await screenshot('guest-entry'); }
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert.equal((await page.locator('[data-action="guest-experience"]').textContent()).trim(), '游客登陆');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
assert.equal(await page.locator('[data-action="logout-wechat"]:visible').count(), 0, 'guests have no logout control');
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
assert.equal(await page.evaluate(() => Object.keys(localStorage).some(k => k.startsWith('juzi.profile.v1.browser.'))), false);
assert.equal((await page.context().cookies()).some(c => c.name === 'juzi_session'), false);
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await screenshot('guest-home');
}
await page.click('[data-action="open-privacy"]'); await page.waitForSelector('#p-privacy.show');
assert.equal(await page.locator('#privacy-policy-link').textContent(), '隐私协议');
const linkStyle = await page.locator('#privacy-policy-link').evaluate(el => ({ weight: getComputedStyle(el).fontWeight, decoration: getComputedStyle(el).textDecorationLine }));
assert(Number(linkStyle.weight) >= 600 && linkStyle.decoration.includes('underline'));
assert.equal(await page.locator('.privacy-text').evaluate(el => getComputedStyle(el).textDecorationLine), 'none');
if (artifacts) await screenshot('welcome-privacy');
await page.click('#privacy-policy-link'); await page.waitForSelector('#p-policy.show');
assert.equal(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')), false, 'opening agreement does not tick consent');
await page.click('[data-action="close-policy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(guestRequests.length, 0, 'no temporary server session before consent');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal(guestRequests.length, 1, 'temporary access is created only after welcome consent');
const cookie = (await page.context().cookies()).find(c => c.name === 'juzi_session');
assert.equal(cookie.expires, -1, 'temporary cookie is non-persistent');
await page.reload(); await page.waitForSelector('#s-splash.active');
const api = page.context().request;
const data = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
profile: { real_age: 30, gender: '女', region: '北京市', daily_skin_type: '干性肌肤' } };
const guestResponse = await api.post(base + 'api/skin-analyze', { data });
assert.equal(guestResponse.headers()['cache-control'], 'no-store');
const guest = await guestResponse.json();
assert.equal(guest.ok, true); assert.equal(guest.report_saved, false); assert(guest.vm && guest.presentation);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 0);
assert.equal((await api.get(base + 'api/reports/' + guest.record_id)).status(), 404);
const failed = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(failed.ok, false);
await page.waitForTimeout(150); assert.deepEqual(diskSnapshot(), baseline, 'guest success/failure writes no identity, report, photo, profile, raw result or admin record');
await page.click('[data-action="history"]');
assert((await page.locator('#identity-note').textContent()).includes('不保存个人信息和检测记录'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
if (artifacts) await screenshot('guest-history');
// Same backend and payload: WeChat must continue to retain complete reports.
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const saved = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(saved.ok, true); assert.equal(saved.report_saved, true);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1);
assert.equal((await (await api.get(base + 'api/reports/' + saved.record_id)).json()).report.id, saved.record_id);
assert(fs.existsSync(path.join(dataRoot, 'records.jsonl')));
assert(fs.existsSync(path.join(dataRoot, 'photos', saved.record_id, 'front.jpg')));
// Visual sample only; not another analysis call.
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#care-list').children.length > 1);
await page.evaluate(() => document.fonts.ready);
await page.locator('.card-care').scrollIntoViewIfNeeded();
const spacing = await page.locator('.card-care').evaluate(el => {
const heading = el.querySelector('.card-heading'), body = el.querySelector('#care-list');
return { margin: parseFloat(getComputedStyle(heading).marginBottom), gap: body.getBoundingClientRect().top - heading.getBoundingClientRect().bottom };
});
assert(Math.abs(spacing.margin - 5.208333) < .1 && Math.abs(spacing.gap - 5.208333) < 1, 'daily care title gap follows current Figma');
if (artifacts) await page.locator('.card-care').screenshot({ path: path.join(artifacts, 'care-spacing.png') });
assert.deepEqual(errors, []);
console.log('PASS: four-character bold/underlined privacy link, current Figma care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
} finally {
if (browser) await browser.close();
if (child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}