Release Vulpes OS 2.7-preview.22
This commit is contained in:
parent
200d1fb235
commit
a138f97877
294 changed files with 515786 additions and 187 deletions
|
|
@ -119,3 +119,24 @@ assert.equal(launches.at(-1)[0].id,'settings');
|
|||
assert.equal(broker.pending.size,0);
|
||||
assert.throws(()=>broker.start({app:{id:'untrusted'}},configure),/PERMISSION_DENIED/);
|
||||
console.log('Keyboard returns to Settings without a pending activity: OK');
|
||||
|
||||
// Gallery shares use the same chooser and ownership checks as Gaia pickers.
|
||||
sms.manifest.activities.share={href:'/index.html#/activity-share',filters:{type:['image/*'],number:{max:5}},disposition:'inline',returnValue:true};
|
||||
const email={id:'email',origin:'http://email.localhost',manifest:{activities:{share:{href:'/index.html',filters:{type:['image/*']},disposition:'window'}}}};
|
||||
apps.push(email);
|
||||
let choice;
|
||||
const sharing=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial),choose:v=>{choice=v;}});
|
||||
const blob={__vulpesBlobV1:true,type:'image/jpeg',base64:'YWJj'};
|
||||
const shared=sharing.start(normalGallery,{name:'share',data:{type:'image/jpeg',blobs:[blob],filenames:['photo.jpg']}});
|
||||
assert.equal(choice.choices.length,2);
|
||||
assert.throws(()=>sharing.select(normalGallery,{id:choice.id,value:0}),/PERMISSION_DENIED/);
|
||||
sharing.select({app:{id:'system'}},{id:choice.id,value:0});
|
||||
assert.equal(new URL(launches.at(-1)[1]).hash,'');
|
||||
sharing.subscribe(smsPeer);
|
||||
const delivered=events.at(-1)[2];
|
||||
assert.equal(delivered.source.data.blobs[0],blob);
|
||||
sharing.finish(smsPeer,{id:delivered.id,result:null});await shared;
|
||||
assert.throws(()=>sharing.route({name:'share',data:{type:'image/jpeg',blobs:Array(6).fill(blob)}},gallery,'sms'),/ACTIVITY_NOT_SUPPORTED/);
|
||||
assert.throws(()=>sharing.start({app:{id:'untrusted'}},{name:'share',data:{type:'image/jpeg'}}),/PERMISSION_DENIED/);
|
||||
assert.equal(isGaiaActivity({name:'share'}),true);
|
||||
console.log('Gallery share chooser, MIME matching, provider limits and cancellation: OK');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function deferred() {
|
|||
}
|
||||
|
||||
// Execute the shared adapter, with controllable media promises instead of a sensor.
|
||||
function setup(native = false, platformName = 'desktop') {
|
||||
function setup(native = false, platformName = 'desktop', videoSupported = false) {
|
||||
const videos = [], streams = [], requests = [], events = new EventTarget();
|
||||
const nativeRequests=[], wakeLocks=[];
|
||||
let hidden = false, captureGate, playGate, labels = true, requireStopped = false;
|
||||
|
|
@ -68,6 +68,7 @@ function setup(native = false, platformName = 'desktop') {
|
|||
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),
|
||||
MediaRecorder:class {static isTypeSupported(){return videoSupported;}},
|
||||
VulpesCompat:{async call(method,params) {
|
||||
if(method==='platform.capabilities')return {platform:native?'tundra':platformName,camera:true};
|
||||
nativeRequests.push(params);
|
||||
|
|
@ -254,3 +255,20 @@ test('native preview attaches inside Gaia and detaches on camera release',async(
|
|||
await new Promise(resolve=>setImmediate(resolve));s.hide();await checked;
|
||||
assert.equal(s.requests.length,1);
|
||||
});
|
||||
|
||||
test('video mode survives reopening and switching sensors', async()=>{
|
||||
const s=setup(false,'desktop',true);
|
||||
for(const name of ['back','front','back']) {
|
||||
const result=await s.api.getCamera(name,{mode:'video'});
|
||||
assert.equal(result.configuration.mode,'video');
|
||||
assert.equal(result.configuration.recorderProfile,'webm');
|
||||
assert.ok(result.camera.capabilities.recorderProfiles.webm);
|
||||
await result.camera.release();
|
||||
}
|
||||
});
|
||||
|
||||
test('unavailable recording mode is rejected before acquiring a sensor',async()=>{
|
||||
const s=setup();
|
||||
await assert.rejects(s.api.getCamera('back',{mode:'video'}),{name:'NotSupportedError'});
|
||||
assert.equal(s.requests.length,0);
|
||||
});
|
||||
|
|
|
|||
41
tests/gaia-motion.test.mjs
Normal file
41
tests/gaia-motion.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
const file=readFileSync(new URL('../host/gaia-theme.js',import.meta.url),'utf8');
|
||||
const source=file.slice(file.indexOf('// Compact is a user preference'),
|
||||
file.indexOf('// One readable application name'));
|
||||
|
||||
async function fixture(reducedMotion) {
|
||||
const writes=[],listeners={},dataset={};
|
||||
const animation=(iterations,duration)=>({playState:'running',effect:{
|
||||
getTiming:()=>({iterations,duration}),updateTiming:value=>writes.push(value)}});
|
||||
const finite=animation(1,300),busy=animation(Infinity,1000);
|
||||
vm.runInNewContext(source,{
|
||||
document:{documentElement:{dataset},getAnimations:()=>[finite,busy],
|
||||
addEventListener:(name,fn)=>listeners[name]=fn},
|
||||
window:{},location:{hostname:'system.localhost'},
|
||||
addEventListener(){},requestAnimationFrame:fn=>fn(),console,
|
||||
VulpesCompat:{call:async()=>({mode:'compact',compactDefault:true,reducedMotion})},
|
||||
});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
return {writes,listeners,dataset,finite,busy};
|
||||
}
|
||||
|
||||
test('compact layout alone preserves desktop and APK animations',async()=>{
|
||||
const f=await fixture(false);
|
||||
assert.equal(f.dataset.vulpesLayout,'compact');
|
||||
assert.equal(f.dataset.vulpesMotion,'standard');
|
||||
assert.equal(f.writes.length,0);
|
||||
assert.equal(f.listeners.animationstart,undefined);
|
||||
});
|
||||
|
||||
test('Nokia shortens finite effects while retaining loading animations',async()=>{
|
||||
const f=await fixture(true);
|
||||
assert.equal(f.dataset.vulpesMotion,'reduced');
|
||||
assert.equal(f.writes.length,1);
|
||||
assert.equal(f.writes[0].duration,1);
|
||||
f.listeners.animationstart({target:{getAnimations:()=>[f.finite,f.busy]}});
|
||||
assert.equal(f.writes.length,2);
|
||||
});
|
||||
43
tests/gaia-radio-power.test.mjs
Normal file
43
tests/gaia-radio-power.test.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const source=readFileSync(new URL('../host/gaia-mobile-settings.js',import.meta.url),'utf8').split('// Expose the single native SIM')[0];
|
||||
function load(call,hooks={}) {
|
||||
const navigator={};
|
||||
vm.runInNewContext(source,{window:{VulpesNativeRadioStatus:true},location:{hostname:'system.localhost'},
|
||||
navigator,VulpesCompat:{call},EventTarget,Event,DOMException,
|
||||
document:{documentElement:{setAttribute(){},toggleAttribute(){},append(){}},createElement:()=>({})},addEventListener(type,fn){hooks.start=fn;},setInterval(fn){hooks.tick=fn;}});
|
||||
return navigator.mozMobileConnections;
|
||||
}
|
||||
test('Nokia Gaia radio requests preserve DOMRequest callbacks and awaitable completion',async()=>{
|
||||
const calls=[];
|
||||
const connections=load(async(method,params)=>{calls.push({method,...params});return {slot:params.slot,radioState:'disabled'};});
|
||||
let events=0;connections[1].addEventListener('radiostatechange',()=>events++);
|
||||
const request=connections[1].setRadioEnabled(false);
|
||||
let success=false;request.onsuccess=()=>success=true;
|
||||
await request;
|
||||
assert.deepEqual(calls,[{method:'platform.radioPower',slot:1,enabled:false}]);
|
||||
assert.equal(request.readyState,'done');assert.equal(request.result,null);
|
||||
assert.equal(success,true);assert.equal(events,1);assert.equal(connections[1].radioState,'disabled');
|
||||
assert.equal(connections[0].radioState,'unknown');
|
||||
});
|
||||
test('a modem refusal propagates without claiming a new radio state',async()=>{
|
||||
const connections=load(async()=>{throw Error('RADIO_BUSY');});
|
||||
const request=connections[0].setRadioEnabled(false);
|
||||
let failed=false;request.onerror=()=>failed=true;
|
||||
await assert.rejects(Promise.resolve(request),/RADIO_BUSY/);
|
||||
assert.equal(failed,true);assert.equal(connections[0].radioState,'unknown');
|
||||
});
|
||||
|
||||
test('unchanged and cached modem snapshots do not cancel Gaia airplane mode',async()=>{
|
||||
const hooks={},stamp=Date.now()/1000;
|
||||
const conn=load(async(method)=>method==='platform.snapshot' ? {recordedAt:stamp,radio:[0,1].map(slot=>({slot,available:true,radioState:'enabled',sim:{present:false}}))} : {radioState:'disabled'},hooks)[0];
|
||||
let events=0;conn.addEventListener('radiostatechange',()=>events++);
|
||||
hooks.start();await new Promise(setImmediate);
|
||||
assert.equal(events,1);assert.equal(conn.radioState,'enabled');
|
||||
await hooks.tick();assert.equal(events,1);
|
||||
await new Promise(r=>setTimeout(r,5));
|
||||
await conn.setRadioEnabled(false);assert.equal(events,2);
|
||||
await hooks.tick();assert.equal(conn.radioState,'disabled');assert.equal(events,2);
|
||||
});
|
||||
25
tests/input-enter.test.mjs
Normal file
25
tests/input-enter.test.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {InputEditor} from '../services/input-editor.mjs';
|
||||
function setup({tag='input',form=true}={}) {
|
||||
const calls=[];
|
||||
const e={isConnected:true,isContentEditable:false,localName:tag,value:'query',type:'search',
|
||||
selectionStart:5,selectionEnd:5,focus(){},classList:{contains:()=>false},
|
||||
form:form?{requestSubmit:()=>calls.push('submit')}:null,
|
||||
dispatchEvent(){throw Error('Software Enter must not dispatch native key events');}};
|
||||
const win={getSelection:()=>({}),document:{hidden:false,execCommand:c=>calls.push(c)}};
|
||||
const editor=new InputEditor(win);editor.focus(e);
|
||||
return {editor,calls};
|
||||
}
|
||||
test('software Enter uses normal form validation and submission without key IPC',()=>{
|
||||
const s=setup();s.editor.edit({operation:'key',key:13});
|
||||
assert.deepEqual(s.calls,['submit']);
|
||||
});
|
||||
test('Enter in a standalone input does not navigate or add a newline',()=>{
|
||||
const s=setup({form:false});s.editor.edit({operation:'key',key:'Enter'});
|
||||
assert.deepEqual(s.calls,[]);
|
||||
});
|
||||
test('Enter in a message adds a line without submitting the form',()=>{
|
||||
const s=setup({tag:'textarea'});s.editor.edit({operation:'key',key:13});
|
||||
assert.deepEqual(s.calls,['insertLineBreak']);
|
||||
});
|
||||
|
|
@ -36,7 +36,7 @@ test('a composer button retains input and other buttons dismiss only after click
|
|||
const {runInNewContext}=await import('node:vm');
|
||||
const source=await readFile(new URL('../host/actors/VulpesChild.sys.mjs',import.meta.url),'utf8');
|
||||
const events=[];
|
||||
const Actor=runInNewContext(source.replace(/^import .*;\n/, '').replace('export class','class')+';VulpesChild',{
|
||||
const Actor=runInNewContext(source.replace(/^import .*;\n/gm, '').replace('export class','class')+';VulpesChild',{
|
||||
JSWindowActorChild:class{},Services:{cpmm:{sharedData:{get:()=>true}}}
|
||||
});
|
||||
const actor=new Actor();actor.sendAsyncMessage=(...args)=>events.push(args);
|
||||
|
|
@ -47,3 +47,24 @@ test('a composer button retains input and other buttons dismiss only after click
|
|||
actor.handleEvent({type:'pointerdown',target:button});actor.handleEvent({type:'click',target:button});
|
||||
assert.equal(events.length,1,'send keeps the composer focused');
|
||||
});
|
||||
|
||||
test('physical keypad suppresses the virtual IME without affecting touch hosts',()=>{
|
||||
const {input,events,field}=setup();input.runtime.hardwareKeyboard=true;
|
||||
input.changed(field,{text:'private text'});
|
||||
assert.equal(input.focus,null);assert.equal(events.length,0);
|
||||
input.runtime.hardwareKeyboard=false;input.changed(field,{text:''});
|
||||
assert.equal(events[0][0],'input-focus');
|
||||
});
|
||||
|
||||
test('an external input uses its visible owner, without gaining keyboard permissions',async()=>{
|
||||
const {input,keyboard}=setup();const web={};let visible=true;
|
||||
input.runtime.inputIdentity=actor=>actor===web && visible?{id:'marketplace'}:actor.app;
|
||||
web.sendQuery=async()=>({text:'test'});
|
||||
input.changed(web,{text:''});
|
||||
assert.equal((await input.request(keyboard,{operation:'key',id:1})).text,'test');
|
||||
await assert.rejects(input.request(web,{operation:'state'}),/PERMISSION_DENIED/);
|
||||
visible=false;
|
||||
await assert.rejects(input.request(keyboard,{operation:'key',id:1}),/INPUT_CONTEXT_EXPIRED/);
|
||||
assert.equal(input.focus,null);
|
||||
input.changed(web,{text:'hidden'});assert.equal(input.focus,null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,3 +38,50 @@ test('focus received before viewport measurement survives a stale initial reply'
|
|||
k.viewport();
|
||||
assert.equal(k.ime.inputcontext.text,'pending');
|
||||
});
|
||||
|
||||
function systemKeyboard({legacy=true}={}) {
|
||||
const events=new EventTarget(), intervals=[], calls=[], states=new Map();
|
||||
const frame={style:{},hidden:true,contentWindow:{postMessage(){}},addEventListener(){}};
|
||||
const context={VulpesNativeInput:true,location:{hostname:'system.localhost'},
|
||||
VulpesCompat:{call:(method,params)=>{calls.push({method,params});return Promise.resolve();}},
|
||||
document:{documentElement:{lang:'fr'},createElement:()=>frame,body:{append(){}}},navigator:{language:'fr'},
|
||||
inputWindowManager:legacy?{}:undefined,Service:{registerState:(name,provider)=>states.set(name,provider),register:(name,provider)=>states.set(name,provider)},
|
||||
console,Event,EventTarget,CustomEvent:class extends Event {constructor(name,options={}){super(name);this.detail=options.detail;}},
|
||||
setInterval:fn=>{intervals.push(fn);return intervals.length;},clearInterval(){},
|
||||
addEventListener:events.addEventListener.bind(events),dispatchEvent:events.dispatchEvent.bind(events)};
|
||||
context.window=context;vm.runInNewContext(source,context);intervals.forEach(fn=>fn());
|
||||
const focus=value=>events.dispatchEvent(new context.CustomEvent('vulpes-service-event',{detail:{type:'input-focus',data:value}}));
|
||||
return {events,context,frame,calls,focus,states};
|
||||
}
|
||||
test('Gaia hide commands target the host keyboard',async()=>{
|
||||
const s=systemKeyboard();
|
||||
await s.context.inputWindowManager.hideInputWindow();
|
||||
await s.context.inputWindowManager.hideInputWindowImmediately();
|
||||
assert.equal(s.calls.length,2);
|
||||
assert.ok(s.calls.every(c=>c.method==='input.request' && c.params.operation==='hide'));
|
||||
});
|
||||
test('keyboardhidden follows layout completion so app switching can resume',async()=>{
|
||||
const s=systemKeyboard(), seen=[];let finish;
|
||||
s.events.addEventListener('keyboardhide',e=>{seen.push('hide');e.detail.waitUntil(new Promise(r=>finish=r));});
|
||||
s.events.addEventListener('keyboardhidden',()=>seen.push('hidden'));
|
||||
s.focus({id:1});s.focus(null);
|
||||
assert.equal(s.frame.hidden,true);assert.deepEqual(seen,['hide']);
|
||||
finish();await new Promise(r=>setImmediate(r));
|
||||
assert.deepEqual(seen,['hide','hidden']);
|
||||
});
|
||||
test('refocus cancels stale keyboardhidden completion',async()=>{
|
||||
const s=systemKeyboard();let finish,hidden=0;
|
||||
s.events.addEventListener('keyboardhide',e=>e.detail.waitUntil(new Promise(r=>finish=r)));
|
||||
s.events.addEventListener('keyboardhidden',()=>hidden++);
|
||||
s.focus({id:1});s.focus(null);s.focus({id:2});finish();
|
||||
await new Promise(r=>setImmediate(r));
|
||||
assert.equal(hidden,0);assert.equal(s.frame.hidden,false);
|
||||
});
|
||||
|
||||
test('standalone host registers hide services without the legacy KeyboardManager',async()=>{
|
||||
const s=systemKeyboard({legacy:false});
|
||||
for(const name of ['hideInputWindow','hideInputWindowImmediately'])
|
||||
await s.states.get(name)[name]();
|
||||
assert.equal(s.calls.length,2);
|
||||
s.focus({id:1});assert.equal(s.states.get('getHeight').getHeight(),260);
|
||||
});
|
||||
|
|
|
|||
93
tests/keypad-browser.test.mjs
Normal file
93
tests/keypad-browser.test.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {nextKeypadTarget} from '../services/keypad-navigation.mjs';
|
||||
|
||||
function actor(name, globals={}) {
|
||||
const source=readFileSync(new URL(`../host/actors/${name}.sys.mjs`,import.meta.url),'utf8')
|
||||
.replace(/^import .*;\n/gm,'').replace('export class','class');
|
||||
return vm.runInNewContext(source+`; new ${name}();`,{
|
||||
JSWindowActorChild:class {}, JSWindowActorParent:class {},
|
||||
KeypadText:class {commit() {}}, nextKeypadTarget, ...globals});
|
||||
}
|
||||
|
||||
test('Up from an empty page returns to the browser toolbar, but other apps do not',()=>{
|
||||
const messages=[],scrolls=[];
|
||||
const child=actor('KeypadChild');
|
||||
child.document={activeElement:{localName:'body'},location:{hostname:'example.org'},
|
||||
querySelectorAll:()=>[],documentElement:{dataset:{}}};
|
||||
child.contentWindow={scrollY:0,scrollBy:v=>scrolls.push(v)};
|
||||
child.sendAsyncMessage=name=>messages.push(name);
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'ArrowUp',browserContent:true}});
|
||||
assert.deepEqual(messages,['Vulpes:KeypadToolbar']);
|
||||
messages.length=0;
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'ArrowUp'}});
|
||||
assert.deepEqual(messages,[]);
|
||||
child.contentWindow.scrollY=80;
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'ArrowUp',browserContent:true}});
|
||||
assert.deepEqual(messages,[]);
|
||||
assert.equal(scrolls.at(-1).top,-80);
|
||||
});
|
||||
|
||||
test('external browser views receive keys through their owned placeholder only',()=>{
|
||||
const messages=[];
|
||||
const root={},child={id:8,parent:root,currentWindowGlobal:{getActor:()=>({sendAsyncMessage:()=>{throw Error('Placeholder used');}})}};
|
||||
const view={browser:{style:{visibility:'visible'},browsingContext:{currentWindowGlobal:{
|
||||
getActor:()=>({sendAsyncMessage:(name,data)=>messages.push({name,data})})}}}};
|
||||
const parent=actor('KeypadParent',{Services:{env:{get:()=> '1'}},
|
||||
BrowsingContext:{get:()=>child},Views:{entries:new Map([[8,view]])}});
|
||||
parent.browsingContext=root;
|
||||
parent.receiveMessage({name:'Vulpes:KeypadRoute',data:{contextId:8,key:'ArrowDown',browserContent:true}});
|
||||
assert.equal(messages[0].name,'Vulpes:Navigate');
|
||||
assert.equal(messages[0].data.browserContent,true);
|
||||
parent.browsingContext={};
|
||||
parent.receiveMessage({name:'Vulpes:KeypadRoute',data:{contextId:8,key:'ArrowDown'}});
|
||||
assert.equal(messages.length,1);
|
||||
});
|
||||
|
||||
test('toolbar focus does not select a bar hidden behind the lockscreen or a dialog',()=>{
|
||||
let selected=0;
|
||||
const bar={checkVisibility:()=>true,getBoundingClientRect:()=>({x:0,y:20,width:200,height:40}),contains:()=>false};
|
||||
const child=actor('KeypadChild');
|
||||
child.document={location:{hostname:'system.localhost'},querySelectorAll:()=>[bar],elementFromPoint:()=>({})};
|
||||
child.select=()=>selected++;
|
||||
child.receiveMessage({name:'Vulpes:FocusBrowserToolbar'});
|
||||
assert.equal(selected,0);
|
||||
bar.contains=()=>true;
|
||||
child.receiveMessage({name:'Vulpes:FocusBrowserToolbar'});
|
||||
assert.equal(selected,1);
|
||||
});
|
||||
|
||||
test('Down leaves the toolbar and routes to its page instead of retaining toolbar focus',()=>{
|
||||
let focused=false;
|
||||
const messages=[];
|
||||
const frame={checkVisibility:()=>true,getBoundingClientRect:()=>({x:0,y:70,width:240,height:200}),
|
||||
browsingContext:{id:9},focus:()=>focused=true,closest:()=>({querySelector:()=>({})})};
|
||||
const child=actor('KeypadChild');
|
||||
child.document={location:{hostname:'system.localhost'},activeElement:{closest:()=>({})},
|
||||
querySelectorAll:selector=>selector==='iframe'?[frame]:[],elementFromPoint:()=>frame};
|
||||
child.contentWindow={innerWidth:240,innerHeight:320};
|
||||
child.sendAsyncMessage=(name,data)=>messages.push({name,data});
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'ArrowDown'}});
|
||||
assert.equal(focused,true);
|
||||
assert.equal(messages[0].name,'Vulpes:KeypadRoute');
|
||||
assert.equal(messages[0].data.browserContent,true);
|
||||
assert.equal(messages[0].data.contextId,9);
|
||||
});
|
||||
|
||||
test('Up escapes a Gecko error page even when its retry button is offscreen',()=>{
|
||||
const messages=[];
|
||||
const root={currentWindowGlobal:{getActor:()=>({sendAsyncMessage:name=>messages.push(name)})}};
|
||||
root.top=root;
|
||||
root.embedderElement={ownerDocument:{getElementById:()=>({browsingContext:root})}};
|
||||
const child={id:9,parent:root};
|
||||
const view={browser:{style:{visibility:'visible'},browsingContext:{currentWindowGlobal:{
|
||||
documentURI:{spec:'about:neterror?e=dnsNotFound&u=https://example.invalid/'},
|
||||
getActor:()=>{throw Error('Error-page controls must not trap Up');}}}}};
|
||||
const parent=actor('KeypadParent',{Services:{env:{get:()=> '1'}},
|
||||
BrowsingContext:{get:()=>child},Views:{entries:new Map([[9,view]])}});
|
||||
parent.browsingContext=root;
|
||||
parent.receiveMessage({name:'Vulpes:KeypadRoute',data:{contextId:9,key:'ArrowUp',browserContent:true}});
|
||||
assert.deepEqual(messages,['Vulpes:FocusBrowserToolbar']);
|
||||
});
|
||||
67
tests/keypad-gaia-controls.test.mjs
Normal file
67
tests/keypad-gaia-controls.test.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {nextKeypadTarget} from '../services/keypad-navigation.mjs';
|
||||
|
||||
function control(localName, y, attributes={}) {
|
||||
const state={checked:false,changes:0};
|
||||
return {localName,state,isConnected:true,
|
||||
getAttribute:name=>attributes[name] ?? null,
|
||||
hasAttribute:name=>name in attributes,
|
||||
closest:()=>null,
|
||||
checkVisibility:()=>attributes.hidden === undefined,
|
||||
getBoundingClientRect:()=>({x:15,y,width:210,height:55,left:15,right:225}),
|
||||
click:()=>{throw Error('Native host click must not bypass Gaia');},
|
||||
shadowRoot:{querySelector:selector=>selector === (localName === 'gaia-switch' ? '#switch' : '#checkbox') ? {
|
||||
click:()=>{state.checked=!state.checked;state.changes++;}
|
||||
}:null},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(elements) {
|
||||
const source=readFileSync(new URL('../host/actors/KeypadChild.sys.mjs',import.meta.url),'utf8')
|
||||
.replace(/^import .*;\n/gm,'').replace('export class','class');
|
||||
const child=vm.runInNewContext(source+';new KeypadChild();',{
|
||||
JSWindowActorChild:class {},KeypadText:class {commit() {}},nextKeypadTarget,
|
||||
});
|
||||
const document={activeElement:elements.at(-1),location:{hostname:'settings.localhost'},
|
||||
documentElement:{dataset:{}},
|
||||
querySelectorAll:selector=>elements.filter(e=>selector.split(',').includes(
|
||||
e.localName === 'a' ? 'a[href]' : e.localName))};
|
||||
child.document=document;child.contentWindow={innerWidth:240,innerHeight:290,scrollBy:()=>{}};
|
||||
child.select=element=>{document.activeElement=element;child.selected=element;};
|
||||
return {child,document,key:key=>child.receiveMessage({name:'Vulpes:Navigate',data:{key}})};
|
||||
}
|
||||
|
||||
test('Wi-Fi → geolocation → airplane mode and back includes Gaia switches above the viewport',()=>{
|
||||
const plane=control('gaia-switch',-20),gps=control('gaia-switch',40),wifi=control('a',100);
|
||||
const {document,key}=fixture([plane,gps,wifi]);
|
||||
for (const [direction,expected] of [
|
||||
['ArrowUp',gps],['ArrowUp',plane],['ArrowDown',gps],['ArrowDown',wifi],
|
||||
]) {key(direction);assert.equal(document.activeElement,expected);}
|
||||
});
|
||||
|
||||
test('OK activates the Gaia shadow control exactly once',()=>{
|
||||
for (const name of ['gaia-switch','gaia-checkbox']) {
|
||||
const element=control(name,20),{key}=fixture([element]);
|
||||
key('Enter');assert.deepEqual(element.state,{checked:true,changes:1});
|
||||
key('Enter');assert.deepEqual(element.state,{checked:false,changes:2});
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled and hidden Gaia controls are skipped',()=>{
|
||||
const plane=control('gaia-switch',-20),disabled=control('gaia-switch',40,{disabled:''});
|
||||
const hidden=control('gaia-checkbox',65,{hidden:''}),wifi=control('a',100);
|
||||
const {document,key}=fixture([plane,disabled,hidden,wifi]);
|
||||
key('ArrowUp');assert.equal(document.activeElement,plane);
|
||||
});
|
||||
|
||||
test('activation cannot toggle a disabled control or a component that has not initialized',()=>{
|
||||
for (const attributes of [{disabled:''},{'aria-disabled':'true'}]) {
|
||||
const element=control('gaia-switch',20,attributes),{child}=fixture([element]);
|
||||
child.activate(element);assert.equal(element.state.changes,0);
|
||||
}
|
||||
const element=control('gaia-switch',20),{child}=fixture([element]);
|
||||
element.shadowRoot=null;child.activate(element);assert.equal(element.state.changes,0);
|
||||
});
|
||||
15
tests/keypad-navigation.test.mjs
Normal file
15
tests/keypad-navigation.test.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {nextKeypadTarget} from '../services/keypad-navigation.mjs';
|
||||
|
||||
test('directional keypad navigation selects the aligned grid item', () => {
|
||||
const grid = [{x:40,y:40},{x:120,y:40},{x:40,y:120},{x:120,y:120}];
|
||||
assert.equal(nextKeypadTarget(grid,-1,'ArrowDown'),0);
|
||||
assert.equal(nextKeypadTarget(grid,0,'ArrowRight'),1);
|
||||
assert.equal(nextKeypadTarget(grid,0,'ArrowDown'),2);
|
||||
assert.equal(nextKeypadTarget(grid,3,'ArrowUp'),1);
|
||||
assert.equal(nextKeypadTarget(grid,3,'ArrowLeft'),2);
|
||||
assert.equal(nextKeypadTarget(grid,0,'ArrowLeft'),-1);
|
||||
assert.equal(nextKeypadTarget([],0,'ArrowDown'),-1);
|
||||
assert.equal(nextKeypadTarget(grid,0,'Enter'),-1);
|
||||
});
|
||||
36
tests/keypad-notifications.test.mjs
Normal file
36
tests/keypad-notifications.test.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
function actor(name, globals) {
|
||||
const source=readFileSync(new URL(`../host/actors/${name}.sys.mjs`,import.meta.url),'utf8')
|
||||
.replace(/^import .*;\n/gm,'').replace('export class','class');
|
||||
return vm.runInNewContext(source+`; new ${name}();`,globals);
|
||||
}
|
||||
|
||||
test('notification key reaches the system even while an application edits text',()=>{
|
||||
const messages=[];
|
||||
const child=actor('KeypadChild',{JSWindowActorChild:class {}});
|
||||
child.document={activeElement:{localName:'input',type:'text'}};
|
||||
child.sendAsyncMessage=name=>messages.push(name);
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'F2'}});
|
||||
child.receiveMessage({name:'Vulpes:Navigate',data:{key:'F2',repeat:true}});
|
||||
assert.deepEqual(messages,['Vulpes:KeypadNotifications']);
|
||||
});
|
||||
|
||||
test('notification shortcut only applies to keypad hosts and preserves the foreground view',()=>{
|
||||
let enabled=false;
|
||||
const messages=[];
|
||||
const parent=actor('KeypadParent',{JSWindowActorParent:class {},
|
||||
Services:{env:{get:()=>enabled?'1':'0'}},
|
||||
Runtime:{broadcast:(...args)=>messages.push(args)},
|
||||
Views:{hideAll:()=>{throw Error('Must preserve foreground application');}}});
|
||||
parent.receiveMessage({name:'Vulpes:KeypadNotifications'});
|
||||
assert.equal(messages.length,0);
|
||||
enabled=true;
|
||||
parent.receiveMessage({name:'Vulpes:KeypadNotifications'});
|
||||
assert.equal(messages.length,1);
|
||||
assert.equal(messages[0][0],'keypad-notifications');
|
||||
assert.equal(messages[0][2],'system');
|
||||
});
|
||||
44
tests/keypad-t9-dictionaries.test.mjs
Normal file
44
tests/keypad-t9-dictionaries.test.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {readFileSync,readdirSync} from 'node:fs';
|
||||
import {test} from 'node:test';
|
||||
import {KeypadText,t9KeySequence} from '../services/keypad-text.mjs';
|
||||
|
||||
const root=new URL('../',import.meta.url);
|
||||
const sourceDirectory=new URL('gaia/apps/keyboard/js/imes/latin/dictionaries/',root);
|
||||
const outputDirectory=new URL('tundra/devices/beatles/t9/',root);
|
||||
const sources=readdirSync(sourceDirectory).filter(name=>name.endsWith('_wordlist.xml'))
|
||||
.map(name=>name.replace('_wordlist.xml','').toLowerCase()).sort();
|
||||
const outputs=readdirSync(outputDirectory).filter(name=>name.endsWith('.tsv'))
|
||||
.map(name=>name.replace('.tsv','')).sort();
|
||||
|
||||
test('Nokia offline T9 includes every bundled Gaia Latin keyboard dictionary locale',()=>{
|
||||
assert.equal(sources.length,42);
|
||||
assert.deepEqual(outputs,sources);
|
||||
});
|
||||
|
||||
test('T9 key maps cover Latin accents, Greek, Cyrillic and extended Latin scripts',()=>{
|
||||
assert.equal(t9KeySequence('café'),'2233');
|
||||
assert.equal(t9KeySequence('привет'),'564236');
|
||||
assert.equal(t9KeySequence('καλημέρα'),'52545372');
|
||||
assert.equal(t9KeySequence('straße'),'7872773');
|
||||
assert.equal(t9KeySequence('łódź'),'5639');
|
||||
});
|
||||
|
||||
test('generated English dictionary ranks home above other 4663 words and cycles them',()=>{
|
||||
const input=new KeypadText();
|
||||
input.setDictionary(readFileSync(new URL('en_us.tsv',outputDirectory),'utf8'));
|
||||
const candidates=input.dictionary.get('4663');
|
||||
assert.ok(candidates?.length>1);
|
||||
assert.equal(candidates[0].word,'good');
|
||||
input.mode='t9';
|
||||
const field={};let text='',state={field,text,start:0,end:0,now:0};
|
||||
for(const key of '4663') {
|
||||
const operation=input.press(key,state);
|
||||
text=text.slice(0,state.start-operation.replace)+operation.text;
|
||||
state={field,text,start:text.length,end:text.length,now:state.now+100};
|
||||
}
|
||||
assert.equal(text,candidates[0].word);
|
||||
const operation=input.press('*',{...state,now:500});
|
||||
assert.equal(operation.replace,text.length);
|
||||
assert.equal(candidates.some(entry=>entry.word===operation.text),true);
|
||||
});
|
||||
51
tests/keypad-text.test.mjs
Normal file
51
tests/keypad-text.test.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {KeypadText,t9KeySequence} from '../services/keypad-text.mjs';
|
||||
|
||||
test('cycles a letter, commits on another key and after the timeout', () => {
|
||||
const input=new KeypadText(), field={};
|
||||
assert.deepEqual(input.press('2',{field,text:'',start:0,end:0,now:0}),{text:'a',replace:0});
|
||||
assert.deepEqual(input.press('2',{field,text:'a',start:1,end:1,now:100}),{text:'b',replace:1});
|
||||
assert.deepEqual(input.press('6',{field,text:'b',start:1,end:1,now:200}),{text:'m',replace:0});
|
||||
assert.deepEqual(input.press('6',{field,text:'bm',start:2,end:2,now:1200}),{text:'m',replace:0});
|
||||
});
|
||||
test('never replaces a character in a different field or changed selection', () => {
|
||||
const input=new KeypadText(), first={};
|
||||
input.press('2',{field:first,text:'',start:0,end:0,now:0});
|
||||
assert.equal(input.press('2',{field:{},text:'a',start:1,end:1,now:100}).replace,0);
|
||||
input.commit();input.press('2',{field:first,text:'',start:0,end:0,now:0});
|
||||
assert.equal(input.press('2',{field:first,text:'ab',start:2,end:2,now:100}).replace,0);
|
||||
});
|
||||
test('telephone digits stay literal; hash changes text case, number and T9 modes', () => {
|
||||
const input=new KeypadText(), state={field:{},text:'',start:0,end:0};
|
||||
for(const key of '12*#')assert.equal(input.press(key,{...state,numeric:true}).text,key);
|
||||
assert.deepEqual(input.press('#',state),{mode:'ABC'});
|
||||
assert.equal(input.press('2',state).text,'A');
|
||||
assert.deepEqual(input.press('#',state),{mode:'123'});
|
||||
assert.equal(input.press('2',state).text,'2');
|
||||
assert.deepEqual(input.press('#',state),{mode:'abc'});
|
||||
assert.deepEqual(input.toggleT9(),{mode:'t9'});
|
||||
assert.equal(input.returnMode,'abc');
|
||||
assert.deepEqual(input.toggleT9(),{mode:'abc'});
|
||||
});
|
||||
test('T9 dictionaries select common English and French words and cycle alternatives',()=>{
|
||||
const input=new KeypadText(),field={};
|
||||
input.setDictionary('hello\t80\ngood\t90\nhome\t100\nbonjour\t100\n');
|
||||
assert.equal(t9KeySequence('hello'),'43556');
|
||||
assert.equal(t9KeySequence('école'),'32653');
|
||||
assert.equal(t9KeySequence('привет'),'564236');
|
||||
input.mode='t9';
|
||||
let state={field,text:'',start:0,end:0,now:0};
|
||||
let operation;
|
||||
for(const key of '4663') {
|
||||
operation=input.press(key,state);
|
||||
const next=state.text.slice(0,state.start-operation.replace)+operation.text;
|
||||
state={...state,text:next,start:next.length,end:next.length,now:state.now+100};
|
||||
}
|
||||
assert.equal(state.text,'home');
|
||||
assert.equal(input.suggestion,'home');
|
||||
operation=input.press('*',{...state,now:500});
|
||||
assert.equal(operation.text,'good');
|
||||
assert.equal(operation.replace,4);
|
||||
assert.equal(input.suggestion,'good');
|
||||
});
|
||||
21
tests/locale-race.test.mjs
Normal file
21
tests/locale-race.test.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
const source=readFileSync(new URL('../host/gaia-compat.js',import.meta.url),'utf8');
|
||||
const setupSource=source.slice(source.indexOf(' let language ='),source.indexOf(' const observers ='));
|
||||
const changeSource=source.match(/if \(data.key === 'language.current'\) \{[\s\S]*?\n \}/)[0];
|
||||
function setup(){
|
||||
let reply;const events=[];
|
||||
const context=vm.createContext({navigator:{language:'fr'},Event,dispatchEvent:e=>events.push(e.type),call:()=>new Promise(r=>{reply=r;})});
|
||||
vm.runInContext(setupSource,context);
|
||||
return {context,events,reply,change(value){context.data={key:'language.current',value};vm.runInContext(changeSource,context);}};
|
||||
}
|
||||
test('a delayed startup locale cannot undo a newer Settings language',async()=>{
|
||||
const s=setup();s.change('en-US');s.reply('fr');await new Promise(r=>setImmediate(r));
|
||||
assert.equal(s.context.navigator.language,'en-US');assert.deepEqual(s.events,['languagechange']);
|
||||
});
|
||||
test('initial locale still initializes a newly opened app',async()=>{
|
||||
const s=setup();s.reply('el');await new Promise(r=>setImmediate(r));
|
||||
assert.equal(s.context.navigator.language,'el');assert.deepEqual(Array.from(s.context.navigator.languages),['el']);
|
||||
});
|
||||
37
tests/media-permission.test.mjs
Normal file
37
tests/media-permission.test.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {requestMediaPermission} from '../services/media-permission.mjs';
|
||||
|
||||
function windowMock() {
|
||||
const win = new EventTarget();
|
||||
win.CustomEvent = CustomEvent;
|
||||
return win;
|
||||
}
|
||||
const request = {id:'test-request',kind:'microphone',origin:'http://camera.localhost:8765',manifestURL:'http://camera.localhost:8765/manifest.webapp'};
|
||||
|
||||
test('the original Gaia media panel receives the app identity and matching decision', async () => {
|
||||
const win=windowMock();let prompt;
|
||||
win.addEventListener('mozChromeEvent', event => {prompt=event.detail;});
|
||||
const pending=requestMediaPermission(win,request);
|
||||
assert.equal(prompt.manifestURL,request.manifestURL);
|
||||
assert.deepEqual(prompt.permissions,{'audio-capture':[]});
|
||||
assert.equal(prompt.type,'permission-prompt');
|
||||
win.dispatchEvent(new CustomEvent('mozContentEvent',{detail:{id:request.id,type:'permission-allow',remember:true}}));
|
||||
assert.deepEqual(await pending,{allowed:true,remember:true});
|
||||
});
|
||||
|
||||
test('foreign or unrelated responses cannot grant media access', async () => {
|
||||
const win=windowMock();let settled=false;
|
||||
const pending=requestMediaPermission(win,request).then(result=>{settled=true;return result;});
|
||||
for (const detail of [{id:'other',type:'permission-allow'},{id:request.id,type:'unrelated'}])
|
||||
win.dispatchEvent(new CustomEvent('mozContentEvent',{detail}));
|
||||
await Promise.resolve();assert.equal(settled,false);
|
||||
win.dispatchEvent(new CustomEvent('mozContentEvent',{detail:{id:request.id,type:'permission-deny'}}));
|
||||
assert.deepEqual(await pending,{allowed:false,remember:false});
|
||||
});
|
||||
|
||||
test('leaving the system document cancels the pending request', async () => {
|
||||
const win=windowMock();const pending=requestMediaPermission(win,request);
|
||||
win.dispatchEvent(new Event('pagehide'));
|
||||
assert.deepEqual(await pending,{allowed:false,remember:false});
|
||||
});
|
||||
|
|
@ -33,3 +33,21 @@ const cancelled=new Packages({save:async()=>assert.fail('unexpected save'),chang
|
|||
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');
|
||||
|
||||
const archive='https://ffapps.danielherr.software/app/2048-for-firefox-os/manifest.webapp';
|
||||
const requested=[];
|
||||
const ffaa=new Packages({save:async()=>{},changed:async()=>{},fetchPackage:async(url,options)=>{
|
||||
requested.push(url);assert.equal(options.redirect,'error');assert.equal(options.credentials,'omit');
|
||||
return requested.length===1?Response.json({package_path:'/app/2048-for-firefox-os/game.zip'}):new Response(bytes);
|
||||
}});
|
||||
assert.equal((await ffaa.prepare(archive)).name,'Test');
|
||||
assert.deepEqual(requested,[archive,'https://ffapps.danielherr.software/app/2048-for-firefox-os/game.zip']);
|
||||
for(const value of ['https://ffapps.danielherr.software/app/game/file.zip?next=evil',
|
||||
'https://ffapps.danielherr.software/app/game/a%2fb.zip','https://ffapps.danielherr.software/app/game/a%5cb.zip',
|
||||
'https://ffapps.danielherr.software.evil.test/app/game/game.zip','file:///app/game/game.zip'])assert.throws(()=>packageURL(value));
|
||||
let reads=0;
|
||||
const escape=new Packages({save:async()=>{},changed:async()=>{},fetchPackage:async()=>{
|
||||
reads++;return Response.json({package_path:'https://private.invalid/game.zip'});
|
||||
}});
|
||||
await assert.rejects(escape.prepare(archive),/INVALID_PACKAGE_URL/);assert.equal(reads,1);
|
||||
console.log('FFAA manifest, bounded archive download and cross-origin rejection: OK');
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ authorizePlatform(camera, 'capture');
|
|||
authorizePlatform(camera, 'cameraPreview', {action:'start',camera:'back'});
|
||||
authorizePlatform(camera, 'cameraPreview', {action:'frame'});
|
||||
authorizePlatform(camera, 'cameraPreview', {action:'stop'});
|
||||
for (const mode of ['off','auto','on','torch']) {
|
||||
authorizePlatform(camera,'cameraPreview',{action:'flash',mode});
|
||||
rejects(gallery,'cameraPreview',{action:'flash',mode});
|
||||
}
|
||||
assert.throws(()=>authorizePlatform(camera,'cameraPreview',{action:'flash',mode:'invalid'}), /INVALID_VALUE/);
|
||||
rejects(gallery, 'cameraPreview', {action:'frame'});
|
||||
rejects(settings, 'cameraPreview', {action:'start',camera:'back'});
|
||||
assert.throws(() => authorizePlatform(camera, 'cameraPreview', {action:'start',camera:'../../device'}), /INVALID_VALUE/);
|
||||
|
|
@ -86,3 +91,8 @@ for(const operation of ['hotspotStatus','hotspotSave','hotspotPower']) {
|
|||
authorizePlatform(settings,'wifi',{operation});
|
||||
for(const id of ['system','communications','marketplace'])rejects(app(id),'wifi',{operation});
|
||||
}
|
||||
|
||||
for (const id of ['system','settings']) authorizePlatform(app(id),'radioPower',{slot:0,enabled:false});
|
||||
for (const id of ['communications','search','sms']) rejects(app(id),'radioPower',{slot:0,enabled:false});
|
||||
for (const params of [{slot:2,enabled:true},{slot:0,enabled:1},{slot:'0',enabled:true}])
|
||||
assert.throws(()=>authorizePlatform(settings,'radioPower',params),/INVALID_VALUE/);
|
||||
|
|
|
|||
21
tests/privacy-defaults.test.mjs
Normal file
21
tests/privacy-defaults.test.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {migratePrivacyDefaults} from '../services/settings.mjs';
|
||||
|
||||
test('existing profiles enable protection and skip the introductory popup once',()=>{
|
||||
const values={'privacy.trackingprotection.enabled':false,'language.current':'fr'};
|
||||
assert.equal(migratePrivacyDefaults(values),true);
|
||||
assert.equal(values['privacy.trackingprotection.enabled'],true);
|
||||
assert.equal(values['privacy.trackingprotection.shown'],true);
|
||||
assert.equal(values['privacy.donottrackheader.enabled'],true);
|
||||
assert.equal(values['privacy.donottrackheader.value'],1);
|
||||
assert.equal(values['language.current'],'fr');
|
||||
});
|
||||
|
||||
test('later explicit privacy choices survive subsequent launches',()=>{
|
||||
const values={};migratePrivacyDefaults(values);
|
||||
values['privacy.trackingprotection.enabled']=false;
|
||||
assert.equal(migratePrivacyDefaults(values),false);
|
||||
assert.equal(values['privacy.trackingprotection.enabled'],false);
|
||||
assert.equal(values['privacy.trackingprotection.shown'],true);
|
||||
});
|
||||
16
tests/radio-state.test.mjs
Normal file
16
tests/radio-state.test.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {radioState} from '../services/radio-state.mjs';
|
||||
test('radio status is bounded and excludes identity and raw data',()=>{
|
||||
const result=radioState([{slot:0,available:true,radioState:'enabled',signal:75,
|
||||
sim:{present:true,cardState:'ready',imsi:'private'},operator:{longName:'Network'},
|
||||
voice:{state:'registered',connected:true,type:'lte',cell:'private'},raw:'private'}]);
|
||||
assert.equal(result[0].voice.type,'lte');assert.equal(result[0].signal,75);
|
||||
assert.ok(!JSON.stringify(result).includes('private'));
|
||||
assert.equal(radioState(Array.from({length:20},(_,slot)=>({slot}))).length,4);
|
||||
});
|
||||
test('unavailable adapters never carry forward a previous SIM or registration',()=>{
|
||||
assert.deepEqual(radioState([{slot:0,available:false,sim:{present:true},voice:{connected:true}}]),
|
||||
[{slot:0,available:false,radioState:'unknown'}]);
|
||||
assert.deepEqual(radioState(null),[]);
|
||||
});
|
||||
30
tests/search-defaults.test.mjs
Normal file
30
tests/search-defaults.test.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {migrateSearchDefaults} from '../services/settings.mjs';
|
||||
|
||||
test('old default and cached provider migrate once; explicit choices survive',()=>{
|
||||
for(const saved of [{},{'search.provider':false}]) {
|
||||
saved['search.cache']={defaultEngine:'google',version:3};
|
||||
assert.equal(migrateSearchDefaults(saved),true);
|
||||
assert.equal(saved['search.provider'],'ddg');
|
||||
assert.equal(saved['search.cache'],undefined);
|
||||
saved['search.provider']='bing';saved['search.cache']={version:3};
|
||||
assert.equal(migrateSearchDefaults(saved),false);
|
||||
assert.equal(saved['search.provider'],'bing');
|
||||
assert.deepEqual(saved['search.cache'],{version:3});
|
||||
}
|
||||
const custom={'search.provider':'google'};
|
||||
migrateSearchDefaults(custom);
|
||||
assert.equal(custom['search.provider'],'google');
|
||||
});
|
||||
|
||||
test('bundled search config uses DuckDuckGo without a SIM-dependent override',()=>{
|
||||
const config=JSON.parse(readFileSync(new URL('../overrides/shared/js/search_providers.json',import.meta.url)));
|
||||
assert.equal(config.defaultEngines.defaultEngine,'ddg');
|
||||
assert.deepEqual(config.simConfigs,{});
|
||||
assert.deepEqual(config.partnerConfig,{});
|
||||
const provider=config.search_providers.ddg.search;
|
||||
assert.equal(provider.url,'https://duckduckgo.com/');
|
||||
assert.deepEqual(provider.params,{q:'{searchTerms}'});
|
||||
});
|
||||
|
|
@ -45,7 +45,7 @@ test('call audio collects split replies and rejects oversized output', async ()
|
|||
});
|
||||
|
||||
test('radio preserves modem errors and separates a broken transport', async () => {
|
||||
globalThis.Services = {env: {get: () => '1'}};
|
||||
globalThis.Services = {env: {get: key => key==='VULPES_TUNDRA_RADIO'?'1':''}};
|
||||
let response = {ok:false, error:'RADIO_NOT_REGISTERED'}, exitCode = 0;
|
||||
globalThis.ChromeUtils = {importESModule: () => ({Subprocess: {call: async () => {
|
||||
let read = false;
|
||||
|
|
@ -61,3 +61,34 @@ test('radio preserves modem errors and separates a broken transport', async () =
|
|||
exitCode = 1;
|
||||
await assert.rejects(tundraRadio('status'), /RADIO_UNAVAILABLE/);
|
||||
});
|
||||
|
||||
test('Nokia uses its private broker and parameters cannot replace the authorized operation', async () => {
|
||||
globalThis.Services={env:{get:key=>key==='VULPES_DEVICE'?'beatles':key==='VULPES_TUNDRA_RADIO'?'1':''}};
|
||||
let command,envelope;
|
||||
globalThis.ChromeUtils={importESModule:()=>({Subprocess:{call:async options=>{
|
||||
command=options;let read=false;
|
||||
return {stdin:{write:async text=>{envelope=JSON.parse(text);},close:async()=>{}},
|
||||
stdout:{readString:async()=>read?null:(read=true,'{"ok":true,"result":[]}')},
|
||||
wait:async()=>({exitCode:0})};
|
||||
}}})};
|
||||
await tundraRadio('calls',{operation:'sms',version:99});
|
||||
assert.equal(command.command,'/work/control-client');
|
||||
assert.deepEqual(command.arguments,['--radio']);
|
||||
assert.deepEqual(envelope,{operation:'calls',version:1});
|
||||
await tundraCallAudio({operation:'status'});
|
||||
assert.deepEqual(envelope,{settings:{operation:'status'},version:1,operation:'callAudio'});
|
||||
});
|
||||
|
||||
test('direct socket transport preserves authorization envelopes and masks modem details',async()=>{
|
||||
globalThis.Services={env:{get:key=>['VULPES_TUNDRA_RADIO','VULPES_SOCKET_CONTROL'].includes(key)?'1':''}};
|
||||
let request,reply={ok:true,result:[]};
|
||||
globalThis.ChromeUtils={importESModule:()=>({unixControl:async(kind,params)=>{request={kind,params};return reply;}})};
|
||||
assert.deepEqual(await tundraRadio('calls',{operation:'sms',version:99}),[]);
|
||||
assert.deepEqual(request,{kind:'radio',params:{operation:'calls',version:1}});
|
||||
reply={ok:false,error:'RADIO_NOT_REGISTERED'};
|
||||
await assert.rejects(tundraRadio('calls'),/RADIO_NOT_REGISTERED/);
|
||||
reply={ok:false,error:'private backend detail'};
|
||||
await assert.rejects(tundraRadio('calls'),/RADIO_REQUEST_FAILED/);
|
||||
reply={result:{active:false}};
|
||||
assert.deepEqual(await tundraCallAudio({operation:'status'}),{active:false});
|
||||
});
|
||||
|
|
|
|||
13
tests/view-context.test.mjs
Normal file
13
tests/view-context.test.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Views} from '../host/Views.sys.mjs';
|
||||
test('cross-site navigation replaces the allowed input context and removes stale IDs',()=>{
|
||||
const data=new Map([['vulpes:host-contexts',[1,2]],['vulpes:native-touch',false]]);let flushed=0,touch;
|
||||
globalThis.Services={ppmm:{sharedData:{get:k=>data.get(k),set:(k,v)=>data.set(k,v),flush:()=>flushed++}}};
|
||||
const entry={topContextId:2,browser:{browsingContext:{id:3,setRDMPaneMaxTouchPoints:n=>touch=n}}};
|
||||
Views.syncContext(entry);
|
||||
assert.deepEqual(data.get('vulpes:host-contexts'),[1,3]);assert.equal(entry.topContextId,3);
|
||||
assert.equal(touch,1);assert.equal(entry.browser.browsingContext.touchEventsOverride,'enabled');
|
||||
Views.syncContext(entry);assert.equal(flushed,1);
|
||||
delete globalThis.Services;
|
||||
});
|
||||
36
tests/view-visibility.test.mjs
Normal file
36
tests/view-visibility.test.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {frameUnobscured} from '../services/view-visibility.mjs';
|
||||
|
||||
function scene(width, hostname = 'system.localhost') {
|
||||
const frame = {};
|
||||
const edge = {matches: selector => selector.includes('.gesture-panel')};
|
||||
const document = {hidden:false, location:{hostname},
|
||||
elementFromPoint: x => x < 20 || x > width - 20 ? edge : frame};
|
||||
return {frame, document, rect:{left:0, top:74, width, height:622}};
|
||||
}
|
||||
|
||||
test('Gaia edge gestures do not hide the web page on narrow screens', () => {
|
||||
for (const width of [320,360,414]) {
|
||||
const {document,frame,rect}=scene(width);
|
||||
assert.equal(frameUnobscured(document,frame,rect),true);
|
||||
}
|
||||
});
|
||||
|
||||
test('an application cannot impersonate the system gesture overlay', () => {
|
||||
const {document,frame,rect}=scene(360,'example.localhost');
|
||||
assert.equal(frameUnobscured(document,frame,rect),false);
|
||||
});
|
||||
|
||||
test('dialogs, missing hit targets and hidden documents still hide external views', () => {
|
||||
const {document,frame,rect}=scene(360);
|
||||
for (const hit of [{matches:()=>false},null]) {
|
||||
document.elementFromPoint=()=>hit;
|
||||
assert.equal(frameUnobscured(document,frame,rect),false);
|
||||
}
|
||||
document.elementFromPoint=()=>frame;
|
||||
document.hidden=true;
|
||||
assert.equal(frameUnobscured(document,frame,rect),false);
|
||||
document.hidden=false;
|
||||
assert.equal(frameUnobscured(document,frame,{...rect,width:0}),false);
|
||||
});
|
||||
22
tests/web-input-focus.test.mjs
Normal file
22
tests/web-input-focus.test.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const source=readFileSync(new URL('../host/actors/WebInputParent.sys.mjs',import.meta.url),'utf8');
|
||||
function setup({visible=true,bundled=false,foreground=true}={}) {
|
||||
const top={},calls=[];
|
||||
const browser={browsingContext:top,ownerDocument:{hasFocus:()=>foreground},focus:()=>calls.push('focus')};
|
||||
const Runtime={identity:()=>bundled?{id:'system'}:null,inputIdentity:()=>visible?{id:'system'}:null,input:{changed:()=>calls.push('input'),cleanup(){}}};
|
||||
const Actor=vm.runInNewContext(source.replace(/^import .*;\n/gm,'').replace('export class','class')+';WebInputParent',{
|
||||
JSWindowActorParent:class{},Runtime,Views:{entries:new Map([[1,{browser}]])}
|
||||
});
|
||||
const actor=new Actor();actor.browsingContext={top};
|
||||
return {actor,calls};
|
||||
}
|
||||
test('a visible web view receives focus without giving the page Gaia permissions',()=>{
|
||||
const {actor,calls}=setup();actor.receiveMessage({name:'Vulpes:WebActivate'});assert.deepEqual(calls,['focus']);
|
||||
assert.throws(()=>actor.receiveMessage({name:'Vulpes:Request'}),/METHOD_NOT_SUPPORTED/);
|
||||
});
|
||||
for(const flags of [{visible:false},{bundled:true},{foreground:false}])test('hidden, bundled and background pages cannot steal focus '+JSON.stringify(flags),()=>{
|
||||
const {actor,calls}=setup(flags);actor.receiveMessage({name:'Vulpes:WebActivate'});assert.deepEqual(calls,[]);
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ test('Wi-Fi bursts share one scan, cache errors, and retry after cooldown', asyn
|
|||
let reply = {result:{networks:[]}};
|
||||
let gate = new Promise(resolve => {release = resolve;});
|
||||
Date.now = () => now;
|
||||
globalThis.Services = {env:{get:() => '1'}};
|
||||
globalThis.Services = {env:{get:key => key==='VULPES_TUNDRA_WIFI'?'1':''}};
|
||||
globalThis.ChromeUtils = {importESModule:() => ({Subprocess:{call:async () => {
|
||||
starts++;
|
||||
let read = false;
|
||||
|
|
@ -136,3 +136,61 @@ test('scan without an association does not send Gaia an invalid signal event', a
|
|||
assert.equal(signals,1);assert.equal(manager.connection.status,'connected');
|
||||
connected=false;await manager.getNetworks();assert.equal(signals,1);
|
||||
});
|
||||
|
||||
test('live native association updates Gaia after reconnect without a scan', async () => {
|
||||
const {readFile}=await import('node:fs/promises');
|
||||
const vm=await import('node:vm');
|
||||
let refresh, state={enabled:true,state:'disconnected',networks:[]}, signals=0;
|
||||
const events={},navigator={mozSettings:{addObserver(){}}};
|
||||
const context=vm.createContext({navigator,console,Event,EventTarget,DOMException,
|
||||
window:{VulpesNativeWifi:true},location:{hostname:'system.localhost'},
|
||||
document:{hidden:true},setInterval:fn=>{refresh=fn;return 1;},clearInterval(){},
|
||||
addEventListener:(name,fn)=>{events[name]=fn;},
|
||||
VulpesCompat:{request:p=>p,call:async()=>state}});
|
||||
vm.runInContext(await readFile(new URL('../host/gaia-wifi.js',import.meta.url),'utf8'),context);
|
||||
await events.DOMContentLoaded();
|
||||
const manager=navigator.mozWifiManager;
|
||||
manager.onconnectioninfoupdate=()=>signals++;
|
||||
state={enabled:true,state:'connected',macAddress:'00:11:22:33:44:55',
|
||||
network:{ssid:'Test',security:'wpa-psk',signal:72,savedId:'0'},
|
||||
connectionInformation:{ipAddress:'192.0.2.3',linkSpeed:72}};
|
||||
await refresh();
|
||||
assert.equal(manager.connection.status,'connected');
|
||||
assert.equal(manager.connectionInformation.ipAddress,'192.0.2.3');
|
||||
assert.equal(manager.connectionInformation.relSignalStrength,72);
|
||||
assert.equal(manager.macAddress,state.macAddress);
|
||||
assert.equal(signals,1);
|
||||
state={enabled:false,state:'disabled'};await refresh();
|
||||
assert.equal(manager.connection.status,'disconnected');
|
||||
assert.equal(manager.connectionInformation,null);
|
||||
});
|
||||
|
||||
test('Gaia signal update tolerates a pending list and SSIDs with selector characters', async () => {
|
||||
const {readFile}=await import('node:fs/promises');
|
||||
const vm=await import('node:vm');
|
||||
let utils,items=[],level;
|
||||
const icon={className:'wifi-icon level-0',classList:{add:value=>{level=value;}}};
|
||||
const context=vm.createContext({console,define:factory=>{utils=factory(()=>({getWifiManager:()=>({})}));},
|
||||
document:{querySelectorAll:selector=>{assert.equal(selector,'li[data-ssid]');return items;}}});
|
||||
vm.runInContext(await readFile(new URL('../gaia/apps/settings/js/modules/wifi_utils.js',import.meta.url),'utf8'),context);
|
||||
const network={ssid:'Cafe "[wifi]"'};
|
||||
assert.doesNotThrow(()=>utils.updateNetworkSignal(network,80));
|
||||
assert.doesNotThrow(()=>utils.updateNetworkSignal(null,80));
|
||||
items=[{dataset:{ssid:network.ssid},querySelector:()=>icon}];
|
||||
utils.updateNetworkSignal(network,80);
|
||||
assert.equal(level,'level-4');
|
||||
});
|
||||
|
||||
test('saved networks outside the scan have zero signal and retain security type', async () => {
|
||||
const {readFile}=await import('node:fs/promises');
|
||||
const vm=await import('node:vm');
|
||||
const navigator={};
|
||||
const context=vm.createContext({navigator,console,Event,EventTarget,DOMException,
|
||||
window:{VulpesNativeWifi:true},location:{hostname:'system.localhost'},addEventListener(){},
|
||||
VulpesCompat:{request:p=>p,call:async()=>({networks:[{id:'4',ssid:'Office',security:'unsupported'}]})}});
|
||||
vm.runInContext(await readFile(new URL('../host/gaia-wifi.js',import.meta.url),'utf8'),context);
|
||||
const [network]=await navigator.mozWifiManager.getKnownNetworks();
|
||||
assert.equal(network.relSignalStrength,0);
|
||||
assert.equal(network.security[0],'WPA-EAP');
|
||||
assert.equal(network.savedId,'4');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue