Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
256 lines
12 KiB
JavaScript
256 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import vm from 'node:vm';
|
|
import {readFileSync} from 'node:fs';
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise(r => { resolve = r; });
|
|
return {promise, resolve};
|
|
}
|
|
|
|
// Execute the shared adapter, with controllable media promises instead of a sensor.
|
|
function setup(native = false, platformName = 'desktop') {
|
|
const videos = [], streams = [], requests = [], events = new EventTarget();
|
|
const nativeRequests=[], wakeLocks=[];
|
|
let hidden = false, captureGate, playGate, labels = true, requireStopped = false;
|
|
const failures=[];
|
|
class Document extends EventTarget { get hidden() { return hidden; } }
|
|
class Video {
|
|
get srcObject() { return this.stream; }
|
|
set srcObject(value) {
|
|
if(requireStopped && value===null && this.stream?.getTracks().some(t=>t.readyState!=="ended"))
|
|
throw Error("Capture producer is still live");
|
|
this.stream = value;
|
|
}
|
|
constructor() { this.dataset = {}; this.style = {}; this.parentNode={append:canvas=>{canvas.isConnected=true;}}; }
|
|
play() { return playGate?.promise || Promise.resolve(); }
|
|
pause() {}
|
|
remove() { const index=videos.indexOf(this); if(index!==-1)videos.splice(index, 1); }
|
|
}
|
|
const document = new Document();
|
|
Object.assign(document, {
|
|
documentElement: {lang:'en'}, body: {append:video => videos.push(video)},
|
|
querySelector: () => null, querySelectorAll: () => [...videos],
|
|
createElement: tag => tag === 'canvas' ? {style:{},dataset:{},
|
|
remove(){this.isConnected=false;},
|
|
captureStream(){throw Error('Native preview must not recapture the canvas');},
|
|
getContext:()=>new Proxy({
|
|
getShaderParameter:()=>native==='working',getProgramParameter:()=>true,
|
|
},{get(target,name){return target[name] || (()=>({}));}}),
|
|
} : new Video(),
|
|
});
|
|
const navigator = {requestWakeLock(topic){const lock={topic,released:false,unlock(){this.released=true;}};wakeLocks.push(lock);return lock;},language:'en', mediaDevices: {
|
|
async enumerateDevices() {
|
|
return ['rear','selfie'].map(deviceId => ({kind:'videoinput', deviceId,
|
|
label: labels ? (deviceId === 'rear' ? 'Back camera' : 'Front camera') : ''}));
|
|
},
|
|
async getUserMedia(constraints) {
|
|
requests.push(constraints);
|
|
if(failures.length) throw failures.shift();
|
|
const id = constraints.video.deviceId?.exact ||
|
|
(constraints.video.facingMode?.ideal === 'user' ? 'selfie' : 'rear');
|
|
const gate = captureGate;
|
|
captureGate = null;
|
|
if (gate) await gate.promise;
|
|
const track = {readyState:'live', stop() { this.readyState = 'ended'; },
|
|
getSettings: () => ({deviceId:id, width:640, height:480})};
|
|
const stream = Object.assign(new EventTarget(), {
|
|
getTracks: () => [track], getVideoTracks: () => [track],
|
|
});
|
|
streams.push(stream);
|
|
return stream;
|
|
},
|
|
}};
|
|
const context = {document, Document, navigator, HTMLMediaElement:Video, console, performance,
|
|
location:{hostname:'camera.localhost',port:'8765'}, parent:{},
|
|
MutationObserver:class {observe() {}},
|
|
Event, DOMException, MediaStream:class extends EventTarget {getTracks(){return [];}getVideoTracks(){return [];}}, CustomEvent:class extends Event {constructor(type,opts){super(type);this.detail=opts.detail;}},
|
|
setTimeout, clearTimeout, queueMicrotask, setInterval:()=>0, clearInterval(){},
|
|
addEventListener:events.addEventListener.bind(events), dispatchEvent:events.dispatchEvent.bind(events),
|
|
VulpesCompat:{async call(method,params) {
|
|
if(method==='platform.capabilities')return {platform:native?'tundra':platformName,camera:true};
|
|
nativeRequests.push(params);
|
|
if(params.action==='start')return {session:nativeRequests.length,width:640,height:480,orientation:90,focusModes:['fixed'],zoomRatios:[1],pictureSizes:[{width:640,height:480}]};
|
|
if(params.action==='frame')return {sequence:nativeRequests.length,data:new ArrayBuffer(640*480*3/2)};
|
|
}},
|
|
};
|
|
context.window = context;
|
|
vm.runInNewContext(readFileSync(new URL('../host/gaia-camera.js', import.meta.url),'utf8'), context);
|
|
return {api:navigator.mozCameras, streams, requests, videos, nativeRequests, wakeLocks,
|
|
viewfinder:()=>{const video=new Video();videos.push(video);return video;},
|
|
failCapture:error=>failures.push(error),
|
|
unlabelled:()=>{labels=false;},
|
|
requireStoppedBeforeDetach:()=>{requireStopped=true;},
|
|
blockCapture:()=>captureGate=deferred(), blockPlay:()=>playGate=deferred(),
|
|
hide:()=>{hidden=true;events.dispatchEvent(new Event('visibilitychange'));},
|
|
show:()=>{hidden=false;events.dispatchEvent(new Event('visibilitychange'));},
|
|
};
|
|
}
|
|
|
|
test('unlabelled device identities stay stable after front/back switches', async()=>{
|
|
const s=setup();s.unlabelled();
|
|
for (const [name,id] of [['back','rear'],['front','selfie'],['back','rear'],['front','selfie']]) {
|
|
const {camera}=await s.api.getCamera(name);
|
|
assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,id);
|
|
}
|
|
await s.streams.at(-1).release();
|
|
});
|
|
|
|
test('native preview setup failure releases the sensor before returning an error', async()=>{
|
|
const s=setup(true);
|
|
await assert.rejects(s.api.getCamera('front'),/Camera shader compilation failed/);
|
|
assert.equal(s.nativeRequests.length,2);
|
|
assert.equal(s.nativeRequests[1].action,'stop');
|
|
assert.equal(s.nativeRequests[1].session,1);
|
|
});
|
|
|
|
test('switch waits for asynchronous close even after release has detached the current stream', async()=>{
|
|
const s=setup();
|
|
const {camera}=await s.api.getCamera('back');
|
|
const gate=deferred();let calls=0;
|
|
camera.vulpesCameraClose=()=>{calls++;return gate.promise;};
|
|
const release=camera.release();
|
|
assert.equal(camera.release(),release);
|
|
const next=s.api.getCamera('front');
|
|
await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(s.requests.length,1);
|
|
gate.resolve();await release;
|
|
const replacement=await next;
|
|
assert.equal(calls,1);
|
|
assert.equal(camera.getTracks()[0].readyState,'ended');
|
|
assert.equal(s.videos.length,1,'only the active capture video is retained');
|
|
await replacement.camera.release();
|
|
assert.equal(s.videos.length,0);
|
|
});
|
|
|
|
test('failed hardware close still stops tracks and allows reopening', async()=>{
|
|
const s=setup();const {camera}=await s.api.getCamera('back');
|
|
camera.vulpesCameraClose=async()=>{throw Error('device disconnected');};
|
|
await assert.rejects(camera.release(),/device disconnected/);
|
|
assert.equal(camera.getTracks()[0].readyState,'ended');
|
|
const next=await s.api.getCamera('front');
|
|
assert.equal(next.camera.getTracks()[0].readyState,'live');
|
|
await next.camera.release();
|
|
});
|
|
|
|
test('concurrent switch waits for a pending open and disposes its stale stream', async()=>{
|
|
const s=setup(),gate=s.blockCapture();
|
|
const first=s.api.getCamera('back');
|
|
const rejected=assert.rejects(first,{name:'AbortError'});
|
|
await new Promise(resolve=>setImmediate(resolve));
|
|
const second=s.api.getCamera('front');
|
|
await new Promise(resolve=>setImmediate(resolve));
|
|
assert.equal(s.requests.length,1,'second sensor must not open before first settles');
|
|
gate.resolve();await rejected;
|
|
const {camera}=await second;
|
|
assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
|
|
assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
|
|
await camera.release();
|
|
});
|
|
|
|
test('hide/show during preview startup leaves no live abandoned stream', async()=>{
|
|
const s=setup(),gate=s.blockPlay();
|
|
const first=s.api.getCamera('back');
|
|
const rejected=assert.rejects(first,{name:'AbortError'});
|
|
await new Promise(resolve=>setImmediate(resolve));
|
|
s.hide();s.show();
|
|
const next=s.api.getCamera('front');
|
|
gate.resolve();await rejected;
|
|
const {camera}=await next;
|
|
assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
|
|
assert.equal(camera.getTracks()[0].readyState,'live');
|
|
await camera.release();
|
|
});
|
|
|
|
|
|
test('release stops capture tracks before detaching the preview video',async()=>{
|
|
const s=setup();s.requireStoppedBeforeDetach();
|
|
const {camera}=await s.api.getCamera('back');
|
|
await camera.release();
|
|
assert.equal(camera.getTracks()[0].readyState,'ended');
|
|
const next=await s.api.getCamera('front');
|
|
await next.camera.release();
|
|
});
|
|
|
|
|
|
test('release announces stopped before the next camera starts',async()=>{
|
|
const s=setup(), {camera}=await s.api.getCamera('back');
|
|
const states=[];camera.addEventListener('previewstatechange',e=>states.push(e.newState));
|
|
await camera.release();await camera.release();
|
|
assert.deepEqual(states,['stopped']);
|
|
});
|
|
|
|
test('Gaia viewfinder recomputes a viewport that was hidden at initialization',()=>{
|
|
let view;
|
|
const window={innerWidth:0,innerHeight:0};
|
|
vm.runInNewContext(readFileSync(new URL('../gaia/apps/camera/js/views/viewfinder.js',import.meta.url),'utf8'),{
|
|
window,define(factory){const module={};factory(name=>name==='view'?{extend:value=>value}:name==='debug'?()=>()=>{}:{},{},module);view=module.exports;},
|
|
});
|
|
const sizes=[];
|
|
const instance=Object.assign({},view,{updatePreviewMetrics(){sizes.push([this.container.width,this.container.height]);}});
|
|
instance.getSize();instance.updatePreview({width:480,height:640},0,false);
|
|
assert.equal(sizes.length,0,'hidden window must not produce a zero-size video');
|
|
window.innerWidth=360;window.innerHeight=666;
|
|
instance.updatePreview({width:480,height:640},0,false);
|
|
window.innerWidth=696;window.innerHeight=330;
|
|
instance.updatePreview({width:480,height:640},0,false);
|
|
assert.deepEqual(sizes,[[360,666],[696,330]]);
|
|
});
|
|
|
|
|
|
test('native preview uses one canvas, releases its worker and never recaptures video',async()=>{
|
|
const s=setup('working');
|
|
const {camera:first}=await s.api.getCamera('back');
|
|
const canvas=first.vulpesPreviewCanvas;
|
|
assert.ok(first.vulpesTiming.frames>0);
|
|
assert.equal(first.vulpesClosed,false);
|
|
await first.release();
|
|
assert.equal(first.vulpesClosed,true);
|
|
const {camera:second}=await s.api.getCamera('front');
|
|
assert.equal(second.vulpesPreviewCanvas,canvas);
|
|
assert.equal(second.vulpesClosed,false);
|
|
await second.release();
|
|
assert.equal(s.nativeRequests.filter(r=>r.action==='stop').length,2);
|
|
assert.equal(s.wakeLocks.length,2);
|
|
assert.ok(s.wakeLocks.every(lock=>lock.topic==='screen' && lock.released));
|
|
});
|
|
|
|
|
|
test('native preview attaches inside Gaia and detaches on camera release',async()=>{
|
|
const s=setup('working'),video=s.viewfinder();
|
|
const {camera}=await s.api.getCamera('front');
|
|
video.mozSrcObject=camera;
|
|
await video.play();
|
|
assert.equal(video.mozSrcObject,camera);
|
|
assert.equal(video.srcObject,undefined,'do not create a native video decoder');
|
|
assert.equal(camera.vulpesPreviewCanvas.isConnected,true);
|
|
assert.equal(video.style.display,'none');
|
|
await camera.release();
|
|
assert.equal(camera.vulpesPreviewCanvas.isConnected,false);
|
|
assert.equal(video.style.display,'');
|
|
assert.equal(camera.vulpesClosed,true);
|
|
});
|
|
|
|
test('Android waits for a busy camera without changing the selected sensor', async()=>{
|
|
const s=setup(false,'android');
|
|
await s.api.getCamera('back');
|
|
s.failCapture(new DOMException('closing','NotReadableError'));
|
|
const {camera}=await s.api.getCamera('front');
|
|
assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
|
|
assert.equal(s.requests.length,3);
|
|
assert.equal(s.requests[1].video.deviceId.exact,s.requests[2].video.deviceId.exact);
|
|
await camera.release();
|
|
});
|
|
test('Android does not retry camera permission refusal',async()=>{
|
|
const s=setup(false,'android');s.failCapture(new DOMException('denied','NotAllowedError'));
|
|
await assert.rejects(s.api.getCamera('back'),{name:'NotAllowedError'});
|
|
assert.equal(s.requests.length,1);
|
|
});
|
|
test('Android stops camera retry when the app becomes hidden',async()=>{
|
|
const s=setup(false,'android');s.failCapture(new DOMException('closing','NotReadableError'));
|
|
const pending=s.api.getCamera('front');
|
|
const checked=assert.rejects(pending,{name:'AbortError'});
|
|
await new Promise(resolve=>setImmediate(resolve));s.hide();await checked;
|
|
assert.equal(s.requests.length,1);
|
|
});
|