Update shared Gaia workflows and Sargo development tooling

Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
This commit is contained in:
OverSu 2026-10-08 14:27:17 +02:00
commit 0041cd9660
101 changed files with 3883 additions and 335 deletions

View file

@ -1,6 +1,17 @@
// Test-only actor, registered by tools/inspect-host.py through Marionette.
export class VulpesProbeChild extends JSWindowActorChild {
async receiveMessage(message) {
if (message.name === 'MouseClick') {
const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
x>=win.innerWidth || y>=win.innerHeight) throw Error('INVALID_TEST_POINT');
const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
for (const type of ['mousemove','mousedown','mouseup']) {
win.synthesizeMouseEvent(type,x,y,{button:0,clickCount:1},{isAsyncEnabled:false});
await new Promise(resolve=>setTimeout(resolve,100));
}
return true;
}
if (message.name === 'Tap') {
const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||

83
tests/activities.test.mjs Normal file
View file

@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import {Activities} from '../services/activities.mjs';
const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}];
const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}};
const picker={app:apps[1]}, stranger={app:apps[2]};
const events=[],launches=[];
const broker=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=> 'request-1'});
const pending=broker.start(caller,{name:'pick',data:{type:'image/*'}});
assert.equal(events.length,0);
broker.subscribe(picker);assert.equal(events.length,1);
assert.throws(()=>broker.finish(stranger,{id:'request-1',result:{}}),/PERMISSION_DENIED/);
assert.throws(()=>broker.start(caller,{name:'pick',data:{type:'image/*'}}),/ACTIVITY_BUSY/);
broker.finish(picker,{id:'request-1',result:{blob:{__vulpesBlobV1:true,type:'image/png',base64:'YWJj'}}});
assert.equal((await pending).blob.base64,'YWJj');assert.equal(launches.at(-1)[1],'http://settings.localhost/#display');
const cancelled=broker.start(caller,{name:'pick',data:{type:'image/*'}});
broker.subscribe(picker);broker.cleanup(picker);await assert.rejects(cancelled,/ACTIVITY_CANCELLED/);
assert.throws(()=>broker.start({app:{id:'untrusted'}},{name:'pick',data:{type:'image/*'}}),/PERMISSION_DENIED/);
assert.throws(()=>broker.provider({name:'pick',data:{type:'video/mp4'}}),/ACTIVITY_NOT_SUPPORTED/);
const abandoned = broker.start(caller,{name:'pick',data:{type:'image/*'}});
assert.throws(()=>broker.cancel(stranger),/PERMISSION_DENIED/);
broker.cancel({app:{id:'system'}});
await assert.rejects(abandoned,/ACTIVITY_CANCELLED/);
const reopened = broker.start(caller,{name:'pick',data:{type:'image/*'}});
broker.subscribe(picker);
broker.finish(picker,{id:'request-1',result:{reopened:true}});
assert.equal((await reopened).reopened,true);
console.log('Activity routing, result ownership, cancellation and return navigation: OK');
const phone={id:'communications',origin:'http://communications.localhost',manifest:{activities:{
pick:{href:'/contacts/index.html?pick',filters:{type:{value:['webcontacts/tel']}}},
dial:{href:'/dialer/index.html#keyboard-view',filters:{type:'webtelephony/number'},disposition:'window'},
open:[{href:'/contacts/views/details/details.html',filters:{type:'webcontacts/contact'}},
{href:'/contacts/views/vcard_load/vcard_load.html',filters:{type:'text/vcard'}}],
}}};
const sms={id:'sms',origin:'http://sms.localhost',manifest:{activities:{new:{href:'/index.html#/activity-new',filters:{type:'websms/sms'},returnValue:true}}}};
apps.push(phone,sms);
const smsPeer={app:sms,manager:{documentURI:{spec:'http://sms.localhost/index.html#composer'}}};
const phonePeer={app:phone};
const choose=broker.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
assert.equal(new URL(launches.at(-1)[1]).searchParams.has('pick'),true);
broker.subscribe(phonePeer);
broker.finish(phonePeer,{id:'request-1',result:{number:'5550199',name:'Test'}});
assert.equal((await choose).number,'5550199');
assert.equal(launches.at(-1)[1],'http://sms.localhost/index.html#composer');
const dial=broker.start(smsPeer,{name:'dial',data:{type:'webtelephony/number',number:'5550199'}});
broker.subscribe(phonePeer);await dial;
assert.equal(broker.pending.size,0,'dial only opens the keypad, with no stale activity left behind');
const draft=broker.start(phonePeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
assert.equal(new URL(launches.at(-1)[1]).hash,'','activity handler chooses the composer after startup');
broker.subscribe(smsPeer);
assert.equal(events.at(-1)[2].source.data.target,'5550199');
broker.finish(smsPeer,{id:'request-1',result:null});await draft;
assert.equal(broker.route({name:'open',data:{type:'webcontacts/contact'}}).url.pathname,'/contacts/views/details/details.html');
assert.throws(()=>broker.route({name:'open',data:{type:'url'}}),/ACTIVITY_NOT_SUPPORTED/);
console.log('Contacts/SMS picker, contact details, SMS draft and dialer routing: OK');
let serial=0;
const nested=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial)});
const contactsPeer={app:phone,manager:{documentURI:{spec:'http://communications.localhost/contacts/index.html'}}};
nested.subscribe(contactsPeer);
const nestedDraft=nested.start(contactsPeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
nested.subscribe(smsPeer);
const nestedPicker=nested.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
assert.equal(launches.at(-1)[2].reload,undefined,'reuse the address book without destroying the outer SMS activity');
assert.equal(events.at(-1)[0],contactsPeer);
nested.finish(contactsPeer,{id:'2',result:{number:'5550199'}});
assert.equal((await nestedPicker).number,'5550199');
assert.equal(nested.pending.size,1);
nested.finish(smsPeer,{id:'1',result:null});await nestedDraft;
assert.equal(nested.pending.size,0);
const gallery={id:'gallery',origin:'http://gallery.localhost',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['image/jpeg']},returnValue:true}}}};
apps.push(gallery);
const normalGallery={app:gallery,manager:{documentURI:{spec:'http://gallery.localhost/index.html'}}};
nested.subscribe(normalGallery);
const photo=nested.start(contactsPeer,{name:'pick',data:{type:'image/jpeg'}});
assert.equal(launches.at(-1)[2].reload,true,'normal Gallery must reload in picker mode');
assert.equal(new URL(launches.at(-1)[1]).hash,'#pick');
const galleryPicker={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
nested.subscribe(galleryPicker);
const photoRequest=events.at(-1)[2].id;
assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/);
nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}});
assert.equal(await (await photo).blob.text(),'image');

