// Isolated HTTP + browser integration. Upstream Face++ is fixture-backed; no paid call. 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 http from 'node:http'; import { spawn, execSync } from 'node:child_process'; import { once } from 'node:events'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const ReportUI = require('../js/report-ui.js'); 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-report-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; global.fetch=async()=>({ok:true,status:200,json:async()=>({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: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); let browser; try { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('test server startup timeout')), 10000); child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } }); child.once('exit', code => { clearTimeout(timeout); reject(new Error('server exited: ' + code)); }); }); const base = 'http://127.0.0.1:18089/'; browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 375, height: 726 } }); await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(base); await page.waitForSelector('#s-splash.active'); 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); assert.equal(result.report_saved, true); assert(result.vm && result.presentation); assert(result.vm.skinAgeRaw < 32); assert.equal(result.vm.skinAgeDisplay, 57); assert.equal(result.vm.essence.tier, 1); // Raw age, not actual60 or display57. assert.equal(result.presentation.product.tier, 1); const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record; assert.deepEqual(adminRecord.presentation, result.presentation); assert.deepEqual(adminRecord.recommendation, result.vm.recommendation); assert.equal(adminRecord.skin_age, result.vm.skinAgeRaw); assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay); for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S); const list = await (await api.get(base + 'api/admin/records')).json(); assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw); assert.equal(list.items[0].skin_age_display, 57); assert.deepEqual(list.items[0].recommendation, result.vm.recommendation); const csv = await (await api.get(base + 'api/admin/export')).text(); assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄')); assert(csv.includes(result.vm.recommendation.version)); const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json(); assert.equal(snapshot.report.photos.length, 3); assert.deepEqual(snapshot.report.presentation, result.presentation); assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps); assert.equal(snapshot.report.photos[0], fixture.photos[0]); const desktop = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); desktop.on('pageerror', e => errors.push(e.message)); await desktop.goto(base + 'admin.html'); const row = desktop.locator('#tbl-records tbody tr').first(); await row.waitFor(); assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁')); assert((await row.textContent()).includes('展示 57岁')); assert((await row.textContent()).includes("1'me 主场精华 I(25+)")); for (const key of ['firm', 'vita', 'stab']) { assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level])); } assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']); if (process.env.H5_ARTIFACT_DIR) { fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true }); await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-list.png'), fullPage: true }); } await desktop.selectOption('#f-dim', 'firm'); await desktop.selectOption('#f-dimlevel', adminRecord.dims.firm.level); const filtered = desktop.waitForResponse(r => r.url().includes('/api/admin/records?')); await desktop.click('#btn-search'); assert.equal((await (await filtered).json()).total, 1); await row.click(); await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据')); const detailText = await desktop.locator('#d-body').textContent(); assert(detailText.includes(result.vm.recommendation.reason)); assert(detailText.includes('前端展示肌龄 57岁')); for (const view of result.presentation.views) assert(detailText.includes(view.text)); for (const care of result.presentation.care) assert(detailText.includes(care.text)); if (process.env.H5_ARTIFACT_DIR) { await desktop.locator('.d-sec').filter({ hasText: '使用原始肌龄' }).scrollIntoViewIfNeeded(); await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-recommendation.png') }); } await desktop.click('#btn-close'); await desktop.click('[data-tab="stats"]'); await desktop.waitForFunction(() => document.querySelector('#dim-dist').textContent.includes('透亮感')); assert(!(await desktop.locator('#dim-dist').textContent()).includes('预警')); // A separate browser cannot access this user's report, even knowing its ID. const other = await browser.newContext(); assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401); await other.request.post(base + 'api/auth/browser', { data: { consent: true } }); assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 404); for (const route of ['data/accounts/identities.json', 'lib/accounts.js', 'server.js', 'test/accounts.js', '%2edata/identities.json']) { assert([403,404].includes((await api.get(base + route)).status()), route); } // Raw HTTP avoids URL clients normalizing away the malicious path under test. for (const route of ['//data/accounts/identities.json', '/%2fdata/accounts/identities.json', '/%5cdata/accounts/identities.json', '//lib/accounts.js', '/%2fserver.js', '/assets/../data/accounts/identities.json']) { const status = await new Promise((resolve, reject) => { http.get({ hostname: '127.0.0.1', port: 18089, path: route }, r => { r.resume(); resolve(r.statusCode); }).on('error', reject); }); assert([403,404].includes(status), route); } await page.click('[data-action="history"]'); await page.click('[data-report-id]'); await page.waitForSelector('#s-report.active'); await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth)); const imagesBefore = await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src)); const copyBefore = await page.locator('.ditem-para').allTextContents(); await page.reload(); await page.waitForSelector('#s-splash.active'); await page.click('[data-action="history"]'); await page.click('[data-report-id]'); await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth)); assert.deepEqual(await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src)), imagesBefore); assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copyBefore); assert.equal(await page.locator('#product-title').textContent(), result.presentation.product.title); await page.goBack(); await page.waitForSelector('#s-history.active'); await page.goBack(); await page.waitForSelector('#s-splash.active'); // Simulate an old, immutable report whose original product was II, even though // today's rule would choose I. Admin must reuse its saved frontend copy, too. const recordFile = path.join(dataRoot, 'records.jsonl'); const record = JSON.parse(fs.readFileSync(recordFile, 'utf8').trim()); const snapshotFile = path.join(dataRoot, 'accounts/reports', record.owner_id, result.record_id + '.json'); const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); legacy.presentation.product = { ...ReportUI.products[1], image: 'assets/img/ui-20261009/product-35.png' }; legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。'; legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] }; delete legacy.vm.recommendation; fs.writeFileSync(snapshotFile, JSON.stringify(legacy)); record.essence = legacy.vm.essence; delete record.presentation; delete record.recommendation; delete record.skin_age_display; fs.writeFileSync(recordFile, JSON.stringify(record) + '\n'); const storedBytes = fs.readFileSync(snapshotFile, 'utf8'); const recordBytes = fs.readFileSync(recordFile, 'utf8'); await page.click('[data-action="history"]'); await page.click('[data-report-id]'); await page.waitForSelector('#s-report.active'); assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title); assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy'); assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text)); await desktop.goto(base + 'admin.html'); await desktop.locator('#tbl-records tbody tr').first().click(); await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文')); assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)')); assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes); assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes); // Missing skin age must not leave the previous product visible in the H5. delete legacy.payload.result.skin_age; const SkinScore = require('../js/skin-score.js'); legacy.vm = SkinScore.buildViewModel(legacy.payload.result, legacy.payload.side, { realAge: 60 }); legacy.presentation = ReportUI.build(legacy.vm, legacy.payload.result); fs.writeFileSync(snapshotFile, JSON.stringify(legacy)); await page.reload(); await page.waitForSelector('#s-splash.active'); await page.click('[data-action="history"]'); await page.click('[data-report-id]'); await page.waitForSelector('#s-report.active'); assert.equal(await page.locator('.card-product').isVisible(), false); assert.equal(await page.locator('#product-unavailable').isVisible(), true); assert.deepEqual(errors, []); console.log('PASS: raw-age recommendation; admin/H5 scores, ages, product, seven-view copy, care, CSV, filters; saved/reloaded and legacy immutable snapshots; missing-age UI; owner isolation/private files/back'); } finally { if (browser) await browser.close(); child.kill('SIGTERM'); if (child.exitCode === null) await once(child, 'exit'); fs.rmSync(dataRoot, { recursive: true, force: true }); }