// 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()=>({ok:true,status:200,json:async()=>++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, 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(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 - 9) < .1 && Math.abs(spacing.gap - 9) < 1, 'care title gap is halved from 18px to 9px'); 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, half 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 }); }