View file

@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
function setup(composer) {
let click, assimilated=false;
const calls=[];
const recipients={numbers:[]};
const context={console,location:{hostname:'sms.localhost',pathname:'/index.html'},
document:{readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
VulpesCompat:{call:async(method,params)=>{calls.push({method,params});}},
VulpesPlatform:'android',Navigation:{isCurrentPanel:()=>composer},
ConversationView:{recipients,activeThread:{participants:['5550123']},assimilateRecipients(){assimilated=true;recipients.numbers=['5550199'];}},
Compose:{type:'sms',getText:()=> 'Message à confirmer'},
};
context.window=context;
vm.runInNewContext(readFileSync('host/gaia-restoration.js','utf8'),context);
return {calls,context,send(){
click({target:{closest:()=>true},preventDefault(){},stopImmediatePropagation(){}});
assert.equal(assimilated,true);
}};
}
test('Android handoff commits the typed recipient before opening its SMS app',()=>{
const s=setup(true);s.send();
assert.equal(s.calls[0].method,'platform.sms');
assert.equal(s.calls[0].params.number,'5550199');
assert.equal(s.calls[0].params.body,'Message à confirmer');
});
test('Android reply uses the conversation participants and preserves the draft',()=>{
const s=setup(false);s.send();
assert.equal(s.calls[0].params.number,'5550123');
assert.equal(s.context.Compose.getText(),'Message à confirmer');
});
test('empty Android messages are not handed off',()=>{
const s=setup(true);s.context.Compose.getText=()=>'';s.send();
assert.equal(s.calls.length,0);
});

View file

@ -0,0 +1,152 @@
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) {
const videos = [], streams = [], requests = [], events = new EventTarget();
const nativeRequests=[];
let hidden = false, captureGate, playGate, labels = true;
class Document extends EventTarget { get hidden() { return hidden; } }
class Video {
get srcObject() { return this.stream; }
set srcObject(value) { this.stream = value; }
constructor() { this.dataset = {}; this.style = {}; }
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' ? {getContext:()=>({
createShader:()=>({}), shaderSource(){}, compileShader(){},
getShaderParameter:()=>false, createProgram:()=>({}),
deleteShader(){}, deleteProgram(){}, getExtension:()=>null,
})} : new Video(),
});
const navigator = {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);
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, 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':'desktop',camera:true};
nativeRequests.push(params);
if(params.action==='start')return {session:42,width:640,height:480,orientation:90};
}},
};
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,
unlabelled:()=>{labels=false;},
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,42);
});
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();
});

