Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -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');

View file

@ -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);
});

View 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);
});

View 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);
});

View 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']);
});

View file

@ -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);
});

View file

@ -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);
});

View 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']);
});

View 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);
});

View 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);
});

View 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');
});

View 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);
});

View 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');
});

View 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']);
});

View 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});
});

View file

@ -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');

View file

@ -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/);

View 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);
});

View 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),[]);
});

View 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}'});
});

View file

@ -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});
});

View 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;
});

View 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);
});

View 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,[]);
});

View file

@ -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');
});