Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
57
tests/ProbeChild.sys.mjs
Normal file
57
tests/ProbeChild.sys.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Test-only actor, registered by tools/inspect-host.py through Marionette.
|
||||
export class VulpesProbeChild extends JSWindowActorChild {
|
||||
async receiveMessage(message) {
|
||||
if (message.name === 'Tap') {
|
||||
const {x,y}=message.data, win=this.contentWindow;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
|
||||
x>=win.innerWidth || y>=win.innerHeight) throw Error('INVALID_TEST_POINT');
|
||||
const points=[{identifier:0,offsetX:x,offsetY:y,radiiX:1,radiiY:1}];
|
||||
win.synthesizeTouchEvent('touchstart',points,0,{isAsyncEnabled:false});
|
||||
const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
|
||||
await new Promise(resolve=>setTimeout(resolve,80));
|
||||
win.synthesizeTouchEvent('touchend',points,0,{isAsyncEnabled:false});
|
||||
return true;
|
||||
}
|
||||
if (message.name === 'RenderedFonts') {
|
||||
const document = this.contentWindow.document;
|
||||
await document.fonts.ready;
|
||||
const result = [];
|
||||
function collect(root) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
for (const face of InspectorUtils.getUsedFontFaces(range, 3, true)) {
|
||||
result.push({
|
||||
scope: root.host?.tagName || 'document',
|
||||
name: face.name,
|
||||
family: face.CSSFamilyName,
|
||||
generic: face.CSSGeneric,
|
||||
uri: face.URI,
|
||||
fallback: face.fromSystemFallback,
|
||||
samples: face.ranges.map(r => r.toString().slice(0, 80)),
|
||||
});
|
||||
}
|
||||
for (const node of root.querySelectorAll('*')) {
|
||||
if (node.shadowRoot) collect(node.shadowRoot);
|
||||
}
|
||||
}
|
||||
collect(document.body);
|
||||
return result;
|
||||
}
|
||||
if (message.name === 'TouchState') {
|
||||
const actor = this.manager.getActor('VulpesTouch');
|
||||
return {
|
||||
top: this.browsingContext.top.id,
|
||||
contexts: Services.cpmm.sharedData.get('vulpes:host-contexts'),
|
||||
enabled: actor.simulator?.enabled,
|
||||
native: Services.cpmm.sharedData.get('vulpes:native-touch') === true,
|
||||
input:Services.cpmm.sharedData.get('vulpes:native-input'),
|
||||
};
|
||||
}
|
||||
if (message.name !== 'Evaluate') throw new Error('Unknown probe command');
|
||||
const sandbox = Cu.Sandbox(this.contentWindow, {
|
||||
sandboxPrototype: this.contentWindow,
|
||||
wantXrays: false,
|
||||
});
|
||||
return await Cu.evalInSandbox('(async () => {' + message.data + '})()', sandbox);
|
||||
}
|
||||
}
|
||||
62
tests/alarms.test.mjs
Normal file
62
tests/alarms.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { Alarms } from '../host/Alarms.sys.mjs';
|
||||
|
||||
let saved;
|
||||
const storage = {
|
||||
load: async () => saved && structuredClone(saved),
|
||||
save: async (state) => {
|
||||
saved = structuredClone(state);
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
id: 'clock',
|
||||
origin: 'http://clock.localhost:8765',
|
||||
manifest: { permissions: { alarms: {} }, messages: [{ alarm: '/onring.html' }] },
|
||||
};
|
||||
const events = [];
|
||||
const actor = {
|
||||
manager: { documentURI: { spec: app.origin + '/onring.html' } },
|
||||
sendAsyncMessage: (_name, event) => events.push(event),
|
||||
};
|
||||
const launched = [];
|
||||
const create = async () => {
|
||||
const alarms = new Alarms(
|
||||
() => app,
|
||||
(id) => launched.push(id),
|
||||
storage,
|
||||
() => 1,
|
||||
);
|
||||
await alarms.init();
|
||||
return alarms;
|
||||
};
|
||||
let alarms = await create();
|
||||
const date = Date.now() + 60_000;
|
||||
const request = {
|
||||
operation: 'add',
|
||||
date,
|
||||
respectTimezone: 'honorTimezone',
|
||||
data: { label: 'Wake up' },
|
||||
local: [2026, 8, 23, 9, 0, 0, 0],
|
||||
};
|
||||
const id = await alarms.request(actor, app, request);
|
||||
alarms = await create();
|
||||
assert.equal((await alarms.request(actor, app, { operation: 'getAll' }))[0].id, id);
|
||||
assert.throws(() => alarms.request(actor, { ...app, manifest: {} }, request), /PERMISSION_DENIED/);
|
||||
await alarms.request(actor, app, { operation: 'subscribe' });
|
||||
alarms.state.records[0].date = Date.now() - 100;
|
||||
await alarms.tick();
|
||||
assert.equal(events[0].data.id, id);
|
||||
assert.equal(saved.pending.length, 0);
|
||||
assert.equal(saved.records.length, 0);
|
||||
assert.equal(launched.length, 0);
|
||||
const pending = await alarms.request(actor, app, { ...request, date: Date.now() - 100 });
|
||||
alarms.cleanup(actor);
|
||||
await alarms.tick();
|
||||
assert.deepEqual(launched, ['clock']);
|
||||
alarms = await create();
|
||||
await alarms.request(actor, app, { operation: 'subscribe' });
|
||||
assert.equal(events.at(-1).data.id, pending);
|
||||
assert.equal(saved.pending.length, 0);
|
||||
console.log(
|
||||
'PASS: shared alarms persist, enforce permissions and deliver queued events after restart.',
|
||||
);
|
||||
88
tests/calendar-alarm.test.mjs
Normal file
88
tests/calendar-alarm.test.mjs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
async function moveAlarm(failure) {
|
||||
const request = {};
|
||||
const source = readFileSync(new URL('../gaia/apps/calendar/js/store/alarm.js', import.meta.url), 'utf8');
|
||||
let Alarm;
|
||||
const modules = {
|
||||
'./abstract': function Abstract() {},
|
||||
'common/calc': {dateToTransport: d => ({utc: +d}), dateFromTransport: d => new Date(d.utc), HOUR: 3600000},
|
||||
'common/create_dom_promise': request => request,
|
||||
core: {db: {transaction: () => ({objectStore: () => ({index: () => ({openCursor: () => request})})})}},
|
||||
'common/debug': () => () => {},
|
||||
'common/promise': {denodeifyAll: () => {}},
|
||||
'common/object': {forEach: () => {}},
|
||||
};
|
||||
vm.runInNewContext(source, {
|
||||
define(factory) {const module = {};factory(id => modules[id], {}, module);Alarm = module.exports;},
|
||||
navigator: {mozAlarms: {add: () => failure ? Promise.reject(failure) : Promise.resolve({type: 'success', target: {result: 42}})}},
|
||||
Date, Promise,
|
||||
});
|
||||
const result = new Promise((resolve, reject) => Alarm.prototype._moveAlarms.call(
|
||||
{_alarmAddThresholdHours: 48}, new Date(), true,
|
||||
(error, value) => error ? reject(error) : resolve(value)));
|
||||
const date = Date.now() + 3600000;
|
||||
request.onsuccess({target: {result: {key: date, value: {eventId: 'test', trigger: {utc: date}},
|
||||
update() {}, continue() {request.onsuccess({target: {result: null}});}}}});
|
||||
return result;
|
||||
}
|
||||
test('a successfully scheduled reminder resolves the Node-style callback', async () => {
|
||||
const result = await moveAlarm();
|
||||
assert.equal(result[0].target.result, 42);
|
||||
});
|
||||
test('a rejected reminder reaches the caller instead of leaving it pending', async () => {
|
||||
const failure = Error('Alarm quota exceeded');
|
||||
await assert.rejects(moveAlarm(failure), error => error === failure);
|
||||
});
|
||||
|
||||
function notificationController(record, failure) {
|
||||
const stats = {locks:0, unlocks:0, persist:0, notifications:0, errors:0};
|
||||
const co = {wrap: fn => (...args) => new Promise((resolve,reject) => {
|
||||
const iterator = fn(...args);
|
||||
function step(method, value) {
|
||||
let next;
|
||||
try {next = iterator[method](value);} catch(error) {reject(error);return;}
|
||||
if(next.done) resolve(next.value);
|
||||
else Promise.resolve(next.value).then(v=>step('next',v),e=>step('throw',e));
|
||||
}
|
||||
step('next');
|
||||
})};
|
||||
const stores = {
|
||||
Alarm: {get: async()=>record, persist:async()=>stats.persist++},
|
||||
Event: {get:async()=>({remote:{title:'Test',id:'test'}})},
|
||||
Busytime: {get:async()=>({start:{utc:Date.now()},_id:'busy'})},
|
||||
};
|
||||
const modules = {
|
||||
'common/calc': {dateFromTransport:d=>new Date(d.utc)}, 'ext/co':co,
|
||||
core: {storeFactory:{get:name=>stores[name]},db:{transaction:()=>({})}},
|
||||
'common/debug':()=>()=>{}, message_handler:{},
|
||||
notification:{sendNotification:async()=>{stats.notifications++;if(failure)throw failure;}},
|
||||
};
|
||||
const exports = {};
|
||||
vm.runInNewContext(readFileSync(new URL('../gaia/apps/calendar/js/controllers/notifications.js',import.meta.url),'utf8'),{
|
||||
define:factory=>factory(id=>modules[id],exports),Date,Promise,
|
||||
navigator:{languages:['fr'],requestWakeLock:()=>{stats.locks++;return {unlock:()=>stats.unlocks++};}},
|
||||
mozIntl:{_gaia:{RelativeDate:()=>({format:async()=> 'now'})}},
|
||||
console:{error:()=>stats.errors++},
|
||||
});
|
||||
return {stats, onAlarm:()=>exports.onAlarm({_id:'queued-alarm'})};
|
||||
}
|
||||
test('queued reminders for deleted events do not dereference a missing alarm',async()=>{
|
||||
const {stats,onAlarm}=notificationController(undefined);
|
||||
await onAlarm();
|
||||
assert.equal(stats.locks,0);assert.equal(stats.notifications,0);assert.equal(stats.persist,0);
|
||||
});
|
||||
test('a failed notification releases the wake lock without marking the reminder delivered',async()=>{
|
||||
const {stats,onAlarm}=notificationController({_id:'alarm'},Error('Permission denied'));
|
||||
await onAlarm();
|
||||
assert.equal(stats.notifications,1);assert.equal(stats.unlocks,1);
|
||||
assert.equal(stats.persist,0);assert.equal(stats.errors,1);
|
||||
});
|
||||
test('a successful reminder is persisted once and releases its wake lock',async()=>{
|
||||
const {stats,onAlarm}=notificationController({_id:'alarm'});
|
||||
await onAlarm();
|
||||
assert.equal(stats.notifications,1);assert.equal(stats.persist,1);assert.equal(stats.unlocks,1);
|
||||
});
|
||||
23
tests/call-journal.test.mjs
Normal file
23
tests/call-journal.test.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {CallJournal} from '../services/call-journal.mjs';
|
||||
test('calls survive reload, preserve direction and import acknowledgement', () => {
|
||||
const j = new CallJournal();
|
||||
j.start({path:'/1',number:'123'}, 1000,'outgoing');
|
||||
j.update([{path:'/1',state:'active'}], 2000);
|
||||
const restored = new CallJournal(JSON.parse(JSON.stringify(j.snapshot())));
|
||||
restored.update([], 5500);
|
||||
assert.equal(restored.completed[0].duration,3500);
|
||||
assert.equal(restored.completed[0].direction,'outgoing');
|
||||
restored.update([{path:'/1',state:'incoming',number:'456'}],6000);
|
||||
restored.update([],6500);
|
||||
assert.equal(restored.completed[1].duration,0);
|
||||
assert.equal(restored.completed[1].direction,'incoming');
|
||||
assert.notEqual(restored.completed[0].id,restored.completed[1].id);
|
||||
restored.acknowledge([restored.completed[0].id]);
|
||||
assert.equal(restored.completed.length,1);
|
||||
});
|
||||
test('rapid outgoing call is logged even before the first successful poll', () => {
|
||||
const j=new CallJournal();j.start({path:'/1',number:'123'},1000,'outgoing');j.update([],1100);
|
||||
assert.equal(j.completed.length,1);assert.equal(j.completed[0].duration,0);
|
||||
});
|
||||
5
tests/contracts.html
Normal file
5
tests/contracts.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Vulpes service contract tests — not Gaia</title>
|
||||
<pre id="result">Running platform tests</pre>
|
||||
<script type="module" src="./contracts.mjs"></script>
|
||||
58
tests/contracts.mjs
Normal file
58
tests/contracts.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { SettingsService } from '../services/settings.mjs';
|
||||
import { IndexedDBStorage } from '../adapters/indexeddb.mjs';
|
||||
|
||||
const results = {};
|
||||
let storage;
|
||||
try {
|
||||
storage = await IndexedDBStorage.open('vulpes-contract-tests');
|
||||
const service = new SettingsService(storage);
|
||||
const writer = service.bindSession('settings', ['settings.read', 'settings.write']);
|
||||
const reader = service.bindSession('homescreen', ['settings.read']);
|
||||
const web = service.bindSession('untrusted-web', []);
|
||||
let id = 0;
|
||||
const req = (method, params) => ({ version: 1, id: ++id, method, params });
|
||||
if (new URL(location.href).searchParams.has('restart')) {
|
||||
const result = await reader(req('settings.get', { key: 'language.current' }));
|
||||
results.survives_engine_restart = result.result.value === 'fr';
|
||||
} else {
|
||||
await writer(req('settings.set', { values: { 'language.current': 'en' } }));
|
||||
const set = await writer(
|
||||
req('settings.set', {
|
||||
values: { 'language.current': 'fr', 'deviceinfo.software': 'Vulpes next lab' },
|
||||
}),
|
||||
);
|
||||
results.write_commits = set.result.committed === true;
|
||||
const read = await reader(req('settings.get', { key: 'language.current' }));
|
||||
results.read_back = read.result.value === 'fr';
|
||||
const denied = await reader({
|
||||
...req('settings.set', { values: { 'language.current': 'de' } }),
|
||||
identity: 'settings',
|
||||
permissions: ['settings.write'],
|
||||
});
|
||||
results.payload_cannot_escalate = denied.error.code === 'PERMISSION_DENIED';
|
||||
const webResult = await web(req('settings.get', { key: 'language.current' }));
|
||||
results.untrusted_session_denied = webResult.error.code === 'PERMISSION_DENIED';
|
||||
const wrongVersion = await writer({
|
||||
...req('settings.get', { key: 'language.current' }),
|
||||
version: 999,
|
||||
});
|
||||
results.version_rejected = wrongVersion.error.code === 'API_VERSION_UNSUPPORTED';
|
||||
const invalid = await writer(
|
||||
req('settings.set', { values: { 'language.current': 'de', invalid: NaN } }),
|
||||
);
|
||||
results.invalid_batch_rejected = invalid.error.code === 'INVALID_VALUE';
|
||||
results.batch_left_unchanged =
|
||||
(await reader(req('settings.get', { key: 'language.current' }))).result.value === 'fr';
|
||||
results.unknown_method_rejected =
|
||||
(await writer(req('os.execute', { command: 'anything' }))).error.code ===
|
||||
'METHOD_NOT_SUPPORTED';
|
||||
results.missing_value_explicit =
|
||||
(await reader(req('settings.get', { key: 'missing.test.key' }))).result.found === false;
|
||||
}
|
||||
} catch (e) {
|
||||
results.failure = String(e);
|
||||
} finally {
|
||||
if (storage) storage.close();
|
||||
}
|
||||
window.testResults = results;
|
||||
document.getElementById('result').textContent = JSON.stringify(results, null, 2);
|
||||
32
tests/gaia-controls.test.mjs
Normal file
32
tests/gaia-controls.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
function amd(path, globals, imports={}) {
|
||||
let module;
|
||||
vm.runInNewContext(readFileSync(path,'utf8'), {...globals,define:factory=>{module=factory(id=>imports[id]);}});
|
||||
return module;
|
||||
}
|
||||
test('brightness works with pointer/keyboard input and stores numbers',()=>{
|
||||
let timers=0;const writes=[],events={};
|
||||
const slider=amd('overrides/settings.gaiamobile.org/js/panels/display/slider_handler.js',{
|
||||
navigator:{mozSettings:{createLock:()=>({set:v=>writes.push(v)})}},
|
||||
setInterval:()=>++timers,clearInterval:id=>{if(id)timers--;},
|
||||
})();
|
||||
const input={value:'0.4',valueAsNumber:.4,addEventListener:(name,fn)=>events[name]=fn};
|
||||
slider.init(input,'screen.brightness');events.input();events.input();events.change();
|
||||
assert.equal(writes.length,1);assert.equal(writes[0]['screen.brightness'],.4);assert.equal(timers,0);
|
||||
input.valueAsNumber=.7;events.change();assert.equal(writes.at(-1)['screen.brightness'],.7);
|
||||
input.valueAsNumber=NaN;events.change();assert.equal(writes.length,2);
|
||||
});
|
||||
test('Wi-Fi failure exits spinner without restarting a failed scan forever',()=>{
|
||||
const req={},list={dataset:{},querySelectorAll:()=>[],insertBefore:n=>list.item=n};
|
||||
let retries=0;
|
||||
const factory=amd('overrides/settings.gaiamobile.org/js/panels/wifi/wifi_network_list.js',{
|
||||
console:{warn:()=>{}},document:{hidden:false,documentElement:{lang:'fr'},createElement:()=>({setAttribute(){}})},
|
||||
window:{performance:{measure(){}},setTimeout:()=>retries++},
|
||||
},{'modules/wifi_context':{addEventListener(){}},'shared/wifi_helper':{getWifiManager:()=>({enabled:true}),getAvailableAndKnownNetworks:()=>req}});
|
||||
const networks=factory({wifiAvailableNetworks:list});networks.scan();req.onerror();
|
||||
assert.equal(list.dataset.state,'ready');assert.match(list.item.textContent,/Réessayez/);
|
||||
assert.equal(retries,0);assert.equal(networks.scanning,false);
|
||||
});
|
||||
27
tests/gallery-thumbnail.test.mjs
Normal file
27
tests/gallery-thumbnail.test.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
test('Gallery creates positive-size thumbnails even when its launch frame is hidden',()=>{
|
||||
const source=readFileSync(new URL('../gaia/apps/gallery/js/MetadataParser.js',import.meta.url),'utf8');
|
||||
const functionSource=source.slice(source.indexOf(' function computeThumbnailWidth()'),source.indexOf(' var thumbnailSize'));
|
||||
function size(width,height) {
|
||||
return vm.runInNewContext(functionSource+';computeThumbnailWidth();',{
|
||||
window:{innerWidth:width,innerHeight:height,devicePixelRatio:2},
|
||||
screen:{width:480,height:800},isPhone:true,
|
||||
});
|
||||
}
|
||||
assert.equal(size(0,0),400);
|
||||
assert.equal(size(480,800),400);
|
||||
assert.equal(size(800,480),400);
|
||||
});
|
||||
test('a queued Gallery preview cannot restart editing after destroy',()=>{
|
||||
const source=readFileSync(new URL('../gaia/apps/gallery/js/ImageEditor.js',import.meta.url),'utf8');
|
||||
const method=source.slice(source.indexOf('ImageEditor.prototype.edit ='),source.indexOf('ImageEditor.prototype.finishEdit ='));
|
||||
const scope={ImageEditor:function(){}};
|
||||
vm.runInNewContext(method,scope);
|
||||
let restarted=false;
|
||||
scope.ImageEditor.prototype.edit.call({previewCanvas:null,generateNewPreview(){restarted=true;}});
|
||||
assert.equal(restarted,false);
|
||||
});
|
||||
32
tests/input.test.mjs
Normal file
32
tests/input.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Input} from '../host/Input.sys.mjs';
|
||||
function setup() {
|
||||
const events=[];
|
||||
const input=new Input({identity:actor=>actor.app,broadcast:(...args)=>events.push(args)});
|
||||
const keyboard={app:{id:'keyboard'}}, field={app:{id:'sms'}};
|
||||
return {input,events,keyboard,field};
|
||||
}
|
||||
test('keyboard cannot edit a stale field or another app issue keyboard commands',async()=>{
|
||||
const {input,keyboard,field}=setup();
|
||||
input.changed(field,{text:'',id:0});
|
||||
await assert.rejects(input.request(field,{operation:'state'}),/PERMISSION_DENIED/);
|
||||
await assert.rejects(input.request(keyboard,{operation:'key',id:0}),/INPUT_CONTEXT_EXPIRED/);
|
||||
assert.equal((await input.request(keyboard,{operation:'state'})).id,1);
|
||||
});
|
||||
for(const action of ['hide','change']) test(`pending edit cannot update state after ${action}`,async()=>{
|
||||
const {input,keyboard,field}=setup();let reply;
|
||||
field.sendQuery=()=>new Promise(resolve=>{reply=resolve;});
|
||||
input.changed(field,{text:'first'});
|
||||
const pending=input.request(keyboard,{operation:'key',id:1});
|
||||
if(action==='hide') input.hide(); else input.changed(field,{text:'second'});
|
||||
reply({text:'first edited'});
|
||||
await assert.rejects(pending,/INPUT_CONTEXT_EXPIRED/);
|
||||
assert.equal(input.focus?.state.text,action==='hide'?undefined:'second');
|
||||
});
|
||||
test('current edit updates the focused field; keyboard focus does not replace it',async()=>{
|
||||
const {input,keyboard,field}=setup();
|
||||
field.sendQuery=async()=>({text:'123',selectionStart:3,selectionEnd:3});
|
||||
input.changed(field,{text:''});input.changed(keyboard,{text:'unrelated'});
|
||||
assert.equal((await input.request(keyboard,{operation:'key',id:1})).text,'123');
|
||||
});
|
||||
29
tests/legacy-apps.json
Normal file
29
tests/legacy-apps.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[
|
||||
{
|
||||
"id": "legacy-2048-for-firefox-os",
|
||||
"slug": "2048-for-firefox-os",
|
||||
"version": "1.0",
|
||||
"page": "https://ffapps.danielherr.software/app/2048-for-firefox-os/",
|
||||
"url": "https://ffapps.danielherr.software/app/2048-for-firefox-os/2048-for-firefox-os-1.0.zip",
|
||||
"archive": "2048-for-firefox-os-1.0.zip",
|
||||
"sha256": "af859c5f90ad9b578ed174ae9d883b45132fca7a026e19d4fca95cc8b4962131"
|
||||
},
|
||||
{
|
||||
"id": "legacy-calculator-2",
|
||||
"slug": "calculator-2",
|
||||
"version": "2.0.4",
|
||||
"page": "https://ffapps.danielherr.software/app/calculator-2/",
|
||||
"url": "https://ffapps.danielherr.software/app/calculator-2/calculator-2-2.0.4.zip",
|
||||
"archive": "calculator-2-2.0.4.zip",
|
||||
"sha256": "e89840283d54319648916007b1388785618645308e6bd662f0916c87221ee9d4"
|
||||
},
|
||||
{
|
||||
"id": "legacy-podcasts",
|
||||
"slug": "podcasts",
|
||||
"version": "2.0.1",
|
||||
"page": "https://ffapps.danielherr.software/app/podcasts/",
|
||||
"url": "https://ffapps.danielherr.software/app/podcasts/podcasts-2.0.1.zip",
|
||||
"archive": "podcasts-2.0.1.zip",
|
||||
"sha256": "d5f69dd6db47e3636bd1fd99ed449ac5f230214e1a60edeaa79f442c5efb4d38"
|
||||
}
|
||||
]
|
||||
99
tests/messages.test.mjs
Normal file
99
tests/messages.test.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Messages} from '../host/Messages.sys.mjs';
|
||||
const app={manifest:{permissions:{sms:{}}}};
|
||||
function store(initial=[]) {
|
||||
let records=structuredClone(initial);
|
||||
return {run:async (_store,op,args)=>{if(op==='get')return {value:structuredClone(records)};records=structuredClone(args[0]);}};
|
||||
}
|
||||
test('concurrent sends keep both records and numeric stable thread IDs',async()=>{
|
||||
const service=new Messages(store(),async()=>({path:'/ril_0/message1'}));
|
||||
const send=body=>service.request(app,{operation:'send',args:['+33100000000',body]});
|
||||
const [a,b]=await Promise.all([send('one'),send('two')]);
|
||||
assert.ok(Number.isSafeInteger(a.id));assert.ok(Number.isSafeInteger(a.threadId));
|
||||
assert.notEqual(a.id,b.id);assert.equal(a.threadId,b.threadId);
|
||||
const rows=await service.request(app,{operation:'getMessages',args:[{threadId:a.threadId}]});
|
||||
assert.equal(rows.length,2);
|
||||
});
|
||||
test('legacy modem-path IDs migrate once without losing message bodies',async()=>{
|
||||
const service=new Messages(store([{id:'/ril_0/message1',threadId:'+33100000000',type:'sms',receiver:'+33100000000',body:'old',timestamp:1}]));
|
||||
const a=await service.request(app,{operation:'getMessages'});
|
||||
const b=await service.request(app,{operation:'getMessages'});
|
||||
assert.deepEqual(a,b);assert.equal(a[0].body,'old');assert.ok(Number.isSafeInteger(a[0].threadId));
|
||||
});
|
||||
test('radio rejection never creates a sent message',async()=>{
|
||||
const service=new Messages(store(),async()=>{throw Error('RADIO_NOT_REGISTERED');});
|
||||
await assert.rejects(service.request(app,{operation:'send',args:['+33100000000','test']}),/RADIO_NOT_REGISTERED/);
|
||||
const failed=await service.request(app,{operation:'getMessages'});
|
||||
assert.equal(failed.length,1);assert.equal(failed[0].delivery,'error');
|
||||
assert.equal(failed[0].body,'test');
|
||||
assert.equal((await service.request(app,{operation:'getThreads'})).length,1);
|
||||
});
|
||||
test('SMS filters isolate unread messages, recipients and date ranges',async()=>{
|
||||
const service=new Messages(store([
|
||||
{id:1,threadId:10,sender:'111',timestamp:1000,read:false,delivery:'received'},
|
||||
{id:2,threadId:10,sender:'111',timestamp:2000,read:true,delivery:'received'},
|
||||
{id:3,threadId:20,receiver:'222',timestamp:3000,read:true,delivery:'sent'},
|
||||
]));
|
||||
const list=async(filter,reverse=false)=>(await service.request(app,{operation:'getMessages',args:[filter,reverse]})).map(m=>m.id);
|
||||
assert.deepEqual(await list({threadId:10,read:false}),[1]);
|
||||
assert.deepEqual(await list({numbers:['222']}),[3]);
|
||||
assert.deepEqual(await list({numbers:['333']}),[]);
|
||||
assert.deepEqual(await list({startDate:new Date(2000),endDate:new Date(3000)},true),[3,2]);
|
||||
assert.deepEqual(await list({delivery:'received'}),[1,2]);
|
||||
await service.request(app,{operation:'markMessageRead',args:[1,true]});
|
||||
assert.deepEqual(await list({read:false}),[]);
|
||||
assert.equal((await service.request(app,{operation:'getThreads'}))[1].unreadCount,0);
|
||||
assert.deepEqual(await service.request(app,{operation:'delete',args:[[1,2,999]]}),[true,true,false]);
|
||||
assert.deepEqual((await service.request(app,{operation:'getThreads'})).map(t=>t.id),[20]);
|
||||
});
|
||||
test('incoming retries persist once and share the outgoing conversation',async()=>{
|
||||
const database=store();
|
||||
const service=new Messages(database,async()=>({path:'/ril_0/message1'}));
|
||||
const sent=await service.request(app,{operation:'send',args:['111','out']});
|
||||
const incoming={receipt:'receipt-1',sender:'111',body:'in',timestamp:10000};
|
||||
const [a,b]=await Promise.all([service.receive(incoming),service.receive(incoming)]);
|
||||
assert.equal(a.id,b.id);assert.equal(a.threadId,sent.threadId);
|
||||
assert.equal(a.read,false);assert.equal(a.delivery,'received');
|
||||
const restarted=new Messages(database);
|
||||
assert.equal((await restarted.receive(incoming)).id,a.id);
|
||||
assert.equal((await restarted.request(app,{operation:'getMessages'})).length,2);
|
||||
});
|
||||
test('failed inbox persistence is retryable without poisoning the queue',async()=>{
|
||||
let fail=true;const database=store(),run=database.run;
|
||||
database.run=async(...args)=>{if(args[1]==='put' && fail){fail=false;throw Error('DISK_FULL');}return run(...args);};
|
||||
const service=new Messages(database);
|
||||
const incoming={receipt:'receipt-2',sender:'222',body:'test',timestamp:1000};
|
||||
await assert.rejects(service.receive(incoming),/DISK_FULL/);
|
||||
await service.receive(incoming);
|
||||
assert.equal((await service.request(app,{operation:'getMessages'})).length,1);
|
||||
});
|
||||
|
||||
test('failed send remains in conversation and emits sending then failed, never sent',async()=>{
|
||||
const events=[];
|
||||
const service=new Messages(store(),async()=>{throw Error('RADIO_NOT_REGISTERED');},(type,m)=>events.push([type,m.delivery]));
|
||||
await assert.rejects(service.request(app,{operation:'send',args:['111','keep me']}));
|
||||
assert.deepEqual(events,[['sending','sending'],['failed','error']]);
|
||||
});
|
||||
test('queued modem messages remain pending until a terminal state arrives',async()=>{
|
||||
const events=[];let state='pending';const database=store();
|
||||
const service=new Messages(database,async()=>({path:'/ril_0/message_123',state:'pending'}),
|
||||
(type,m)=>events.push([type,m.delivery]),async()=>({state}));
|
||||
const message=await service.request(app,{operation:'send',args:['111','queued']});
|
||||
assert.equal(message.delivery,'sending');
|
||||
await service.refreshOutgoing();assert.deepEqual(events,[['sending','sending']]);
|
||||
state='failed';await service.refreshOutgoing();
|
||||
assert.deepEqual(events,[['sending','sending'],['failed','error']]);
|
||||
await service.refreshOutgoing();assert.equal(events.length,2);
|
||||
assert.equal((await service.request(app,{operation:'getMessages'}))[0].delivery,'error');
|
||||
});
|
||||
test('pending transmission status survives a host restart',async()=>{
|
||||
const database=store();
|
||||
const first=new Messages(database,async()=>({path:'/ril_0/message_123',state:'pending'}));
|
||||
await first.request(app,{operation:'send',args:['111','queued']});
|
||||
const events=[];
|
||||
const restarted=new Messages(database,null,(type)=>events.push(type),async()=>({state:'sent'}));
|
||||
await restarted.refreshOutgoing();
|
||||
assert.deepEqual(events,['sent']);
|
||||
assert.equal((await restarted.request(app,{operation:'getMessages'}))[0].delivery,'sent');
|
||||
});
|
||||
47
tests/native-camera.test.mjs
Normal file
47
tests/native-camera.test.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
function camera() {
|
||||
const workers = [];
|
||||
const context = {setTimeout, clearTimeout, Subprocess: {async call() {
|
||||
let finish;
|
||||
const done = new Promise(resolve => { finish = resolve; });
|
||||
const worker = {closed:false, writes:[],
|
||||
stdin:{async close() {worker.closed=true;finish();}, async write(text) {worker.writes.push(text);}},
|
||||
wait:()=>done, kill:()=>finish()};
|
||||
workers.push(worker); return worker;
|
||||
}}};
|
||||
const source = readFileSync(new URL('../host/NativeCamera.sys.mjs', import.meta.url),'utf8')
|
||||
.replace(/^import .*;\n/gm,'').replace('export class NativeCamera','globalThis.NativeCamera = class NativeCamera');
|
||||
vm.runInNewContext(source,context);
|
||||
const service = new context.NativeCamera();
|
||||
service.packet = async () => ({ready:true,format:'nv21',width:640,height:480});
|
||||
return {service,workers};
|
||||
}
|
||||
|
||||
test('a delayed stop or frame from the old camera cannot close its replacement',async()=>{
|
||||
const {service,workers}=camera(), actor={};
|
||||
const old=await service.request(actor,{action:'start',camera:'back'});
|
||||
const next=await service.request(actor,{action:'start',camera:'front'});
|
||||
assert.notEqual(old.session,next.session);assert.equal(workers[0].closed,true);
|
||||
await service.request(actor,{action:'stop',session:old.session});
|
||||
assert.equal(workers[1].closed,false);
|
||||
await assert.rejects(service.request(actor,{action:'frame',session:old.session}),/CAMERA_NOT_OPEN/);
|
||||
assert.equal(workers[1].closed,false);
|
||||
await service.request(actor,{action:'frame',session:next.session});
|
||||
assert.deepEqual(workers[1].writes,['frame\n']);
|
||||
await service.request(actor,{action:'stop',session:next.session});
|
||||
assert.equal(workers[1].closed,true);
|
||||
});
|
||||
|
||||
test('a different actor cannot stop or use the current camera',async()=>{
|
||||
const {service,workers}=camera(), owner={}, other={};
|
||||
const meta=await service.request(owner,{action:'start'});
|
||||
await service.request(other,{action:'stop',session:meta.session});
|
||||
await assert.rejects(service.request(other,{action:'frame',session:meta.session}),/CAMERA_NOT_OPEN/);
|
||||
await assert.rejects(service.request(other,{action:'start'}),/CAMERA_BUSY/);
|
||||
assert.equal(workers[0].closed,false);
|
||||
await service.request(owner,{action:'stop',session:meta.session});
|
||||
});
|
||||
13
tests/notification-l10n.test.mjs
Normal file
13
tests/notification-l10n.test.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
test('an empty raw notification body is not treated as an undefined translation key',async()=>{
|
||||
const keys=[];
|
||||
const scope={Notification:class {constructor(title,options){this.title=title;this.options=options;}},
|
||||
document:{l10n:{formatValue:id=>{keys.push(id);return Promise.resolve(id);}},documentElement:{getAttribute:()=> 'fr'}},Promise};
|
||||
vm.runInNewContext(readFileSync(new URL('../gaia/shared/js/notification_helper.js',import.meta.url),'utf8'),scope);
|
||||
const notification=await scope.NotificationHelper.send({id:'alarm-start-notice'},{bodyL10n:{raw:''},closeOnClick:false});
|
||||
assert.equal(notification.title,'alarm-start-notice');
|
||||
assert.deepEqual(keys,['alarm-start-notice']);
|
||||
});
|
||||
50
tests/notifications.test.mjs
Normal file
50
tests/notifications.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Notifications} from '../services/notifications.mjs';
|
||||
const app={id:'sms',origin:'http://sms.localhost:8765',manifestURL:'http://sms.localhost:8765/manifest.webapp',manifest:{icons:{'284':'/sms.png'}}};
|
||||
test('notification ownership, tags and persistence',()=>{
|
||||
const store=new Notifications();
|
||||
const first=store.show(app,{title:'Message',body:'Test',tag:'thread-1'});
|
||||
const second=store.show(app,{title:'Message 2',tag:'thread-1'});
|
||||
assert.equal(first.id,second.id);assert.equal(store.list().length,1);
|
||||
assert.equal(first.icon,app.origin+'/sms.png');
|
||||
const other=store.show({...app,id:'calendar'},{tag:'thread-1'});
|
||||
assert.notEqual(other.id,first.id);
|
||||
assert.throws(()=>store.remove(first.id,'calendar'),/PERMISSION_DENIED/);
|
||||
const reloaded=new Notifications(JSON.parse(JSON.stringify(store.list())));
|
||||
assert.equal(reloaded.list('sms')[0].title,'Message 2');
|
||||
reloaded.remove(first.id,'sms');assert.equal(reloaded.list('sms').length,0);
|
||||
});
|
||||
test('missed-call notifications open the manifest dialer entry, not Communications root',async()=>{
|
||||
const {notificationLaunch}=await import('../services/notifications.mjs');
|
||||
const phone={...app,id:'communications',manifest:{launch_path:'/',
|
||||
messages:[{notification:'/dialer/index.html#keyboard-view'}],
|
||||
entry_points:{dialer:{launch_path:'/dialer/index.html#keyboard-view'}}}};
|
||||
assert.deepEqual(notificationLaunch(phone),{manifestURL:app.manifestURL,origin:app.origin,
|
||||
url:app.origin+'/dialer/index.html#keyboard-view',entryPoint:'dialer'});
|
||||
assert.equal(notificationLaunch(app).url,app.origin+'/index.html');
|
||||
assert.throws(()=>notificationLaunch({...app,manifest:{launch_path:'https://example.org/'}}),/INVALID_NOTIFICATION_TARGET/);
|
||||
});
|
||||
|
||||
// Exercise Gaia's original renderer: a silent notification must remain visible.
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
test('Gaia notification sound and vibration respect per-notification flags',()=>{
|
||||
const events={sound:0,vibration:0,inserted:0};
|
||||
const node=()=>({dataset:{},classList:{add(){}},setAttribute(){},appendChild(){},querySelector(){return null;},insertBefore(){events.inserted++;}});
|
||||
const scope={window:{location:{origin:'http://system.localhost:8765'},dispatchEvent(){},setTimeout(){},Service:{query:()=>false}},
|
||||
navigator:{languages:['fr'],vibrate(){events.vibration++;}},
|
||||
SettingsListener:{observe(){}},Service:{query:()=>false,request(){}},
|
||||
document:{createElement:node,createEvent:()=>({initCustomEvent(){}}),hidden:false},
|
||||
mozIntl:{_gaia:{RelativeDate:()=>({formatElement(){}})}},
|
||||
Audio:class {play(){events.sound++;}},setTimeout(){},clearTimeout(){}};
|
||||
scope.window.navigator=scope.navigator;
|
||||
vm.runInNewContext(readFileSync(new URL('../overrides/system.gaiamobile.org/js/notification_screen.js',import.meta.url),'utf8'),scope);
|
||||
const n=scope.NotificationScreen;
|
||||
Object.assign(n,{container:{querySelector:node},getLockScreenContainer(){return null;},addUnreadNotification(){},updateToaster(){},toaster:node(),clearAllButton:{}});
|
||||
const show=mozbehavior=>n.addNotification({id:'test',manifestURL:'http://sms.localhost:8765/manifest.webapp',mozbehavior});
|
||||
show({nosound:true,novibrate:true});assert.equal(events.inserted,1);assert.equal(events.sound,0);assert.equal(events.vibration,0);
|
||||
show({nosound:true});assert.equal(events.sound,0);assert.equal(events.vibration,1);
|
||||
show({novibrate:true});assert.equal(events.sound,1);assert.equal(events.vibration,1);
|
||||
show({});assert.equal(events.sound,2);assert.equal(events.vibration,2);
|
||||
});
|
||||
51
tests/platform.test.mjs
Normal file
51
tests/platform.test.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { authorizePlatform } from '../services/platform.mjs';
|
||||
const app = (id, permissions = {}) => ({ id, manifest: { permissions } });
|
||||
const settings = app('settings', { 'desktop-notification': {}, settings: { access: 'readwrite' } });
|
||||
const gallery = app('gallery', { 'device-storage:pictures': { access: 'readwrite' } });
|
||||
const camera = app('camera', { 'device-storage:pictures': { access: 'readwrite' } });
|
||||
const rejects = (a, op, p = {}) =>
|
||||
assert.throws(
|
||||
() => authorizePlatform(a, op, p),
|
||||
/PERMISSION_DENIED|METHOD_NOT_SUPPORTED|QuotaExceededError/,
|
||||
);
|
||||
authorizePlatform(settings, 'settings', { panel: 'wifi' });
|
||||
authorizePlatform(settings, 'wifi', { operation: 'scan' });
|
||||
authorizePlatform(app('system'), 'wifi', { operation: 'disconnect' });
|
||||
rejects(app('costcontrol'), 'wifi');
|
||||
rejects(gallery, 'wifi', { operation: 'connect' });
|
||||
rejects(settings, 'wifi', { password: 'x'.repeat(4097) });
|
||||
authorizePlatform(gallery, 'pick', { type: 'pictures' });
|
||||
authorizePlatform(camera, 'capture');
|
||||
authorizePlatform(camera, 'cameraPreview', {action:'start',camera:'back'});
|
||||
authorizePlatform(camera, 'cameraPreview', {action:'frame'});
|
||||
authorizePlatform(camera, 'cameraPreview', {action:'stop'});
|
||||
rejects(gallery, 'cameraPreview', {action:'frame'});
|
||||
rejects(settings, 'cameraPreview', {action:'start',camera:'back'});
|
||||
assert.throws(() => authorizePlatform(camera, 'cameraPreview', {action:'start',camera:'../../device'}), /INVALID_VALUE/);
|
||||
rejects(gallery, 'settings');
|
||||
rejects(gallery, 'capture');
|
||||
rejects(gallery, 'pick', { type: '../../private' });
|
||||
rejects(gallery, 'notification', { owner: 'settings' });
|
||||
rejects(gallery, 'sms');
|
||||
for (const operation of ['calls', 'answer', 'hangup']) {
|
||||
authorizePlatform(app('system'), operation);
|
||||
authorizePlatform(app('communications'), operation);
|
||||
rejects(gallery, operation);
|
||||
rejects(settings, operation);
|
||||
}
|
||||
rejects(camera, 'contact');
|
||||
rejects(settings, 'systemReady');
|
||||
authorizePlatform(app('system'), 'systemReady');
|
||||
rejects(settings, 'notification', { body: 'a'.repeat(65537) });
|
||||
rejects(settings, 'execute', { command: 'id' });
|
||||
console.log('PASS: shared platform permissions and notification size limits.');
|
||||
|
||||
for (const params of [{action:'focus',area:{left:-100,top:-100,right:100,bottom:100}},
|
||||
{action:'zoom',zoom:2},{action:'picture',size:{width:4032,height:3024}}]) {
|
||||
authorizePlatform(camera,'cameraPreview',params);
|
||||
rejects(gallery,'cameraPreview',params);
|
||||
}
|
||||
for (const params of [{action:'zoom',zoom:NaN},{action:'focus',area:{left:2,right:1,top:0,bottom:1}},
|
||||
{action:'picture',size:{width:-1,height:100}},{action:'execute'}])
|
||||
assert.throws(()=>authorizePlatform(camera,'cameraPreview',params),/INVALID_VALUE/);
|
||||
57
tests/screen-power.test.mjs
Normal file
57
tests/screen-power.test.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {ScreenPower} from '../services/screen-power.mjs';
|
||||
|
||||
test('wake and brightness commands cannot apply a stale disabled state', async () => {
|
||||
const written = [];
|
||||
let release;
|
||||
const pending = new Promise(resolve => {release=resolve;});
|
||||
const state = {screenEnabled:false, screenBrightness:0};
|
||||
const power = new ScreenPower(state, async next => {
|
||||
if (!written.length) await pending;
|
||||
written.push({...next});
|
||||
});
|
||||
const wake = power.set('screenEnabled',true);
|
||||
const brightness = power.set('screenBrightness',0.5);
|
||||
await Promise.resolve();
|
||||
assert.equal(written.length,0);
|
||||
release();
|
||||
await Promise.all([wake,brightness]);
|
||||
assert.deepEqual(written,[{screenEnabled:true,screenBrightness:0},{screenEnabled:true,screenBrightness:0.5}]);
|
||||
});
|
||||
|
||||
test('hardware failure preserves committed state and allows the next request', async () => {
|
||||
const state = {screenEnabled:true,screenBrightness:1};
|
||||
let fail = true;
|
||||
const power = new ScreenPower(state,async () => {if(fail)throw Error('offline');});
|
||||
await assert.rejects(power.set('screenEnabled',false),/offline/);
|
||||
assert.equal(state.screenEnabled,true);
|
||||
fail=false;
|
||||
await power.set('screenBrightness',0.5);
|
||||
assert.deepEqual(state,{screenEnabled:true,screenBrightness:0.5});
|
||||
});
|
||||
|
||||
|
||||
test('brightness bursts coalesce without crossing an off/on boundary', async () => {
|
||||
const state = {screenEnabled:true, screenBrightness:1};
|
||||
const written = [];
|
||||
let release;
|
||||
const gate = new Promise(resolve => {release=resolve;});
|
||||
const power = new ScreenPower(state, async next => {
|
||||
if (!written.length) await gate;
|
||||
written.push({...next});
|
||||
});
|
||||
const first = power.set('screenBrightness',0.99);
|
||||
await Promise.resolve();
|
||||
const burst = Array.from({length:100},(_,i)=>power.set('screenBrightness',i/100));
|
||||
const off = power.set('screenEnabled',false);
|
||||
const dark = power.set('screenBrightness',0.35);
|
||||
const on = power.set('screenEnabled',true);
|
||||
const last = power.set('screenBrightness',0.8);
|
||||
release();
|
||||
await Promise.all([first,...burst,off,dark,on,last]);
|
||||
assert.equal(written.length,6);
|
||||
assert.deepEqual(written.map(s=>[s.screenEnabled,s.screenBrightness]),[
|
||||
[true,.99],[true,.99],[false,.99],[false,.35],[true,.35],[true,.8]
|
||||
]);
|
||||
});
|
||||
20
tests/test_publication.py
Normal file
20
tests/test_publication.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec=importlib.util.spec_from_file_location('publication',Path(__file__).resolve().parents[1]/'tools/prepare-publication.py')
|
||||
pub=importlib.util.module_from_spec(spec);spec.loader.exec_module(pub)
|
||||
|
||||
class Publication(unittest.TestCase):
|
||||
def test_internal_documents_and_credentials_are_excluded(self):
|
||||
for name in ['docs/CHANTIER.md','docs/ROADMAP.json','tundra/docs/STATUS.md',
|
||||
'README-internal.md','gaia/README.md','changelog.json',
|
||||
'tundra/out/example/client-key','tundra/logs/device/storage-layout.json',
|
||||
'android/keys/preview.keystore','android/dist/preview.apk',
|
||||
'profiles/example/cookies.sqlite','logs/publication/forgejo-token']:
|
||||
with self.subTest(name=name):self.assertIsNone(pub.target_name(name))
|
||||
def test_sources_readmes_and_license_notices_survive(self):
|
||||
for name in ['README.md','README.fr.md','host/shell.js','tundra/boot/native-root.sh',
|
||||
'gaia/LICENSE','NOTICE','docs/media/vulpes-logo.png']:
|
||||
self.assertEqual(pub.target_name(name),name)
|
||||
self.assertEqual(pub.target_name('gaia/vendor/LICENSE.md'),'gaia/vendor/LICENSE')
|
||||
63
tests/tundra-state.test.mjs
Normal file
63
tests/tundra-state.test.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tundraSnapshot, tundraCapabilities, tundraRadio, tundraCallAudio } from '../host/Tundra.sys.mjs';
|
||||
|
||||
test('Tundra rejects missing/stale status and preserves the real battery value', async () => {
|
||||
globalThis.IOUtils = {
|
||||
readJSON: async () => {
|
||||
throw Error('missing');
|
||||
},
|
||||
};
|
||||
assert.equal((await tundraSnapshot()).available, false);
|
||||
let data = {
|
||||
schema: 1,
|
||||
recordedAt: Date.now() / 1000 - 30,
|
||||
battery: { level: 0.73, charging: false },
|
||||
};
|
||||
IOUtils.readJSON = async () => data;
|
||||
assert.equal((await tundraSnapshot()).available, false);
|
||||
data.recordedAt = Date.now() / 1000;
|
||||
assert.equal((await tundraSnapshot()).battery.level, 0.73);
|
||||
data.battery.level = 2;
|
||||
assert.equal((await tundraSnapshot()).battery, null);
|
||||
});
|
||||
|
||||
test('native capabilities report the camera adapter and gate modem/Wi-Fi services', () => {
|
||||
globalThis.Services = { appinfo: { platformVersion: '156.0.1' } };
|
||||
const caps = tundraCapabilities({ version: '2.7-preview.7', gaia: '2.7.0' });
|
||||
assert.equal(caps.platform, 'tundra');
|
||||
assert.equal(caps.gecko, '156.0.1');
|
||||
assert.equal(caps.camera, true);
|
||||
for (const name of ['sms', 'dial', 'wifiControl']) assert.equal(caps[name], false);
|
||||
});
|
||||
|
||||
test('call audio collects split replies and rejects oversized output', async () => {
|
||||
let chunks = ['{"res', 'ult":{"active":true}}', null], killed = false;
|
||||
globalThis.ChromeUtils = {importESModule: () => ({Subprocess: {call: async () => ({
|
||||
stdin:{write:async()=>{},close:async()=>{}},
|
||||
stdout:{readString:async()=>chunks.shift()},
|
||||
wait:async()=>({exitCode:0}),kill:()=>{killed=true;},
|
||||
})}})};
|
||||
assert.deepEqual(await tundraCallAudio({operation:'status'}),{active:true});
|
||||
chunks=['x'.repeat(4097)];
|
||||
await assert.rejects(tundraCallAudio({operation:'status'}),/CALL_AUDIO_INVALID_REPLY/);
|
||||
assert.equal(killed,true);
|
||||
});
|
||||
|
||||
test('radio preserves modem errors and separates a broken transport', async () => {
|
||||
globalThis.Services = {env: {get: () => '1'}};
|
||||
let response = {ok:false, error:'RADIO_NOT_REGISTERED'}, exitCode = 0;
|
||||
globalThis.ChromeUtils = {importESModule: () => ({Subprocess: {call: async () => {
|
||||
let read = false;
|
||||
return {stdin:{write:async () => {}, close:async () => {}},
|
||||
stdout:{readString:async () => read ? null : (read = true, JSON.stringify(response))},
|
||||
wait:async () => ({exitCode})};
|
||||
}}})};
|
||||
await assert.rejects(tundraRadio('status'), /RADIO_NOT_REGISTERED/);
|
||||
response = {ok:true, result:{online:true}};
|
||||
assert.deepEqual(await tundraRadio('status'), {online:true});
|
||||
response = {ok:false, error:'sensitive backend detail'};
|
||||
await assert.rejects(tundraRadio('status'), /RADIO_REQUEST_FAILED/);
|
||||
exitCode = 1;
|
||||
await assert.rejects(tundraRadio('status'), /RADIO_UNAVAILABLE/);
|
||||
});
|
||||
109
tests/wifi-throttle.test.mjs
Normal file
109
tests/wifi-throttle.test.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tundraWifi } from '../host/Tundra.sys.mjs';
|
||||
|
||||
test('Wi-Fi bursts share one scan, cache errors, and retry after cooldown', async () => {
|
||||
const realNow = Date.now;
|
||||
let now = realNow(), starts = 0, release;
|
||||
let reply = {result:{networks:[]}};
|
||||
let gate = new Promise(resolve => {release = resolve;});
|
||||
Date.now = () => now;
|
||||
globalThis.Services = {env:{get:() => '1'}};
|
||||
globalThis.ChromeUtils = {importESModule:() => ({Subprocess:{call:async () => {
|
||||
starts++;
|
||||
let read = false;
|
||||
return {stdin:{write:async () => {},close:async () => {}},
|
||||
stdout:{readString:async () => {
|
||||
await gate;
|
||||
if (read) return null;
|
||||
read = true;
|
||||
return JSON.stringify(reply);
|
||||
}},wait:async () => ({exitCode:0})};
|
||||
}}})};
|
||||
try {
|
||||
const burst = Array.from({length:1000}, () => tundraWifi({operation:'scan'}));
|
||||
assert.equal(starts, 1);
|
||||
release();
|
||||
assert.ok((await Promise.all(burst)).every(r => r.networks.length === 0));
|
||||
await tundraWifi({operation:'scan'});
|
||||
assert.equal(starts, 1);
|
||||
now += 6000;
|
||||
reply = {error:'WIFI_UNAVAILABLE'};
|
||||
const failures = await Promise.allSettled(Array.from({length:1000}, () => tundraWifi({operation:'scan'})));
|
||||
assert.ok(failures.every(r => r.status === 'rejected'));
|
||||
assert.equal(starts, 2);
|
||||
await assert.rejects(tundraWifi({operation:'scan'}), /WIFI_UNAVAILABLE/);
|
||||
assert.equal(starts, 2);
|
||||
now += 6000;
|
||||
reply = {result:{networks:[]}};
|
||||
await tundraWifi({operation:'scan'});
|
||||
assert.equal(starts, 3);
|
||||
reply = {result:{disconnected:true}};
|
||||
await tundraWifi({operation:'disconnect'});
|
||||
reply = {result:{networks:[]}};
|
||||
await tundraWifi({operation:'scan'});
|
||||
assert.equal(starts, 5, 'disconnect invalidates the scan cache');
|
||||
} finally { Date.now = realNow; }
|
||||
});
|
||||
|
||||
test('Gaia scans disconnected Wi-Fi every 20 seconds and stops when disabled', async () => {
|
||||
const {readFile} = await import('node:fs/promises');
|
||||
const vm = await import('node:vm');
|
||||
let timer, interval, scans = 0, cleared = 0;
|
||||
const observers = {};
|
||||
const manager = {connection:{status:'disconnected'}, getNetworks:() => {scans++;}};
|
||||
const window = {navigator:{mozSettings:{},mozWifiManager:manager,battery:{addEventListener(){}}},addEventListener(){}};
|
||||
const context = vm.createContext({window,console,
|
||||
LazyLoader:{load:async () => {}}, Service:{request:async () => {},registerState(){}},
|
||||
WifiIcon:class {start(){} update(){}}, WifiWakeLockManager:class {start(){}},
|
||||
SettingsListener:{getSettingsLock:() => ({get:() => ({})}),observe:(key,value,cb) => {observers[key]=cb;cb(value);}},
|
||||
setInterval:(cb,ms) => {timer=cb;interval=ms;return 123;},clearTimeout:id => {if(id===123) cleared++;}
|
||||
});
|
||||
vm.runInContext(await readFile(new URL('../overrides/system.gaiamobile.org/js/wifi.js',import.meta.url),'utf8'),context);
|
||||
window.Wifi.start();
|
||||
assert.equal(interval,20000);
|
||||
assert.equal(scans,0);
|
||||
timer();assert.equal(scans,1);
|
||||
manager.connection.status='connected';timer();assert.equal(scans,1);
|
||||
observers['wifi.enabled'](false);assert.equal(cleared,1);
|
||||
});
|
||||
|
||||
test('Native Wi-Fi acknowledges unchanged settings and completes delayed radio changes', async () => {
|
||||
const {readFile} = await import('node:fs/promises');
|
||||
const vm = await import('node:vm');
|
||||
const events = {}, observers = new Map(), changes = [];
|
||||
let hardware = false, completeRadio;
|
||||
const navigator = {mozSettings:{addObserver:(key,fn)=>observers.set(key,fn)}};
|
||||
const call = async (method, params) => {
|
||||
if(method==='settings.set') {
|
||||
for(const [key,value] of Object.entries(params.values)) observers.get(key)?.({settingValue:value});
|
||||
return;
|
||||
}
|
||||
if(params.operation==='setEnabled') {
|
||||
changes.push(params.enabled);
|
||||
await new Promise(resolve=>{completeRadio=resolve;});
|
||||
hardware=params.enabled;
|
||||
}
|
||||
return {enabled:hardware,networks:[]};
|
||||
};
|
||||
const context=vm.createContext({navigator,console,Event,EventTarget,DOMException,
|
||||
window:{VulpesNativeWifi:true},location:{hostname:'system.localhost'},
|
||||
VulpesCompat:{call,request:x=>x},addEventListener:(name,fn)=>{events[name]=fn;}});
|
||||
vm.runInContext(await readFile(new URL('../host/gaia-wifi.js',import.meta.url),'utf8'),context);
|
||||
let disabled=0,enabled=0;
|
||||
navigator.mozWifiManager.ondisabled=()=>disabled++;
|
||||
navigator.mozWifiManager.onenabled=()=>enabled++;
|
||||
await events.DOMContentLoaded();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(disabled,1,'initial same-state acknowledgement releases Gaia controls');
|
||||
observers.get('wifi.enabled')({settingValue:false});
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(disabled,2);
|
||||
assert.equal(changes.length,0,'no unnecessary hardware call');
|
||||
observers.get('wifi.enabled')({settingValue:true});
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(navigator.mozWifiManager.enabled,false,'do not announce success before hardware');
|
||||
completeRadio();await new Promise(setImmediate);
|
||||
assert.equal(enabled,1);
|
||||
assert.equal(navigator.mozWifiManager.enabled,true);
|
||||
});
|
||||
Loading…
Reference in a new issue