59
tests/contacts.test.mjs Normal file
View file

@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {Contacts} from '../host/Contacts.sys.mjs';
let serial=0;
globalThis.Services={uuid:{generateUUID:()=>({toString:()=>`test-${++serial}`})}};
const writer={manifest:{permissions:{contacts:{access:'readwrite'}}}};
const reader={manifest:{permissions:{contacts:{access:'readonly'}}}};
async function addressBook() {
let stored;
const events=[];
const storage={async run(_store,operation,args) {
if(operation==='get') return {value:structuredClone(stored)};
stored=structuredClone(args[0]);
}};
const contacts=new Contacts(storage,event=>events.push(event));
await contacts.init();
return {contacts,events,storage};
}
test('contact edits, photo, date and favourite survive reopening the store',async()=>{
const {contacts,events,storage}=await addressBook();
const first=await contacts.request(writer,{operation:'save',contact:{
givenName:['Élodie'],familyName:['Test'],tel:[{value:'+33 6 00 00 00 00'}],
photo:[new Blob(['portrait'],{type:'image/jpeg'})],bday:new Date('1995-08-19T00:00:00Z'),
}});
const edited=await contacts.request(writer,{operation:'save',contact:{...first,familyName:['Modifiée'],category:['favorite']}});
assert.equal(edited.id,first.id);
assert.equal(edited.published,first.published);
const reopened=new Contacts(storage,()=>{});await reopened.init();
const [row]=await reopened.request(reader,{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:first.id}});
assert.deepEqual(row.familyName,['Modifiée']);
assert.deepEqual(row.category,['favorite']);
assert.equal(await row.photo[0].text(),'portrait');
assert.equal(row.bday.toISOString(),'1995-08-19T00:00:00.000Z');
assert.deepEqual(events.map(e=>e.reason),['create','update']);
await contacts.request(writer,{operation:'remove',id:first.id});
assert.equal(await contacts.request(reader,{operation:'count'}),0);
});
test('a failed write emits no contactchange and does not poison later saves',async()=>{
const {contacts,events,storage}=await addressBook();
const run=storage.run;
storage.run=async()=>{throw Error('disk full');};
await assert.rejects(contacts.request(writer,{operation:'save',contact:{name:['First']}}),/disk full/);
assert.equal(events.length,0);
assert.equal(await contacts.request(reader,{operation:'count'}),0);
storage.run=run;
await contacts.request(writer,{operation:'save',contact:{name:['Second']}});
assert.equal(await contacts.request(reader,{operation:'count'}),1);
});
test('read-only callers cannot change the address book',async()=>{
const {contacts}=await addressBook();
for(const operation of ['save','remove','clear'])
await assert.rejects(contacts.request(reader,{operation,contact:{name:['Denied']},id:'anything'}),/PERMISSION_DENIED/);
await assert.rejects(contacts.request({manifest:{}},{operation:'find'}),/PERMISSION_DENIED/);
});

View file

@ -0,0 +1,28 @@
import base64
import json
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
from installed_apps import InstalledApps
class StoreTests(unittest.TestCase):
def test_persistence_removal_and_permission_boundary(self):
with tempfile.TemporaryDirectory() as folder:
store=InstalledApps(folder,8765)
item=dict(id='user-'+'1'*24,manifest=dict(name='Test',type='certified',permissions={'settings':{}}),files={'index.html':base64.b64encode(b'hello').decode()})
store.save(item)
restored=InstalledApps(folder,8765)
self.assertEqual(restored.read(item['id'],'index.html'),b'hello')
self.assertEqual(restored.records()[0]['manifest']['permissions'],{})
self.assertEqual(restored.records()[0]['manifest']['type'],'web')
self.assertEqual(json.loads(restored.read(item['id'],'manifest.webapp'))['permissions'],{})
self.assertEqual((Path(folder)/'.token').stat().st_mode & 0o777,0o600)
self.assertIsNone(restored.read(item['id'],'../.token'))
with self.assertRaises(ValueError):restored.remove('system')
with self.assertRaises(ValueError):restored.save({**item,'id':'../escape'})
restored.remove(item['id'])
self.assertEqual(InstalledApps(folder,8765).records(),[])
if __name__=='__main__':unittest.main()

