Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
|
|
@ -1,6 +1,21 @@
|
|||
// Test-only actor, registered by tools/inspect-host.py through Marionette.
|
||||
export class VulpesProbeChild extends JSWindowActorChild {
|
||||
async receiveMessage(message) {
|
||||
if (message.name === 'Swipe') {
|
||||
const {x,y,toX,toY}=message.data, win=this.contentWindow;
|
||||
if (![x,y,toX,toY].every(Number.isFinite) ||
|
||||
[x,toX].some(v=>v<0 || v>=win.innerWidth) ||
|
||||
[y,toY].some(v=>v<0 || v>=win.innerHeight)) throw Error('INVALID_TEST_POINT');
|
||||
const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
|
||||
for (let step=0;step<=12;step++) {
|
||||
const points=[{identifier:0,offsetX:x+(toX-x)*step/12,
|
||||
offsetY:y+(toY-y)*step/12,radiiX:1,radiiY:1}];
|
||||
win.synthesizeTouchEvent(step===0?'touchstart':'touchmove',points,0,{isAsyncEnabled:false});
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
}
|
||||
win.synthesizeTouchEvent('touchend',[{identifier:0,offsetX:toX,offsetY:toY,radiiX:1,radiiY:1}],0,{isAsyncEnabled:false});
|
||||
return true;
|
||||
}
|
||||
if (message.name === 'MouseClick') {
|
||||
const {x,y}=message.data, win=this.contentWindow;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {Activities} from '../services/activities.mjs';
|
||||
import {Activities, isGaiaActivity} from '../services/activities.mjs';
|
||||
const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}];
|
||||
const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}};
|
||||
const picker={app:apps[1]}, stranger={app:apps[2]};
|
||||
|
|
@ -81,3 +81,41 @@ const photoRequest=events.at(-1)[2].id;
|
|||
assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/);
|
||||
nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}});
|
||||
assert.equal(await (await photo).blob.text(),'image');
|
||||
|
||||
const music={id:'music',origin:'http://music.localhost',manifestURL:'http://music.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/pick.html',filters:{type:['audio/*']}}}}};
|
||||
const video={id:'video',origin:'http://video.localhost',manifestURL:'http://video.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['video/*']}}}}};
|
||||
apps.push(music,video);
|
||||
for(const app of apps) app.manifestURL ||= app.origin+'/manifest.webapp';
|
||||
gallery.manifest.activities.pick.filters.type.push('image/*');
|
||||
phone.manifest.activities.pick.filters.type.value.push('text/vcard');
|
||||
let menu;
|
||||
const chooser=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial),choose:data=>{menu=data;}});
|
||||
const attachment={name:'pick',data:{type:['image/*','audio/*','video/*','text/vcard']}};
|
||||
const attach=chooser.start(smsPeer,attachment);
|
||||
assert.deepEqual(menu.choices.map(c=>c.manifest),[gallery,music,video,phone].map(a=>a.manifestURL));
|
||||
assert.throws(()=>chooser.select(smsPeer,{id:menu.id,value:0}),/PERMISSION_DENIED/);
|
||||
assert.throws(()=>chooser.select({app:{id:'system'}},{id:menu.id,value:99}),/INVALID_ACTIVITY_CHOICE/);
|
||||
chooser.select({app:{id:'system'}},{id:menu.id,value:'0'});
|
||||
const peer={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
|
||||
chooser.subscribe(peer);
|
||||
chooser.finish(peer,{id:events.at(-1)[2].id,result:{blob:new Blob(['picked']),name:'photo.jpg'}});
|
||||
assert.equal((await attach).name,'photo.jpg');
|
||||
const cancelChoice=chooser.start(smsPeer,attachment);
|
||||
chooser.select({app:{id:'system'}},{id:menu.id,value:-1});await assert.rejects(cancelChoice,/ACTIVITY_CANCELLED/);
|
||||
const closed=chooser.start(smsPeer,attachment);chooser.cleanup(smsPeer);await assert.rejects(closed,/ACTIVITY_CANCELLED/);
|
||||
console.log('SMS attachment chooser: four original providers, ownership, cancellation and result: OK');
|
||||
|
||||
const tones={id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{configure:{href:'/manage.html',filters:{target:{required:true,pattern:'ringtone'}},disposition:'inline'}}}};
|
||||
apps[2].manifest.activities.configure=tones.manifest.activities.configure;
|
||||
assert.equal(isGaiaActivity({name:'configure',data:{target:'ringtone'}}),true);
|
||||
assert.equal(broker.route({name:'configure',data:{target:'ringtone'}}).url.pathname,'/manage.html');
|
||||
assert.throws(()=>broker.route({name:'configure',data:{target:'other'}}),/ACTIVITY_NOT_SUPPORTED/);
|
||||
apps[0].manifest={launch_path:'/index.html'};
|
||||
const keyboardPeer={app:{id:'keyboard'}};
|
||||
const configure={name:'moz_configure_window',data:{target:'device'}};
|
||||
assert.equal(isGaiaActivity(configure),true);
|
||||
await broker.start(keyboardPeer,configure);
|
||||
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');
|
||||
|
|
|
|||
53
tests/android-input-bridge.test.mjs
Normal file
53
tests/android-input-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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('../android/extension/content.js',import.meta.url),'utf8');
|
||||
async function setup(enabled=true) {
|
||||
const sent=[],events=new Map();let receive;
|
||||
const page={navigator:{},dispatchEvent(){}};
|
||||
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
|
||||
class Editor {
|
||||
constructor(){this.editing=false;}
|
||||
focus(e){this.inputElement=e;}
|
||||
state(){return {inputMode:this.inputElement.getAttribute('inputmode'),text:'',inputType:'text'};}
|
||||
edit(){return this.state();}
|
||||
}
|
||||
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
|
||||
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
|
||||
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
|
||||
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
|
||||
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
|
||||
vm.runInNewContext(source,context);
|
||||
receive({id:sent[0].id,reply:{result:enabled}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
return {sent,events,page,receive,context};
|
||||
}
|
||||
function field(mode='numeric') {
|
||||
const attrs=new Map([['inputmode',mode]]);
|
||||
return {localName:'input',type:'text',classList:{contains:()=>false},
|
||||
getAttribute:key=>attrs.get(key)??null,setAttribute:(k,v)=>attrs.set(k,v),removeAttribute:k=>attrs.delete(k)};
|
||||
}
|
||||
test('Android bridge keeps the intended keypad mode after editing, while suppressing the Android IME',async()=>{
|
||||
const s=await setup(),e=field();
|
||||
s.events.get('focusin')({target:e});
|
||||
assert.equal(e.getAttribute('inputmode'),'none');
|
||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
||||
s.receive({edit:{id:3,params:{operation:'key',key:1}}});
|
||||
assert.equal(s.sent.at(-1).editReply,3);
|
||||
assert.equal(s.sent.at(-1).result.inputMode,'numeric');
|
||||
s.receive({event:{type:'input-mode',data:{enabled:false}}});
|
||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
assert.equal(s.page.VulpesNativeInput,false);
|
||||
});
|
||||
test('Android keyboard remains the default and hidden Gaia restores the editor mode',async()=>{
|
||||
const s=await setup(false),e=field();
|
||||
s.events.get('focusin')({target:e});
|
||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
assert.equal(s.sent.length,1);
|
||||
s.receive({event:{type:'input-mode',data:{enabled:true}}});
|
||||
s.events.get('focusin')({target:e});
|
||||
s.context.document.hidden=true;s.events.get('visibilitychange')();
|
||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
||||
});
|
||||
|
|
@ -8,8 +8,12 @@ function setup(composer) {
|
|||
const calls=[];
|
||||
const recipients={numbers:[]};
|
||||
const context={console,location:{hostname:'sms.localhost',pathname:'/index.html'},
|
||||
document:{readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
|
||||
VulpesCompat:{call:async(method,params)=>{calls.push({method,params});}},
|
||||
addEventListener(){},MutationObserver:class {observe(){}},
|
||||
document:{documentElement:{lang:'fr'},readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
|
||||
VulpesCompat:{call:async(method,params)=>{
|
||||
if(method==='platform.capabilities')return {mms:false};
|
||||
calls.push({method,params});
|
||||
}},
|
||||
VulpesPlatform:'android',Navigation:{isCurrentPanel:()=>composer},
|
||||
ConversationView:{recipients,activeThread:{participants:['5550123']},assimilateRecipients(){assimilated=true;recipients.numbers=['5550199'];}},
|
||||
Compose:{type:'sms',getText:()=> 'Message à confirmer'},
|
||||
|
|
|
|||
61
tests/bluetooth.test.mjs
Normal file
61
tests/bluetooth.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {authorizePlatform} from '../services/platform.mjs';
|
||||
function setup(host='settings', platform='tundra') {
|
||||
const events=new EventTarget(), requests=[], timers=[];
|
||||
const state={available:true,enabled:false,address:'00:11:22:33:44:55',name:'Vulpes',
|
||||
discovering:false,discoverable:false,devices:[],pairing:[]};
|
||||
const ctx={EventTarget,Event,DOMException,console,navigator:{},document:{hidden:false},
|
||||
location:{hostname:host+'.localhost',pathname:'/index.html'},
|
||||
addEventListener:events.addEventListener.bind(events),setTimeout:fn=>timers.push(fn),
|
||||
setInterval:()=>0,clearInterval(){},VulpesPlatform:platform,VulpesCompat:{async call(method,p){
|
||||
requests.push({method,...p});
|
||||
if(p.operation==='power')state.enabled=p.enabled;
|
||||
if(p.operation==='scan')state.discovering=true;
|
||||
if(p.operation==='stopScan')state.discovering=false;
|
||||
if(p.operation==='pair')state.devices[0].paired=true;
|
||||
if(p.operation==='unpair')state.devices=[];
|
||||
return structuredClone(state);
|
||||
}}};
|
||||
ctx.window=ctx;vm.runInNewContext(readFileSync('host/gaia-bluetooth.js','utf8'),ctx);
|
||||
return {ctx,state,events,requests,timers,async ready(){events.dispatchEvent(new Event('DOMContentLoaded'));await new Promise(r=>setImmediate(r));}};
|
||||
}
|
||||
test('only installed trusted system apps can control Bluetooth',()=>{
|
||||
assert.throws(()=>authorizePlatform({id:'gallery',manifest:{}},'bluetooth',{}),/PERMISSION/);
|
||||
authorizePlatform({id:'settings',manifest:{}},'bluetooth',{});
|
||||
assert.throws(()=>authorizePlatform({id:'settings',manifest:{}},'bluetooth',{name:'x'.repeat(5000)}),/Quota/);
|
||||
assert.equal(setup('gallery').ctx.navigator.mozBluetooth,undefined);
|
||||
assert.equal(setup('settings','android').ctx.navigator.mozBluetooth,undefined);
|
||||
});
|
||||
test('Gaia receives adapter, discovery and pairing events in the expected order',async()=>{
|
||||
const s=setup();await s.ready();
|
||||
const a=s.ctx.navigator.mozBluetooth.defaultAdapter;assert.ok(a);assert.equal(a.state,'disabled');
|
||||
await a.enable();assert.equal(a.state,'enabled');
|
||||
s.state.devices=[{address:'22:33:44:55:66:77',name:'Test headset',paired:false,connected:false,uuids:[],cod:{}}];
|
||||
const h=await a.startDiscovery();let device;h.ondevicefound=e=>device=e.device;
|
||||
for(const timer of s.timers)await timer();await new Promise(r=>setImmediate(r));
|
||||
assert.equal(device.name,'Test headset');let paired=false;a.ondevicepaired=()=>paired=true;
|
||||
await a.pair(device.address);assert.equal(paired,true);assert.equal(a.getPairedDevices().length,1);
|
||||
await a.unpair(device.address);assert.equal(a.getPairedDevices().length,0);
|
||||
await a.stopDiscovery();assert.equal(a.discovering,false);
|
||||
});
|
||||
test('unsupported profiles reject instead of reporting a successful transfer',async()=>{
|
||||
const s=setup();await s.ready();
|
||||
await assert.rejects(s.ctx.navigator.mozBluetooth.defaultAdapter.sendFile('address',{}),{name:'NotSupportedError'});
|
||||
});
|
||||
test('the original pairing view receives the passkey and submits an explicit decision',async()=>{
|
||||
const s=setup('bluetooth');s.ctx.location.pathname='/onpair.html';
|
||||
let shown;
|
||||
s.ctx.Pairview={init(method,options){shown={method,options};}};
|
||||
s.state.pairing=[{id:'request-1',method:'confirmation',deviceName:'Test peer',passkey:'012345'}];
|
||||
await s.ready();
|
||||
assert.equal(shown.method,'confirmation');
|
||||
assert.equal(shown.options.handle.passkey,'012345');
|
||||
assert.equal(shown.options.deviceName,'Test peer');
|
||||
assert.equal(s.requests.some(r=>r.operation==='reply'),false);
|
||||
await shown.options.handle.accept();
|
||||
assert.equal(s.requests.find(r=>r.operation==='reply').accept,true);
|
||||
assert.equal(s.requests.find(r=>r.operation==='reply').id,'request-1');
|
||||
});
|
||||
|
|
@ -10,15 +10,20 @@ function deferred() {
|
|||
}
|
||||
|
||||
// Execute the shared adapter, with controllable media promises instead of a sensor.
|
||||
function setup(native = false) {
|
||||
function setup(native = false, platformName = 'desktop') {
|
||||
const videos = [], streams = [], requests = [], events = new EventTarget();
|
||||
const nativeRequests=[];
|
||||
let hidden = false, captureGate, playGate, labels = true;
|
||||
const nativeRequests=[], wakeLocks=[];
|
||||
let hidden = false, captureGate, playGate, labels = true, requireStopped = false;
|
||||
const failures=[];
|
||||
class Document extends EventTarget { get hidden() { return hidden; } }
|
||||
class Video {
|
||||
get srcObject() { return this.stream; }
|
||||
set srcObject(value) { this.stream = value; }
|
||||
constructor() { this.dataset = {}; this.style = {}; }
|
||||
set srcObject(value) {
|
||||
if(requireStopped && value===null && this.stream?.getTracks().some(t=>t.readyState!=="ended"))
|
||||
throw Error("Capture producer is still live");
|
||||
this.stream = value;
|
||||
}
|
||||
constructor() { this.dataset = {}; this.style = {}; this.parentNode={append:canvas=>{canvas.isConnected=true;}}; }
|
||||
play() { return playGate?.promise || Promise.resolve(); }
|
||||
pause() {}
|
||||
remove() { const index=videos.indexOf(this); if(index!==-1)videos.splice(index, 1); }
|
||||
|
|
@ -27,19 +32,22 @@ function setup(native = false) {
|
|||
Object.assign(document, {
|
||||
documentElement: {lang:'en'}, body: {append:video => videos.push(video)},
|
||||
querySelector: () => null, querySelectorAll: () => [...videos],
|
||||
createElement: tag => tag === 'canvas' ? {getContext:()=>({
|
||||
createShader:()=>({}), shaderSource(){}, compileShader(){},
|
||||
getShaderParameter:()=>false, createProgram:()=>({}),
|
||||
deleteShader(){}, deleteProgram(){}, getExtension:()=>null,
|
||||
})} : new Video(),
|
||||
createElement: tag => tag === 'canvas' ? {style:{},dataset:{},
|
||||
remove(){this.isConnected=false;},
|
||||
captureStream(){throw Error('Native preview must not recapture the canvas');},
|
||||
getContext:()=>new Proxy({
|
||||
getShaderParameter:()=>native==='working',getProgramParameter:()=>true,
|
||||
},{get(target,name){return target[name] || (()=>({}));}}),
|
||||
} : new Video(),
|
||||
});
|
||||
const navigator = {language:'en', mediaDevices: {
|
||||
const navigator = {requestWakeLock(topic){const lock={topic,released:false,unlock(){this.released=true;}};wakeLocks.push(lock);return lock;},language:'en', mediaDevices: {
|
||||
async enumerateDevices() {
|
||||
return ['rear','selfie'].map(deviceId => ({kind:'videoinput', deviceId,
|
||||
label: labels ? (deviceId === 'rear' ? 'Back camera' : 'Front camera') : ''}));
|
||||
},
|
||||
async getUserMedia(constraints) {
|
||||
requests.push(constraints);
|
||||
if(failures.length) throw failures.shift();
|
||||
const id = constraints.video.deviceId?.exact ||
|
||||
(constraints.video.facingMode?.ideal === 'user' ? 'selfie' : 'rear');
|
||||
const gate = captureGate;
|
||||
|
|
@ -57,19 +65,23 @@ function setup(native = false) {
|
|||
const context = {document, Document, navigator, HTMLMediaElement:Video, console, performance,
|
||||
location:{hostname:'camera.localhost',port:'8765'}, parent:{},
|
||||
MutationObserver:class {observe() {}},
|
||||
Event, DOMException, CustomEvent:class extends Event {constructor(type,opts){super(type);this.detail=opts.detail;}},
|
||||
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),
|
||||
VulpesCompat:{async call(method,params) {
|
||||
if(method==='platform.capabilities')return {platform:native?'tundra':'desktop',camera:true};
|
||||
if(method==='platform.capabilities')return {platform:native?'tundra':platformName,camera:true};
|
||||
nativeRequests.push(params);
|
||||
if(params.action==='start')return {session:42,width:640,height:480,orientation:90};
|
||||
if(params.action==='start')return {session:nativeRequests.length,width:640,height:480,orientation:90,focusModes:['fixed'],zoomRatios:[1],pictureSizes:[{width:640,height:480}]};
|
||||
if(params.action==='frame')return {sequence:nativeRequests.length,data:new ArrayBuffer(640*480*3/2)};
|
||||
}},
|
||||
};
|
||||
context.window = context;
|
||||
vm.runInNewContext(readFileSync(new URL('../host/gaia-camera.js', import.meta.url),'utf8'), context);
|
||||
return {api:navigator.mozCameras, streams, requests, videos, nativeRequests,
|
||||
return {api:navigator.mozCameras, streams, requests, videos, nativeRequests, wakeLocks,
|
||||
viewfinder:()=>{const video=new Video();videos.push(video);return video;},
|
||||
failCapture:error=>failures.push(error),
|
||||
unlabelled:()=>{labels=false;},
|
||||
requireStoppedBeforeDetach:()=>{requireStopped=true;},
|
||||
blockCapture:()=>captureGate=deferred(), blockPlay:()=>playGate=deferred(),
|
||||
hide:()=>{hidden=true;events.dispatchEvent(new Event('visibilitychange'));},
|
||||
show:()=>{hidden=false;events.dispatchEvent(new Event('visibilitychange'));},
|
||||
|
|
@ -90,7 +102,7 @@ test('native preview setup failure releases the sensor before returning an error
|
|||
await assert.rejects(s.api.getCamera('front'),/Camera shader compilation failed/);
|
||||
assert.equal(s.nativeRequests.length,2);
|
||||
assert.equal(s.nativeRequests[1].action,'stop');
|
||||
assert.equal(s.nativeRequests[1].session,42);
|
||||
assert.equal(s.nativeRequests[1].session,1);
|
||||
});
|
||||
|
||||
test('switch waits for asynchronous close even after release has detached the current stream', async()=>{
|
||||
|
|
@ -150,3 +162,95 @@ test('hide/show during preview startup leaves no live abandoned stream', async()
|
|||
assert.equal(camera.getTracks()[0].readyState,'live');
|
||||
await camera.release();
|
||||
});
|
||||
|
||||
|
||||
test('release stops capture tracks before detaching the preview video',async()=>{
|
||||
const s=setup();s.requireStoppedBeforeDetach();
|
||||
const {camera}=await s.api.getCamera('back');
|
||||
await camera.release();
|
||||
assert.equal(camera.getTracks()[0].readyState,'ended');
|
||||
const next=await s.api.getCamera('front');
|
||||
await next.camera.release();
|
||||
});
|
||||
|
||||
|
||||
test('release announces stopped before the next camera starts',async()=>{
|
||||
const s=setup(), {camera}=await s.api.getCamera('back');
|
||||
const states=[];camera.addEventListener('previewstatechange',e=>states.push(e.newState));
|
||||
await camera.release();await camera.release();
|
||||
assert.deepEqual(states,['stopped']);
|
||||
});
|
||||
|
||||
test('Gaia viewfinder recomputes a viewport that was hidden at initialization',()=>{
|
||||
let view;
|
||||
const window={innerWidth:0,innerHeight:0};
|
||||
vm.runInNewContext(readFileSync(new URL('../gaia/apps/camera/js/views/viewfinder.js',import.meta.url),'utf8'),{
|
||||
window,define(factory){const module={};factory(name=>name==='view'?{extend:value=>value}:name==='debug'?()=>()=>{}:{},{},module);view=module.exports;},
|
||||
});
|
||||
const sizes=[];
|
||||
const instance=Object.assign({},view,{updatePreviewMetrics(){sizes.push([this.container.width,this.container.height]);}});
|
||||
instance.getSize();instance.updatePreview({width:480,height:640},0,false);
|
||||
assert.equal(sizes.length,0,'hidden window must not produce a zero-size video');
|
||||
window.innerWidth=360;window.innerHeight=666;
|
||||
instance.updatePreview({width:480,height:640},0,false);
|
||||
window.innerWidth=696;window.innerHeight=330;
|
||||
instance.updatePreview({width:480,height:640},0,false);
|
||||
assert.deepEqual(sizes,[[360,666],[696,330]]);
|
||||
});
|
||||
|
||||
|
||||
test('native preview uses one canvas, releases its worker and never recaptures video',async()=>{
|
||||
const s=setup('working');
|
||||
const {camera:first}=await s.api.getCamera('back');
|
||||
const canvas=first.vulpesPreviewCanvas;
|
||||
assert.ok(first.vulpesTiming.frames>0);
|
||||
assert.equal(first.vulpesClosed,false);
|
||||
await first.release();
|
||||
assert.equal(first.vulpesClosed,true);
|
||||
const {camera:second}=await s.api.getCamera('front');
|
||||
assert.equal(second.vulpesPreviewCanvas,canvas);
|
||||
assert.equal(second.vulpesClosed,false);
|
||||
await second.release();
|
||||
assert.equal(s.nativeRequests.filter(r=>r.action==='stop').length,2);
|
||||
assert.equal(s.wakeLocks.length,2);
|
||||
assert.ok(s.wakeLocks.every(lock=>lock.topic==='screen' && lock.released));
|
||||
});
|
||||
|
||||
|
||||
test('native preview attaches inside Gaia and detaches on camera release',async()=>{
|
||||
const s=setup('working'),video=s.viewfinder();
|
||||
const {camera}=await s.api.getCamera('front');
|
||||
video.mozSrcObject=camera;
|
||||
await video.play();
|
||||
assert.equal(video.mozSrcObject,camera);
|
||||
assert.equal(video.srcObject,undefined,'do not create a native video decoder');
|
||||
assert.equal(camera.vulpesPreviewCanvas.isConnected,true);
|
||||
assert.equal(video.style.display,'none');
|
||||
await camera.release();
|
||||
assert.equal(camera.vulpesPreviewCanvas.isConnected,false);
|
||||
assert.equal(video.style.display,'');
|
||||
assert.equal(camera.vulpesClosed,true);
|
||||
});
|
||||
|
||||
test('Android waits for a busy camera without changing the selected sensor', async()=>{
|
||||
const s=setup(false,'android');
|
||||
await s.api.getCamera('back');
|
||||
s.failCapture(new DOMException('closing','NotReadableError'));
|
||||
const {camera}=await s.api.getCamera('front');
|
||||
assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
|
||||
assert.equal(s.requests.length,3);
|
||||
assert.equal(s.requests[1].video.deviceId.exact,s.requests[2].video.deviceId.exact);
|
||||
await camera.release();
|
||||
});
|
||||
test('Android does not retry camera permission refusal',async()=>{
|
||||
const s=setup(false,'android');s.failCapture(new DOMException('denied','NotAllowedError'));
|
||||
await assert.rejects(s.api.getCamera('back'),{name:'NotAllowedError'});
|
||||
assert.equal(s.requests.length,1);
|
||||
});
|
||||
test('Android stops camera retry when the app becomes hidden',async()=>{
|
||||
const s=setup(false,'android');s.failCapture(new DOMException('closing','NotReadableError'));
|
||||
const pending=s.api.getCamera('front');
|
||||
const checked=assert.rejects(pending,{name:'AbortError'});
|
||||
await new Promise(resolve=>setImmediate(resolve));s.hide();await checked;
|
||||
assert.equal(s.requests.length,1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Input} from '../host/Input.sys.mjs';
|
||||
import {Input} from '../services/input.mjs';
|
||||
function setup() {
|
||||
const events=[];
|
||||
const input=new Input({identity:actor=>actor.app,broadcast:(...args)=>events.push(args)});
|
||||
|
|
@ -30,3 +30,20 @@ test('current edit updates the focused field; keyboard focus does not replace it
|
|||
input.changed(field,{text:''});input.changed(keyboard,{text:'unrelated'});
|
||||
assert.equal((await input.request(keyboard,{operation:'key',id:1})).text,'123');
|
||||
});
|
||||
|
||||
test('a composer button retains input and other buttons dismiss only after click',async()=>{
|
||||
const {readFile}=await import('node:fs/promises');
|
||||
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',{
|
||||
JSWindowActorChild:class{},Services:{cpmm:{sharedData:{get:()=>true}}}
|
||||
});
|
||||
const actor=new Actor();actor.sendAsyncMessage=(...args)=>events.push(args);
|
||||
const button={localName:'button',closest:()=>null};
|
||||
actor.handleEvent({type:'pointerdown',target:button});assert.equal(events.length,0);
|
||||
actor.handleEvent({type:'click',target:button});assert.equal(events.length,1);assert.equal(events[0][1],null);
|
||||
button.closest=()=>button;
|
||||
actor.handleEvent({type:'pointerdown',target:button});actor.handleEvent({type:'click',target:button});
|
||||
assert.equal(events.length,1,'send keeps the composer focused');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,3 +97,13 @@ test('pending transmission status survives a host restart',async()=>{
|
|||
assert.deepEqual(events,['sent']);
|
||||
assert.equal((await restarted.request(app,{operation:'getMessages'}))[0].delivery,'sent');
|
||||
});
|
||||
|
||||
test('unsupported MMS never invokes the SMS modem or records a sent message',async()=>{
|
||||
let submissions=0;
|
||||
const service=new Messages(store(),async()=>{submissions++;return {};});
|
||||
for(const operation of ['sendMMS','retrieveMMS']) {
|
||||
await assert.rejects(service.request(app,{operation,args:[{}]}),/NotSupportedError/);
|
||||
}
|
||||
assert.equal(submissions,0);
|
||||
assert.deepEqual(await service.request(app,{operation:'getMessages'}),[]);
|
||||
});
|
||||
|
|
|
|||
34
tests/mms.test.mjs
Normal file
34
tests/mms.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Messages} from '../host/Messages.sys.mjs';
|
||||
import {mmsPayload,receivedMms} from '../services/mms.mjs';
|
||||
const app={manifest:{permissions:{sms:{}}}};
|
||||
const part={id:'photo.jpg',location:'photo.jpg',content:{__vulpesBlobV1:true,type:'image/jpeg',base64:'/9j/2Q=='}};
|
||||
const payload={receivers:['+15550199'],subject:'Photo',smil:'',attachments:[part]};
|
||||
function store(){let rows=[];return {run:async(_,op,args)=>op==='get'?{value:structuredClone(rows)}:(rows=structuredClone(args[0]))};}
|
||||
test('MMS remains pending until MMSC acceptance and keeps attachments',async()=>{
|
||||
const events=[];let status={state:'pending'};
|
||||
const mms={validate:mmsPayload,send:async p=>{assert.deepEqual(p,payload);return {path:'id',state:'pending'};},status:async()=>status};
|
||||
const s=new Messages(store(),null,(t,m)=>events.push([t,m]),null,mms);
|
||||
const message=await s.request(app,{operation:'sendMMS',args:[payload]});assert.equal(message.delivery,'sending');
|
||||
await s.refreshOutgoing();assert.equal(events.length,1);
|
||||
status={state:'sent'};await s.refreshOutgoing();assert.equal(events.at(-1)[0],'sent');
|
||||
assert.deepEqual(events.at(-1)[1].attachments,[part]);
|
||||
assert.deepEqual((await s.request(app,{operation:'getThreads'}))[0].participants,payload.receivers);
|
||||
});
|
||||
test('bearer failure retains failed MMS bubble without a sent event',async()=>{
|
||||
const events=[];const s=new Messages(store(),null,t=>events.push(t),null,{validate:mmsPayload,send:async()=>({path:'id'}),status:async()=>({state:'failed',error:'MMS_DATA_UNAVAILABLE'})});
|
||||
await s.request(app,{operation:'sendMMS',args:[payload]});await s.refreshOutgoing();
|
||||
const m=(await s.request(app,{operation:'getMessages'}))[0];assert.equal(m.delivery,'error');assert.equal(m.error,'MMS_DATA_UNAVAILABLE');assert.deepEqual(m.attachments,[part]);assert.deepEqual(events,['sending','failed']);
|
||||
});
|
||||
test('incoming MMS is deduplicated and preserves native attachment blobs',async()=>{
|
||||
const row=receivedMms({receipt:'push-1',type:'mms',sender:'+15550199',receivers:[],parts:[{id:'photo.jpg',type:'image/jpeg',data:'/9j/2Q=='},{id:'text.txt',type:'text/plain',data:btoa('Hello')}]});
|
||||
const s=new Messages(store());const a=await s.receive(row),b=await s.receive(row);
|
||||
assert.equal(a.id,b.id);assert.equal(a.body,'Hello');assert.equal(a.type,'mms');assert.equal(a.attachments[0].content.base64,part.content.base64);
|
||||
assert.equal((await s.request(app,{operation:'getThreads'}))[0].lastMessageType,'mms');
|
||||
});
|
||||
test('reject invalid recipients, missing blobs and oversized MMS',()=>{
|
||||
assert.throws(()=>mmsPayload({...payload,receivers:['file:///etc/passwd']}));
|
||||
assert.throws(()=>mmsPayload({...payload,attachments:[{content:{}}]}));
|
||||
assert.throws(()=>mmsPayload({...payload,attachments:[{...part,content:{...part.content,base64:'A'.repeat(900000)}}]}));
|
||||
});
|
||||
|
|
@ -49,3 +49,40 @@ for (const params of [{action:'focus',area:{left:-100,top:-100,right:100,bottom:
|
|||
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/);
|
||||
|
||||
for (const action of ['sim','smsc','calls','messages','deliveryReports']) {
|
||||
const params={action,...(action==='deliveryReports'?{enabled:true}:{})};
|
||||
authorizePlatform(settings,'mobileSettings',params);
|
||||
for (const id of ['system','communications','sms','camera','marketplace'])
|
||||
rejects(app(id),'mobileSettings',params);
|
||||
}
|
||||
for (const params of [{action:'enterPin',pin:'1234'},{action:'deliveryReports',enabled:'true'}])
|
||||
assert.throws(()=>authorizePlatform(settings,'mobileSettings',params),/INVALID_VALUE/);
|
||||
console.log('PASS: mobile Settings queries are restricted to Settings; unsafe actions rejected.');
|
||||
|
||||
for(const action of ['network','dataEnabled','roaming']) {
|
||||
const params={action,...(action==='network'?{}:{enabled:false})};
|
||||
authorizePlatform(settings,'mobileSettings',params);
|
||||
rejects(app('system'),'mobileSettings',params);
|
||||
}
|
||||
for(const action of ['clock','setTimezone','setTime']) {
|
||||
authorizePlatform(settings,'deviceSettings',{action});
|
||||
for(const id of ['system','communications','marketplace'])rejects(app(id),'deviceSettings',{action});
|
||||
}
|
||||
assert.throws(()=>authorizePlatform(settings,'deviceSettings',{action:'execute'}),/INVALID_VALUE/);
|
||||
assert.throws(()=>authorizePlatform(settings,'mobileSettings',{action:'roaming',enabled:1}),/INVALID_VALUE/);
|
||||
|
||||
for(const action of ['radioMode','setRadioMode','apns','editApn']) {
|
||||
authorizePlatform(settings,'mobileSettings',{action});
|
||||
for(const id of ['system','communications','marketplace'])rejects(app(id),'mobileSettings',{action});
|
||||
}
|
||||
|
||||
for(const params of [{action:'calls'},{action:'forwarding'},{action:'setCallerId',mode:'default'},{action:'setCallWaiting',enabled:true},{action:'setForwarding',reason:'busy',enabled:false,number:''}]) {
|
||||
authorizePlatform(settings,'mobileSettings',params);
|
||||
for(const id of ['system','communications','marketplace'])rejects(app(id),'mobileSettings',params);
|
||||
}
|
||||
|
||||
for(const operation of ['hotspotStatus','hotspotSave','hotspotPower']) {
|
||||
authorizePlatform(settings,'wifi',{operation});
|
||||
for(const id of ['system','communications','marketplace'])rejects(app(id),'wifi',{operation});
|
||||
}
|
||||
|
|
|
|||
53
tests/settings-dialogs.test.mjs
Normal file
53
tests/settings-dialogs.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const load=name=>readFile(new URL('../gaia/apps/settings/js/modules/'+name+'.js',import.meta.url),'utf8');
|
||||
const serviceSource=await load('dialog_service');
|
||||
const managerSource=await load('dialog_manager');
|
||||
const defer=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};};
|
||||
const settle=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
function serviceFixture() {
|
||||
let service;const opens=[],closes=[];let rejectClose=false;
|
||||
const panel=Object.assign(new EventTarget(),{classList:{remove(){}},hidden:false});
|
||||
const factory=()=>({panel,cleanup(){}});
|
||||
const manager={open(dialog,options){const d=defer();opens.push({options,...d});return d.promise;},close(dialog,type){closes.push(type);return rejectClose?Promise.reject(Error('invalid')):Promise.resolve('value');}};
|
||||
vm.runInNewContext(serviceSource,{Promise,CustomEvent,document:{getElementById:()=>panel,querySelector:()=>null},define:fn=>{service=fn(name=>name==='settings'?{currentPanel:'#root'}:name==='modules/defer'?defer:name==='modules/dialog_manager'?manager:factory);}});
|
||||
return {service,opens,closes,panel,reject:value=>{rejectClose=value;}};
|
||||
}
|
||||
test('early cancel waits for opening and queued reopen runs after closure',async()=>{
|
||||
const f=serviceFixture();const first=f.service.show('one');const second=f.service.show('two');
|
||||
f.opens[0].options.onWrapCancel();f.opens[0].options.onWrapCancel();
|
||||
await settle();assert.equal(f.closes.length,0);assert.equal(f.opens.length,1);
|
||||
f.opens[0].resolve();assert.equal((await first).type,'cancel');await settle();
|
||||
assert.equal(f.closes.length,1);assert.equal(f.opens.length,2);
|
||||
f.opens[1].resolve();f.opens[1].options.onWrapCancel();await second;assert.equal(f.service._navigating,false);
|
||||
});
|
||||
test('failed validation preserves dialog and permits a later submit',async()=>{
|
||||
const f=serviceFixture();let failures=0;f.panel.addEventListener('dialogvalidationfailed',()=>failures++);
|
||||
const shown=f.service.show('one');f.opens[0].resolve();await settle();
|
||||
f.reject(true);f.opens[0].options.onWrapSubmit();await settle();
|
||||
assert.equal(failures,1);assert.equal(f.service._navigating,true);
|
||||
f.reject(false);f.opens[0].options.onWrapSubmit();assert.equal((await shown).type,'submit');assert.equal(f.service._navigating,false);
|
||||
});
|
||||
function transitionFixture(duration='0.4s') {
|
||||
let manager,next=0;const timers=new Map();const classes=new Set();
|
||||
const panel=Object.assign(new EventTarget(),{hidden:true,classList:{toggle(name,on){on?classes.add(name):classes.delete(name);}}});
|
||||
vm.runInNewContext(managerSource,{Promise,document:{querySelector:()=>({})},window:{getComputedStyle:()=>({transitionDuration:duration,transitionDelay:'0s'})},
|
||||
setTimeout:(fn,ms)=>{timers.set(++next,{fn,ms});return next;},clearTimeout:id=>timers.delete(id),define:fn=>{manager=fn(()=>({}));}});
|
||||
return {manager,panel,classes,timers,step(){const [id,timer]=timers.entries().next().value;timers.delete(id);timer.fn();return timer.ms;}};
|
||||
}
|
||||
test('missing visibility transition and disabled animations both complete',async()=>{
|
||||
for(const duration of ['0.4s','0s']) {
|
||||
const f=transitionFixture(duration);const opened=f.manager._transit('open',{panel:f.panel},{});
|
||||
assert.equal(f.panel.hidden,false);assert.equal(f.step(),150);assert(f.classes.has('current'));
|
||||
assert.equal(f.step(),duration==='0s'?100:500);await opened;
|
||||
const closed=f.manager._transit('close',{panel:f.panel},{});f.step();f.step();await closed;
|
||||
assert.equal(f.panel.hidden,true);assert.equal(f.timers.size,0);
|
||||
}
|
||||
});
|
||||
test('cancelled visibility transition resolves once and removes fallback',async()=>{
|
||||
const f=transitionFixture();const p=f.manager._transit('close',{panel:f.panel},{});f.step();
|
||||
const e=new Event('transitioncancel');Object.defineProperty(e,'propertyName',{value:'visibility'});f.panel.dispatchEvent(e);await p;
|
||||
assert.equal(f.panel.hidden,true);assert.equal(f.timers.size,0);
|
||||
});
|
||||
115
tests/test_changelog.py
Normal file
115
tests/test_changelog.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Changelog exports and remote publication boundaries."""
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def module(name, file):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT/'tools'/file)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
export = module('export', 'export-changelog.py')
|
||||
sync = module('sync', 'sync-forgejo-changelog.py')
|
||||
|
||||
|
||||
class ChangelogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source, self.languages = export.load_source(ROOT/'changelog.json')
|
||||
self.tag = 'android-2.7-preview.14'
|
||||
|
||||
def test_all_outputs_reuse_source_entries(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
files = export.generate(ROOT/'changelog.json', root, root/'site.json', root/'releases')
|
||||
data = json.loads(files[root/'site.json'])
|
||||
self.assertEqual(data['languages'], self.languages)
|
||||
for lang, name in [('fr', 'CHANGELOG.md'), ('en', 'CHANGELOG.en.md')]:
|
||||
for entry in self.source['entries']:
|
||||
self.assertIn(entry['text'][lang], files[root/name])
|
||||
|
||||
def test_range_and_summary(self):
|
||||
documents = export.release_documents(self.source, self.tag)
|
||||
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
|
||||
for lang in export.LANGUAGES:
|
||||
text = documents[f'CHANGELOG.{lang}.md']
|
||||
self.assertIn('## 2.7-preview.14', text)
|
||||
self.assertNotIn('## 2.7-156.0.1 ', text)
|
||||
future = copy.deepcopy(self.source['entries'][0])
|
||||
future['id'] = 'future'
|
||||
self.source['entries'].insert(0, future)
|
||||
self.assertEqual(documents, export.release_documents(self.source, self.tag))
|
||||
|
||||
def test_scope_and_fixed_ids(self):
|
||||
self.assertTrue(any(g['platforms'] == ['pc', 'models']
|
||||
for entry in self.languages['fr'] for g in entry['groups']))
|
||||
self.assertEqual(len(self.source['entries']), len({e['id'] for e in self.source['entries']}))
|
||||
|
||||
def test_invalid_source_rejected_before_output(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d)/'source.json'
|
||||
del self.source['entries'][0]['text']['en']
|
||||
p.write_text(json.dumps(self.source))
|
||||
with self.assertRaisesRegex(ValueError, 'Missing translation'):
|
||||
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
|
||||
self.assertFalse((Path(d)/'site.json').exists())
|
||||
|
||||
def test_flat_entry_keeps_explicit_platforms_in_both_languages(self):
|
||||
for language in export.LANGUAGES:
|
||||
for source, rendered in zip(self.source['entries'], self.languages[language]):
|
||||
self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
|
||||
self.assertEqual(rendered['models'], source['metadata']['models'])
|
||||
self.assertEqual(rendered['status'], source['metadata']['status'])
|
||||
self.assertEqual(rendered['groups'][0]['platforms'], rendered['platforms'])
|
||||
|
||||
def test_badges_are_images_with_escaped_attributes(self):
|
||||
rendered = export.inline(' **Camera**')
|
||||
self.assertIn('<img class="cl-badge"', rendered)
|
||||
self.assertIn('alt="Fix"', rendered)
|
||||
self.assertIn('<strong>Camera</strong>', rendered)
|
||||
self.assertNotIn('<a ', rendered)
|
||||
self.assertNotIn('<img', export.inline(''))
|
||||
self.assertNotIn(' onerror="', export.inline(''))
|
||||
|
||||
def test_invalid_platform_is_rejected(self):
|
||||
self.source['entries'][0]['metadata']['platforms'] = ['unknown']
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)/'invalid.json'
|
||||
path.write_text(json.dumps(self.source))
|
||||
with self.assertRaisesRegex(ValueError, 'Invalid platforms'):
|
||||
export.load_source(path)
|
||||
|
||||
def test_remote_notes_only(self):
|
||||
docs = export.release_documents(self.source, self.tag)
|
||||
original = {'id': 24, 'tag_name': self.tag, 'body': 'old notes', 'assets': [
|
||||
{'id': 38, 'name': 'preview.apk', 'browser_download_url': 'https://example/apk'},
|
||||
*[{'id': n, 'name': f'CHANGELOG.{lang}.md', 'browser_download_url': f'https://example/{lang}'}
|
||||
for n, lang in [(40, 'fr'), (41, 'en')]]]}
|
||||
calls = []
|
||||
def request(url, token=None, method='GET', payload=None, content_type=None):
|
||||
calls.append((method, url))
|
||||
if '/releases/tags/' in url:
|
||||
return json.dumps(original).encode()
|
||||
if url.startswith('https://example/'):
|
||||
lang = url.rsplit('/', 1)[-1]
|
||||
return docs[f'CHANGELOG.{lang}.md'].encode()
|
||||
self.assertEqual(method, 'PATCH')
|
||||
self.assertEqual(set(payload), {'body'})
|
||||
original['body'] = payload['body']
|
||||
return b'{}'
|
||||
with patch.object(sync, 'request', side_effect=request), patch.dict('os.environ', {'FORGEJO_TOKEN': 'test'}):
|
||||
self.assertTrue(sync.sync(ROOT/'changelog.json', self.tag, publish=True))
|
||||
self.assertEqual(len([c for c in calls if c[0] != 'GET']), 1)
|
||||
self.assertFalse(any(url.endswith('/apk') for _, url in calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -8,13 +8,13 @@ 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.md','CHANGELOG.en.md',
|
||||
'README-internal.md','gaia/README.md','tundra/CHANGELOG.md','tickets/2026-10-13.md',
|
||||
'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',
|
||||
for name in ['README.md','README.fr.md','CHANGELOG.md','CHANGELOG.en.md','tools/export-changelog.py','tools/render-changelog.py','host/shell.js','tundra/boot/native-root.sh',
|
||||
'gaia/LICENSE','NOTICE','changelog.json','docs/media/vulpes-logo.png']:
|
||||
self.assertEqual(pub.target_name(name),name)
|
||||
self.assertEqual(pub.target_name('gaia/vendor/LICENSE.md'),'gaia/vendor/LICENSE')
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ test('Wi-Fi bursts share one scan, cache errors, and retry after cooldown', asyn
|
|||
assert.ok((await Promise.all(burst)).every(r => r.networks.length === 0));
|
||||
await tundraWifi({operation:'scan'});
|
||||
assert.equal(starts, 1);
|
||||
await tundraWifi({operation:'known'});
|
||||
await tundraWifi({operation:'status'});
|
||||
await tundraWifi({operation:'scan'});
|
||||
assert.equal(starts, 3, 'read-only status and saved networks preserve the radio scan cache');
|
||||
starts -= 2;
|
||||
now += 6000;
|
||||
reply = {error:'WIFI_UNAVAILABLE'};
|
||||
const failures = await Promise.allSettled(Array.from({length:1000}, () => tundraWifi({operation:'scan'})));
|
||||
|
|
|
|||
Loading…
Reference in a new issue