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:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
|
|
@ -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
83
tests/activities.test.mjs
Normal 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');
|
||||
38
tests/android-sms-handoff.test.mjs
Normal file
38
tests/android-sms-handoff.test.mjs
Normal 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);
|
||||
});
|
||||
152
tests/camera-lifecycle.test.mjs
Normal file
152
tests/camera-lifecycle.test.mjs
Normal 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
59
tests/contacts.test.mjs
Normal 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/);
|
||||
});
|
||||
28
tests/installed_apps_test.py
Normal file
28
tests/installed_apps_test.py
Normal 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
35
tests/packages.test.mjs
Normal 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');
|
||||
35
tests/settings-codec.test.mjs
Normal file
35
tests/settings-codec.test.mjs
Normal 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');
|
||||
});
|
||||
29
tests/settings-transitions.test.mjs
Normal file
29
tests/settings-transitions.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue