'use strict'; const { test } = require('node:test'), assert = require('node:assert/strict'); const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); const root = path.resolve(__dirname, '..'); const flush = () => new Promise(resolve => setImmediate(resolve)); function setup(options = {}) { const timers = [], scopes = [], navigation = [], submitted = [], exported = [], cameraIds = [], nativeCameras = [], engineOptions = [], updates = []; let clock = 1000, definition, selected, failRead = false; const app = options.app || { globalData: { profile: { real_age: 30 } } }; const user = { id: 'guest-one', kind: 'taobao_guest' }; const api = { user: () => user, createAnalysisAttempt: () => ({ requestId: 'same-attempt', profile: null }), analyze: async (photos, profile, signal, attempt) => { submitted.push({ photos, signal, attempt }); return { result: {} }; }, ...options.api }; const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8'); const platform = { getSystemInfoSync: () => ({ windowWidth: 375, windowHeight: 726 }), createArCameraContext: id => { cameraIds.push(id); return { faceBeauty() {} }; }, chooseImage(value) { selected = value; value.success({ apFilePaths: ['photo-' + submitted.length] }); value.complete(); }, showToast() {}, redirectTo: value => navigation.push(value.url), navigateBack() {}, getImageInfo(value) { value.success({ width: 4000, height: 3000 }); }, getFileSystemManager: () => ({ readFile(value) { if (failRead) value.fail({ errorMessage: '读取失败' }); else value.success({ data: '/9j/2Q==' }); } }), createCanvasContext(id) { assert.match(markup, /]*id="compress-canvas"/); assert.equal(id, 'compress-canvas'); return { clearRect() {}, drawImage() {}, draw(keep, callback) { callback(); }, toTempFilePath(value) { exported.push(value); value.success({ apFilePath: 'jpeg' }); }, toDataURL(value) { value.success({ dataURL: 'data:image/jpeg;base64,/9j/2Q==' }); } }; }, canvasPutImageData(value) { assert.equal(value.data.length, value.width * value.height * 4); value.success(); } }; if (options.nativeFace) platform.createCameraContext = id => { cameraIds.push(id); const record = { id, starts: 0, stops: 0, photos: [] }; nativeCameras.push(record); const context = { takePhoto(value) { record.photos.push(value); }, onCameraFrame(callback) { record.callback = callback; return { start(value) { record.starts++; if (options.nativeStartFailure) value.fail({}); else value.success(); }, stop() { record.stops++; } }; } }; if (options.nativeMissingFrames) delete context.onCameraFrame; return context; }; Object.assign(platform, options.my); function later(fn, delay) { const row = { fn, delay, active: true }; timers.push(row); return row; } const clear = row => { if (row) row.active = false; }; const imageModule = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/image.js'), 'utf8'), { module: imageModule, my: platform, Promise, setTimeout: later, clearTimeout: clear, Uint8Array, Uint8ClampedArray, ArrayBuffer }); const cameraConfig = { environment: options.environment || 'development', transport: options.transport || 'cloud', nativeCamera: options.nativeCamera !== false, cameraDebug: options.cameraDebug === undefined ? !!(options.query && options.query.debug === 'local-face') : options.cameraDebug === true, baseUrl: options.baseUrl || '', arPixelFormat: options.arPixelFormat || 'RGBA', nativePixelFormat: options.nativePixelFormat, nativeFaceBackend: options.nativeFaceBackend, photoInputMode: options.photoInputMode, nativeFrameSize: options.nativeFrameSize, nativeFaceInputSize: options.nativeFaceInputSize, nativeFaceMaxSide: options.nativeFaceMaxSide, nativeFaceLandmarkModel: options.nativeFaceLandmarkModel, arFrontPosition: options.arFrontPosition, captureMode: options.captureMode || (options.nativeFace ? 'native-face' : options.ar ? 'ar' : 'system') }; const developmentModule = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/development.js'), 'utf8'), { module: developmentModule, require: () => cameraConfig }); const deps = { '../../utils/api': api, '../../utils/image': imageModule.exports, '../../utils/development': developmentModule.exports, '../../utils/voice': { VOICE: { step: [], countdown: '', shot: '', analyzing: '', fail: '' }, play() {}, stop() {}, ...options.voice }, '../../utils/permissions': { cameraSupported: () => !options.cameraUnsupported, settings: async () => ({ 'scope.camera': true }), state: (value, key) => value[key], ensure: async scope => { scopes.push(scope); }, diagnose: async () => [], ...options.permissions }, '../../utils/native-face': { create(value) { engineOptions.push(value); return options.detectorFactory ? options.detectorFactory(value) : options.detector || { init: async () => ({ backend: 'cpu' }), detect: async frame => nativeResult(frame.noseX), dispose() {} }; } }, '../../config': cameraConfig }; vm.runInNewContext(fs.readFileSync(path.join(root, 'pages/camera/camera.js'), 'utf8'), { Page(value) { definition = value; }, require(name) { return name in deps ? deps[name] : require(path.resolve(root, 'pages/camera', name)); }, my: platform, getApp: options.getApp || (() => app), Promise, setTimeout: later, clearTimeout: clear, Date: { now: () => clock } }); const page = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), setData(value, callback) { updates.push(value); Object.assign(this.data, value); if (callback) callback(); } }); page.onLoad(options.query); page.onReady(); page.onShow(); return { page, api, app, timers, scopes, navigation, submitted, exported, cameraIds, nativeCameras, engineOptions, cameraConfig, updates, platform, image: imageModule.exports, failRead(value) { failRead = value; }, setClock(value) { clock = value; }, timer(delay, at) { const row = timers.find(item => item.active && item.delay === delay); assert(row, 'expected timer ' + delay); row.active = false; clock = at === undefined ? clock + delay : at; row.fn(); } }; } test('mounted camera bounds replace early system viewport dimensions after a native title bar is added', () => { let measured; const f = setup({ my: { createSelectorQuery() { return { select(value) { assert.equal(value, '.cam'); return this; }, boundingClientRect() { return this; }, exec(fn) { measured = fn; } }; } } }); assert.equal(f.page.viewHeight, 662, 'initial framing excludes the custom header until mounted bounds arrive'); measured([{ width: 375, height: 682 }]); assert.equal(f.page.viewHeight, 682); assert.equal(f.page.viewWidth, 375); assert.match(f.page.data.maskStageStyle, /height:682px/); f.page.onHide(); measured([{ width: 375, height: 500 }]); assert.equal(f.page.viewHeight, 682, 'a hidden page ignores late layout results'); f.page.onUnload(); }); test('camera recovery control is hidden during normal startup, verification and shot transitions', () => { const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8'); const expression = markup.match(/class="cam-enable" a:if="\{\{([^}]+)\}\}"/)[1]; const visible = state => Function('s', 'with(s){return !!(' + expression + ')}')({ cameraSupported: true, cameraEnabled: false, identityPending: false, previewUrl: '', doneTip: '', debugComplete: false, analyzing: false, analysisError: '', camError: false, permissionMessage: '', ...state }); assert.equal(visible({}), false, 'not-yet-mounted camera is not a recovery error'); assert.equal(visible({ camError: true }), true, 'real failures retain a way to reopen'); assert.equal(visible({ permissionMessage: '相机权限未开启' }), true); for (const state of [{ identityPending: true }, { cameraEnabled: true }, { previewUrl: 'photo' }, { doneTip: '已完成拍摄' }, { analyzing: true }, { analysisError: '稍后重试' }, { debugComplete: true }]) { assert.equal(visible({ camError: true, ...state }), false); } }); test('retry hides the old camera error while authorizing and restores recovery on denial', async () => { let reject; const f = setup({ permissions: { ensure: () => new Promise((_, no) => { reject = no; }) } }); await flush(); f.page.onCameraError(); f.page.onEnableCamera(); assert.equal(f.page.data.camError, false); assert.equal(f.page.data.permissionMessage, ''); reject(Error('相机权限未开启')); await flush(); assert.equal(f.page.data.camError, true); assert.equal(f.page.data.permissionMessage, '相机权限未开启'); f.page.onUnload(); }); for (const guest of [true, false]) test((guest ? 'guest' : 'Taobao account') + ' main flow connects login, profile, automatic photos, real upload client, report and product', async () => { const config = require('../config'), storage = new Map(), calls = [], navigation = [], opened = []; const app = { globalData: { profile: null, photos: null, payload: null } }; const user = { id: guest ? 'flow-guest' : 'flow-account', kind: guest ? 'taobao_guest' : 'taobao' }; const fixtureContext = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, '../h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext); const payload = { ok: true, ...fixtureContext.window.MOCK_FIXTURE }; const platform = { getStorageSync: ({ key }) => ({ data: storage.get(key) }), setStorageSync: ({ key, data }) => storage.set(key, data), removeStorageSync: ({ key }) => storage.delete(key), authorize(value) { calls.push({ route: 'authorize', scope: value.scopes }); value.success({}); }, redirectTo: ({ url }) => navigation.push(url), navigateTo: ({ url }) => navigation.push(url), showToast(value) { throw Error('unexpected business error: ' + value.content); }, tb: { openDetail: value => opened.push(value.itemId) } }; const module = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'utils/api.js'), 'utf8'), { module, Promise, Date, Math, setTimeout: fn => setTimeout(fn, 0), getApp: () => app, my: platform, require(name) { if (name === '../config') return config; if (name !== './transport') throw Error('unexpected API dependency: ' + name); return { request(route, body, token) { route = route.replace('/api/taobao/', ''); calls.push({ route, body, token }); return Promise.resolve().then(() => { if (route === 'challenge') return { ok: true, challenge: 'flow-challenge' }; if (route === 'guest-session' || route === 'session') return { ok: true, token: 'flow-token', user }; assert.equal(token, 'flow-token'); if (route === 'me') return { ok: true, user }; if (route === 'photo') return { ok: true }; if (route === 'skin-analyze') return { ok: true, job: 'flow-job' }; if (route === 'analysis') return { ok: true, status: 'complete', payload }; if (route === 'logout') return { ok: true }; throw Error('unexpected business request: ' + route); }); } }; } }); const api = module.exports; function load(route) { let definition; vm.runInNewContext(fs.readFileSync(path.join(root, route + '.js'), 'utf8'), { Page(value) { definition = value; }, Promise, Date, my: platform, getApp: () => app, setTimeout: () => 1, clearTimeout() {}, require(name) { return /\/utils\/api(?:\.js)?$/.test(name) ? api : require(path.resolve(root, path.dirname(route), name)); } }); return Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)), setData(patch, callback) { Object.assign(this.data, patch); if (callback) callback(); } }); } const login = load('pages/login/login'); login.onLoad({ switch: '1' }); login.onShow(); login.onToggleAgree(); if (guest) login.onGuest(); else login.onTaobaoLogin(); await flush(); assert.equal(navigation.at(-1), '/pages/splash/splash'); assert.equal(calls.filter(v => v.route === 'authorize').length, guest ? 0 : 1); const splash = load('pages/splash/splash'); splash.onShow(); await flush(); splash.onOpenPrivacy(); splash.onToggleAgree(); splash.onAgreeContinue(); assert.equal(navigation.at(-1), '/pages/profile/profile'); const profile = load('pages/profile/profile'); profile.onLoad(); await profile.onShow(); profile.onOpenBirth(); profile.onPickerConfirm(); profile.onOpenGender(); profile.onPickerConfirm(); profile.onOpenRegion(); profile.onPickerConfirm(); profile.onSubmit(); assert.equal(profile.data.showGuide, true); profile.onStartCamera(); assert.equal(navigation.at(-1), '/pages/camera/camera'); const f = setup({ app, api, nativeFace: true, captureMode: config.captureMode, photoInputMode: config.photoInputMode, my: platform, permissions: { settings: async () => ({}) } }); await flush(); assert.equal(f.page.data.debugMode, false); assert.equal(f.page.data.cameraEnabled, true); assert.equal(f.engineOptions.length, 1); assert.deepEqual(f.scopes, ['scope.camera']); for (const [index, nose, start] of [[0, 200, 1000], [1, 188, 7000], [2, 212, 13000]]) { await armNative(f, nose, start); const shot = f.nativeCameras[0].photos[index]; shot.success({ tempImagePath: 'flow-photo-' + index }); shot.complete(); await flush(); if (index < 2) f.timer(2000); } await flush(); assert.equal(navigation.at(-1), '/pages/report/report'); assert.deepEqual(calls.filter(v => v.route === 'photo').map(v => v.body.view), ['front', 'left', 'right']); const submissions = calls.filter(v => v.route === 'skin-analyze'); assert.equal(submissions.length, 1); assert.equal(submissions[0].body.profile.gender, '女'); assert(submissions[0].body.request_id); assert(app.globalData.payload.result); assert.equal(app.globalData.photos.length, 3); const report = load('pages/report/report'); report.onLoad(); await report.onShow(); assert.equal(report.data.identityError, ''); assert.equal(report.data.cards.length, 3); assert.equal(report.data.cards.flatMap(card => card.views || []).length, 7); report.onMore(); assert.deepEqual(opened, ['1090510453077']); assert.equal([...storage.keys()].some(key => /profile|session/.test(key)), !guest); if (guest) { report.onEndExperience(); await flush(); assert.equal(api.user(), null); assert.equal(app.globalData.payload, null); } report.onUnload(); f.page.onUnload(); profile.onUnload(); splash.onUnload(); login.onUnload(); api.clear(); }); test('first main-flow camera visit requests camera scope and starts automatic capture without a second enable tap', async () => { const f = setup({ nativeFace: true, photoInputMode: require('../config').photoInputMode, permissions: { settings: async () => ({}) } }); await flush(); assert.deepEqual(f.scopes, ['scope.camera']); assert.equal(f.page.data.cameraEnabled, true); assert.equal(f.page.data.debugMode, false); assert.equal(f.nativeCameras[0].starts, 1); f.page.onUnload(); }); test('an explicitly denied camera permission is not requested repeatedly by the main flow', async () => { const f = setup({ nativeFace: true, permissions: { settings: async () => ({ 'scope.camera': false }) } }); await flush(); assert.deepEqual(f.scopes, []); assert.equal(f.nativeCameras.length, 0); assert.match(f.page.data.permissionMessage, /相机权限未开启/); f.page.onUnload(); }); test('upload-first guest flow never initializes live recognition and submits three resized JPEGs to one report', async () => { const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', photoInputMode: 'upload' }); await flush(); assert.equal(f.page.data.uploadOnly, true); assert.equal(f.page.data.autoCapture, false); assert.equal(f.engineOptions.length, 0); assert.equal(f.nativeCameras.length, 0); for (let i = 0; i < 3; i++) { f.page.onAlbum(); await flush(); if (i < 2) f.timer(2000); } await flush(); assert.equal(f.submitted.length, 1); assert.equal(f.submitted[0].photos.length, 3); assert.deepEqual(f.scopes, ['scope.album', 'scope.album', 'scope.album']); assert(f.navigation.includes('/pages/report/report')); assert.equal(f.engineOptions.length, 0); }); test('upload-first configuration preserves the isolated real-time development probe', async () => { const f = setup({ nativeFace: true, photoInputMode: 'upload', query: { debug: 'local-face' } }); await flush(); assert.equal(f.page.data.uploadOnly, false); assert.equal(f.page.data.localFaceProbe, true); assert.equal(f.page.data.autoCapture, true); assert.equal(f.engineOptions.length, 1); f.page.onUnload(); }); test('three system-camera images resize on a mounted canvas and reach a report without extra photo confirmation', async () => { const f = setup(); await flush(); for (let i = 0; i < 3; i++) { f.page.onSystemCamera(); await flush(); if (i < 2) { assert(f.page.data.doneTip); f.timer(2000); } } await flush(); assert.deepEqual(f.scopes, ['scope.camera', 'scope.camera', 'scope.camera']); assert.equal(f.exported.length, 3); assert(f.exported.every(item => item.destWidth === 1280 && item.destHeight === 960 && item.fileType === 'jpg')); assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report'); assert.equal(f.app.globalData.photos.length, 3); assert.equal(f.page.data.analyzing, false); }); test('photo-processing failure leaves an actionable error and retry uses the same three captures', async () => { const f = setup(); await flush(); f.failRead(true); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush(); assert.match(f.page.data.analysisError, /读取失败/); assert.equal(f.page.data.analyzing, false); assert.equal(f.submitted.length, 0); f.failRead(false); f.page.onRetryAnalysis(); await flush(); assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report'); }); test('foreground resumes the original submitted attempt and never starts a second paid analysis', async () => { let complete, resumed = 0; const f = setup({ api: { analyze(photos, profile, signal, attempt) { attempt.profile = profile; attempt.job = 'original-job'; return new Promise(resolve => { complete = resolve; }); }, resumeAnalysis: async attempt => { assert.equal(attempt.job, 'original-job'); resumed++; return { result: {} }; } } }); await flush(); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush(); f.page.onHide(); assert.equal(f.page.data.analyzing, true); f.page.onShow(); complete({ result: {} }); await flush(); await flush(); assert.equal(resumed, 1); assert.equal(f.navigation.at(-1), '/pages/report/report'); }); test('raw AR pixel buffers require an explicit supported format; unsupported captures never silently upload', async () => { const f = setup({ ar: true }); await flush(); const data = new Uint8Array([1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255]); await assert.rejects(f.image.captureFrameToBase64({ width: 2, height: 2, data }, f.page), /格式尚未确认/); assert.equal(f.submitted.length, 0); const photo = await f.image.captureFrameToBase64({ width: 2, height: 2, pixelFormat: 'RGBA', data }, f.page); assert.match(photo, /^data:image\/jpeg;base64,/); }); test('a delayed image-info callback after timeout cannot paint over a newer compression', async () => { let info; const f = setup({ my: { getImageInfo(value) { info = value; } } }); await flush(); const pending = f.image.compressToBase64('old-photo', f.page); const rejected = assert.rejects(pending, /处理超时/); f.timer(20000); await rejected; info.success({ width: 4000, height: 3000 }); assert.equal(f.exported.length, 0); assert.equal(f.page.data.compressWidth, 1); }); test('native completion cannot unlock the next-step transition after successful capture', async () => { let capture; const f = setup({ my: { createCameraContext: () => ({ takePhoto(value) { capture = value; } }) } }); await flush(); f.page.onShutter(); capture.success({ tempImagePath: 'front' }); capture.complete(); assert.equal(f.page.data.stepIndex, 1); assert(f.page.data.doneTip); assert.equal(f.page.busy, true); }); test('a system chooser return waits for foreground identity verification before accepting the photo', async () => { let chosen, verify, restores = 0; const f = setup({ api: { restore: () => ++restores === 1 ? Promise.resolve() : new Promise(resolve => { verify = resolve; }) }, my: { chooseImage(value) { chosen = value; } } }); await flush(); f.page.onAlbum(); await flush(); f.page.onHide(); f.page.onShow(); chosen.success({ apFilePaths: ['private-photo'] }); chosen.complete(); assert.equal(f.page.data.identityPending, true); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0); verify(); await flush(); assert.equal(f.page.photos[0], 'private-photo'); assert.equal(f.page.data.stepIndex, 1); }); test('real AR landmarks trigger one countdown and JPEG capture; hidden pages cannot auto-capture', async () => { const f = setup({ ar: true }); await flush(); const landmarks = Array.from({ length: 212 }, (_, index) => index % 2 ? 250 : 200); for (const [i, x, y] of [[52,120,150],[61,280,150],[46,200,230],[49,200,261.5],[35,150,130],[40,250,130],[16,200,400]]) { landmarks[i * 2] = x; landmarks[i * 2 + 1] = y; } const frame = { width: 400, height: 700, capturedImage: new Uint8Array(400 * 700 * 4).buffer, anchors: [{ rect: [70,90,330,440], landmarks }] }; f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame }); assert.equal(f.page.data.countdown, 3); f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame }); f.timer(300); await flush(); assert.equal(f.page.data.stepIndex, 1); assert.equal(f.page.photos.length, 1); assert.match(f.page.preparedPhotos[0], /^data:image\/jpeg/); f.page.onHide(); const count = f.page.photos.length; f.page.onARFrame({ detail: frame }); assert.equal(f.page.photos.length, count); }); test('AR requests front by default; switching remounts the requested lens without discarding completed captures', async () => { const f = setup({ ar: true }); await flush(); const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8'); assert.match(markup, / { const f = setup({ ar: true, arFrontPosition: 'back' }); await flush(); const markup = fs.readFileSync(path.join(root, 'pages/camera/camera.axml'), 'utf8'); assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.arDevicePosition, 'back'); assert.match(markup, /]*devicePosition="{{arDevicePosition}}"/); assert.match(markup, /]*device-position="{{devicePosition}}"/); const guides = [...markup.matchAll(/<(?:image|view)[^>]*class="[^"]*(?:cam-mask-image|cam-live-scan)[^"]*"/g)]; assert.equal(guides.length, 2); for (const element of guides) { assert.match(element[0], /devicePosition === 'back'/); assert.doesNotMatch(element[0], /arDevicePosition/); } f.page.onDiagnose(); await flush(); assert.match(f.page.data.diagText, /logicalPosition=front\nrequestedPosition=back\nactualPosition=未核验/); f.page.onFlip(); assert.equal(f.page.data.devicePosition, 'back'); assert.equal(f.page.data.arDevicePosition, 'front'); assert.equal(f.cameraIds.at(-1), 'skin-ar-camera-2'); f.page.onFlip(); assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.arDevicePosition, 'back'); const ordinary = setup({ arFrontPosition: 'back', my: { createCameraContext: () => ({ takePhoto() {} }) } }); await flush(); assert.equal(ordinary.page.data.useAR, false); assert.equal(ordinary.page.data.devicePosition, 'front'); ordinary.page.onFlip(); assert.equal(ordinary.page.data.devicePosition, 'back'); }); test('switching cancels countdown and rejects frames and stop events from the previous AR component', async () => { const f = setup({ ar: true }); await flush(); f.page.latestFrame = { old: true }; f.page.holdState = { old: true }; f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3); const oldSequence = f.page.seq; f.page.onFlip(); assert.equal(f.page.seq, oldSequence + 1); assert.equal(f.page.data.countdown, 0); assert.equal(f.page.busy, false); assert.equal(f.page.latestFrame, null); assert.equal(f.page.holdState, null); assert(!f.timers.some(row => row.active && row.delay === 1000)); f.page.onARFrame({ currentTarget: { dataset: { cameraSession: 1 } }, detail: { width: 100, height: 100, anchors: [] } }); assert.equal(f.page.latestFrame, null); f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-1' } }); f.page.onCameraError({ currentTarget: { dataset: { cameraSession: 1 } } }); assert.equal(f.page.data.cameraEnabled, true); assert.equal(f.page.data.camError, false); }); function captureFrame() { const landmarks = Array.from({ length: 212 }, (_, index) => index % 2 ? 250 : 200); for (const [i, x, y] of [[52,120,150],[61,280,150],[46,200,230],[49,200,261.5],[35,150,130],[40,250,130],[16,200,400]]) { landmarks[i * 2] = x; landmarks[i * 2 + 1] = y; } return { width: 400, height: 700, pixelFormat: 'RGBA', data: new Uint8Array(400 * 700 * 4), anchors: [{ rect: [70,90,330,440], landmarks }] }; } async function beginNativeExport(f, frame) { f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame }); assert.equal(f.page.data.countdown, 3); f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame }); f.timer(300); await flush(); } test('foreground camera remount waits for an unfinished native canvas export and drops its stale photo', async () => { const writes = []; let active = 0, peak = 0; const f = setup({ ar: true, my: { canvasPutImageData(value) { active++; peak = Math.max(peak, active); writes.push(() => { active--; value.success(); }); } } }); await flush(); const frame = captureFrame(); await beginNativeExport(f, frame); assert.equal(writes.length, 1); assert.equal(active, 1); f.page.onHide(); f.page.onShow(); await flush(); assert.equal(f.page.data.cameraSession, 2); f.page.onARFrame({ detail: frame }); f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 0, 'new live frames cannot arm another export while native work is pending'); assert.match(f.page.data.liveHint, /正在准备相机/); assert.equal(writes.length, 1); assert(!f.timers.some(t => t.active && t.delay === 1000)); writes.shift()(); await flush(); assert.equal(f.page.data.previewUrl, ''); assert.equal(f.page.photos.length, 0); assert.equal(f.page.preparedPhotos, undefined, 'the hidden-session export cannot reappear as a captured photo'); f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3, 'settling the previous export permits fresh capture'); f.page.onHide(); assert.equal(peak, 1); }); test('three chosen photos queue their compression behind an older native export and still complete once', async () => { const writes = []; const f = setup({ ar: true, my: { canvasPutImageData(value) { writes.push(() => value.success()); } } }); await flush(); await beginNativeExport(f, captureFrame()); assert.equal(writes.length, 1); f.page.onHide(); f.page.onShow(); await flush(); f.page.photos = ['new-front', 'new-right', 'new-left']; f.page.submitPhotos(); await flush(); assert.equal(f.exported.length, 0, 'ordinary compression must not draw/export on the canvas while the old native write is pending'); assert.equal(f.submitted.length, 0); writes.shift()(); await flush(); await flush(); assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report'); assert.deepEqual(f.page.photos, ['new-front', 'new-right', 'new-left']); assert.equal(f.page.data.previewUrl, '', 'old raw-frame completion never restores the capture preview'); }); test('camera errors during the fill-light delay cancel the shutter before any native pixel write', async () => { let writes = 0; const f = setup({ ar: true, my: { canvasPutImageData(value) { writes++; value.success(); } } }); await flush(); const frame = captureFrame(); f.page.onARFrame({ detail: frame }); f.setClock(1600); f.page.onARFrame({ detail: frame }); f.timer(1000); f.timer(1000); f.timer(1000); f.page.onARFrame({ detail: frame }); assert.equal(f.page.data.fillLight, true); const originalSeq = f.page.seq; f.page.onCameraError({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } }); await flush(); assert.equal(f.page.seq, originalSeq + 1); assert.equal(f.page.latestFrame, null); assert.equal(f.page.data.fillLight, false); assert.equal(f.page.data.countdown, 0); assert.equal(f.page.busy, false); assert(!f.timers.some(t => t.active && t.delay === 300)); assert.equal(writes, 0); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0); }); test('native stop while exporting keeps the canvas serialized and cannot accept the late photo', async () => { const writes = []; const f = setup({ ar: true, my: { canvasPutImageData(value) { writes.push(() => value.success()); } } }); await flush(); await beginNativeExport(f, captureFrame()); assert.equal(writes.length, 1); const exporting = f.page.canvasOperation, originalSeq = f.page.seq; f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } }); assert.equal(f.page.seq, originalSeq + 1); assert.equal(f.page.canvasOperation, exporting); assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.busy, false); writes.shift()(); await flush(); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.stepIndex, 0); assert.equal(f.page.data.previewUrl, ''); assert.equal(f.page.preparedPhotos, undefined); assert.equal(f.page.canvasOperation, null); f.page.onEnableCamera(); await flush(); assert.equal(f.page.data.cameraEnabled, true); const currentSession = f.page.data.cameraSession; f.page.onCameraError({ currentTarget: { id: 'skin-ar-camera-' + (currentSession - 1) } }); assert.equal(f.page.data.cameraEnabled, true, 'late native errors cannot stop a restarted component'); }); test('a native camera stop cannot cancel an already-submitted analysis or discard its report', async () => { let complete; const f = setup({ ar: true, api: { analyze() { return new Promise(resolve => { complete = resolve; }); } } }); await flush(); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush(); assert(complete); const seq = f.page.seq, signal = f.page.signal; f.page.onCameraStop({ currentTarget: { id: 'skin-ar-camera-' + f.page.data.cameraSession } }); assert.equal(f.page.seq, seq); assert.equal(signal.canceled, false); assert.equal(f.page.data.analyzing, true); complete({ result: {} }); await flush(); assert.equal(f.navigation.at(-1), '/pages/report/report'); }); test('report generation releases the live camera; errors keep it stopped until explicit retake', async () => { let failAnalysis; const f = setup({ ar: true, api: { analyze() { return new Promise((resolve, reject) => { failAnalysis = reject; }); } } }); await flush(); assert.equal(f.page.data.cameraEnabled, true); const session = f.page.data.cameraSession; f.page.latestFrame = captureFrame(); f.page.photos = ['front', 'right', 'left']; f.page.submitPhotos(); await flush(); assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.latestFrame, null); assert.equal(f.page.arContext, null); assert.equal(f.page.cameraCtx, null); f.page.onARFrame({ detail: captureFrame() }); assert.equal(f.page.latestFrame, null); failAnalysis(new Error('temporary network failure')); await flush(); assert.match(f.page.data.analysisError, /network failure/); f.page.mountCamera(); assert.equal(f.page.data.cameraEnabled, false); f.page.onRestart(); assert.equal(f.page.data.cameraEnabled, true); assert.equal(f.page.data.cameraSession, session + 1); assert.equal(f.page.data.stepIndex, 0); assert.equal(f.page.photos.length, 0); }); function nativeResult(noseX = 200) { const frame = captureFrame(); frame.anchors[0].landmarks[46 * 2] = noseX; const normalized = require('../utils/ar-camera').normalize(frame); return { width: normalized.width, height: normalized.height, detections: normalized.detections, elapsedMs: 10 }; } async function nativeFrame(f, noseX = 200) { f.nativeCameras.at(-1).callback({ width: 400, height: 700, data: new Uint8Array(400 * 700 * 4).buffer, noseX }); await flush(); } async function armNative(f, noseX, start) { f.setClock(start); await nativeFrame(f, noseX); f.setClock(start + 600); await nativeFrame(f, noseX); assert.equal(f.page.data.countdown, 3); f.timer(1000); f.timer(1000); f.timer(1000); await nativeFrame(f, noseX); f.timer(300); await flush(); } test('ordinary front frames use local geometry and automatically take three JPEGs before submitting one report', async () => { const f = setup({ nativeFace: true, photoInputMode: require('../config').photoInputMode, nativePixelFormat: 'BGRA', arPixelFormat: 'ARGB', my: { canvasPutImageData() { throw Error('ordinary automatic capture must use takePhoto, not raw AR export'); } } }); await flush(); assert.equal(f.page.data.devicePosition, 'front'); assert.equal(f.page.data.useAR, false); assert.equal(f.page.data.autoCapture, true); assert.equal(f.nativeCameras[0].id, 'skin-camera-1'); assert.equal(f.nativeCameras[0].starts, 1); assert.equal(f.engineOptions[0].defaultPixelFormat, 'BGRA', 'ordinary raw frames use their own setting, independently of the AR format'); assert.equal(f.engineOptions[0].inputSize, 256); assert.equal(f.engineOptions[0].scoreThreshold, 0.25, 'the model does not prefilter faces more strictly than the H5 front gate'); const record = f.nativeCameras[0]; for (const [index, noseX, start, file] of [[0, 200, 1000, 'front-jpeg'], [1, 140, 7000, 'right-jpeg'], [2, 260, 13000, 'left-jpeg']]) { await armNative(f, noseX, start); assert.equal(record.photos.length, index + 1); assert.equal(f.submitted.length, 0); const shot = record.photos[index]; assert.equal(shot.quality, 'high'); const beforeAcceptance = f.updates.length; shot.success({ tempImagePath: file }); shot.complete(); await flush(); const acceptedUpdates = f.updates.slice(beforeAcceptance); assert(!acceptedUpdates.some(value => value.previewUrl), 'automatic photos never mount the manual retake/use-photo overlay'); const firstRender = acceptedUpdates[0]; assert.equal(firstRender.previewUrl, ''); assert.equal(firstRender.fillLight, false); assert.equal(firstRender.countdown, 0); if (index < 2) assert(firstRender.doneTip, 'the first render directly shows the completed-step transition'); else assert.equal(firstRender.analyzing, true, 'the third photo goes directly to analysis without exposing capture controls'); if (index < 2) { assert(f.page.data.doneTip); assert.equal(f.page.busy, true); f.timer(2000); } } await flush(); assert.deepEqual(Array.from(f.page.photos), ['front-jpeg', 'right-jpeg', 'left-jpeg']); assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 1); assert.equal(f.navigation.at(-1), '/pages/report/report'); assert.equal(record.stops, 1); assert.equal(f.page.data.cameraEnabled, false); assert(!JSON.stringify(f.page.data).includes('landmarks'), 'native face coordinates never enter setData'); }); test('measured dim and bright face warnings reach the existing hint without blocking automatic photos', async () => { for (const [luma, hint] of [[50, '建议到光线明亮处拍摄'], [240, '光线过强,建议避开强光']]) { const f = setup({ nativeFace: true, detector: { init: async () => ({}), detect: async input => ({ ...nativeResult(input.noseX), luma }), dispose() {} } }); await flush(); await armNative(f, 200, 1000); assert.equal(f.page.data.liveHint, hint + ',保持不动即可拍摄'); assert.equal(f.nativeCameras[0].photos.length, 1); assert.equal(f.page.latestNativeVerdict.observation.luma, luma); f.page.onUnload(); } }); test('formal automatic capture speaks step and pose guidance without cutting the step for countdown', async () => { const spoken = [], stops = []; const f = setup({ nativeFace: true, voice: { VOICE: { step: ['step1-front', 'step2-rightface', 'step3-leftface'], hint: { '请向左转头': 'hint-turn-left', '保持不动': 'hint-hold' }, countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail' }, play(id, opts) { spoken.push({ id, opts: opts || {} }); }, stop(hard) { stops.push(hard); }, dropHint() {} } }); await flush(); assert.equal(spoken[0].id, 'step1-front'); await armNative(f, 200, 1000); assert.equal(spoken.find(v => v.id === 'countdown-321').opts.cut, undefined); const shot = f.nativeCameras[0].photos[0]; shot.success({ tempImagePath: 'front-jpeg' }); shot.complete(); await flush(); f.timer(2000); assert(spoken.some(v => v.id === 'shot-done')); assert(spoken.some(v => v.id === 'step2-rightface')); f.setClock(8000); await nativeFrame(f, 260); f.setClock(8500); await nativeFrame(f, 260); assert.equal(spoken.filter(v => v.id === 'hint-turn-left').length, 1); assert.equal(spoken.find(v => v.id === 'hint-turn-left').opts.hint, true); f.page.onHide(); assert(stops.includes(true)); f.page.onUnload(); }); test('formal countdown lets the step finish, starts once, and canceled or late audio cannot take a photo', async () => { const waiting = [], spoken = []; const f = setup({ nativeFace: true, voice: { afterCurrent(fn) { waiting.push(fn); }, play(id) { spoken.push(id); }, VOICE: { step: ['front'], countdown: 'countdown', shot: 'shot' } } }); await flush(); await nativeFrame(f); f.setClock(1600); await nativeFrame(f); assert.equal(f.page.busy, true); assert.equal(f.page.data.countdown, 0); assert.equal(waiting.length, 1); waiting[0](); assert.equal(f.page.data.countdown, 3); waiting[0](); assert.equal(spoken.filter(v => v === 'countdown').length, 1); f.page.onHide(); waiting[0](); assert.equal(f.nativeCameras[0].photos.length, 0); f.page.onUnload(); const callbacks = []; const canceled = setup({ nativeFace: true, voice: { afterCurrent(fn) { callbacks.push(fn); } } }); await flush(); await nativeFrame(canceled); canceled.setClock(1600); await nativeFrame(canceled); canceled.setClock(1900); await nativeFrame(canceled, 260); assert.equal(canceled.page.busy, false); callbacks[0](); assert.equal(canceled.page.data.countdown, 0); assert.equal(canceled.nativeCameras[0].photos.length, 0); canceled.page.onUnload(); }); test('a stalled step voice cannot block countdown beyond three seconds or start it twice', async () => { const callbacks = [], spoken = []; const f = setup({ nativeFace: true, voice: { afterCurrent(fn) { callbacks.push(fn); }, play(id) { spoken.push(id); }, VOICE: { step: ['front'], countdown: 'countdown' } } }); await flush(); await nativeFrame(f); f.setClock(1600); await nativeFrame(f); f.setClock(4500); await nativeFrame(f); f.timer(3000, 4600); assert.equal(f.page.data.countdown, 3); callbacks[0](); assert.equal(spoken.filter(v => v === 'countdown').length, 1); f.page.onUnload(); }); test('front-camera side recognition reverses yaw while guidance, photo labels and rear-camera mapping stay unchanged', async () => { for (const rear of [false, true]) { const f = setup({ nativeFace: true }); await flush(); if (rear) { f.page.onFlip(); await flush(); } for (const [step, tip, wrong, correct, hint] of [ [1, '请右脸出镜', rear ? 140 : 260, rear ? 260 : 140, '请向左转头'], [2, '请左脸出镜', rear ? 260 : 140, rear ? 140 : 260, '请向右转头'] ]) { f.page.setData({ stepIndex: step }); f.page.renderStep(); assert.equal(f.page.data.stepTip, tip); f.setClock(step * 2000); await nativeFrame(f, wrong); assert.equal(f.page.latestNativeVerdict.ok, false); assert.equal(f.page.data.liveHint, hint); assert.equal(f.page.data.countdown, 0); f.setClock(step * 2000 + 500); await nativeFrame(f, correct); assert.equal(f.page.latestNativeVerdict.ok, true); assert.equal(f.page.data.maskName, step === 1 ? 'right' : 'left'); assert.equal(f.nativeCameras.at(-1).photos.length, 0, 'one valid frame alone cannot shoot'); } f.page.onUnload(); } }); test('native inference remains single-flight across hide/resume and old frame results cannot start a new-session countdown', async () => { let finish, detected = 0, disposed = 0; const detector = { init: async () => ({}), detect: () => { detected++; return detected === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve(nativeResult()); }, dispose() { disposed++; } }; const f = setup({ nativeFace: true, detector }); await flush(); f.nativeCameras[0].callback({ width: 400, height: 700, data: new ArrayBuffer(16) }); await flush(); assert.equal(detected, 1); f.page.onHide(); assert.equal(f.nativeCameras[0].stops, 1); f.page.onShow(); await flush(); assert.equal(f.nativeCameras.length, 2); await nativeFrame(f); assert.equal(detected, 1, 'a pending old detector operation blocks parallel inference'); finish(nativeResult()); await flush(); assert.equal(f.page.data.countdown, 0); assert.equal(f.page.latestNativeVerdict, null); f.setClock(1600); await nativeFrame(f); f.setClock(2200); await nativeFrame(f); assert.equal(f.page.data.countdown, 3); const old = f.nativeCameras[1]; f.page.onFlip(); await flush(); assert.equal(old.stops, 1); assert.equal(f.page.data.devicePosition, 'back'); assert.equal(f.page.data.countdown, 0); old.callback({ width: 400, height: 700, data: new ArrayBuffer(16) }); await flush(); assert.equal(f.page.latestNativeVerdict, null); f.page.onUnload(); assert.equal(disposed, 1); assert.equal(f.nativeCameras.at(-1).stops, 1); }); test('native takePhoto stays locked across hide/resume and its stale JPEG cannot become a captured step', async () => { const f = setup({ nativeFace: true }); await flush(); await armNative(f, 200, 1000); const oldShot = f.nativeCameras[0].photos[0]; assert(oldShot); f.page.onHide(); f.page.onShow(); await flush(); await nativeFrame(f); f.setClock(6000); await nativeFrame(f); assert.equal(f.page.data.countdown, 0); assert.equal(f.nativeCameras[1].photos.length, 0); oldShot.success({ tempImagePath: 'old-private-jpeg' }); oldShot.complete(); await flush(); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.previewUrl, ''); assert.equal(f.page.nativePhotoOperation, null); await armNative(f, 200, 7000); assert.equal(f.nativeCameras[1].photos.length, 1); f.nativeCameras[1].photos[0].fail({}); await flush(); assert.equal(f.page.data.cameraEnabled, false); assert.match(f.page.data.permissionMessage, /未成功返回照片/); assert.equal(f.submitted.length, 0); }); test('armed native countdown takes a photo at zero without requiring another recognized frame', async () => { const f = setup({ nativeFace: true }); await flush(); await nativeFrame(f); f.setClock(1600); await nativeFrame(f); assert.equal(f.page.data.countdown, 3); f.timer(1000); f.timer(1000); f.timer(1000); f.timer(300); assert.equal(f.nativeCameras[0].photos.length, 1); assert.equal(f.page.nativeCaptureWait, null); }); test('motion, out-of-frame or missing-face updates cannot cancel an armed countdown', async t => { for (const kind of ['angle', 'position', 'confidence', 'no-face']) await t.test(kind, async () => { let changed = false, detects = 0; const detector = { init: async () => ({}), detect: async () => { detects++; const result = nativeResult(changed && kind === 'angle' ? 260 : 200); if (changed && kind === 'position') result.detections[0].detection.box.x = -50; if (changed && kind === 'confidence') result.detections[0].detection.score = .01; if (changed && kind === 'no-face') result.detections = []; return result; }, dispose() {} }; const f = setup({ nativeFace: true, detector }); await flush(); await nativeFrame(f); f.setClock(1600); await nativeFrame(f); assert.equal(f.page.data.countdown, 3); changed = true; for (const left of [3, 2, 1]) { f.setClock(1800 + (3 - left) * 1000); await nativeFrame(f); assert.equal(f.page.data.countdown, left); assert.equal(f.page.busy, true); f.timer(1000); } f.timer(300); assert.equal(detects, 2, 'recognition gates pause during the countdown'); assert.equal(f.nativeCameras[0].photos.length, 1); assert.equal(f.page.nativeCaptureWait, null); }); }); test('an inference already in flight cannot cancel countdown or trigger an extra photo after the shutter', async () => { let finish, detects = 0; const detector = { init: async () => ({}), detect: () => ++detects === 2 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve(nativeResult()), dispose() {} }; const f = setup({ nativeFace: true, detector }); await flush(); await nativeFrame(f); f.setClock(1400); await nativeFrame(f); f.page.startAutoCapture(); assert.equal(f.page.data.countdown, 3); finish({ ...nativeResult(), detections: [] }); await flush(); assert.equal(f.page.data.countdown, 3); assert.equal(f.page.busy, true); f.timer(1000); f.timer(1000); f.timer(1000); f.timer(300); assert.equal(f.nativeCameras[0].photos.length, 1); }); test('hiding, flipping or camera errors still cancel an armed countdown', async t => { for (const action of ['hide', 'flip', 'error']) await t.test(action, async () => { const f = setup({ nativeFace: true }); await flush(); await nativeFrame(f); f.setClock(1600); await nativeFrame(f); assert.equal(f.page.data.countdown, 3); const oldSequence = f.page.seq; if (action === 'hide') f.page.onHide(); else if (action === 'flip') f.page.onFlip(); else f.page.onCameraError(); f.page.finishNativeCountdown(oldSequence); await flush(); assert(f.nativeCameras.every(camera => !camera.photos.length)); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.countdown, 0); assert(!f.timers.some(timer => timer.active && timer.delay === 1000)); }); }); test('three consecutive slow local inferences explicitly stop automatic capture without refreshing their source timestamps', async () => { let finish; const f = setup({ nativeFace: true, detector: { init: async () => ({}), detect: () => new Promise(resolve => { finish = resolve; }), dispose() {} } }); await flush(); for (const inputAt of [1000, 3000, 5000]) { f.setClock(inputAt); await nativeFrame(f); f.setClock(inputAt + 1600); finish(nativeResult()); await flush(); assert.equal(f.page.data.countdown, 0); assert.equal(f.nativeCameras[0].photos.length, 0); } assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.nativeCameras[0].stops, 1); assert.match(f.page.data.permissionMessage, /识别链路连续耗时超过1.5秒/); assert.match(f.page.data.permissionMessage, /未完成自动拍摄/); assert.equal(f.page.nativeCaptureWait, null); assert.equal(f.page.busy, false); }); test('missing listener, local model failure, no real frames and detector errors explicitly stop automatic capture', async t => { for (const kind of ['missing', 'start-fail', 'init-fail', 'empty-stream', 'detect-fail']) await t.test(kind, async () => { let choices = 0; const detector = { init: async () => { if (kind === 'init-fail') throw Error('model unavailable'); }, detect: async () => { if (kind === 'detect-fail') throw Error('unsupported pixels'); return nativeResult(); }, dispose() {} }; const f = setup({ nativeFace: true, detector, nativeMissingFrames: kind === 'missing', nativeStartFailure: kind === 'start-fail', my: { chooseImage() { choices++; } } }); await flush(); if (kind === 'empty-stream') f.timer(10000); if (kind === 'detect-fail') await nativeFrame(f); assert.equal(f.page.data.cameraEnabled, false); assert.equal(f.page.data.autoCapture, true); assert.equal(f.page.data.countdown, 0); assert.match(f.page.data.permissionMessage, /未完成自动拍摄/); assert.equal(f.page.photos.length, 0); f.page.onShutter(); assert.equal(choices, 0, 'the automatic mode never silently falls back to system/manual capture'); }); }); function probeIsolation() { const calls = []; const api = Object.fromEntries(['user', 'restore', 'analyze', 'resumeAnalysis', 'createAnalysisAttempt', 'discardAnalysis'].map(name => [name, () => { calls.push('api.' + name); throw Error('local probe must not use account or report APIs'); }])); return { calls, api, getApp() { calls.push('getApp'); throw Error('local probe must not access global user data'); }, voice: { play() { calls.push('voice.play'); }, stop() { calls.push('voice.stop'); } } }; } test('cloud local-face probe captures three temporary photos with slight side turns without any account/report/global-data or voice calls', async () => { const isolated = probeIsolation(), copied = []; let exited = 0; const f = setup({ nativeFace: true, transport: 'cloud', query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, my: { setClipboard(value) { copied.push(value.text); value.success(); }, navigateBack() { exited++; }, chooseImage() { throw Error('local face probe must not use manual capture'); }, request() { throw Error('no network'); }, uploadFile() { throw Error('no upload'); }, setStorageSync() { throw Error('no persistent photos'); } } }); await flush(); assert.equal(f.page.debugMode, true); assert.equal(f.page.data.localFaceProbe, true); assert.equal(f.page.debugKind, 'local-face'); assert.equal(f.page.data.captureProbeVersion, 'LOCAL-FACE-20261102-09'); assert(f.page.data.probeToolbarTop >= 112); assert.equal(f.nativeCameras[0].starts, 1, 'no nativeReady event is required when real frames are available'); f.page.onAlbum(); f.page.onSystemCamera(); f.page.choose(['camera']); for (const [index, noseX, start] of [[0, 200, 1000], [1, 188, 7000], [2, 212, 13000]]) { if (index) { const pose = require('../vendor/head-pose').estimate(nativeResult(noseX).detections[0].landmarks.positions, false); assert(Math.abs(pose.yaw) >= 12 && Math.abs(pose.yaw) < 24, 'small turns exercise the relaxed gate'); } await armNative(f, noseX, start); const shot = f.nativeCameras[0].photos[index]; shot.success({ tempImagePath: 'PRIVATE_LOCAL_PHOTO_' + index }); shot.complete(); await flush(); if (index < 2) { assert.equal(f.page.data.stepIndex, index + 1); f.timer(2000); } } await flush(); assert.equal(f.page.data.debugComplete, true); assert.equal(f.exported.length, 3); assert.equal(f.submitted.length, 0); assert.deepEqual(Array.from(f.page.data.debugPhotos, item => item.key), ['front', 'right', 'left']); assert(f.page.data.debugPhotos.every(item => item.width === 1280 && item.height === 960)); assert.equal(f.page.probeAutoCaptured, 3); assert.equal(f.navigation.length, 0); assert.deepEqual(isolated.calls, []); f.page.onCopyCaptureDiagnostics(); assert.equal(copied.length, 1); assert.match(copied[0], /^LOCAL-FACE-20261102-09/); assert.match(copied[0], /autoCapturedPhotoCount=3; done=true/); assert.match(copied[0], /stage=complete; failureStage=none/); assert.match(copied[0], /framesProcessed=6/); assert.match(copied[0], /quality.reason=ok/); assert.doesNotMatch(copied[0], /PRIVATE_|data:image|landmarks|positions|real_age|tempImagePath/); f.page.onDebugRestart(); await flush(); assert.equal(f.page.data.debugComplete, false); assert.equal(f.page.data.debugPhotos.length, 0); assert.equal(f.page.probeAutoCaptured, 0); assert.equal(f.nativeCameras.length, 2); assert.equal(f.nativeCameras[1].starts, 1); assert.deepEqual(isolated.calls, []); f.cameraConfig.environment = 'production'; const seq = f.page.seq; f.page.onDebugRestart(); assert.equal(f.page.seq, seq, 'restart rechecks the local-face gate'); f.page.onCancel(); f.page.onUnload(); assert.equal(exited, 1); assert.equal(f.page.photos.length, 0); assert.equal(f.page.data.debugPhotos.length, 0); assert.deepEqual(isolated.calls, []); }); test('local-face probe cancellation drops a pending native JPEG and cannot create account state or publish a preview', async () => { const isolated = probeIsolation(); const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice }); await flush(); await armNative(f, 200, 1000); const shot = f.nativeCameras[0].photos[0]; assert(shot); f.page.onCancel(); f.page.onUnload(); shot.success({ tempImagePath: 'PRIVATE_LATE_PROBE_PHOTO' }); shot.complete(); await flush(); assert.equal(f.page.photos.length, 0); assert.equal(f.page.probeAutoCaptured, 0); assert.equal(f.page.data.debugPhotos.length, 0); assert.equal(f.page.data.previewUrl, ''); assert.equal(f.exported.length, 0); assert.deepEqual(isolated.calls, []); }); test('local-face probe gate accepts cloud development only with native-face and a native camera, and never widens the legacy camera gate', async () => { for (const patch of [{ environment: 'production' }, { captureMode: 'ar' }, { nativeCamera: false }, { cameraDebug: false }]) { const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: { user: () => null }, ...patch }); await flush(); assert.equal(f.page.debugMode, false); assert.equal(f.page.data.localFaceProbe, false); assert.equal(f.navigation[0], '/pages/splash/splash'); assert.equal(f.nativeCameras.length, 0); } const legacyCloud = setup({ nativeFace: true, cameraDebug: true, transport: 'cloud', query: { debug: 'camera' }, api: { user: () => null } }); await flush(); assert.equal(legacyCloud.page.debugMode, false); assert.equal(legacyCloud.navigation[0], '/pages/splash/splash'); assert.equal(legacyCloud.nativeCameras.length, 0); }); test('unsupported local probe still exposes independent safe capability diagnostics without accessing permissions or identity', async () => { const isolated = probeIsolation(), copied = []; const f = setup({ nativeFace: true, cameraUnsupported: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, my: { canIUse: name => name !== 'camera', setClipboard(value) { copied.push(value.text); }, getSystemInfoSync: () => ({ statusBarHeight: 64, safeArea: { top: 68 }, titleBarHeight: 48 }) } }); await flush(); assert.equal(f.page.data.cameraEnabled, false); assert.match(f.page.data.permissionMessage, /能力检查/); assert(f.page.data.probeToolbarTop >= 128); assert.equal(copied.length, 0); f.page.onCopyCaptureDiagnostics(); assert.match(copied[0], /typeof.createCameraContext=function/); assert.match(copied[0], /canIUse.camera=no; canIUse.createCameraContext=yes/); assert.match(copied[0], /framesProcessed=0; inflight=false/); assert.equal(f.nativeCameras.length, 0); assert.deepEqual(isolated.calls, []); }); test('local capture diagnostics distinguish model initialization and listener failures without copying native error text', async () => { for (const kind of ['init', 'listener']) { const isolated = probeIsolation(); const f = setup({ nativeFace: true, query: { debug: 'local-face' }, nativeMissingFrames: kind === 'listener', api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, detector: { init: async () => { const error = Error('PRIVATE_NATIVE_ERROR'); error.code = 'NATIVE_FACE_BACKEND'; throw error; }, dispose() {}, detect() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, new RegExp('stage=failed; failureStage=' + kind)); assert.match(f.page.data.diagText, /framesProcessed=0/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_NATIVE_ERROR/); if (kind === 'init') assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_BACKEND/); assert.deepEqual(isolated.calls, []); } }); test('capture probe reports actual runtime/config/timings and counts high-rate skipped frames without per-frame UI work', async () => { let finish; const secret = 'PRIVATE_ENGINE_OR_PIXEL', isolated = probeIsolation(); const detector = { init: async () => ({ backend: 'cpu', inputSize: 128, maxSide: 160, landmarkModel: 'tiny', cpuOptimization: 'pad-copy', private: secret }), detect: () => new Promise(resolve => { finish = resolve; }), dispose() {} }; const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, detector, nativeFrameSize: 'small', nativeFaceInputSize: 128, nativeFaceMaxSide: 160, nativeFaceLandmarkModel: 'tiny', my: { SDKVersion: '2.8.0', getSystemInfoSync: () => ({ model: 'Phone-test', version: '11.2.0', userId: secret }), createOffscreenCanvas() { throw Error('diagnostics must not initialize graphics'); }, createCanvas: undefined, createWorker() { throw Error('diagnostics must not start workers'); } } }); await flush(); assert.equal(f.page.data.nativeFrameSize, 'small'); assert.equal(f.engineOptions[0].inputSize, 128); assert.equal(f.engineOptions[0].maxSide, 160); assert.equal(f.engineOptions[0].landmarkModel, 'tiny'); const raw = { width: 540, height: 960, data: new ArrayBuffer(2073600), rotation: 0, private: secret }; const baseline = f.updates.length; for (let index = 0; index < 101; index++) f.nativeCameras[0].callback(raw); assert.equal(f.updates.length, baseline, 'accepted inference and skipped frames perform no setData until a result is available'); assert.equal(f.page.nativeAcceptedCalls, 1); assert.equal(f.page.nativeFramesSkippedInFlight, 100); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /frameEvents.total=101; skippedInFlight=100; acceptedCalls=1/); assert.match(f.page.data.diagText, /runtime.SDKVersion=2.8.0/); assert.match(f.page.data.diagText, /frameSize.requested=small/); assert.match(f.page.data.diagText, /typeof.createOffscreenCanvas=function; typeof.createCanvas=undefined; typeof.createWorker=function/); assert.match(f.page.data.diagText, /engine.actual.backend=cpu; inputSize=128; maxSide=160; landmarkModel=tiny/); assert.match(f.page.data.diagText, /cpuOptimization=pad-copy/); f.setClock(1200); finish({ ...nativeResult(), elapsedMs: 199, backend: 'cpu', inputSize: 128, maxSide: 160, landmarkModel: 'tiny', cpuOptimization: 'pad-copy', timings: { snapshotMs: 2, modelMs: 0, tensorMs: 1, detectorMs: 160, landmarkMs: 36, totalMs: 199, private: secret } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, /currentDetection.sourceAgeMs=200/); assert.match(report, /timings.snapshotMs=2/); assert.match(report, /timings.detectorMs=160/); assert.match(report, /timings.landmarkMs=36/); assert.match(report, /timings.totalMs=199/); assert.match(report, /frame.width=540; frame.height=960; frame.byteLength=2073600/); assert.doesNotMatch(report, /PRIVATE_|data:image|positions|landmarks|userId/); assert.deepEqual(isolated.calls, []); }); test('invalid performance config and nonnumeric timing fields remain safe, without changing freshness policy', async () => { const isolated = probeIsolation(), secret = 'PRIVATE_TIMING'; const f = setup({ nativeFace: true, query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, nativeFrameSize: secret, nativeFaceInputSize: secret, nativeFaceMaxSide: -1, nativeFaceLandmarkModel: secret, detector: { init: async () => ({ backend: secret, landmarkModel: secret, cpuOptimization: 'none' }), detect: async () => ({ ...nativeResult(), backend: secret, landmarkModel: secret, cpuOptimization: secret, timings: { snapshotMs: secret, detectorMs: -1, landmarkMs: NaN, totalMs: 17 } }), dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /cpuOptimization=none/); await nativeFrame(f); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /cpuOptimization=not_reported/); assert.equal(f.page.data.nativeFrameSize, 'medium'); assert.equal(f.engineOptions[0].inputSize, 256); assert.equal(f.engineOptions[0].maxSide, 320); assert.equal(f.engineOptions[0].landmarkModel, 'full'); assert.match(f.page.data.diagText, /timings.snapshotMs=not_reported/); assert.match(f.page.data.diagText, /timings.detectorMs=not_reported/); assert.match(f.page.data.diagText, /timings.landmarkMs=not_reported/); assert.match(f.page.data.diagText, /timings.totalMs=17/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_TIMING/); assert.deepEqual(isolated.calls, []); }); function gpuSummary(overrides = {}) { return { requested: true, selftest: 'passed', stage: 'ready', code: 'NONE', contextLost: false, matmulDraws: 2, convDraws: 3, pixelReads: 4, selftestMs: 45, warmupMs: 80, nativeStrategy: 'standard', nativeAttempts: 1, nativeInitialStage: 'not_run', nativeInitialCode: 'NONE', drawStrategy: 'indexed', quadStripDraws: 0, ...overrides }; } function gpuInit(overrides = {}) { return { backend: 'webgl', inputSize: 160, maxSide: 320, landmarkModel: 'tiny', gpu: gpuSummary(), ...overrides }; } function nativeGpuProbe(overrides = {}) { return { stage: 'passed', code: 'NONE', clear: 'passed', constantArrays: 'passed', constantIndexed: 'passed', constantQuad: 'not_run', shader: 'passed', byteTexture: 'passed', floatTexture: 'passed', changedBytes: 16, mismatchCount: 0, expectedChecksum: 2400, actualChecksum: 2400, glError: 0, framebufferStatus: 36053, readReturn: 'undefined', vertexBufferBytes: 32, indexBufferBytes: 12, pixel0: 0, pixel1: 4294967295, pixel2: 123456, pixel3: 654321, ...overrides }; } test('GPU startup diagnostics cache byte-readback profile and fixed tensor values without refreshing from later real detections or stats', async () => { const synthetic = { actualLength: 256, nonFiniteCount: 0, mismatchCount: 0, maxAbsError: 0, firstExpected: 0, firstActual: 0, expectedChecksum: 2805, actualChecksum: 2805 }; let started = false, getterReads = 0; const initGPU = gpuSummary({ profile: 'packed_byte', attemptCount: 2, nativeStrategy: 'buffer_copy_framebuffer_y', nativeAttempts: 8, drawStrategy: 'quad_strip', quadStripDraws: 474, nativeInitialStage: 'constant_arrays', nativeInitialCode: 'VALUE_MISMATCH', floatReads: 0, byteReads: 5, glError: 0, framebufferStatus: 36053, nativeProbe: nativeGpuProbe({ constantIndexed: 'not_run', constantQuad: 'passed' }), matmul: synthetic, conv: { ...synthetic, actualLength: 128, firstExpected: 10, firstActual: 10, expectedChecksum: 1920, actualChecksum: 1920 } }); const detectionGPU = gpuSummary({ profile: 'unpacked_byte', attemptCount: 3, matmulDraws: 8181, convDraws: 8181, pixelReads: 8181, floatReads: 8181, byteReads: 8181, selftestMs: 8181, warmupMs: 8181, glError: 8181, framebufferStatus: 8181, nativeStrategy: 'framebuffer_y', nativeAttempts: 3, nativeInitialStage: 'float_texture', nativeInitialCode: 'API_FAILED', drawStrategy: 'indexed', quadStripDraws: 8181, nativeProbe: nativeGpuProbe({ changedBytes: 8181, actualChecksum: 8181, readReturn: 'PRIVATE_READ_RETURN', constantArrays: 'PRIVATE_ARRAYS', constantIndexed: 'PRIVATE_INDEXED', constantQuad: 'PRIVATE_QUAD', vertexBufferBytes: 8181, indexBufferBytes: 8181, pixel0: 8181, pixel1: 8181, pixel2: 8181, pixel3: 8181 }), matmul: { ...synthetic, firstActual: 8181, actualChecksum: 8181 }, conv: { ...synthetic, firstActual: 8181 } }); const lateStats = gpuSummary(); for (const key of ['profile', 'attemptCount', 'nativeStrategy', 'nativeAttempts', 'nativeInitialStage', 'nativeInitialCode', 'drawStrategy', 'quadStripDraws', 'matmulDraws', 'convDraws', 'pixelReads', 'floatReads', 'byteReads', 'selftestMs', 'warmupMs', 'matmul', 'conv', 'glError', 'framebufferStatus', 'nativeProbe']) Object.defineProperty(lateStats, key, { get() { getterReads++; return 'PRIVATE_REAL_FRAME'; } }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => gpuInit({ gpu: initGPU }), stats: () => ({ gpu: started ? lateStats : initGPU }), detect: async () => ({ ...nativeResult(), ...gpuInit({ gpu: detectionGPU }) }), dispose() {} } }); await flush(); started = true; await nativeFrame(f); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, /gpu.profile=packed_byte; attemptCount=2/); assert.match(report, /gpu.nativeStrategy=buffer_copy_framebuffer_y; nativeAttempts=8/); assert.match(report, /gpu.drawStrategy=quad_strip; quadStripDraws=474/); assert.match(report, /gpu.nativeInitialStage=constant_arrays; nativeInitialCode=VALUE_MISMATCH/); assert.match(report, /gpu.synthetic.floatReads=0; byteReads=5/); assert.match(report, /gpu.matmulDraws=2; convDraws=3; pixelReads=4; selftestMs=45; warmupMs=80/); assert.match(report, /gpu.synthetic.matmul.actualLength=256/); assert.match(report, /gpu.synthetic.matmul.actualChecksum=2805/); assert.match(report, /gpu.synthetic.conv.actualLength=128/); assert.match(report, /gpu.synthetic.conv.firstActual=10/); assert.match(report, /gpu.synthetic.glError=0; framebufferStatus=36053/); assert.match(report, /gpu.native.stage=passed; code=NONE; clear=passed; shader=passed; byteTexture=passed; floatTexture=passed/); assert.match(report, /gpu.native.changedBytes=16; mismatchCount=0; expectedChecksum=2400; actualChecksum=2400/); assert.match(report, /gpu.native.glError=0; framebufferStatus=36053; readReturn=undefined/); assert.match(report, /gpu.native.constantArrays=passed; constantIndexed=not_run; constantQuad=passed/); assert.match(report, /gpu.native.vertexBufferBytes=32; indexBufferBytes=12/); assert.match(report, /gpu.native.pixel0=0; pixel1=4294967295; pixel2=123456; pixel3=654321/); assert.equal(getterReads, 0); assert.doesNotMatch(report, /8181|PRIVATE_REAL_FRAME|PRIVATE_READ_RETURN|PRIVATE_ARRAYS|PRIVATE_INDEXED|PRIVATE_QUAD/); }); test('GPU startup diagnostics reject invalid counters, unknown profiles and unsafe synthetic values without reading getters', async () => { let getterReads = 0; const metrics = { actualLength: -1, nonFiniteCount: 0.5, mismatchCount: Infinity, maxAbsError: -1, firstExpected: NaN, firstActual: null, expectedChecksum: 'PRIVATE_CHECKSUM', actualChecksum: {} }; Object.defineProperty(metrics, 'firstExpected', { get() { getterReads++; return 'PRIVATE_GETTER'; } }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => gpuInit({ gpu: gpuSummary({ profile: 'PRIVATE_PROFILE', attemptCount: 4, floatReads: -1, byteReads: 2.5, matmul: metrics, conv: null }) }), detect: async () => nativeResult(), dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, /gpu.profile=not_reported; attemptCount=not_reported/); assert.match(report, /gpu.synthetic.floatReads=not_reported; byteReads=not_reported/); for (const key of ['actualLength', 'nonFiniteCount', 'mismatchCount', 'maxAbsError', 'firstExpected', 'expectedChecksum', 'actualChecksum']) assert.match(report, new RegExp('gpu\\.synthetic\\.matmul\\.' + key + '=not_reported')); assert.match(report, /gpu.synthetic.matmul.firstActual=null/); assert.equal(getterReads, 0); assert.doesNotMatch(report, /PRIVATE_|\[object Object\]/); }); test('native GPU preflight failure preserves the fixed-input failure stage and never starts real camera frames', async () => { let disposed = 0; const failedProbe = nativeGpuProbe({ stage: 'clear', code: 'READ_UNCHANGED', clear: 'not_run', shader: 'not_run', byteTexture: 'not_run', floatTexture: 'not_run', changedBytes: 0, mismatchCount: 16, expectedChecksum: 2400, actualChecksum: 2640, readReturn: 'typedarray' }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => { const error = Error('PRIVATE_NATIVE_PREFLIGHT'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED'; error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED', nativeProbe: failedProbe, glError: 1282, framebufferStatus: 36054 }); throw error; }, detect() { throw Error('preflight must stop before real frame detection'); }, dispose() { disposed++; } } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.page.data.cameraEnabled, false); assert.equal(disposed, 1); assert.match(report, /stage=failed; failureStage=init/); assert.match(report, /quality.code=NATIVE_FACE_WEBGL_PREFLIGHT_FAILED/); assert.match(report, /selftest=failed; stage=native_preflight; code=PREFLIGHT_FAILED/); assert.match(report, /gpu.native.stage=clear; code=READ_UNCHANGED; clear=not_run; shader=not_run; byteTexture=not_run; floatTexture=not_run/); assert.match(report, /gpu.native.changedBytes=0; mismatchCount=16; expectedChecksum=2400; actualChecksum=2640/); assert.match(report, /gpu.synthetic.glError=1282; framebufferStatus=36054/); assert.match(report, /framesProcessed=0/); assert.match(report, /autoCapturedPhotoCount=0/); assert.doesNotMatch(report, /PRIVATE_NATIVE_PREFLIGHT/); }); test('native GPU diagnostics whitelist startup enums and finite integer metadata without evaluating getters', async () => { let getters = 0; const probe = nativeGpuProbe({ stage: 'PRIVATE_STAGE', code: 'PRIVATE_CODE', clear: {}, constantArrays: 'PRIVATE_ARRAYS', constantIndexed: {}, shader: 'wrong', byteTexture: 'PRIVATE_TEXTURE', floatTexture: null, changedBytes: -1, mismatchCount: 0.5, expectedChecksum: Infinity, actualChecksum: NaN, glError: 'PRIVATE_ERROR', framebufferStatus: {}, readReturn: 'PRIVATE_RETURN', vertexBufferBytes: -1, indexBufferBytes: 12.5, pixel0: -1, pixel1: 1.5, pixel2: Infinity, pixel3: 4294967296 }); Object.defineProperty(probe, 'changedBytes', { get() { getters++; return 'PRIVATE_GETTER'; } }); Object.defineProperty(probe, 'pixels', { get() { getters++; return 'PRIVATE_PIXELS'; } }); Object.defineProperty(probe, 'vertexBufferBytes', { get() { getters++; return 'PRIVATE_BUFFER'; } }); Object.defineProperty(probe, 'pixel0', { get() { getters++; return 'PRIVATE_PIXEL'; } }); const gpu = gpuSummary({ nativeProbe: probe, glError: -1, framebufferStatus: 36053.5, nativeStrategy: 'PRIVATE_STRATEGY', nativeAttempts: 9, nativeInitialStage: 'PRIVATE_INITIAL_STAGE', nativeInitialCode: {}, drawStrategy: 'PRIVATE_DRAW', quadStripDraws: 0.5 }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => gpuInit({ gpu }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, /gpu.synthetic.glError=not_reported; framebufferStatus=not_reported/); assert.match(report, /gpu.native.stage=not_reported; code=not_reported; clear=not_reported; shader=not_reported; byteTexture=not_reported; floatTexture=not_reported/); assert.match(report, /gpu.native.changedBytes=not_reported; mismatchCount=not_reported; expectedChecksum=not_reported; actualChecksum=not_reported/); assert.match(report, /gpu.native.glError=not_reported; framebufferStatus=not_reported; readReturn=not_reported/); assert.match(report, /gpu.nativeStrategy=not_reported; nativeAttempts=not_reported/); assert.match(report, /gpu.drawStrategy=not_reported; quadStripDraws=not_reported/); assert.match(report, /gpu.nativeInitialStage=not_reported; nativeInitialCode=not_reported/); assert.match(report, /gpu.native.constantArrays=not_reported; constantIndexed=not_reported/); assert.match(report, /gpu.native.vertexBufferBytes=not_reported; indexBufferBytes=not_reported/); assert.match(report, /gpu.native.pixel0=not_reported; pixel1=not_reported; pixel2=not_reported; pixel3=not_reported/); assert.equal(getters, 0); assert.doesNotMatch(report, /PRIVATE_|\[object Object\]/); }); test('constant array and indexed draw failures preserve startup stages and fixed synthetic pixels without starting the real listener', async t => { for (const stage of ['constant_arrays', 'constant_indexed']) await t.test(stage, async () => { const failedProbe = nativeGpuProbe({ stage, code: 'VALUE_MISMATCH', constantArrays: stage === 'constant_arrays' ? 'not_run' : 'passed', constantIndexed: 'not_run', shader: 'not_run', byteTexture: 'not_run', floatTexture: 'not_run', actualChecksum: 1020 }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => { const error = Error('PRIVATE_DRAW_EXCEPTION'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED'; error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED', nativeStrategy: 'buffer_copy', nativeAttempts: 2, nativeProbe: failedProbe }); throw error; }, detect() { throw Error('failed synthetic draw cannot start actual model detection'); }, dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, new RegExp('gpu\\.native\\.stage=' + stage + '; code=VALUE_MISMATCH')); assert.match(report, /gpu.nativeStrategy=buffer_copy; nativeAttempts=2/); assert.match(report, /gpu.native.constantArrays=(?:not_run|passed); constantIndexed=not_run/); assert.match(report, /gpu.native.vertexBufferBytes=32; indexBufferBytes=12/); assert.match(report, /gpu.native.pixel0=0; pixel1=4294967295; pixel2=123456; pixel3=654321/); assert.equal(f.nativeCameras[0].starts, 0); assert.match(report, /framesProcessed=0/); assert.doesNotMatch(report, /PRIVATE_DRAW_EXCEPTION/); }); }); test('native strategy startup getters and noninteger attempts remain withheld from copied diagnostics', async () => { let getters = 0; const gpu = gpuSummary({ nativeAttempts: 1.5 }); Object.defineProperty(gpu, 'nativeStrategy', { get() { getters++; return 'PRIVATE_NATIVE_STRATEGY'; } }); Object.defineProperty(gpu, 'nativeInitialStage', { get() { getters++; return 'PRIVATE_INITIAL_STAGE'; } }); Object.defineProperty(gpu, 'nativeInitialCode', { get() { getters++; return 'PRIVATE_INITIAL_CODE'; } }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => gpuInit({ gpu }), detect: async () => nativeResult(), dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /gpu.nativeStrategy=not_reported; nativeAttempts=not_reported/); assert.match(f.page.data.diagText, /gpu.nativeInitialStage=not_reported; nativeInitialCode=not_reported/); assert.equal(getters, 0); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_/); }); test('native API exceptions expose only the fixed preflight code without copying native exception text', async () => { const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => { const error = Error('PRIVATE_NATIVE_EXCEPTION'); error.code = 'NATIVE_FACE_WEBGL_PREFLIGHT_FAILED'; error.gpu = gpuSummary({ selftest: 'failed', stage: 'native_preflight', code: 'PREFLIGHT_FAILED', nativeProbe: nativeGpuProbe({ stage: 'byte_texture', code: 'API_FAILED', byteTexture: 'not_run', floatTexture: 'not_run' }) }); throw error; }, detect() { throw Error('failed native preflight cannot detect'); }, dispose() {} } }); await flush(); f.page.onCopyCaptureDiagnostics(); const report = f.page.data.diagText; assert.match(report, /gpu.native.stage=byte_texture; code=API_FAILED/); assert.equal(f.nativeCameras[0].starts, 0); assert.doesNotMatch(report, /PRIVATE_NATIVE_EXCEPTION/); }); test('WebGL initialization waits for numerical selftests and model warmup, exposing only safe GPU progress before any real-frame listener', async () => { let finishInit, ready = false; const isolated = probeIsolation(); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, api: isolated.api, getApp: isolated.getApp, voice: isolated.voice, detector: { init: () => new Promise(resolve => { finishInit = resolve; }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() {}, stats: () => ({ backend: ready ? 'webgl' : '', gpu: ready ? gpuSummary() : gpuSummary({ selftest: 'running', stage: 'warmup', code: 'NONE', matmulDraws: 2, convDraws: 3, pixelReads: 4 }) }) } }); await flush(); assert.equal(f.engineOptions[0].backend, 'webgl'); assert.equal(f.engineOptions[0].platform, f.platform); assert.match(f.page.data.liveHint, /校验图形加速并预热模型/); assert.equal(f.page.data.captureProbeVersion, 'LOCAL-FACE-20261102-09'); assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.nativeCameras[0].callback, undefined); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /engine.requested.backend=webgl/); assert.match(f.page.data.diagText, /selftest=running; stage=warmup/); assert.match(f.page.data.diagText, /gpu.matmulDraws=2; convDraws=3; pixelReads=4/); assert.match(f.page.data.diagText, /framesProcessed=0/); ready = true; finishInit(gpuInit()); await flush(); assert.equal(f.nativeCameras[0].starts, 1); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /engine.actual.backend=webgl/); assert.match(f.page.data.diagText, /selftest=passed; stage=ready/); assert.deepEqual(isolated.calls, []); }); test('required WebGL rejects silent CPU fallback, incomplete numerical proofs and private GPU failure fields', async t => { for (const kind of ['cpu-fallback', 'missing-readback', 'selftest-failed']) await t.test(kind, async () => { let disposed = 0, getterReads = 0; const failedGPU = gpuSummary({ selftest: 'failed', stage: 'readback', code: 'READBACK_FAILED', private: 'PRIVATE_GPU_ERROR' }); Object.defineProperty(failedGPU, 'warmupMs', { get() { getterReads++; return 'PRIVATE_GETTER'; } }); const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => { if (kind === 'selftest-failed') { const error = Error('PRIVATE_NATIVE_GL_ERROR'); error.code = 'NATIVE_FACE_WEBGL_READBACK_FAILED'; error.gpu = failedGPU; throw error; } return kind === 'cpu-fallback' ? gpuInit({ backend: 'cpu' }) : gpuInit({ gpu: gpuSummary({ pixelReads: 0 }) }); }, detect() { throw Error('GPU failure must not run a frame'); }, dispose() { disposed++; } } }); await flush(); assert.equal(f.nativeCameras[0].starts, 0); assert.equal(f.page.data.cameraEnabled, false); assert.equal(disposed, 1); assert.equal(f.page.nativeInitPromise, null); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /stage=failed; failureStage=init/); assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_WEBGL_(?:BACKEND|READBACK)_FAILED/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_|getParameter|shaderSource/); assert.equal(getterReads, 0); }); }); test('unverified strip geometry is diagnosed as a guarded drawing refusal before actual camera frames start', async () => { const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detector: { init: async () => { const error = Error('PRIVATE_INDEX_BUFFER'); error.code = 'NATIVE_FACE_WEBGL_DRAW_UNSUPPORTED'; error.gpu = gpuSummary({ code: 'DRAW_UNSUPPORTED', stage: 'matmul', selftest: 'failed', drawStrategy: 'quad_strip', quadStripDraws: 6, nativeProbe: nativeGpuProbe({ constantIndexed: 'not_run', constantQuad: 'passed' }) }); throw error; }, detect() { throw Error('guard refusal must stop before real frames'); }, dispose() {} } }); await flush(); assert.equal(f.nativeCameras[0].starts, 0); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_WEBGL_DRAW_UNSUPPORTED/); assert.match(f.page.data.diagText, /gpu.drawStrategy=quad_strip; quadStripDraws=6/); assert.match(f.page.data.diagText, /constantIndexed=not_run; constantQuad=passed/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_INDEX_BUFFER/); }); test('WebGL context loss while backgrounded retires the resolved engine and rebuilds only after its pending detection settles', async () => { const engines = []; let loseContext; const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detectorFactory: options => { const record = { options, disposed: 0, detects: 0 }; engines.push(record); return { init: async () => gpuInit(), dispose() { record.disposed++; }, detect: () => { record.detects++; return engines.length === 1 ? new Promise((resolve, reject) => { loseContext = reject; }) : Promise.resolve({ ...nativeResult(), ...gpuInit() }); } }; } }); await flush(); await nativeFrame(f); assert.equal(engines[0].detects, 1); const oldCallback = f.nativeCameras[0].callback; f.page.onHide(); f.page.onShow(); await flush(); assert.equal(engines.length, 1); assert.equal(f.nativeCameras[1].starts, 0, 'foreground must not restart a listener on an engine still checking an old frame'); const error = Error('PRIVATE_CONTEXT_LOST'); error.code = 'NATIVE_FACE_WEBGL_CONTEXT_LOST'; error.gpu = gpuSummary({ selftest: 'failed', stage: 'context_lost', code: 'CONTEXT_LOST', contextLost: true }); loseContext(error); await flush(); await flush(); assert.equal(engines[0].disposed, 1); assert.equal(engines.length, 2); assert.equal(engines[1].options.backend, 'webgl'); assert.equal(f.nativeCameras[1].starts, 1); assert.equal(f.page.data.cameraEnabled, true); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /selftest=passed; stage=ready; code=NONE; contextLost=false/); assert.doesNotMatch(f.page.data.diagText, /PRIVATE_CONTEXT_LOST|NATIVE_FACE_WEBGL_CONTEXT_LOST/); await flush(); assert.equal(engines.length, 2, 'a stale record cannot schedule an extra rebuild or overwrite the new diagnostics'); oldCallback({ width: 400, height: 700, data: new ArrayBuffer(16) }); assert.equal(engines[1].detects, 0); f.setClock(1600); await nativeFrame(f); assert.equal(engines[1].detects, 1); assert.equal(f.page.latestNativeVerdict.ok, true); f.page.onUnload(); assert.equal(engines[1].disposed, 1); }); test('an initialization timeout remains diagnosable and does not build a parallel GPU engine while old init is pending', async () => { const engines = []; let finishOldInit; const f = setup({ nativeFace: true, nativeFaceBackend: 'webgl', query: { debug: 'local-face' }, detectorFactory: () => { const record = { disposed: 0 }; engines.push(record); return { init: () => engines.length === 1 ? new Promise(resolve => { finishOldInit = resolve; }) : Promise.resolve(gpuInit()), stats: () => ({ gpu: gpuSummary({ selftest: 'running', stage: 'warmup' }) }), detect: async () => ({ ...nativeResult(), ...gpuInit() }), dispose() { record.disposed++; } }; } }); await flush(); f.timer(30000); assert.equal(f.page.data.cameraEnabled, false); assert.equal(engines[0].disposed, 1); f.page.onCopyCaptureDiagnostics(); assert.match(f.page.data.diagText, /quality.code=NATIVE_FACE_INIT_TIMEOUT/); assert.match(f.page.data.diagText, /selftest=running; stage=warmup/); f.page.onEnableCamera(); await flush(); assert.equal(engines.length, 1); assert.equal(f.nativeCameras[1].starts, 0); finishOldInit(gpuInit()); await flush(); await flush(); assert.equal(engines.length, 2); assert.equal(f.nativeCameras[1].starts, 1); assert.equal(f.page.data.countdown, 0); assert.equal(f.page.photos.length, 0); });