35
tests/packages.test.mjs Normal file
View file

@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import {execFileSync} from 'node:child_process';
import {unpackPackage,packageURL,Packages} from '../services/packages.mjs';
const source='https://vulpes-os.org/marketplace/index.php?download=42';
function zip(extra={},manifest={}) {
return new Uint8Array(execFileSync('python3',['-c',`import sys,io,zipfile,json
b=io.BytesIO();args=json.loads(sys.argv[1])
with zipfile.ZipFile(b,'w',zipfile.ZIP_DEFLATED) as z:
z.writestr('manifest.webapp',json.dumps(dict(name='Test',launch_path='/index.html',icons={'128':'/icon.png'},**args['manifest'])))
z.writestr('index.html','<h1>Test</h1>');z.writestr('icon.png',b'icon')
for name,value in args['extra'].items():z.writestr(name,value)
sys.stdout.buffer.write(b.getvalue())`,JSON.stringify({extra,manifest})]));
}
assert.equal(packageURL(source+'&lang=fr'),source);
for(const url of ['http://vulpes-os.org/marketplace/index.php?download=42','https://evil.test/marketplace/index.php?download=42','https://vulpes-os.org/x?download=42','https://vulpes-os.org/marketplace/index.php?download=../42'])assert.throws(()=>packageURL(url));
const bytes=zip({}, {type:'certified',permissions:{settings:{access:'readwrite'},'webapps-manage':{}}});
const item=await unpackPackage(bytes,source);
assert.equal(item.manifest.type,'web');assert.deepEqual(item.manifest.permissions,{});assert.match(item.id,/^user-[a-f0-9]{24}$/);
for(const name of ['../escape','/absolute','dir/../../escape','dir\\escape','a:b'])await assert.rejects(unpackPackage(zip({[name]:'bad'}),source));
await assert.rejects(unpackPackage(bytes.slice(0,-2),source));
const corrupt=bytes.slice();corrupt[100]^=255;await assert.rejects(unpackPackage(corrupt,source));
let saved=null,changes=0;
const service=new Packages({save:async value=>saved=value,changed:async()=>changes++,uuid:()=> 'token',fetchPackage:async()=>new Response(bytes)});
const prepared=await service.prepare(source);assert.equal(saved,null);assert.equal(prepared.name,'Test');
assert.equal(await service.install(prepared.token),item.id);assert.equal(changes,1);assert.equal(saved.id,item.id);
await assert.rejects(service.install(prepared.token),/INSTALL_EXPIRED/);
console.log('Package URL policy, ZIP boundaries/CRC, permission stripping and one-use confirmation: OK');
// Cancellation during download must not produce a confirmation token.
let finishDownload;
const cancelled=new Packages({save:async()=>assert.fail('unexpected save'),changed:async()=>{},
fetchPackage:()=>new Promise(resolve=>{finishDownload=resolve;})});
const pending=cancelled.prepare(source);cancelled.cancel();finishDownload(new Response(bytes));
await assert.rejects(pending,/DOWNLOAD_CANCELLED/);assert.equal(cancelled.pending.size,0);
console.log('Cancelled download cannot be installed: OK');

View file

