215 lines
13 KiB
JavaScript
215 lines
13 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
|
const root = path.resolve(__dirname, '..');
|
|
const file = path.join(root, 'pages/camnative/camnative.js');
|
|
|
|
function setup(options = {}) {
|
|
let definition, clock = 1000;
|
|
const timers = [], contexts = [], photos = [], renderCallbacks = [];
|
|
const platform = {
|
|
createCameraContext(id) {
|
|
const record = { id, starts: [], stops: [], callback: null };
|
|
record.context = {
|
|
onCameraFrame(callback) {
|
|
record.callback = callback;
|
|
return { start(value) { record.starts.push(value); if (options.startSuccess) value.success(); },
|
|
stop(value) { record.stops.push(value); } };
|
|
},
|
|
takePhoto(value) { photos.push(value); }
|
|
};
|
|
if (options.noFrames) delete record.context.onCameraFrame;
|
|
contexts.push(record); return record.context;
|
|
},
|
|
...options.my
|
|
};
|
|
vm.runInNewContext(fs.readFileSync(file, 'utf8'), {
|
|
Page(value) { definition = value; }, my: platform, Date: { now: () => clock },
|
|
require(name) {
|
|
if (name === '../../utils/ar-camera') return require('../utils/ar-camera');
|
|
if (name === '../../utils/development') return { nativeFaceProbeEnabled: () => true };
|
|
throw Error('Probe must not access accounts, uploads or external services: ' + name);
|
|
},
|
|
setTimeout(fn, delay) { const row = { fn, delay, active: true }; timers.push(row); return row; },
|
|
clearTimeout(row) { if (row) row.active = false; }
|
|
});
|
|
const page = Object.assign({}, definition, {
|
|
data: JSON.parse(JSON.stringify(definition.data)),
|
|
setData(value, callback) { Object.assign(this.data, value); if (callback) { if (options.deferRender) renderCallbacks.push(callback); else callback(); } }
|
|
});
|
|
page.onLoad(); page.onShow(); page.onReady();
|
|
return { page, contexts, photos, timers, renderCallbacks,
|
|
timer(delay) { const row = timers.find(value => value.active && value.delay === delay); assert(row, 'missing timer ' + delay); row.active = false; clock += delay; row.fn(); },
|
|
advance(ms) { clock += ms; },
|
|
event(session) { return { currentTarget: { id: 'native-camera-' + session, dataset: { cameraSession: session } } }; }
|
|
};
|
|
}
|
|
|
|
test('copy toolbar and initial version survive missing system geometry and a tall status bar', () => {
|
|
const fallback = setup();
|
|
assert.equal(fallback.page.data.probeVersion, 'CAM-NATIVE-20261102-04');
|
|
assert.equal(fallback.page.data.toolbarTop, 112);
|
|
const notched = setup({ my: { getSystemInfoSync: () => ({ statusBarHeight: 64, safeArea: { top: 68 }, titleBarHeight: 48 }) } });
|
|
assert(notched.page.data.toolbarTop >= 68 + 48 + 12);
|
|
const failed = setup({ my: { getSystemInfoSync() { throw Error('unavailable'); } } });
|
|
assert.equal(failed.page.data.toolbarTop, 112);
|
|
});
|
|
function raw() { return { width: 2, height: 2, data: new Uint8Array(16), timestamp: 123 }; }
|
|
|
|
test('native diagnostic requests front, remounts with unique IDs, and rejects previous component events', () => {
|
|
const f = setup();
|
|
assert.equal(f.page.data.devicePosition, 'front');
|
|
assert.equal(f.contexts[0].id, 'native-camera-1');
|
|
f.page.onFlip();
|
|
assert.equal(f.page.data.devicePosition, 'back');
|
|
assert.equal(f.contexts[1].id, 'native-camera-2');
|
|
assert.equal(f.contexts[0].stops.length, 1);
|
|
f.contexts[0].callback(raw()); f.page.onNativeReady(f.event(1));
|
|
f.page.onNativeError(f.event(1)); f.page.onNativeStop(f.event(1));
|
|
assert.equal(f.page.data.frameCount, 0); assert.equal(f.page.data.nativeReady, false);
|
|
assert.equal(f.page.data.cameraEnabled, true);
|
|
f.page.onNativeReady(f.event(2)); assert.equal(f.page.data.nativeReady, true);
|
|
});
|
|
|
|
test('listener start success never masquerades as a frame; 3s and 10s deadlines stop an empty stream', () => {
|
|
const f = setup({ startSuccess: true });
|
|
assert.match(f.page.data.startStatus, /调用成功/); assert.equal(f.page.data.frameCount, 0);
|
|
f.timer(3000); assert.match(f.page.data.frameStatus, /3秒未收到/);
|
|
f.timer(10000); assert.match(f.page.data.frameStatus, /10秒仍未收到/);
|
|
assert.equal(f.contexts[0].stops.length, 1);
|
|
f.contexts[0].callback(raw()); assert.equal(f.page.data.frameCount, 0);
|
|
});
|
|
|
|
test('only dimensions plus real bytes count as raw frames, metadata never retains pixel contents, stop discards late callbacks', () => {
|
|
const f = setup();
|
|
f.contexts[0].callback({ width: 2, height: 2, data: 'not-confirmed-raw' });
|
|
assert.equal(f.page.data.frameCount, 0); assert.equal(f.page.data.invalidFrameCount, 1);
|
|
f.advance(400); f.contexts[0].callback(raw());
|
|
assert.equal(f.page.data.frameCount, 1); assert.equal(f.page.data.frameCallbacks, 2);
|
|
assert.match(f.page.data.frameInfo, /"byteLength":16/);
|
|
assert(!f.page.data.frameInfo.includes('not-confirmed-raw'));
|
|
assert(!f.timers.some(row => row.active && (row.delay === 3000 || row.delay === 10000)));
|
|
assert.equal(f.page.latestFrame, undefined);
|
|
f.page.onStopFrames(); f.contexts[0].callback(raw());
|
|
assert.equal(f.page.data.frameCount, 1); assert.equal(f.contexts[0].stops.length, 1);
|
|
});
|
|
|
|
test('deferred mount callbacks cannot create an old camera after hide or replace a newer session', () => {
|
|
const f = setup({ deferRender: true });
|
|
const staleMount = f.renderCallbacks.shift();
|
|
f.page.onHide(); f.page.onShow();
|
|
const currentMount = f.renderCallbacks.shift();
|
|
staleMount(); assert.equal(f.contexts.length, 0);
|
|
currentMount(); assert.equal(f.contexts.length, 1); assert.equal(f.contexts[0].id, 'native-camera-2');
|
|
f.page.onHide(); f.contexts[0].starts[0].success(); f.contexts[0].callback(raw());
|
|
assert.equal(f.page.data.frameCount, 0); assert.equal(f.page.data.cameraEnabled, false);
|
|
});
|
|
|
|
test('native photo exports stay serialized across hide/show and old callbacks cannot publish private previews', () => {
|
|
const f = setup();
|
|
f.page.onTakePhoto(); f.page.onTakePhoto(); f.page.onFlip();
|
|
assert.equal(f.photos.length, 1); assert.equal(f.page.data.devicePosition, 'front');
|
|
f.page.onHide(); f.page.onShow(); f.page.onTakePhoto();
|
|
assert.equal(f.photos.length, 1); assert.equal(f.page.data.photoBusy, true);
|
|
f.photos[0].success({ tempImagePath: 'old-hidden-photo' });
|
|
assert.equal(f.page.data.photoPath, ''); assert.equal(f.page.data.photoBusy, false);
|
|
f.page.onTakePhoto(); assert.equal(f.photos.length, 2);
|
|
f.photos[0].complete(); assert.equal(f.page.data.photoBusy, true);
|
|
f.photos[1].success({ tempImagePath: 'current-local-photo' }); f.photos[1].complete();
|
|
assert.equal(f.page.data.photoPath, 'current-local-photo'); assert.equal(f.page.data.photoBusy, false);
|
|
f.page.onClearPhoto(); assert.equal(f.page.data.photoPath, '');
|
|
});
|
|
|
|
test('photo timeout does not unlock a noncancelable native export, and unload suppresses its final callback', () => {
|
|
const f = setup();
|
|
f.page.onStopFrames(); f.page.onTakePhoto(); f.timer(10000);
|
|
assert.match(f.page.data.photoStatus, /10秒未返回/); assert.equal(f.page.data.photoBusy, true);
|
|
f.page.onTakePhoto(); assert.equal(f.photos.length, 1);
|
|
f.page.onUnload(); f.photos[0].success({ tempImagePath: 'late-photo' }); f.photos[0].complete();
|
|
assert.equal(f.page.data.photoPath, ''); assert(!f.timers.some(row => row.active));
|
|
});
|
|
|
|
test('a native export that times out in the background remains locked and reports timeout on return', () => {
|
|
const f = setup();
|
|
f.page.onStopFrames(); f.page.onTakePhoto(); f.page.onHide(); f.timer(10000);
|
|
f.page.onShow(); assert.equal(f.page.data.photoBusy, true); assert.match(f.page.data.photoStatus, /10秒未返回/);
|
|
f.page.onTakePhoto(); assert.equal(f.photos.length, 1);
|
|
f.photos[0].fail({ errorMessage: '原操作已结束' });
|
|
assert.equal(f.page.data.photoBusy, false); assert.equal(f.page.data.photoPath, '');
|
|
});
|
|
|
|
test('missing runtime APIs report an explicit failure, and diagnostic source has no business or network dependency', () => {
|
|
const missing = setup({ my: { createCameraContext: undefined } });
|
|
assert.match(missing.page.data.contextStatus, /缺少 my.createCameraContext/);
|
|
const noFrames = setup({ noFrames: true });
|
|
assert.match(noFrames.page.data.frameStatus, /缺少 onCameraFrame/);
|
|
noFrames.page.onTakePhoto(); assert.equal(noFrames.photos.length, 1);
|
|
const source = fs.readFileSync(file, 'utf8');
|
|
assert.doesNotMatch(source, /\bgetApp\(|my\.(?:request|uploadFile|setStorage|setStorageSync|chooseImage)\b/);
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camnative/camnative.axml'), 'utf8');
|
|
assert.match(markup, /device-position="{{devicePosition}}"/);
|
|
for (const match of markup.matchAll(/\bon[A-Z]\w*="(\w+)"/g)) assert.equal(typeof noFrames.page[match[1]], 'function', match[1]);
|
|
});
|
|
|
|
test('copy includes fresh scalar counters and only permitted metadata, never paths, pixels, coordinates or native error text', () => {
|
|
const copied = [];
|
|
const f = setup({ startSuccess: true, my: {
|
|
SDKVersion: '2.8.0',
|
|
getSystemInfoSync: () => ({ brand: 'TestPhone', system: 'Android 15', version: '11.2.0', userId: 'PRIVATE_ACCOUNT', token: 'PRIVATE_TOKEN' }),
|
|
setClipboard(value) { copied.push(value.text); value.success(); }
|
|
} });
|
|
assert.equal(copied.length, 0); assert.equal(f.page.data.diagnosticText, '');
|
|
for (let index = 0; index < 8; index++) f.contexts[0].callback({ ...raw(), pixelFormat: 'rgba',
|
|
format: 'PRIVATE_NATIVE_FORMAT', face: { points: ['PRIVATE_FACE_POINTS'] }, private: 'PRIVATE_FRAME_FIELD' });
|
|
// UI updates are throttled; an explicit report still reads all eight callbacks.
|
|
assert.equal(f.page.data.frameCount, 1);
|
|
f.page.onTakePhoto(); f.photos[0].success({ tempImagePath: 'PRIVATE_PHOTO_PATH', private: 'PRIVATE_PHOTO_METADATA' });
|
|
f.page.setData({ cameraStatus: 'PRIVATE_CAMERA_ERROR', frameInfo: 'PRIVATE_UNSAFE_FRAME_INFO' });
|
|
f.page.onStopFrames(); f.contexts[0].stops[0].fail({ errorCode: 7, errorMessage: 'PRIVATE_ERROR_TEXT', code: 'PRIVATE_OTHER_CODE' });
|
|
f.page.onCopyDiagnostics();
|
|
assert.equal(copied.length, 1); const report = copied[0];
|
|
assert.match(report, /^CAM-NATIVE-20261102-04/); assert.match(report, /system.brand=TestPhone/);
|
|
assert.match(report, /runtime.SDKVersion=2.8.0/); assert.match(report, /rawFrames=8/);
|
|
assert.match(report, /frame.width=2; frame.height=2; frame.byteLength=16/);
|
|
assert.match(report, /frame.pixelFormat=RGBA; frame.format=unrecognized/);
|
|
assert.match(report, /context.takePhoto=true; context.onCameraFrame=true/);
|
|
assert.match(report, /listener.start.state=success/); assert.match(report, /listener.stop.state=failure; listener.stop.code=7/);
|
|
assert.match(report, /requestedPosition=front/); assert.match(report, /actualPosition=not_verified/);
|
|
assert.match(report, /photo.success=true; photo.returnedPath=true; photo.previewPresent=true/);
|
|
assert.doesNotMatch(report, /PRIVATE_|tempImagePath|landmark|points|timestamp/);
|
|
assert.equal(f.page.data.diagnosticText, report); assert.match(f.page.data.copyStatus, /已复制/);
|
|
});
|
|
|
|
test('generating selectable diagnostics and missing clipboard fallback have no camera, capture or clipboard side effects', () => {
|
|
const f = setup();
|
|
const contexts = f.contexts.length, starts = f.contexts[0].starts.length, timers = f.timers.length;
|
|
assert.equal(f.page.data.diagnosticText, '');
|
|
f.page.onGenerateDiagnostics();
|
|
assert.match(f.page.data.diagnosticText, /^CAM-NATIVE-20261102-04/);
|
|
assert.match(f.page.data.copyStatus, /可长按/);
|
|
f.page.onCopyDiagnostics(); assert.match(f.page.data.copyStatus, /缺少剪贴板 API/);
|
|
assert.equal(f.contexts.length, contexts); assert.equal(f.contexts[0].starts.length, starts);
|
|
assert.equal(f.contexts[0].stops.length, 0); assert.equal(f.photos.length, 0); assert.equal(f.timers.length, timers);
|
|
const markup = fs.readFileSync(path.join(root, 'pages/camnative/camnative.axml'), 'utf8');
|
|
assert.match(markup, /class="native-toolbar(?:\s|")/); assert.match(markup, /onTap="onGenerateDiagnostics"/);
|
|
assert.match(markup, /selectable="{{true}}"/);
|
|
const css = fs.readFileSync(path.join(root, 'pages/camnative/camnative.acss'), 'utf8');
|
|
assert.match(css, /\.native-toolbar\s*\{[^}]*position:fixed/);
|
|
const height = Number(/\.native-toolbar\s*\{[^}]*height:(\d+)rpx/.exec(css)[1]);
|
|
const padding = Number(/\.native-test\s*\{\s*padding:(\d+)rpx/.exec(css)[1]);
|
|
assert(padding > height, 'fixed toolbar must leave room above the page title');
|
|
});
|
|
|
|
test('clipboard failure and synchronous throws keep selectable safe text, and arbitrary string error codes are withheld', () => {
|
|
const f = setup({ my: { setClipboard(value) { value.fail({ errorMessage: 'PRIVATE_CLIPBOARD_ERROR' }); } } });
|
|
f.contexts[0].starts[0].fail({ code: 'PRIVATE_CODE', errorMessage: 'PRIVATE_NATIVE_ERROR' });
|
|
f.page.onCopyDiagnostics();
|
|
assert.match(f.page.data.copyStatus, /复制失败/); assert.match(f.page.data.diagnosticText, /listener.start.state=failure; listener.start.code=unavailable/);
|
|
assert.doesNotMatch(f.page.data.diagnosticText + f.page.data.copyStatus, /PRIVATE_/);
|
|
const throwing = setup({ my: { setClipboard() { throw new Error('PRIVATE_THROW'); } } });
|
|
throwing.page.onCopyDiagnostics(); assert.match(throwing.page.data.copyStatus, /复制失败/);
|
|
assert.match(throwing.page.data.diagnosticText, /^CAM-NATIVE-20261102-04/);
|
|
assert.doesNotMatch(throwing.page.data.copyStatus, /PRIVATE_/);
|
|
});
|