Release Vulpes OS 2.7-preview.22
This commit is contained in:
parent
200d1fb235
commit
a138f97877
294 changed files with 515786 additions and 187 deletions
26
tundra/tests/beatles-keymap-test.c
Normal file
26
tundra/tests/beatles-keymap-test.c
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <assert.h>
|
||||
#define main keypad_reader_main
|
||||
#include "../probes/beatles-keypad.c"
|
||||
#undef main
|
||||
|
||||
int main(void) {
|
||||
for(unsigned code=KEY_1;code<=KEY_0;code++)assert(symbol(code)!=NoSymbol);
|
||||
const unsigned controls[]={KEY_UP,KEY_DOWN,KEY_LEFT,KEY_RIGHT,KEY_OK,
|
||||
KEY_BACK,KEY_BACKSPACE,KEY_MENU,KEY_SEND,KEY_POWER,KEY_CAMERA,
|
||||
KEY_NUMERIC_STAR,KEY_NUMERIC_POUND,KEY_VOLUMEUP,KEY_VOLUMEDOWN};
|
||||
for(unsigned i=0;i<sizeof(controls)/sizeof(controls[0]);i++)
|
||||
assert(symbol(controls[i])!=NoSymbol);
|
||||
assert(native_code("Notifications")==KEY_CAMERA);
|
||||
assert(symbol(KEY_CAMERA)==XK_F2);
|
||||
/* Do not give the assistance key an accidental call/emergency action. */
|
||||
assert(symbol(112)==NoSymbol);
|
||||
assert(!allow_key(KEY_SEND,1,1));
|
||||
assert(!allow_key(KEY_2,1,1));
|
||||
assert(allow_key(KEY_SEND,0,1));
|
||||
assert(allow_key(KEY_VOLUMEUP,1,1));
|
||||
assert(allow_key(KEY_VOLUMEDOWN,1,1));
|
||||
assert(allow_key(KEY_2,1,0));
|
||||
puts("Nokia key map and closed-lid input policy: OK");
|
||||
return 0;
|
||||
}
|
||||
56
tundra/tests/beatles-lid-test.c
Normal file
56
tundra/tests/beatles-lid-test.c
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <assert.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/fb.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#define ROOT "/nonexistent-vulpes-lid-test"
|
||||
static char keys_path[]="/tmp/vulpes-lid-keys-XXXXXX";
|
||||
#define BEATLES_KEYS_BRIGHTNESS keys_path
|
||||
static int last_panel=-1;
|
||||
static int inner=-1,outer=-1,fail_inner=0;
|
||||
static int panel_ioctl(int fd,unsigned long request,int value) {
|
||||
assert(request==FBIOBLANK);
|
||||
last_panel=fd;
|
||||
if(fd==10){if(fail_inner)return -1;inner=value;}
|
||||
else {assert(fd==11);outer=value;}
|
||||
return 0;
|
||||
}
|
||||
#define ioctl panel_ioctl
|
||||
#include "../probes/beatles-lid.h"
|
||||
int main(void) {
|
||||
assert(beatles_hall_open(0)==1);
|
||||
assert(beatles_hall_open(1)==0);
|
||||
assert(beatles_hall_open(-1)==-1);
|
||||
assert(beatles_hall_open(2)==-1);
|
||||
int fixture=mkstemp(keys_path);assert(fixture>=0);close(fixture);
|
||||
lid_keys_write(40);assert(lid_keys_read()==40);
|
||||
struct lid_state lid={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
|
||||
lid_apply(&lid,1,1,100);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,0,1,101);assert(inner==FB_BLANK_POWERDOWN && outer==FB_BLANK_UNBLANK);
|
||||
assert(lid_keys_read()==0);
|
||||
lid_apply(&lid,0,1,117);assert(outer==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,-1,1,118);assert(inner==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,1,1,119);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
|
||||
assert(last_panel==10 && lid_keys_read()==40);
|
||||
fail_inner=1;lid_apply(&lid,0,1,120);assert(lid.opened==1 && !lid.outer_on);
|
||||
fail_inner=0;lid_apply(&lid,0,0,121);assert(!lid.outer_on);
|
||||
lid_apply(&lid,0,1,150);assert(lid.outer_on); /* late renderer still gets 15s */
|
||||
lid_apply(&lid,0,1,166);assert(!lid.outer_on);
|
||||
struct lid_state closed={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
|
||||
lid_apply(&closed,0,0,200);
|
||||
assert(inner==FB_BLANK_POWERDOWN && !closed.outer_on && lid_keys_read()==0);
|
||||
/* Xorg unblanks fb0 while initializing, without a physical lid transition. */
|
||||
inner=FB_BLANK_UNBLANK;
|
||||
lid_apply(&closed,0,0,205);
|
||||
lid_apply(&closed,0,1,220);
|
||||
assert(inner==FB_BLANK_POWERDOWN && closed.outer_on && lid_keys_read()==0);
|
||||
lid_apply(&closed,0,1,234);assert(closed.outer_on);
|
||||
lid_apply(&closed,0,1,235);assert(!closed.outer_on);
|
||||
lid.outer=-1;lid_apply(&lid,1,1,170);lid_apply(&lid,0,1,171);assert(!lid.outer_on);
|
||||
lid_restore(&lid);assert(lid_keys_read()==40);unlink(keys_path);
|
||||
puts("Clamshell panel transitions and keypad backlight: OK");return 0;
|
||||
}
|
||||
44
tundra/tests/test_beatles_boot.py
Normal file
44
tundra/tests/test_beatles_boot.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'tools'))
|
||||
from beatles_boot import read_cpio, write_cpio, add_file, pack_boot, unpack_boot
|
||||
|
||||
class BootTests(unittest.TestCase):
|
||||
def archive(self):
|
||||
entries={'.':{'fields':[1,stat.S_IFDIR|0o750,1000,1001,2,17,0,0,0,0,0,2,0],'data':b''}}
|
||||
add_file(entries,'init.rc',b'on boot\n',0o640)
|
||||
entries['init.rc']['fields'][2:4]=[1000,1001]
|
||||
return entries
|
||||
def test_repack_preserves_ownership_and_modes(self):
|
||||
original=self.archive();repacked=read_cpio(write_cpio(original))
|
||||
self.assertEqual(repacked['init.rc']['data'],b'on boot\n')
|
||||
self.assertEqual(repacked['init.rc']['fields'][1:6],original['init.rc']['fields'][1:6])
|
||||
self.assertEqual(write_cpio(original),write_cpio(original))
|
||||
def test_missing_trailer_and_truncated_entry_rejected(self):
|
||||
raw=gzip.decompress(write_cpio(self.archive()))
|
||||
for end in (90,115,180):
|
||||
with self.assertRaises(ValueError):read_cpio(gzip.compress(raw[:end]))
|
||||
def test_traversal_rejected(self):
|
||||
entries=self.archive();add_file(entries,'../escape',b'unsafe')
|
||||
with self.assertRaises(ValueError):read_cpio(write_cpio(entries))
|
||||
def test_duplicate_added_file_rejected(self):
|
||||
with self.assertRaises(ValueError):add_file(self.archive(),'init.rc',b'bad')
|
||||
def image(self):
|
||||
header=bytearray(2048);header[:8]=b'ANDROID!'
|
||||
struct.pack_into('<10I',header,8,3,0x80008000,4,0x81000000,0,0,0x80000100,2048,0,0)
|
||||
return bytes(header)+b'abc'+b'\0'*2045+b'test'+b'\0'*2044+b'old signature'
|
||||
def test_changed_image_drops_original_signature_and_preserves_kernel(self):
|
||||
result=unpack_boot(pack_boot(self.image(),b'new ramdisk'))
|
||||
self.assertEqual(result['kernel'],b'abc');self.assertEqual(result['ramdisk'],b'new ramdisk')
|
||||
self.assertEqual(result['trailer'],b'')
|
||||
def test_unsupported_header_and_short_payload_rejected(self):
|
||||
with self.assertRaises(ValueError):unpack_boot(self.image()[:2500])
|
||||
image=bytearray(self.image());struct.pack_into('<I',image,40,1)
|
||||
with self.assertRaises(ValueError):unpack_boot(image)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
38
tundra/tests/test_beatles_boot_guard.py
Normal file
38
tundra/tests/test_beatles_boot_guard.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'devices/beatles/boot/vulpes-boot.sh'
|
||||
|
||||
class GuardTests(unittest.TestCase):
|
||||
def run_guard(self,mode):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);base=root/'runtime';base.mkdir()
|
||||
screen=root/'fb';screen.touch();ril=socket.socket(socket.AF_UNIX)
|
||||
try:
|
||||
ril.bind(str(root/'ril'))
|
||||
start=root/'start';start.write_text('#!/bin/sh\necho "$1" >> '+str(root/'fallback')+'\n');start.chmod(0o755)
|
||||
supervisor=root/'display'
|
||||
action='exit 1' if mode=='launch-failed' else 'exit 0'
|
||||
if mode=='ready':action='printf \'{"readyMs":100}\' > '+str(base/'rootfs/work/gaia-audit.json')
|
||||
supervisor.write_text('#!/bin/sh\n'+action+'\n');supervisor.chmod(0o755)
|
||||
if mode!='missing':
|
||||
(base/'rootfs/opt/firefox').mkdir(parents=True);(base/'rootfs/work').mkdir()
|
||||
engine=base/'rootfs/opt/firefox/firefox';engine.touch();engine.chmod(0o755)
|
||||
(base/'runtime-ready').touch()
|
||||
if mode=='disabled':(base/'disable-boot').touch()
|
||||
script=SOURCE.read_text().replace('/data/local/vulpes-beatles',str(base)).replace('/system/bin/start',str(start)).replace('/sbin/vulpes-display',str(supervisor)).replace('/system/bin/busybox grep','grep').replace('/dev/graphics/fb0',str(screen)).replace('/dev/socket/rilproxy',str(root/'ril')).replace('sleep 1',':')
|
||||
proc=subprocess.run(['sh','-c',script],capture_output=True,text=True,timeout=5)
|
||||
self.assertEqual(proc.returncode,0,proc.stderr)
|
||||
return (root/'fallback').exists(),(base/'stop-session').exists()
|
||||
finally:ril.close()
|
||||
def test_missing_runtime_falls_back(self):self.assertEqual(self.run_guard('missing'),(True,False))
|
||||
def test_opt_out_falls_back(self):self.assertEqual(self.run_guard('disabled'),(True,False))
|
||||
def test_failed_launch_falls_back(self):self.assertEqual(self.run_guard('launch-failed'),(True,False))
|
||||
def test_stalled_gaia_requests_stop_and_falls_back(self):self.assertEqual(self.run_guard('stalled'),(True,True))
|
||||
def test_ready_gaia_does_not_start_second_ui(self):self.assertEqual(self.run_guard('ready'),(False,False))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
60
tundra/tests/test_beatles_control_client.py
Normal file
60
tundra/tests/test_beatles_control_client.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class ControlRelayTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.directory=tempfile.TemporaryDirectory()
|
||||
cls.root=Path(cls.directory.name)
|
||||
code=(Path(__file__).parents[1]/'probes/beatles-control-client.c').read_text()
|
||||
for name in ('phone-control','wifi-control'):
|
||||
code=code.replace('/work/'+name+'.sock',str(cls.root/(name+'.sock')))
|
||||
source=cls.root/'client.c';source.write_text(code);cls.binary=cls.root/'client'
|
||||
subprocess.run(['cc','-Wall','-Wextra','-Werror',str(source),'-o',str(cls.binary)],check=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):cls.directory.cleanup()
|
||||
|
||||
def exchange(self,mode,reply):
|
||||
path=self.root/(('phone' if mode=='--radio' else 'wifi')+'-control.sock')
|
||||
path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
||||
server.bind(str(path));server.listen(1);server.settimeout(3)
|
||||
def broker():
|
||||
with server.accept()[0] as conn:
|
||||
conn.settimeout(3);data=b''
|
||||
while b'\n' not in data:data+=conn.recv(4096)
|
||||
for part in reply:conn.sendall(part)
|
||||
return data
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future=pool.submit(broker)
|
||||
result=subprocess.run([str(self.binary),mode],input=b'{"operation":"status"}',capture_output=True,timeout=4)
|
||||
request=future.result()
|
||||
return result,request
|
||||
|
||||
def test_radio_and_wifi_preserve_broker_envelopes(self):
|
||||
for mode in ('--radio','--wifi'):
|
||||
result,request=self.exchange(mode,[b'{"ok":true,',b'"result":[]}\n'])
|
||||
self.assertEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'{"ok":true,"result":[]}\n')
|
||||
self.assertEqual(request,b'{"operation":"status"}\n')
|
||||
|
||||
def test_disconnect_and_oversized_response_fail(self):
|
||||
for reply in ([b'{"incomplete":'],[b'x'*65537]):
|
||||
result,_=self.exchange('--wifi',reply)
|
||||
self.assertNotEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'')
|
||||
|
||||
def test_unknown_target_and_multiple_requests_are_rejected(self):
|
||||
for mode,data in [('--shell',b'{}'),('--radio',b'{}\n{}'),('--wifi',b'x'*8193)]:
|
||||
result=subprocess.run([str(self.binary),mode],input=data,capture_output=True,timeout=3)
|
||||
self.assertNotEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
63
tundra/tests/test_beatles_cover.py
Normal file
63
tundra/tests/test_beatles_cover.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import tempfile
|
||||
import struct
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('cover',Path(__file__).parents[1]/'devices/beatles/cover-display.py')
|
||||
cover=importlib.util.module_from_spec(spec);spec.loader.exec_module(cover)
|
||||
|
||||
class CoverTests(unittest.TestCase):
|
||||
def test_animation_timing_padding_and_loop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'animation'
|
||||
first=b'\x00\xf8'*(240*240);second=b'\x1f\x00'*(240*240)
|
||||
path.write_bytes(b'VULPANM1'+struct.pack('<I',2)+struct.pack('<I',50)+first+struct.pack('<I',100)+second)
|
||||
animation=cover.Animation(path)
|
||||
try:
|
||||
self.assertEqual(animation.frame(0,480),(first,.05,0))
|
||||
self.assertEqual(animation.frame(.05,480),(second,.1,1))
|
||||
self.assertEqual(animation.frame(.15,480),(first,.05,0))
|
||||
padded,_,_=animation.frame(0,484)
|
||||
self.assertEqual(len(padded),240*484)
|
||||
self.assertEqual(padded[480:484],bytes(4))
|
||||
finally:animation.close()
|
||||
|
||||
def test_animation_rejects_invalid_and_truncated_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'animation'
|
||||
for data in (b'',b'WRONGHDR'+struct.pack('<I',1),b'VULPANM1'+struct.pack('<I',241),
|
||||
b'VULPANM1'+struct.pack('<II',1,1)+bytes(240*240*2)):
|
||||
path.write_bytes(data)
|
||||
with self.assertRaises(ValueError):cover.Animation(path)
|
||||
|
||||
def test_unknown_and_stale_are_not_full_battery_or_registered_network(self):
|
||||
self.assertEqual(cover.labels({},100),('—','—'))
|
||||
self.assertEqual(cover.labels({'recordedAt':1,'battery':{'level':1}},100),('—','—'))
|
||||
def test_live_values_and_absent_second_sim(self):
|
||||
state={'recordedAt':100,'battery':{'level':.73},'radio':[
|
||||
{'available':False},{'available':True,'operator':{'longName':'Orange F'}}]}
|
||||
self.assertEqual(cover.labels(state,105),('73 %','Orange F'))
|
||||
def test_custom_title_and_colors(self):
|
||||
title,ink,battery=cover.preferences({'vulpes.cover.title':' Mon Nokia ',
|
||||
'vulpes.cover.titleColor':'#FF0000','vulpes.cover.batteryColor':'#00ff00'})
|
||||
self.assertEqual(title,'Mon Nokia');self.assertEqual(ink,(1,0,0));self.assertEqual(battery,(0,1,0))
|
||||
|
||||
def test_invalid_preferences_and_long_title_are_safe(self):
|
||||
title,ink,battery=cover.preferences({'vulpes.cover.title':['invalid'],
|
||||
'vulpes.cover.titleColor':'url(file:///x)','vulpes.cover.batteryColor':None})
|
||||
self.assertEqual(title,'VULPES');self.assertEqual(ink,battery)
|
||||
self.assertEqual(cover.preferences({'vulpes.cover.title':'A'*30+'\n'})[0],'A'*24)
|
||||
self.assertEqual(cover.preferences({'vulpes.cover.title':'\n'})[0],'VULPES')
|
||||
|
||||
def test_invalid_battery(self):
|
||||
self.assertEqual(cover.labels({'recordedAt':100,'battery':{'level':2}},100),('—','—'))
|
||||
|
||||
def test_rejected_refresh_can_recover_without_stopping_gaia(self):
|
||||
with patch.object(cover.fcntl,'ioctl',side_effect=[PermissionError(1,'panel asleep'),0]):
|
||||
self.assertEqual(cover.present(3,bytearray(160)),{'ok':False,'errno':1})
|
||||
self.assertEqual(cover.present(3,bytearray(160)),{'ok':True})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
37
tundra/tests/test_beatles_hardware.py
Normal file
37
tundra/tests/test_beatles_hardware.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import sys,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
|
||||
from hardware_control import HardwareControl
|
||||
|
||||
class HardwareTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name)
|
||||
for name,value in [('backlight',170),('backlight-max',255),('vibrator',0)]:
|
||||
(self.root/name).write_text(str(value))
|
||||
self.hw=HardwareControl(self.root)
|
||||
def value(self,name):return int((self.root/name).read_text())
|
||||
def test_brightness_and_restore(self):
|
||||
self.hw.request({'operation':'screen','enabled':True,'level':.2})
|
||||
self.assertEqual(self.value('backlight'),51)
|
||||
self.hw.request({'operation':'screen','enabled':False,'level':.2})
|
||||
self.assertEqual(self.value('backlight'),0)
|
||||
self.hw.restore();self.assertEqual(self.value('backlight'),170)
|
||||
def test_vibration_sequence_cancel_and_shutdown(self):
|
||||
with patch('hardware_control.time.monotonic',return_value=10) as clock:
|
||||
self.hw.request({'operation':'vibrate','pattern':[120,80,200]})
|
||||
self.assertEqual(self.value('vibrator'),120)
|
||||
clock.return_value=10.13;self.hw.tick();self.assertEqual(self.value('vibrator'),0)
|
||||
clock.return_value=10.22;self.hw.tick();self.assertEqual(self.value('vibrator'),200)
|
||||
self.hw.request({'operation':'vibrate','pattern':[]})
|
||||
self.assertEqual(self.value('vibrator'),0);self.assertFalse(self.hw.pattern)
|
||||
self.hw.request({'operation':'vibrate','pattern':[200]});self.hw.restore()
|
||||
self.assertEqual(self.value('vibrator'),0)
|
||||
def test_invalid_requests_never_write(self):
|
||||
for request in ({'operation':'vibrate','pattern':[10001]}, {'operation':'vibrate','pattern':[True]},
|
||||
{'operation':'vibrate','pattern':[5000,5001]}, {'operation':'screen','enabled':True,'level':float('nan')},
|
||||
{'operation':'screen','enabled':1,'level':.5},{'operation':'shell'},None):
|
||||
with self.assertRaises(ValueError):self.hw.request(request)
|
||||
self.assertEqual(self.value('backlight'),170);self.assertEqual(self.value('vibrator'),0)
|
||||
213
tundra/tests/test_beatles_phone.py
Normal file
213
tundra/tests/test_beatles_phone.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
DEVICE=Path(__file__).parents[1]/'devices/beatles'
|
||||
sys.path.insert(0,str(DEVICE))
|
||||
import radio_control as control
|
||||
import sms_pdu as sms
|
||||
from radio_status import Parcel
|
||||
|
||||
HELLO='00040A91214365870900006201812100008005E8329BFD06'
|
||||
|
||||
class PduTests(unittest.TestCase):
|
||||
def test_standard_gsm_vector_and_unicode(self):
|
||||
self.assertEqual(sms.pack7(sms.septets('hello')).hex(),'e8329bfd06')
|
||||
self.assertEqual(sms.submit('+1234567890','hello'),'01000A912143658709000005E8329BFD06')
|
||||
row=sms.deliver(HELLO)
|
||||
self.assertEqual(row['sender'],'+1234567890');self.assertEqual(row['body'],'hello')
|
||||
self.assertEqual(row['timestamp'],1792317600000)
|
||||
self.assertEqual(sms.submit('123','漢').lower(),'0100038121f30008026f22')
|
||||
def test_extensions_and_length_limits(self):
|
||||
text='é € {} [] ^ ~ \\';values=sms.septets(text)
|
||||
self.assertEqual(sms.unpack7(sms.pack7(values),len(values)),text)
|
||||
sms.submit('123','A'*160)
|
||||
for text in ('A'*161,'€'*81,'漢'*71):
|
||||
with self.assertRaisesRegex(ValueError,'SMS_TOO_LONG'):sms.submit('123',text)
|
||||
for value in ('112;reboot','*#06#','',None,'+123\n'):
|
||||
with self.assertRaises(ValueError):sms.submit(value,'hello')
|
||||
def test_truncated_and_unsupported_pdus(self):
|
||||
for end in range(0,len(HELLO),2):
|
||||
with self.assertRaises((ValueError,UnicodeError)):sms.deliver(HELLO[:end])
|
||||
for pdu in ('00ff','xx','00040A9121436587097F006201812100008005E8329BFD06'):
|
||||
with self.assertRaises(ValueError):sms.deliver(pdu)
|
||||
def test_unicode_and_concatenation(self):
|
||||
prefix='00440A912143658709000862018121000080'
|
||||
row=sms.deliver(prefix+'080500030702010041')
|
||||
self.assertEqual(row['body'],'A');self.assertEqual(row['concat'],(7,2,1))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
|
||||
inbox.receive(prefix+'080500030702020042');self.assertEqual(inbox.value['inbox'],[])
|
||||
inbox.receive(prefix+'080500030702010041')
|
||||
self.assertEqual(inbox.value['inbox'][0]['body'],'AB')
|
||||
self.assertEqual(control.Inbox(path).value['inbox'][0]['body'],'AB')
|
||||
def test_durable_inbox_dedup_and_failed_save(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
|
||||
with patch.object(inbox,'save',side_effect=OSError('disk full')):
|
||||
with self.assertRaises(OSError):inbox.receive(HELLO)
|
||||
self.assertEqual(inbox.value['seen'],{})
|
||||
inbox.receive(HELLO);inbox.receive(HELLO)
|
||||
self.assertEqual(len(inbox.value['inbox']),1)
|
||||
self.assertEqual(path.stat().st_mode&0o777,0o600)
|
||||
receipt=inbox.value['inbox'][0]['receipt'];inbox.acknowledge([receipt])
|
||||
inbox.receive(HELLO);self.assertEqual(inbox.value['inbox'],[])
|
||||
|
||||
class ControlTests(unittest.TestCase):
|
||||
def test_unanswered_request_is_not_sent_again(self):
|
||||
radio=control.ControlRadio('/unused',0,None)
|
||||
radio.pending={10:(9,0)}
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(radio,'send') as send:
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.exchange(9)
|
||||
send.assert_not_called()
|
||||
|
||||
def test_call_list_timeout_is_short_and_request_stays_pending(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.sock=unittest.mock.Mock()
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(control.time,'monotonic',side_effect=[0,0,1,2,3]),patch.object(broker,'step'):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_TIMEOUT'):broker.exchange(9)
|
||||
self.assertIsNone(radio.waiting)
|
||||
self.assertEqual(len(radio.pending),1)
|
||||
|
||||
def test_call_layout_and_caller_privacy(self):
|
||||
call=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(0)+control.string(None)+control.integer(0,0)
|
||||
self.assertEqual(control.current_calls(Parcel(call)),[{'index':2,'state':'incoming','number':'+123456789','direction':'incoming'}])
|
||||
hidden=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(1)+control.string(None)+control.integer(0,0)
|
||||
self.assertEqual(control.current_calls(Parcel(hidden))[0]['number'],'')
|
||||
self.assertEqual(control.current_calls(Parcel(b'')),[])
|
||||
def test_late_modem_reply_does_not_complete_another_request(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
|
||||
radio.response(9,0,Parcel(control.integer(0)),19);self.assertIsNone(radio.result)
|
||||
radio.response(9,0,Parcel(control.integer(0)),20);self.assertEqual(radio.result,(True,[]))
|
||||
def test_malformed_call_list_does_not_drop_radio_transport(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
|
||||
radio.sock=unittest.mock.Mock()
|
||||
radio.response(9,0,Parcel(control.integer(1)),20)
|
||||
self.assertEqual(radio.result,(False,'RADIO_INVALID_CALL_LIST'))
|
||||
radio.sock.close.assert_not_called()
|
||||
|
||||
def test_unknown_operations_do_not_reach_the_modem(self):
|
||||
broker=control.Broker([],None)
|
||||
with patch.object(broker,'exchange') as exchange:
|
||||
for value in ({'version':1,'operation':'shell'},{'operation':'sms'},[],None):
|
||||
with self.assertRaises(ValueError):broker.request(value)
|
||||
exchange.assert_not_called()
|
||||
def test_calls_answer_hangup_and_sms_parcels(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
|
||||
broker=control.Broker([radio],None)
|
||||
row={'index':2,'state':'incoming','number':'123','direction':'incoming'}
|
||||
with patch.object(broker,'exchange',return_value=[row]) as exchange:
|
||||
calls=broker.request({'version':1,'operation':'calls'});path=calls[0]['path']
|
||||
broker.request({'version':1,'operation':'hangup','path':path})
|
||||
self.assertEqual(exchange.call_args.args,(12,control.integer(1,2)))
|
||||
broker.request({'version':1,'operation':'answer','path':path})
|
||||
self.assertEqual(exchange.call_args.args,(40,))
|
||||
with self.assertRaisesRegex(ValueError,'INVALID_CALL'):
|
||||
broker.request({'version':1,'operation':'hangup','path':'/stale/call/2'})
|
||||
with patch.object(broker,'exchange',return_value={'state':'sent'}) as exchange:
|
||||
self.assertEqual(broker.request({'version':1,'operation':'sms','number':'123','body':'hello'}),{'state':'sent'})
|
||||
self.assertEqual(exchange.call_args.args,(25,control.integer(2)+control.string(None)+control.string(sms.submit('123','hello'))))
|
||||
def test_second_call_is_rejected_before_dial(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(broker,'calls',return_value=[{'path':'/live/call/1'}]),patch.object(broker,'exchange') as exchange:
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.request({'version':1,'operation':'dial','number':'123'})
|
||||
exchange.assert_not_called()
|
||||
|
||||
def test_sms_ack_only_after_storage(self):
|
||||
inbox=unittest.mock.Mock();radio=control.ControlRadio('/unused',0,inbox)
|
||||
with patch.object(radio,'send') as send:
|
||||
radio.unsolicited(1003,Parcel(control.string(HELLO)))
|
||||
inbox.receive.assert_called_once_with(HELLO);send.assert_called_with(37,control.integer(2,1,0))
|
||||
inbox.receive.side_effect=OSError('full')
|
||||
radio.unsolicited(1003,Parcel(control.string(HELLO)))
|
||||
send.assert_called_with(37,control.integer(2,0,255))
|
||||
|
||||
|
||||
class RadioPowerTests(unittest.TestCase):
|
||||
def broker(self):
|
||||
radios=[control.ControlRadio('/unused',i,None) for i in range(2)]
|
||||
for radio in radios:
|
||||
radio.state={'radioState':'enabled'};radio.sock=unittest.mock.Mock()
|
||||
return control.Broker(radios,None)
|
||||
def test_slot_payload_readback_and_restore(self):
|
||||
broker=self.broker()
|
||||
def exchange(request,payload,slot=0):
|
||||
self.assertEqual(request,23)
|
||||
count,enabled=struct.unpack('<ii',payload);self.assertEqual(count,1)
|
||||
broker.radios[slot].state['radioState']='enabled' if enabled else 'disabled'
|
||||
with patch.object(broker,'exchange',side_effect=exchange) as send,patch.object(broker,'calls',return_value=[]):
|
||||
self.assertEqual(broker.request({'version':1,'operation':'radioPower','slot':1,'enabled':False}),
|
||||
{'slot':1,'radioState':'disabled'})
|
||||
self.assertEqual(broker.radios[0].state['radioState'],'enabled')
|
||||
send.assert_called_once_with(23,control.integer(1,0),slot=1)
|
||||
broker.restore_power()
|
||||
self.assertEqual(broker.radios[1].state['radioState'],'enabled')
|
||||
def test_idempotent_power_does_not_send(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange') as send:
|
||||
broker.radio_power(0,True);send.assert_not_called()
|
||||
def test_calls_invalid_values_and_unknown_state_block_power(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange') as send,patch.object(broker,'calls',return_value=[{'state':'active'}]):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.radio_power(0,False)
|
||||
for slot,enabled in [(False,False),(2,False),(0,0),(0,'false')]:
|
||||
with self.assertRaisesRegex(ValueError,'INVALID_REQUEST'):broker.radio_power(slot,enabled)
|
||||
broker.radios[0].state['radioState']='unknown'
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_NOT_READY'):broker.radio_power(0,False)
|
||||
send.assert_not_called()
|
||||
def test_acknowledgement_without_state_change_fails(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange'),patch.object(broker,'calls',return_value=[]),patch.object(control.time,'monotonic',side_effect=[0,9]):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_READBACK_FAILED'):broker.radio_power(0,False)
|
||||
self.assertEqual(broker.original_power,{0:True})
|
||||
|
||||
|
||||
class AudioTests(unittest.TestCase):
|
||||
def test_call_mode_routing_and_restore(self):
|
||||
from call_audio import CallAudio
|
||||
audio=CallAudio();state={'mode':0,'route':0,'muted':0};commands=[]
|
||||
def transaction(service,method,*values):
|
||||
commands.append((service,method,values))
|
||||
if (service,method)==('policy',6):return state['route']
|
||||
if (service,method)==('flinger',18):return state['muted']
|
||||
if (service,method)==('policy',3):state['mode']=values[0]
|
||||
elif (service,method)==('policy',5):state['route']=values[1]
|
||||
elif (service,method)==('flinger',17):state['muted']=values[0]
|
||||
else:raise AssertionError('Unexpected audio transaction')
|
||||
return 0
|
||||
with patch.object(audio,'transact',side_effect=transaction):
|
||||
audio.update([{'state':'incoming'}]);self.assertEqual(state['mode'],1)
|
||||
audio.update([{'state':'active'}]);self.assertEqual(state['mode'],2)
|
||||
self.assertTrue(audio.request({'operation':'set','muted':True,'speaker':True})['speaker'])
|
||||
audio.restore();self.assertEqual(state,{'mode':0,'route':0,'muted':0})
|
||||
def test_audio_failure_does_not_throw_from_call_tracking(self):
|
||||
from call_audio import CallAudio
|
||||
audio=CallAudio()
|
||||
with patch.object(audio,'transact',side_effect=OSError('service missing')):
|
||||
audio.update([{'state':'active'}]);self.assertFalse(audio.available)
|
||||
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):audio.request({'operation':'status'})
|
||||
|
||||
class RilRequestSafetyTests(unittest.TestCase):
|
||||
def test_call_list_uses_android_request_nine(self):
|
||||
broker=control.Broker([],None)
|
||||
with patch.object(broker,'exchange',return_value=[]) as exchange:
|
||||
self.assertEqual(broker.calls(),[])
|
||||
exchange.assert_called_once_with(9)
|
||||
def test_pin_puk_and_identity_requests_never_reach_socket(self):
|
||||
radio=control.ControlRadio('/unused',0,None)
|
||||
radio.sock=unittest.mock.Mock()
|
||||
for request in (2,3,4,5,6,7,8,11,28,38,39,43):
|
||||
with self.assertRaisesRegex(ValueError,'RADIO_INVALID_REQUEST'):radio.send(request)
|
||||
radio.sock.sendall.assert_not_called()
|
||||
self.assertEqual(control.CONTROL_REQUESTS,{9,10,12,23,25,37,40})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
65
tundra/tests/test_beatles_radio.py
Normal file
65
tundra/tests/test_beatles_radio.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
spec=importlib.util.spec_from_file_location('beatles_radio',Path(__file__).resolve().parents[1]/'devices/beatles/radio_status.py')
|
||||
radio=importlib.util.module_from_spec(spec);spec.loader.exec_module(radio)
|
||||
def integers(*values):return struct.pack('<'+'i'*len(values),*values)
|
||||
def string(value):
|
||||
if value is None:return integers(-1)
|
||||
data=integers(len(value))+value.encode('utf-16-le')+b'\0\0'
|
||||
return data+b'\0'*((-len(data))%4)
|
||||
|
||||
class RadioTest(unittest.TestCase):
|
||||
def test_registration_does_not_publish_cell_location(self):
|
||||
payload=integers(4)+b''.join(map(string,['5','ffff','123456','14']))
|
||||
result=radio.decode(20,radio.Parcel(payload))['voice']
|
||||
self.assertEqual(result['type'],'lte');self.assertTrue(result['connected']);self.assertTrue(result['roaming'])
|
||||
self.assertNotIn('123456',str(result));self.assertNotIn('ffff',str(result))
|
||||
def test_lte_signal_and_registration_without_data_bearer(self):
|
||||
values=[99,0,0,0,0,0,0,99,95,0,0,0,0]
|
||||
self.assertEqual(radio.decode(19,radio.Parcel(integers(*values)))['signal'],47)
|
||||
values[8]=2147483647
|
||||
self.assertIsNone(radio.decode(19,radio.Parcel(integers(*values)))['signal'])
|
||||
payload=integers(4)+b''.join(map(string,['1','','','14']))
|
||||
state=radio.decode(21,radio.Parcel(payload))['data']
|
||||
self.assertEqual(state['state'],'registered');self.assertFalse(state['connected'])
|
||||
def test_sim_absent_and_locked(self):
|
||||
self.assertEqual(radio.decode(1,radio.Parcel(integers(0,0,-1,-1,-1,0))),{'sim':{'present':False,'cardState':'absent'}})
|
||||
payload=integers(1,0,0,-1,-1,1,2,2,0)+string('PRIVATE_AID')+string('SIM')+integers(0,1,0)
|
||||
result=radio.decode(1,radio.Parcel(payload))
|
||||
self.assertEqual(result,{'sim':{'present':True,'cardState':'pinRequired'}})
|
||||
def test_bounds_and_unknown_signal(self):
|
||||
self.assertIsNone(radio.decode(19,radio.Parcel(integers(99)))['signal'])
|
||||
for data in [b'',integers(999999),integers(2)+b'a\0']:
|
||||
with self.assertRaises(ValueError):radio.Parcel(data).string()
|
||||
with self.assertRaises(ValueError):radio.decode(25,radio.Parcel(b''))
|
||||
def test_partial_frame_and_disconnect_drop_state(self):
|
||||
client,server=socket.socketpair()
|
||||
r=radio.Radio('/unused',0);r.sock=client;r.pending={3:(1,0)}
|
||||
body=integers(0,3,0,0,0,-1,-1,-1,0);frame=struct.pack('>I',len(body))+body
|
||||
server.sendall(frame[:7]);r.receive();self.assertFalse(r.state['available'])
|
||||
server.sendall(frame[7:]);r.receive();self.assertFalse(r.state['sim']['present'])
|
||||
r.close();server.close();self.assertEqual(r.state,{'slot':0,'available':False,'radioState':'unknown'})
|
||||
def test_handover_grace_and_bounded_reconnect_backoff(self):
|
||||
r=radio.Radio('/unused',0);r.sock=Mock();r.serial=1;r.last_poll=100;r.pending={1:(9,90)}
|
||||
with patch.object(radio.time,'monotonic',return_value=110):
|
||||
r.poll();self.assertIsNotNone(r.sock)
|
||||
with patch.object(radio.time,'monotonic',return_value=140):
|
||||
r.poll();self.assertIsNone(r.sock);self.assertEqual(r.retry_at,145)
|
||||
r.close();self.assertEqual(r.retry_at,150)
|
||||
for _ in range(8):r.close()
|
||||
self.assertEqual(r.retry_at,200)
|
||||
|
||||
def test_battery_unknown_is_not_empty(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
p=Path(root);self.assertIsNone(radio.battery(p))
|
||||
(p/'capacity').write_text('73');(p/'status').write_text('Discharging')
|
||||
self.assertEqual(radio.battery(p),{'level':.73,'charging':False})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
20
tundra/tests/test_beatles_sync.py
Normal file
20
tundra/tests/test_beatles_sync.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
spec=importlib.util.spec_from_file_location('sync',Path(__file__).resolve().parents[1]/'tools/sync-beatles-gaia.py')
|
||||
sync=importlib.util.module_from_spec(spec);spec.loader.exec_module(sync)
|
||||
|
||||
class SharedGaiaSync(unittest.TestCase):
|
||||
def test_incremental_changes_and_removal(self):
|
||||
self.assertEqual(sync.changes({'host/shell.js':'new','assets/a':'same','overrides/new.js':'new'},
|
||||
{'host/shell.js':'old','assets/a':'same','overrides/old.js':'old'}),
|
||||
(['host/shell.js','overrides/new.js'],['overrides/old.js']))
|
||||
def test_profiles_and_escape_cannot_be_deleted(self):
|
||||
for path in ('profiles/user.json','../profile','host/../../profile','/data/profile','hostile/file'):
|
||||
with self.subTest(path=path),self.assertRaises(ValueError):sync.changes({}, {path:'digest'})
|
||||
def test_first_sync_and_no_change(self):
|
||||
files={'host/shell.js':'digest','release.json':'digest'}
|
||||
self.assertEqual(sync.changes(files,{}),(sorted(files),[]))
|
||||
self.assertEqual(sync.changes(files,files),([],[]))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
26
tundra/tests/test_beatles_t9.py
Normal file
26
tundra/tests/test_beatles_t9.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'package_beatles', ROOT / 'tundra/tools/package-beatles-build.py')
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(package)
|
||||
|
||||
|
||||
class BeatlesT9Package(unittest.TestCase):
|
||||
def test_all_offline_lexicons_are_included_at_runtime_path(self):
|
||||
files = {}
|
||||
package.add_beatles_t9(files)
|
||||
self.assertEqual(len(files), 42)
|
||||
self.assertEqual(
|
||||
set(files),
|
||||
{f'opt/vulpes/tundra/devices/beatles/t9/{locale}.tsv'
|
||||
for locale in (path.stem for path in
|
||||
(ROOT / 'tundra/devices/beatles/t9').glob('*.tsv'))})
|
||||
self.assertTrue(all(path.is_file() for path in files.values()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
41
tundra/tests/test_beatles_volume.py
Normal file
41
tundra/tests/test_beatles_volume.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import Mock,patch
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
|
||||
from stream_volume import StreamVolume
|
||||
|
||||
|
||||
class VolumeTests(unittest.TestCase):
|
||||
def test_route_readback_and_restoration(self):
|
||||
volume=StreamVolume();state={(0,1):3,(0,2):5}
|
||||
def transaction(method,*args):
|
||||
if method==24:return [3]
|
||||
stream,device=args[0],args[-1]
|
||||
if method==17:return [state[stream,device],0]
|
||||
if method==16:state[stream,device]=args[1];return [0]
|
||||
raise AssertionError(method)
|
||||
volume.transact=transaction
|
||||
self.assertEqual(volume.request({'stream':'telephony','level':.4})['index'],2)
|
||||
self.assertEqual(state,{(0,1):2,(0,2):2})
|
||||
volume.request({'stream':'telephony','level':.8})
|
||||
volume.restore();self.assertEqual(state,{(0,1):3,(0,2):5})
|
||||
|
||||
def test_invalid_request_never_touches_hardware(self):
|
||||
volume=StreamVolume();volume.transact=Mock()
|
||||
for value in (None,{}, {'stream':'shell','level':.5}, *({'stream':'content','level':n} for n in (True,-1,2,'1',float('nan'),float('inf')))):
|
||||
with self.assertRaises(ValueError):volume.request(value)
|
||||
volume.transact.assert_not_called()
|
||||
|
||||
def test_readback_failure_is_reported(self):
|
||||
volume=StreamVolume();volume.transact=Mock(side_effect=[[2],[3,0],[0],[3,0]])
|
||||
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):
|
||||
volume.request({'stream':'content','level':.5})
|
||||
|
||||
def test_parcel_formats(self):
|
||||
volume=StreamVolume()
|
||||
for text,expected in (("Result: Parcel(00000008 00000000 '........')\n",[8,0]),
|
||||
("Result: Parcel(\n 0x00000000: 00000008 00000000 '........'\n)\n",[8,0])):
|
||||
with patch('stream_volume.subprocess.run',return_value=Mock(returncode=0,stdout=text)):
|
||||
self.assertEqual(volume.transact(17,3,2),expected)
|
||||
102
tundra/tests/test_beatles_wifi.py
Normal file
102
tundra/tests/test_beatles_wifi.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('beatles_wifi',Path(__file__).parents[1]/'devices/beatles/wifi_control.py')
|
||||
wifi=importlib.util.module_from_spec(spec);spec.loader.exec_module(wifi)
|
||||
|
||||
class WifiTests(unittest.TestCase):
|
||||
def fixture(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_scan=[{'id':'aa:bb:cc:dd:ee:ff','ssid':'Test "é"','security':'wpa-psk'}]
|
||||
obj.enabled=lambda:True
|
||||
obj.known=lambda:[]
|
||||
obj.stopping=False
|
||||
obj.lease=lambda state:None
|
||||
return obj
|
||||
|
||||
def test_scan_decodes_ssids_and_does_not_offer_enterprise_as_psk(self):
|
||||
data='bssid / frequency / signal level / flags / ssid\n'
|
||||
data+='aa:bb:cc:dd:ee:ff\t2412\t-45\t[WPA2-PSK-CCMP][ESS]\tCaf\\xc3\\xa9\\\\Test\n'
|
||||
data+='00:11:22:33:44:55\t5200\t-80\t[WPA2-EAP-CCMP]\tOffice\n'
|
||||
rows=wifi.parse_scan(data)
|
||||
self.assertEqual(rows[0]['ssid'],'Café\\Test')
|
||||
self.assertEqual(rows[0]['signal'],100)
|
||||
self.assertEqual(rows[1]['security'],'unsupported')
|
||||
self.assertEqual(wifi.parse_scan('header\nnot-a-bssid\t2412\t-20\t[ESS]\tfake'),[])
|
||||
|
||||
def test_invalid_password_cannot_create_or_mutate_network(self):
|
||||
with patch.object(wifi,'command') as command:
|
||||
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_PASSWORD'):
|
||||
self.fixture().connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'short'})
|
||||
command.assert_not_called()
|
||||
|
||||
def test_forget_rejects_command_injection(self):
|
||||
with patch.object(wifi,'command') as command:
|
||||
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_REQUEST'):
|
||||
self.fixture().request({'operation':'forget','id':'0\nREMOVE_NETWORK all'})
|
||||
command.assert_not_called()
|
||||
|
||||
def test_connect_encodes_ssid_and_passphrase_before_supplicant(self):
|
||||
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
calls=[]
|
||||
def command(text,required=True):
|
||||
calls.append(text)
|
||||
return '7' if text=='ADD_NETWORK' else 'OK'
|
||||
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
|
||||
self.assertIn('SET_NETWORK 7 ssid '+'Test "é"'.encode().hex(),calls)
|
||||
psk=next(c for c in calls if c.startswith('SET_NETWORK 7 psk ')).split()[-1]
|
||||
self.assertEqual(len(psk),64)
|
||||
self.assertNotIn('test-only-password',' '.join(calls))
|
||||
self.assertIn('SAVE_CONFIG',calls)
|
||||
|
||||
def test_saved_network_reuses_credentials_without_reading_password(self):
|
||||
obj=self.fixture();obj.known=lambda:[{'id':'3','ssid':'Test "é"','security':'wpa-psk'}]
|
||||
obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
with patch.object(wifi,'command',return_value='OK') as command,patch.object(wifi,'android',return_value='ok'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff'})
|
||||
self.assertEqual([call.args[0] for call in command.call_args_list],['SELECT_NETWORK 3'])
|
||||
|
||||
def test_save_failure_rolls_back_new_profile(self):
|
||||
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
calls=[]
|
||||
def command(text,required=True):
|
||||
calls.append(text)
|
||||
return '7' if text=='ADD_NETWORK' else 'FAIL' if text=='SAVE_CONFIG' else 'OK'
|
||||
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
|
||||
with self.assertRaisesRegex(RuntimeError,'WIFI_SAVE_FAILED'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
|
||||
self.assertIn('REMOVE_NETWORK 7',calls)
|
||||
self.assertIn('ENABLE_NETWORK all',calls)
|
||||
|
||||
class LeaseTests(unittest.TestCase):
|
||||
def test_dhcp_service_gets_dynamic_interface_argument(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_lease=0;obj.lease_network=None;obj.route=None
|
||||
state={'state':'connected','network':{'ssid':'Test','savedId':'0'}}
|
||||
with patch.object(wifi,'android',return_value='stopped') as android,patch.object(wifi.time,'monotonic',return_value=100):
|
||||
obj.lease(state)
|
||||
commands=[call.args for call in android.call_args_list]
|
||||
self.assertIn(('setprop','ctl.start','dhcpcd_wlan0:wlan0'),commands)
|
||||
self.assertNotIn(('setprop','ctl.restart','dhcpcd_wlan0'),commands)
|
||||
|
||||
def test_routes_are_restored_when_link_down_removed_cached_routes(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_lease=0;obj.lease_network=('Test','0');obj.rule_owned=True
|
||||
obj.route=('192.0.2.0/24','192.0.2.1')
|
||||
properties={'dhcp.wlan0.result':'ok','dhcp.wlan0.ipaddress':'192.0.2.3',
|
||||
'dhcp.wlan0.gateway':'192.0.2.1','dhcp.wlan0.mask':'255.255.255.0'}
|
||||
def native(*args,**kwargs):
|
||||
return properties.get(args[1],'') if args[0]=='getprop' else ''
|
||||
with patch.object(wifi,'android',side_effect=native) as android,patch.object(wifi.time,'monotonic',return_value=100):
|
||||
obj.lease({'state':'connected','network':{'ssid':'Test','savedId':'0'}})
|
||||
commands=[call.args for call in android.call_args_list]
|
||||
self.assertIn(('ip','route','add','default','via','192.0.2.1','dev','wlan0','table','21052'),commands)
|
||||
self.assertEqual([c for c in commands if c[:3]==('ip','route','flush')],
|
||||
[('ip','route','flush','table','21052')])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -22,7 +22,9 @@ class Buttons(unittest.TestCase):
|
|||
|
||||
def test_vibration_pattern_progresses_without_blocking_and_can_be_cancelled(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(b,'VIBRATOR',Path(d)),patch.object(b.time,'monotonic',return_value=10):
|
||||
(Path(d)/'trigger').write_text('[transient]')
|
||||
v=b.Vibration();v.start([200,100,300])
|
||||
self.assertEqual((Path(d)/'trigger').read_text(),'none')
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'255')
|
||||
self.assertEqual(v.deadline,10.2)
|
||||
v.advance(10.1);self.assertEqual((Path(d)/'brightness').read_text(),'255')
|
||||
|
|
@ -32,3 +34,18 @@ class Buttons(unittest.TestCase):
|
|||
v.start([200]);v.start([]);self.assertIsNone(v.deadline);self.assertEqual((Path(d)/'brightness').read_text(),'0')
|
||||
for pattern in [None,[-1],[True],[10001],[5000,5001],[1]*33]:
|
||||
with self.assertRaises(ValueError):v.start(pattern)
|
||||
|
||||
def test_wake_recovery_waits_for_gaia_and_recovers_if_unresponsive(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(b,'BACKLIGHT',Path(d)):
|
||||
(Path(d)/'max_brightness').write_text('1000')
|
||||
(Path(d)/'brightness').write_text('0')
|
||||
recovery=b.WakeRecovery()
|
||||
with patch.object(b,'wake_recovery',recovery):
|
||||
recovery.arm(10);recovery.advance(11)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'0')
|
||||
b.control({'operation':'screen','enabled':True,'level':.3})
|
||||
recovery.advance(12)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'300')
|
||||
b.control({'operation':'screen','enabled':False,'level':.3})
|
||||
recovery.arm(20);recovery.advance(21.6)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'500')
|
||||
|
|
|
|||
31
tundra/tests/test_clock_state.py
Normal file
31
tundra/tests/test_clock_state.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
spec=importlib.util.spec_from_file_location('clock',Path(__file__).parents[1]/'runtime/clock-state.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
|
||||
class ClockState(unittest.TestCase):
|
||||
def test_preserves_time_plus_powered_off_elapsed_without_setting_rtc(self):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(m,'STATE',Path(d)/'time.json'), patch.object(m,'RTC',Path(d)/'rtc'):
|
||||
m.RTC.write_text('1000')
|
||||
with patch.object(m.time,'time',return_value=1792340000): m.save()
|
||||
m.RTC.write_text('1600')
|
||||
with patch.object(m.time,'time',return_value=1792000000), patch.object(m.time,'clock_settime') as write:
|
||||
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340600)
|
||||
self.assertEqual(m.RTC.read_text(),'1600')
|
||||
self.assertEqual(m.STATE.stat().st_mode & 0o777,0o600)
|
||||
def test_never_rewinds_valid_wall_time(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=None):
|
||||
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
|
||||
with patch.object(m.time,'time',return_value=1792350000),patch.object(m.time,'clock_settime') as write:
|
||||
self.assertFalse(m.restore());write.assert_not_called()
|
||||
def test_corrupt_state_or_reset_rtc_does_not_invent_elapsed_time(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=10),patch.object(m.time,'time',return_value=100):
|
||||
m.STATE.write_text('invalid')
|
||||
self.assertFalse(m.restore())
|
||||
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
|
||||
with patch.object(m.time,'clock_settime') as write:
|
||||
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340000)
|
||||
|
|
@ -5,6 +5,8 @@ from unittest.mock import patch
|
|||
spec=importlib.util.spec_from_file_location('mobile',Path(__file__).parents[1]/'runtime/mobile-settings.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
class NetworkClock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.checkpoint=patch.object(m,'checkpoint_clock');self.checkpoint.start();self.addCleanup(self.checkpoint.stop)
|
||||
def test_monotonic_age_is_added_to_nitz(self):
|
||||
payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':530}}]}
|
||||
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
|
||||
|
|
|
|||
|
|
@ -129,6 +129,18 @@ on boot
|
|||
'mount ext4 /dev/mmcblk0p72 /data','mount tmpfs /dev/block/x /data'):
|
||||
self.assertFalse(policy.command_allowed(line.split()),line)
|
||||
|
||||
def test_network_config_on_empty_overlay_creates_firewall_aliases(self):
|
||||
import tempfile
|
||||
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
|
||||
native = importlib.util.module_from_spec(spec); spec.loader.exec_module(native)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root/'opt/vulpes/trial').mkdir(parents=True)
|
||||
(root/'opt/vulpes/trial/session.py').touch()
|
||||
native.prepare(root, network=True, hardware=True)
|
||||
for name in ('iptables', 'ip6tables'):
|
||||
self.assertEqual((root/'usr/sbin'/name).readlink(), Path(name+'-legacy'))
|
||||
|
||||
def test_interactive_boot_checks_are_opt_in(self):
|
||||
import tempfile
|
||||
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
|
||||
|
|
|
|||
43
tundra/tests/test_shared_ui.py
Normal file
43
tundra/tests/test_shared_ui.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
|
||||
import shared_ui
|
||||
|
||||
|
||||
class SharedUiTests(unittest.TestCase):
|
||||
def test_working_documents_stay_private_and_license_text_is_retained(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
files = []
|
||||
for name, text in [('host/app.js', 'app code'),
|
||||
('overrides/README.md', 'private notes'),
|
||||
('overrides/LICENSE.md', 'upstream license')]:
|
||||
path = root/name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
files.append(path)
|
||||
with patch.object(shared_ui, 'ROOT', root/'tundra'), \
|
||||
patch.object(shared_ui, 'selected_files', return_value=files):
|
||||
payload, hashes = shared_ui.build_payload()
|
||||
with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
|
||||
self.assertEqual(set(archive.getnames()),
|
||||
{'opt/vulpes/host/app.js', 'opt/vulpes/overrides/LICENSE'})
|
||||
self.assertEqual(archive.extractfile('opt/vulpes/overrides/LICENSE').read(),
|
||||
b'upstream license')
|
||||
self.assertNotIn('overrides/README.md', hashes)
|
||||
|
||||
def test_call_log_fix_is_in_incremental_phone_updates(self):
|
||||
names = {str(p.relative_to(shared_ui.ROOT.parent)) for p in shared_ui.selected_files()}
|
||||
for name in ('dialer/js/call_log.js', 'dialer/gaia_build_defer_index.js'):
|
||||
self.assertIn('assets/webapps/communications.gaiamobile.org/'+name, names)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,21 +1,40 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tarfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
TOOLS=Path(__file__).resolve().parents[1]/'tools'
|
||||
sys.path.insert(0,str(TOOLS))
|
||||
from standalone_volume import checked_volume
|
||||
from shared_ui import differential_payload
|
||||
spec=importlib.util.spec_from_file_location('standalone_boot',TOOLS/'build-standalone-boot.py')
|
||||
boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
|
||||
|
||||
|
||||
class StandaloneBootTests(unittest.TestCase):
|
||||
def test_shared_payload_keeps_changed_missing_and_different_mode_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory)
|
||||
for name in ('same','changed','mode'):
|
||||
(root/name).write_bytes(b'old');(root/name).chmod(0o644)
|
||||
source=io.BytesIO()
|
||||
with tarfile.open(fileobj=source,mode='w') as archive:
|
||||
for name,data,mode in [('same',b'old',0o644),('changed',b'new',0o644),('mode',b'old',0o755),('missing',b'new',0o644)]:
|
||||
item=tarfile.TarInfo(name);item.size=len(data);item.mode=mode
|
||||
archive.addfile(item,io.BytesIO(data))
|
||||
result,count=differential_payload(source.getvalue(),root)
|
||||
self.assertEqual(count,1)
|
||||
with tarfile.open(fileobj=io.BytesIO(result)) as archive:
|
||||
self.assertEqual(archive.getnames(),['changed','mode','missing'])
|
||||
self.assertEqual(archive.extractfile('changed').read(),b'new')
|
||||
|
||||
def setUp(self):
|
||||
self.volume={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':'userdata.ext4',
|
||||
'uuid':'12345678-1234-1234-1234-123456789abc','bytes':8*1024**3,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ spec.loader.exec_module(image)
|
|||
|
||||
|
||||
class SystemImageTests(unittest.TestCase):
|
||||
def test_editable_gaia_overrides_are_in_the_system_image(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
product = Path(temporary)
|
||||
image.copy_source_overrides(product)
|
||||
project = image.module('test_project', image.PROJECT/'tools/project.py')
|
||||
for app, names in project.SOURCE_OVERRIDES.items():
|
||||
for name in names:
|
||||
relative = Path('gaia/apps')/app/name
|
||||
self.assertEqual((product/relative).read_bytes(),
|
||||
(image.PROJECT/relative).read_bytes())
|
||||
|
||||
def test_hardware_reference_requires_services_not_just_display(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
|
|
|||
Loading…
Reference in a new issue