@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const context = {window:{}, Blob, Date, btoa, atob, Uint8Array};
vm.runInNewContext(readFileSync(new URL('../host/settings-codec.js',import.meta.url),'utf8'),context);
const codec=context.window.VulpesSettingsCodec;
test('native media replies retain the structured-cloned Blob and date', async()=>{
const blob=new Blob(['jpeg bytes'],{type:'image/jpeg'}), date=new Date();
const result=codec.decode({blob,lastModified:date});
assert.equal(result.blob,blob);
assert.equal(result.lastModified,date);
assert.equal(await result.blob.text(),'jpeg bytes');
});
test('Android JSON media and settings still round-trip binary content',async()=>{
const source=new Blob([new Uint8Array([0,128,255,10])],{type:'image/png'});
const wire=JSON.parse(JSON.stringify(await codec.encode({photo:source})));
const result=codec.decode(wire);
assert.equal(result.photo.type,'image/png');
assert.deepEqual(new Uint8Array(await result.photo.arrayBuffer()),new Uint8Array([0,128,255,10]));
});
test('a near-match marker is ordinary data, not an injected Blob',()=>{
const result=codec.decode({__vulpesBlobV1:true,type:'image/png',base64:'AA==',other:1});
assert.equal(result.other,1);
assert.equal(result instanceof Blob,false);
});
test('Android contact birthdays keep their ISO date instead of becoming empty objects',async()=>{
const date=new Date('1995-08-19T00:00:00.000Z');
const wire=await codec.encode({bday:date,photo:[new Blob(['portrait'],{type:'image/jpeg'})]});
const result=codec.decode(JSON.parse(JSON.stringify(wire)));
assert.equal(result.bday,date.toISOString());
assert.equal(await result.photo[0].text(),'portrait');
});

View file

@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFile} from 'node:fs/promises';
const source=await readFile(new URL('../gaia/apps/settings/js/modules/page_transitions.js',import.meta.url),'utf8');
function fixture(duration='0.4s') {
let module, timer, delay, calls=0, ready=0;
const panel=Object.assign(new EventTarget(),{id:'wifi',className:''});
vm.runInNewContext(source,{define:fn=>{module=fn();},CustomEvent,
window:{scrollX:0,scrollY:0,dispatchEvent:()=>ready++,getComputedStyle:()=>({transitionDuration:duration,transitionDelay:'0s'})},
setTimeout:(cb,ms)=>{timer=cb;delay=ms;return 1;},clearTimeout:()=>{}});
module.oneColumn({id:'root',className:'current'},panel,()=>calls++);
return {panel,finish:()=>timer(),state:()=>({calls,ready,delay})};
}
test('Settings reaches onShow when no transition event arrives',()=>{
const f=fixture();assert.equal(f.state().calls,0);f.finish();
assert.deepEqual(f.state(),{calls:1,ready:1,delay:500});
f.panel.dispatchEvent(new Event('transitionend'));f.finish();
assert.equal(f.state().calls,1);
});
test('normal and cancelled transitions finish exactly once',()=>{
for(const type of ['transitionend','transitioncancel']){
const f=fixture();f.panel.dispatchEvent(new Event(type));f.finish();
assert.equal(f.state().calls,1);assert.equal(f.state().ready,1);
}
});
test('disabled animations do not leave navigation pending',()=>{
const f=fixture('0s');assert.equal(f.state().delay,100);f.finish();assert.equal(f.state().calls,1);
});

View file

@ -107,3 +107,27 @@ test('Native Wi-Fi acknowledges unchanged settings and completes delayed radio c
assert.equal(enabled,1);
assert.equal(navigator.mozWifiManager.enabled,true);
});
test('scan without an association does not send Gaia an invalid signal event', async () => {
const {readFile}=await import('node:fs/promises');
const vm=await import('node:vm');
let connected=false, signals=0;
const navigator={};
const context=vm.createContext({navigator,console,Event,EventTarget,DOMException,
window:{VulpesNativeWifi:true},location:{hostname:'settings.localhost'},addEventListener(){},
VulpesCompat:{request:p=>p,call:async()=>({enabled:true,networks:[
{ssid:'Test network',id:'/ap/1',signal:80,security:'wpa-psk',connected}
]})}});
vm.runInContext(await readFile(new URL('../host/gaia-wifi.js',import.meta.url),'utf8'),context);
const manager=navigator.mozWifiManager;
manager.onconnectioninfoupdate=event=>{
// The original Settings WifiUtils reads event.network.ssid unconditionally.
assert.equal(event.network.ssid,'Test network');
assert.equal(event.relSignalStrength,80);signals++;
};
assert.equal((await manager.getNetworks()).length,1);
assert.equal(signals,0);assert.equal(manager.connectionInformation,null);
connected=true;await manager.getNetworks();
assert.equal(signals,1);assert.equal(manager.connection.status,'connected');
connected=false;await manager.getNetworks();assert.equal(signals,1);
});