300 lines
21 KiB
JavaScript
300 lines
21 KiB
JavaScript
// 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;
|
||
if(process.env.H5_ACNE_MAX_TEST==='1') {
|
||
const keys=['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'];
|
||
const set=(target,counts)=>keys.forEach((k,i)=>target[k]={count:counts[i]});
|
||
set(f.result,[2,0,0,0,3]);f.result.score_info.acne_score=100;
|
||
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
|
||
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
|
||
}
|
||
let callCount=0;
|
||
global.fetch=async(url,options)=>{
|
||
callCount++;
|
||
const sideOnly=options.body.get('return_maps')==='texture_enhanced_lines';
|
||
if(sideOnly){if(options.body.has('left_side_image_file')||options.body.has('right_side_image_file'))throw Error('side wrinkle photo must be its own main image');}
|
||
if(callCount>3)throw Error('unexpected paid request count');
|
||
return{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', NODE_ENV:'test', 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.equal(result.wrinkle_views.version,'independent-side-wrinkles-v1');
|
||
assert.equal(result.wrinkle_views.left.status,'pending');assert.equal(result.wrinkle_views.right.status,'pending');
|
||
assert(result.wrinkle_job.token);
|
||
const layers = await (await api.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).json();
|
||
assert.equal(layers.ok,true);assert.equal(layers.wrinkle_views.left.status,'ready');assert.equal(layers.wrinkle_views.right.status,'ready');
|
||
const cachedLayers = await (await api.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).json();
|
||
assert.deepEqual(cachedLayers,layers);
|
||
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
|
||
assert.equal(result.vm.total, expectedTotal);
|
||
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
|
||
assert(result.vm.skinAgeRaw < 32);
|
||
const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw));
|
||
assert.equal(result.vm.skinAgeDisplay, expectedAge);
|
||
assert.equal(result.vm.essence.tier, 3); // Display age 55, not raw age below32.
|
||
assert.equal(result.presentation.product.tier, 3);
|
||
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);
|
||
assert.equal(adminRecord.total_score, expectedTotal);
|
||
assert.equal(adminRecord.total_score_rule, 'dimensions-433-v1');
|
||
assert.equal(adminRecord.grade_rule, ReportUI.gradeRule);
|
||
for (const dim of Object.values(adminRecord.dims)) assert.equal(dim.level,ReportUI.internalLevel(dim.S));
|
||
for (const item of Object.values(adminRecord.sub_scores)) assert.equal(item.level,ReportUI.internalLevel(item.S));
|
||
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
|
||
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
|
||
assert.equal(adminRecord.vita_score_rule,'combined-tone-smooth-min-v2');
|
||
assert.equal(Object.keys(adminRecord.sub_scores).length,7);
|
||
assert.equal(adminRecord.dims.vita.P,Math.min(...['tone','smooth'].map(k=>adminRecord.cats[k].P).filter(v=>v!=null)));
|
||
if(process.env.H5_ACNE_MAX_TEST==='1') {
|
||
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
|
||
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
|
||
}
|
||
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, expectedAge);
|
||
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
|
||
assert.equal(list.items[0].total_score, expectedTotal);
|
||
const csv = await (await api.get(base + 'api/admin/export')).text();
|
||
assert(csv.includes('原始肌龄,前端展示肌龄(推荐依据)'));
|
||
assert(csv.includes(result.vm.recommendation.version));
|
||
assert(csv.includes('综合得分(4:3:3)'));
|
||
assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge));
|
||
assert.equal(csv.trim().split('\n')[1].split(',')[12], String(expectedTotal));
|
||
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.wrinkle_views,layers.wrinkle_views);
|
||
assert.equal(snapshot.report.payload.wrinkle_job.status,'complete');
|
||
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
|
||
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
|
||
assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views.
|
||
assert.equal(snapshot.report.photos[2],fixture.photos[0]);
|
||
assert.equal(snapshot.report.vm.total, expectedTotal);
|
||
assert.equal(JSON.parse(fs.readFileSync(path.join(dataRoot, 'records.jsonl'), 'utf8').trim()).total_score, expectedTotal, 'new records persist weighted score');
|
||
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.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal));
|
||
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
|
||
assert((await row.textContent()).includes('展示 ' + expectedAge + '岁'));
|
||
assert((await row.textContent()).includes("1'me 主场精华 III(50+)"));
|
||
for (const key of ['firm', 'vita', 'stab']) {
|
||
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelForScore(result.vm.dims[key].S)));
|
||
}
|
||
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', { value: adminRecord.dims.firm.level });
|
||
const filtered = desktop.waitForResponse(r => r.url().includes('/api/admin/records?'));
|
||
await desktop.click('#btn-search');
|
||
const filteredResponse = await filtered;
|
||
assert.equal((await filteredResponse.json()).total, 1, filteredResponse.url() + ' expected grade=' + adminRecord.dims.firm.level);
|
||
await row.click();
|
||
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
|
||
const detailText = await desktop.locator('#d-body').textContent();
|
||
assert(detailText.includes('综合得分 ' + expectedTotal));
|
||
assert(detailText.includes('紧塑力×4+透亮感×3+稳韧力×3'));
|
||
assert(detailText.includes('痘痘三视图计分明细'));
|
||
assert(detailText.includes('黑眼圈评分×20%'));
|
||
const acneAuditRows=desktop.locator('.d-sec').filter({hasText:'痘痘三视图计分明细'}).locator('tbody tr');
|
||
assert.equal(await acneAuditRows.count(),5);
|
||
for(const [i,t] of adminRecord.acne_scoring.types.entries()) {
|
||
assert.deepEqual(await acneAuditRows.nth(i).locator('td').allTextContents(),[t.name,...['front','left','right'].map(k=>t.views[k]==null?'-':String(t.views[k])),t.count==null?'-':String(t.count),String(t.weight),t.deduction==null?'-':String(t.deduction)]);
|
||
}
|
||
if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') });
|
||
assert(detailText.includes(result.vm.recommendation.reason));
|
||
assert(detailText.includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
|
||
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');
|
||
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal));
|
||
await page.waitForFunction(()=>[...document.querySelectorAll('#contour-sides figure')].every(f=>!f.hidden));
|
||
assert.equal(await page.locator('#contour-left').getAttribute('src'),snapshot.report.photos[1]);
|
||
assert.equal(await page.locator('#contour-right').getAttribute('src'),snapshot.report.photos[2]);
|
||
if (process.env.H5_ARTIFACT_DIR) {
|
||
await page.waitForTimeout(400); // Capture after the report's entrance fade.
|
||
await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'report-weighted-total.png') });
|
||
}
|
||
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 = '历史报告原文,不应按新文案重新计算。';
|
||
delete legacy.presentation.gradeRule; // Old copy must regrade for display only.
|
||
delete legacy.presentation.copyRule;
|
||
for (const care of legacy.presentation.care) { delete care.key; delete care.level; care.text='旧护理待刷新'; }
|
||
legacy.vm.skinAgeDisplay = 57; // Old snapshot retains the retired display clamp.
|
||
legacy.vm.total = 12; // Old stored upstream total must not control the current display.
|
||
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;
|
||
record.skin_age_display = 57;
|
||
record.total_score = 12;
|
||
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('#stat-age b').textContent(), String(expectedAge), 'historical raw age uses current ±5 display rule, not saved display');
|
||
assert.equal(await page.locator('#stat-score b').textContent(), String(expectedTotal), 'old H5 report uses saved dimensions, not saved upstream total');
|
||
const legacyList = await (await api.get(base + 'api/admin/records')).json();
|
||
assert.equal(legacyList.items[0].skin_age_display, expectedAge);
|
||
assert.equal(legacyList.items[0].total_score, expectedTotal);
|
||
const legacyDetail = await (await api.get(base + 'api/admin/records/' + result.record_id)).json();
|
||
assert.equal(legacyDetail.record.skin_age_display, expectedAge);
|
||
assert.equal(legacyDetail.record.total_score, expectedTotal);
|
||
assert.equal(legacyDetail.record.presentation.gradeRule,ReportUI.gradeRule);
|
||
assert.equal(legacyDetail.record.presentation.copyRule,ReportUI.copyRule);
|
||
for (const item of legacyDetail.record.presentation.care) { assert(item.key); assert(item.level); assert(!item.text.includes('旧护理待刷新')); }
|
||
for (const view of legacyDetail.record.presentation.views) assert.equal(view.level,ReportUI.levelForScore(view.S));
|
||
const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',');
|
||
assert.equal(legacyCsv[12], String(expectedTotal));
|
||
assert.equal(legacyCsv[14], String(expectedAge));
|
||
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[2].title);
|
||
assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[2].image, 'historical product refreshes to current displayed age without rewriting stored snapshot');
|
||
assert((await page.locator('.ditem-para').allTextContents()).includes(ReportUI.forDisplay(legacy.vm,legacy.payload.result,legacy.presentation).views[0].text));
|
||
assert(!(await page.locator('#care-list').textContent()).includes('旧护理待刷新'));
|
||
await desktop.goto(base + 'admin.html');
|
||
await desktop.locator('#tbl-records tbody tr').first().click();
|
||
await desktop.waitForFunction(text => document.querySelector('#d-body').textContent.includes(text), legacyDetail.record.presentation.views[0].text);
|
||
assert((await desktop.locator('#d-body').textContent()).includes('2026-10-16-display-skin-age-v2'));
|
||
assert((await desktop.locator('#d-body').textContent()).includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁'));
|
||
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
|
||
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
|
||
// An incomplete set must show no total everywhere, not fall back to the API score.
|
||
const missingVm = JSON.parse(JSON.stringify(legacy.vm)); missingVm.dims.stab.S = null;
|
||
const missingRec = JSON.parse(JSON.stringify(record)); missingRec.dims.stab.S = null;
|
||
fs.writeFileSync(snapshotFile, JSON.stringify({ ...legacy, vm: missingVm }));
|
||
fs.writeFileSync(recordFile, JSON.stringify(missingRec) + '\n');
|
||
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('#stat-score b').textContent(), '--');
|
||
assert.equal((await (await api.get(base + 'api/admin/records')).json()).items[0].total_score, null);
|
||
assert.equal((await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record.total_score, null);
|
||
await desktop.goto(base + 'admin.html');
|
||
assert.equal(await desktop.locator('#tbl-records tbody tr').first().locator('td').nth(6).textContent(), '--');
|
||
assert.equal((await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(',')[12], '');
|
||
fs.writeFileSync(recordFile, 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.equal(await page.locator('#stat-age b').textContent(), '--');
|
||
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 });
|
||
}
|