Release Vulpes OS 2.7-preview.22

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

View file

@ -57,6 +57,7 @@ with source as archive:
("settings", "js/panels/display/panel.js"),
("settings", "js/panels/wifi/panel.js"),
("system", "js/screen_manager.js"),
("system", "js/app_chrome.js"),
("system", "js/notification_screen.js"),
("system", "style/system/system.css"),
("communications", "dialer/style/call_log.css"),

View file

@ -222,6 +222,8 @@ def release_documents(source, tag):
summary = re.sub(r'^!\[(?:APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
'', summary, flags=re.MULTILINE)
body += label + '\n\n' + summary + '\n\n'
if release.get('appendix'):
body += release['appendix'].strip() + '\n\n'
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
('en', '🇬🇧 Full history, stage by stage')]:
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Regenerate bundled language-name fallback fonts from installed Noto fonts.
Requires fonttools with WOFF2 support and Arch noto-fonts/noto-fonts-cjk.
Normal builds use the bundled subsets and do not need these packages.
"""
from pathlib import Path
import json,hashlib,shutil
from fontTools.ttLib import TTFont
from fontTools import subset
root=Path(__file__).resolve().parents[1]
out=root/'gaia/shared/elements/vulpes-fonts/languages';out.mkdir(exist_ok=True)
labels=json.loads((root/'assets/webapps/settings.gaiamobile.org/shared/resources/languages.json').read_text())
wanted=set(map(ord,''.join(labels.values())))
fira=TTFont(out.parent/'FiraSans-Regular.otf');covered=set(fira.getBestCmap());missing=wanted-covered
sources=['/usr/share/fonts/noto/NotoSans'+s+'-Regular.ttf' for s in ['Arabic','Bengali','Devanagari','Gurmukhi','Khmer']]+['/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc']
css=['/* Native language names missing from Fira Sans. Subsets retain shaping tables. */']
report=[]
for source in sources:
p=Path(source);font=TTFont(p,fontNumber=0);chars=missing&set(font.getBestCmap())
if not chars:continue
name=p.stem.replace('NotoSans','VulpesLanguage');family=name+' Subset'
attrib={str(i):font['name'].getDebugName(i) for i in (0,5,13,14)}
opt=subset.Options();opt.flavor='woff2';opt.name_IDs=['*'];opt.name_legacy=True;opt.name_languages=['*']
sub=subset.Subsetter(options=opt);sub.populate(unicodes=chars);sub.subset(font)
for record in font['name'].names:
if record.nameID in (1,4,6,16):record.string=(family if record.nameID!=6 else name).encode(record.getEncoding())
font.flavor='woff2';target=out/(name+'.woff2');font.save(target)
css.append('@font-face {\n font-family: FiraSans;\n src: url("languages/'+target.name+'") format("woff2");\n font-weight: 100 900;\n font-style: normal;\n font-display: swap;\n unicode-range: '+','.join('U+%04X'%c for c in sorted(chars))+';\n}')
report.append({'file':target.name,'source':p.name,'sourceSha256':hashlib.sha256(p.read_bytes()).hexdigest(),'attribution':attrib,'unicode':sorted(chars)})
missing-=chars
if missing:raise SystemExit('Uncovered label characters: '+str(missing))
shutil.copy('/usr/share/licenses/noto-fonts-cjk/LICENSE',out/'LICENSE')
(out/'provenance.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
p=out.parent/'fonts.css';text=p.read_text();start='\n/* Native language names missing from Fira Sans.'
text=text.split(start)[0];p.write_text(text.rstrip()+'\n\n'+'\n\n'.join(css)+'\n')
print(len(report),'fallback fonts,',sum(p.stat().st_size for p in out.glob('*.woff2')),'bytes')

View file

@ -30,6 +30,14 @@ target.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).th
def register(client):
assert client.execute("return PathUtils.profileDir;").split("/")[-1].startswith("host-")
# Marionette initially selects its auxiliary Firefox window. Keep focus in
# Gaia while testing carets, software-keyboard input and visible overlays.
for handle in client.call('WebDriver:GetWindowHandles'):
client.call('WebDriver:SwitchToWindow', {'handle': handle})
if client.execute('return document.documentURI;') == 'chrome://vulpes/content/shell.xhtml':
break
else:
raise RuntimeError('Vulpes test window not found')
client.execute(
"try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}"
)

View file

@ -114,7 +114,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_error(400)
return
if path == "/_vulpes/platform-config.js":
data = (('window.VulpesPlatform='+json.dumps('tundra' if os.environ.get('VULPES_TUNDRA')=='1' else 'desktop')+';window.VulpesNativeInput='+('true' if os.environ.get('VULPES_TUNDRA')=='1' or os.environ.get('VULPES_TEST_INPUT')=='1' else 'false')+';window.VulpesNativeTelephony=') + ('true' if os.environ.get('VULPES_TUNDRA_RADIO') == '1' or os.environ.get('VULPES_TEST_TELEPHONY') == '1' else 'false') + ';window.VulpesNativeWifi=' + ('true' if os.environ.get('VULPES_TUNDRA_WIFI') == '1' else 'false') + ';').encode()
data = (('window.VulpesPlatform='+json.dumps('tundra' if os.environ.get('VULPES_TUNDRA')=='1' else 'desktop')+';window.VulpesNativeInput='+('true' if os.environ.get('VULPES_TUNDRA')=='1' or os.environ.get('VULPES_TEST_INPUT')=='1' else 'false')+';window.VulpesNativeTelephony=') + ('true' if os.environ.get('VULPES_TUNDRA_RADIO') == '1' or os.environ.get('VULPES_TEST_TELEPHONY') == '1' else 'false') + ';window.VulpesNativeRadioStatus=' + ('true' if os.environ.get('VULPES_TUNDRA_STATUS') == '1' else 'false') + ';window.VulpesNativeWifi=' + ('true' if os.environ.get('VULPES_TUNDRA_WIFI') == '1' else 'false') + ';').encode()
self.send_response(200)
self.send_header("Content-Type", "text/javascript")
self.send_header("Content-Length", str(len(data)))

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Check address-bar input and external views in an isolated desktop profile."""
import importlib.util,json,time
from pathlib import Path
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
results={}
with Client(2857) as c:
probe.register(c)
c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;")
def ev(code):
r=probe.evaluate(c,'system',"const rocketbar=core.appCore.rocketbar;"+code)
if 'error' in r:raise AssertionError(r)
return r.get('value')
def wait(code):
end=time.monotonic()+20
while time.monotonic()<end:
r=ev(code)
if r:return r
time.sleep(.15)
raise AssertionError(code)
ev('ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
ev('await rocketbar.activate();rocketbar.focus();return true;')
wait('return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>0;')
ev('await VulpesCompat.call("input.request",{operation:"hide"});return true;')
wait('return document.querySelector("#vulpes-gaia-keyboard").hidden;')
assert ev('return document.activeElement===rocketbar.input;')
ev('rocketbar.focus();return true;')
results['refocus_opens_keyboard']=wait('return !document.querySelector("#vulpes-gaia-keyboard").hidden;')
ev('rocketbar.handleCancel();await VulpesCompat.call("input.request",{operation:"hide"});return true;')
wait('return !rocketbar.active && !rocketbar.isClosing;')
time.sleep(.5)
ev('await rocketbar.activate();rocketbar.focus();return true;')
wait('return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>0;')
ev('rocketbar.setInput("Vulpes OS");rocketbar.handleInput();return true;')
reply=probe.evaluate(c,'keyboard','navigator.mozInputMethod.inputcontext.sendKey(13,0).catch(console.error);return true;')
assert reply.get('value') is True,reply
wait('return document.querySelector("#vulpes-gaia-keyboard").hidden && Service.query("getActiveApp")?.origin.startsWith("https://duckduckgo.com/");')
time.sleep(8)
results['views']=c.execute("return [...ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs').Views.entries.values()].map(e=>({url:e.browser.currentURI.spec,visible:e.browser.style.visibility,rect:e.browser.getBoundingClientRect().toJSON(),geometry:e.geometry}));")
assert any(v['visible']=='visible' and v['rect']['height']>0 for v in results['views']),results['views']
results['system']=ev('return {active:Service.query("getActiveApp")?.origin,searchActive:rocketbar.active,errors:__vulpesDiagnostics.slice(-5)};')
point=ev('const r=Service.query("getActiveApp").appChrome.title.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};')
script="const done=arguments[arguments.length-1];Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',"+json.dumps(point)+").then(done,e=>done({error:String(e)}));"
assert c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],'newSandbox':True,'sandbox':'system'})['value'] is True
results['address_first_tap_keyboard']=wait('return !document.querySelector("#vulpes-gaia-keyboard").hidden && document.activeElement===rocketbar.input;')
c.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();return true;")
wait('return !rocketbar.active && !rocketbar.isClosing;')
c.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();return true;")
time.sleep(2)
results['home_returns']=wait('return ["http://homescreen.localhost:8765","http://verticalhome.localhost:8765"].includes(Service.query("getActiveApp")?.origin);')
results['web_views_hidden_after_home']=c.execute("return [...ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs').Views.entries.values()].every(e=>e.browser.style.visibility==='hidden');")
assert results['web_views_hidden_after_home']
print(json.dumps(results,indent=2))
Path('logs/feedback-20261014-browser-video/browser-results.json').write_text(json.dumps(results,indent=2)+'\n')

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Exercise the shared layout selector and Gaia grid in an isolated host profile."""
import base64
import importlib.util
import json
from pathlib import Path
import time
from marionette_client import Client
spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
checks = []
with Client(2857) as client:
profile = client.execute('return PathUtils.profileDir;')
assert Path(profile).name.startswith(('host-', 'pc-keypad-')), 'Test profile required'
client.execute("try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}")
def ev(app, code):
result = probe.evaluate(client, app, code)
assert 'error' not in result, result
return result.get('value')
def wait(app, code):
for _ in range(100):
value = ev(app, code)
if value: return value
time.sleep(.1)
raise AssertionError((app, code))
def check(name, result):
assert result, name
checks.append(name)
print(name + ': OK', flush=True)
def reload_home():
before = ev('homescreen', 'setTimeout(()=>location.reload(),50);return performance.timeOrigin;')
wait('homescreen', f'return performance.timeOrigin!=={before} && !!window.appWindow?.apps && !!document.documentElement.dataset.vulpesLayout;')
previous = ev('homescreen', 'return await VulpesCompat.call("layout.current",{});')
env = client.execute("return Services.env.get('VULPES_KEYPAD');")
try:
ev('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
wait('settings', 'return typeof require==="function";')
ev('settings', 'const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");return true;')
wait('settings', 'return !!document.getElementById("vulpes-layout");')
def choose(mode):
ev('settings', f'const s=document.getElementById("vulpes-layout");s.value={json.dumps(mode)};s.dispatchEvent(new Event("change",{{bubbles:true}}));return true;')
wait('settings', f'return !document.getElementById("vulpes-layout").disabled && (await VulpesCompat.call("layout.current",{{}})).mode==={json.dumps(mode)};')
choose('compact')
check('selector-applies-to-home-live', wait('homescreen', 'return document.documentElement.dataset.vulpesLayout==="compact";'))
check('selector-applies-to-system-live', wait('system', 'return document.documentElement.dataset.vulpesLayout==="compact";'))
ev('system', 'window.dispatchEvent(new CustomEvent("home"));return true;')
geometry = wait('homescreen', '''const icons=[...document.querySelectorAll('gaia-app-icon')].slice(0,9);
if(icons.length!==9)return false;
const rects=icons.map(e=>e.getBoundingClientRect());
if(rects[0].top!==rects[2].top || rects[3].top<=rects[0].bottom)return false;
return rects.map(r=>r.toJSON());''')
check('three-columns-without-overlapping-rows', bool(geometry))
check('single-panel-no-indicators', ev('homescreen', 'return ["pages-panel","page-indicator-header"].every(id=>getComputedStyle(document.getElementById(id)).display==="none") && document.getElementById("panels").scrollWidth===document.getElementById("panels").clientWidth;'))
check('home-search-hidden', ev('system', 'return getComputedStyle(document.querySelector("#homescreen > .chrome")).display==="none";'))
check('home-reclaims-search-space', ev('homescreen', 'return innerHeight>280;'))
check('motion-policy-follows-device-not-layout', ev('system', 'return document.documentElement.dataset.vulpesMotion === ((await VulpesCompat.call("layout.current",{})).reducedMotion ? "reduced" : "standard");'))
check('icons-48-css-pixels', ev('homescreen', 'return [...document.querySelectorAll("gaia-app-icon")].slice(0,9).every(e=>e._container.querySelector("img")?.getBoundingClientRect().width===48);'))
check('compact-labels-outside-icon-frame', ev('homescreen', 'return [...document.querySelectorAll("gaia-app-icon")].slice(0,9).every(e=>e.getBoundingClientRect().height===52 && getComputedStyle(e.shadowRoot.getElementById("title-container")).clipPath!=="none");'))
ev('homescreen', 'const icon=[...document.querySelectorAll("gaia-app-icon")].find(e=>e.dataset.identifier.includes("settings.localhost"));icon.setAttribute("tabindex","-1");icon.focus();icon.setAttribute("data-vulpes-keypad-focus", "");return true;')
check('settings-name-in-heading', wait('homescreen', 'return document.getElementById("vulpes-compact-app-name").textContent==="Paramètres";'))
check('heading-fits-settings-name', ev('homescreen', 'const h=document.getElementById("vulpes-compact-app-name");return h.scrollWidth===h.clientWidth && h.scrollHeight===h.clientHeight && parseFloat(getComputedStyle(h).fontSize)===20;'))
choose('standard')
check('user-can-disable-compact', wait('homescreen', 'return document.documentElement.dataset.vulpesLayout==="standard";'))
check('classic-search-restored', wait('system', 'return getComputedStyle(document.querySelector("#homescreen > .chrome")).display!=="none";'))
check('classic-second-panel-restored', ev('homescreen', 'return getComputedStyle(document.getElementById("pages-panel")).display!=="none";'))
check('classic-labels-restored-heading-hidden', ev('homescreen', 'return getComputedStyle(document.getElementById("vulpes-compact-app-name")).display==="none" && getComputedStyle(document.querySelector("gaia-app-icon").shadowRoot.getElementById("title-container")).clipPath==="none";'))
choose('compact');reload_home()
check('saved-choice-survives-reload', ev('homescreen', 'return document.documentElement.dataset.vulpesLayout==="compact";'))
client.execute("Services.env.set('VULPES_KEYPAD','');")
choose('auto');reload_home()
check('automatic-touch-device-is-standard', ev('homescreen', 'return document.documentElement.dataset.vulpesLayout==="standard";'))
choose('compact')
check('touch-device-can-opt-in', wait('homescreen', 'return document.documentElement.dataset.vulpesLayout==="compact";'))
client.execute("Services.env.set('VULPES_KEYPAD','1');")
choose('auto');reload_home()
check('automatic-keypad-device-is-compact', ev('homescreen', 'return document.documentElement.dataset.vulpesLayout==="compact";'))
ev('system', 'window.dispatchEvent(new CustomEvent("home"));return true;')
wait('homescreen', 'return [...document.querySelectorAll("gaia-app-icon")].filter(e=>e._container?.querySelector("img")?.naturalWidth>0).length>=9 && getComputedStyle(document.querySelector("#apps")).visibility==="visible";')
ev('homescreen', 'const icon=[...document.querySelectorAll("gaia-app-icon")].find(e=>e.dataset.identifier.includes("settings.localhost"));icon.setAttribute("tabindex","-1");icon.focus();icon.setAttribute("data-vulpes-keypad-focus", "");return true;')
wait('homescreen', 'return document.getElementById("vulpes-compact-app-name").textContent==="Paramètres";')
time.sleep(.5)
for handle in client.call('WebDriver:GetWindowHandles'):
client.call('WebDriver:SwitchToWindow', {'handle':handle})
if client.execute('return window.location.href;') == 'chrome://vulpes/content/shell.xhtml': break
Path('logs/compact-layout.png').write_bytes(base64.b64decode(client.call('WebDriver:TakeScreenshot', {'full':False})['value']))
Path('logs/compact-layout.json').write_text(json.dumps({'checks':checks,'geometry':geometry}, indent=2))
finally:
client.execute('Services.env.set("VULPES_KEYPAD",'+json.dumps(env)+');')
ev('settings', 'await navigator.mozSettings.createLock().set({"vulpes.layout":'+json.dumps(previous['mode'])+'});return true;')
print(json.dumps({'checks':checks}, indent=2))

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Check compact Gaia Phone with a simulated modem, in the desktop test profile."""
import importlib.util
import json
from pathlib import Path
import time
from marionette_client import Client
ROOT=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('probe',ROOT/'tools/probe-desktop.py')
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
with Client(2857) as client:
probe.register(client)
def ev(app,code):
result=probe.evaluate(client,app,code,'/dialer/' if app=='communications' else '/')
if 'error' in result:raise RuntimeError(result['error'])
return result.get('value')
def wait(app,code):
for _ in range(100):
result=ev(app,code)
if result:return result
time.sleep(.2)
raise AssertionError((app,code))
assert client.execute("return Services.env.get('VULPES_KEYPAD')==='1' && Services.env.get('VULPES_TEST_TELEPHONY')==='1';")
client.execute("""const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
Runtime.compactOriginal=Runtime.platform.request.bind(Runtime.platform);
Runtime.compactCalls=[];Runtime.compactDials=0;
Runtime.platform.request=async(actor,app,op,params)=>{
if(op==='calls')return Runtime.compactCalls;
if(op==='dial'){
Runtime.compactDials++;
const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
await new Promise(resolve=>setTimeout(resolve,700));
Runtime.compactCalls=[{path:'/test/compact/1',number:params.number,state:'dialing'}];
return {path:'/test/compact/1'};
}
if(op==='hangup'){Runtime.compactCalls=[];return {};}
if(op==='callAudio')return {muted:false,speaker:false};
return Runtime.compactOriginal(actor,app,op,params);
};return true;""")
try:
ev('system','dispatchEvent(new CustomEvent("home"));return true;')
wait('homescreen','return !!document.querySelector("gaia-app-icon");')
ev('homescreen','document.querySelector("gaia-app-icon").click();return true;')
wait('communications','return !!window.KeypadManager && document.readyState==="complete";')
ev('communications','setTimeout(()=>location.reload(),50);return true;');time.sleep(1)
wait('communications','return document.readyState==="complete" && !!window.CallHandler && !!window.VulpesPlatformDial;')
ev('communications','dispatchEvent(new Event("resize"));return true;')
layout=ev('communications','''const rect=id=>document.getElementById(id).getBoundingClientRect().toJSON();
return {size:[innerWidth,innerHeight],bar:rect('keypad-callbar'),tabs:rect('option-recents'),
keys:[...document.querySelectorAll('.keypad-key')].map(e=>({rect:e.getBoundingClientRect().toJSON(),overflow:e.scrollHeight>e.clientHeight || e.scrollWidth>e.clientWidth})),
hidden:document.getElementById('views').classList.contains('hide-toolbar')};''')
assert layout['size'][0]==240 and not layout['hidden'],layout
assert layout['bar']['bottom']<=layout['tabs']['top']+1,layout
assert all(not k['overflow'] and k['rect']['bottom']<=layout['bar']['top']+1 for k in layout['keys']),layout
ev('communications','KeypadManager.updatePhoneNumber("5550100","begin",false);document.getElementById("keypad-callbar-call-action").click();document.getElementById("keypad-callbar-call-action").click();return true;')
wait('callscreen','return navigator.mozTelephony.calls[0]?.state==="dialing";')
assert client.execute("return ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs').Runtime.compactDials;")==1
client.execute("""const w=Services.wm.getMostRecentWindow('vulpes:host');
const root=w.document.getElementById('system').browsingContext;
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://callscreen.localhost:'))return b;for(const c of b.children){const f=find(c);if(f)return f;}return null;}
find(root).currentWindowGlobal.getActor('Keypad').sendAsyncMessage('Vulpes:Navigate',{key:'F4',repeat:false});return true;""")
wait('system','return navigator.mozTelephony.calls.length===0;')
result={'dialPadFits':True,'tabsRemainAfterResize':True,'repeatedPressSingleCall':True,'originalCallScreen':True,'redKeyHangup':True}
(ROOT/'logs/compact-phone.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,indent=2))
finally:
client.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.compactCalls=[];Runtime.platform.request=Runtime.compactOriginal;return true;")

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Keypad input in real Gaia forms, isolated profile; no calls or SMS sent."""
import importlib.util,json,os,subprocess,time
from pathlib import Path
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
state=json.loads(Path('tundra/out/beatles/gecko-156.0.1/pc-test-pids.json').read_text())
env={**os.environ,'DISPLAY':':'+state['display']}
checks=[]
with Client(2857) as c:
assert Path(c.execute('return PathUtils.profileDir;')).name=='pc-keypad-profile'
c.execute("try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}")
def ev(app,code):
host,path=('communications','/contacts/') if app=='contacts' else (app,'/')
r=probe.evaluate(c,host,code,path);assert 'error' not in r,r
return r.get('value')
def wait(app,code):
for _ in range(150):
r=ev(app,code)
if r:return r
time.sleep(.1)
raise AssertionError((app,code))
def check(name,value):
assert value,name
checks.append(name);print(name+': OK',flush=True)
def key(*keys):
subprocess.run(['xdotool','search','--name','Vulpes — port','windowraise','windowfocus','key','--clearmodifiers','--delay','130',*keys],env=env,check=True,stdout=subprocess.DEVNULL)
time.sleep(.15)
def focus(app,selector):
wait(app,'return !!document.querySelector('+json.dumps(selector)+');')
ev(app,'const e=document.querySelector('+json.dumps(selector)+');e.setAttribute("tabindex","0");e.scrollIntoView({block:"nearest"});e.focus();return true;')
def launch(app,entry=None):
params={'manifestURL':'http://'+app+'.localhost:8765/manifest.webapp'}
if entry:params['entryPoint']=entry
ev('system','await VulpesCompat.call("apps.launch",'+json.dumps(params)+');return true;')
wait('system','return typeof ScreenManager!=="undefined" && !!window.Service;')
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
launch('communications','contacts')
wait('contacts','return document.readyState==="complete" && !!document.getElementById("add-contact-button");')
focus('contacts','#add-contact-button');key('Return')
wait('contacts','return document.getElementById("givenName")?.getBoundingClientRect().height>0;')
focus('contacts','#givenName');key('2','2','6','6','6','2','2')
check('contact-name-multitap',wait('contacts','return document.getElementById("givenName").value==="bob";'))
key('BackSpace');check('delete-letter',wait('contacts','return document.getElementById("givenName").value==="bo";'))
key('numbersign','2');check('uppercase-mode',wait('contacts','return document.getElementById("givenName").value==="boA";'))
key('numbersign','2');check('number-mode',wait('contacts','return document.getElementById("givenName").value==="boA2";'))
key('numbersign')
focus('contacts','#number_0');key('5','5','5','0','1','0','0')
check('telephone-digits-remain-numeric',wait('contacts','return document.getElementById("number_0").value==="5550100";'))
check('physical-keypad-does-not-open-software-keyboard',ev('system','return !document.querySelector(".inputWindow.active");'))
# Leave the unsaved form; no personal data is written.
key('Escape')
launch('sms');wait('sms','return !!document.getElementById("threads-composer-link");')
focus('sms','#threads-composer-link');key('Return')
wait('sms','return typeof ConversationView!=="undefined" && Navigation.isCurrentPanel("composer") && document.activeElement.classList.contains("recipient");')
# Wait for the original Gaia panel transition, including its final focus.
ev('sms','await App.whenReady();await new Promise(r=>setTimeout(r,400));return true;')
focus('sms','#messages-recipients-list [contenteditable]');key('5','5','5','0','1','0','0','Return')
check('sms-recipient-accepted',wait('sms','return ConversationView.recipients.numbers.includes("5550100");'))
focus('sms','#messages-input');key('2','2','6','6','6','2','2')
check('sms-body-multitap',wait('sms','return document.getElementById("messages-input").textContent.trim()==="bob";'))
# Clear the unsent text through the same editor.
key('BackSpace','BackSpace','BackSpace')
ev('sms','ConversationView.cleanFields();return true;')
launch('settings');wait('settings','return typeof require==="function";')
ev('settings','const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");return true;')
focus('settings','#vulpes-layout');key('Return')
check('select-opens-gaia-dialog',wait('settings','return !!document.getElementById("vulpes-value-selector");'))
key('Down');check('selection-stays-in-modal',wait('settings','return !!document.activeElement.closest("#vulpes-value-selector");'))
key('Escape');check('back-cancels-selector',wait('settings','return !document.getElementById("vulpes-value-selector");'))
key('Escape');check('back-leaves-display-panel',wait('settings','return location.hash!=="#display";'))
Path('logs/keypad-workflows.json').write_text(json.dumps({'checks':checks,'input':'Native XTest on desktop, shared Gaia at 240x320'},indent=2))
print(json.dumps(checks,indent=2))

View file

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Compare Gaia JPEG metadata with the real Gecko decoder for all EXIF orientations."""
import base64
import importlib.util
import io
import json
from pathlib import Path
from PIL import Image
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
results=[]
with Client(2857) as c:
probe.register(c)
loaded=probe.evaluate(c,'system',"""for(const path of ['blobview.js','media/jpeg_metadata_parser.js','media/image_size.js']) {
await new Promise((ok,fail)=>{const s=document.createElement('script');s.src='/shared/js/'+path;s.onload=ok;s.onerror=fail;document.head.append(s);});
}return true;""")
assert loaded.get('value') is True,loaded
for orientation in range(1,9):
image=Image.new('RGB',(80,40),'red');exif=image.getexif();exif[274]=orientation
out=io.BytesIO();image.save(out,'JPEG',exif=exif)
url='data:image/jpeg;base64,'+base64.b64encode(out.getvalue()).decode()
value=probe.evaluate(c,'system',f"""const blob=await (await fetch({json.dumps(url)})).blob();
const meta=await new Promise((ok,fail)=>getImageSize(blob,ok,fail));
const img=new Image();img.src=URL.createObjectURL(blob);try {{await img.decode();
return {{width:meta.width,height:meta.height,rotation:meta.rotation || 0,mirrored:!!meta.mirrored,
decodedWidth:img.naturalWidth,decodedHeight:img.naturalHeight}};
}}finally{{URL.revokeObjectURL(img.src);}}""")
row=value['value'];row['exif']=orientation
assert row['width']==row['decodedWidth'] and row['height']==row['decodedHeight'],row
assert row['rotation']==0 and row['mirrored'] is False,row
results.append(row)
print(json.dumps(results,indent=2))

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Exercise Gecko's bounded Unix-socket transport against a local test broker."""
import json,socket,tempfile,threading,time
from pathlib import Path
from marionette_client import Client
def check(response,limit=65536,timeout=1000,stall=False):
with tempfile.TemporaryDirectory(prefix='vulpes-socket-') as tmp:
path=str(Path(tmp)/'broker.sock');server=socket.socket(socket.AF_UNIX);server.bind(path);server.listen(1)
received=[]
def serve():
with server,server.accept()[0] as conn:
received.append(json.loads(conn.makefile('rb').readline()))
if stall:time.sleep(.3);return
for b in response:
try:conn.sendall(bytes([b]))
except BrokenPipeError:break
thread=threading.Thread(target=serve);thread.start()
with Client(2857) as client:
result=client.call('WebDriver:ExecuteAsyncScript',{'script':"const done=arguments[arguments.length-1];const {exchange}=ChromeUtils.importESModule('resource://vulpes/host/UnixControl.sys.mjs');exchange("+json.dumps(path)+",{operation:'status',label:'été'},"+str(limit)+','+str(timeout)+").then(value=>done({value}),error=>done({error:String(error)}));",'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':5000})['value']
thread.join();assert received==[{'operation':'status','label':'été'}],received
return result
results={}
results['fragmented_utf8']=check('{"ok":true,"result":"été"}\n'.encode())
assert results['fragmented_utf8']=={'value':{'ok':True,'result':'été'}},results
results['bounded']=check(b'x'*100+b'\n',limit=32)
assert 'BROKER_REPLY_TOO_LARGE' in results['bounded']['error'],results
results['timeout']=check(b'',timeout=80,stall=True)
assert 'BROKER_TIMEOUT' in results['timeout']['error'],results
results['truncated']=check(b'{"ok":')
assert 'error' in results['truncated'],results
print(json.dumps(results,indent=2,ensure_ascii=False))

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Exercise one-click input, native web selectors and URL sharing without sending."""
import importlib.util
import json
from pathlib import Path
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
class Page(BaseHTTPRequestHandler):
def do_GET(self):
data=b'''<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web controls test</title>
<style>input,select,[contenteditable]{font:20px sans-serif;display:block;margin:20px;height:40px;width:240px}</style>
<input id="text"><select id="select"><option value="one">One</option><option value="two">Two</option></select>
<div id="editor" contenteditable><span>Editable</span></div><div id="shadow"></div>
<script>document.querySelector('#shadow').attachShadow({mode:'open'}).innerHTML='<input id="inside" style="height:40px">';</script>'''
self.send_response(200);self.send_header('Content-Type','text/html');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
def log_message(self,*args):pass
server=ThreadingHTTPServer(('127.0.0.1',0),Page)
threading.Thread(target=server.serve_forever,daemon=True).start()
url=f'http://127.0.0.1:{server.server_port}/'
checks={}
try:
with Client(2857) as c:
for _ in range(120):
if c.execute("return !!Services.wm.getMostRecentWindow('vulpes:host')?.document.getElementById('system');"):break
time.sleep(.25)
probe.register(c)
c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;")
def query(app,kind,data):
selector="""const w=Services.wm.getMostRecentWindow('vulpes:host');let bc;
if(APP==='web')bc=[...w.document.querySelectorAll('browser')].find(b=>b.id!=='system' && b.style.visibility==='visible')?.browsingContext;
else {function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://'+APP+'.localhost:8765/'))return b;for(const child of b.children){const v=find(child);if(v)return v;}}bc=find(w.document.getElementById('system').browsingContext);}
if(!bc){done({missing:true});return;}
bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery(KIND,DATA).then(value=>done({value}),error=>done({error:String(error)}));"""
script='const done=arguments[arguments.length-1];'+selector.replace('APP',json.dumps(app)).replace('KIND',json.dumps(kind)).replace('DATA',json.dumps(data))
r=c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],'sandbox':'system','newSandbox':True})['value']
if r.get('missing'):return None
assert 'value' in r,r
return r['value']
def ev(app,code):return query(app,'Evaluate',code)
def wait(app,code):
for _ in range(80):
result=ev(app,code)
if result:return result
time.sleep(.15)
raise AssertionError(code)
def click(app,expression):
r=ev(app,'return ('+expression+').getBoundingClientRect().toJSON();')
query(app,'MouseClick',{'x':r['x']+r['width']/2,'y':r['y']+r['height']/2})
time.sleep(.5)
wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';")
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("activities.cancel",{});core.appCore.rocketbar.handleCancel();dispatchEvent(new CustomEvent("home"));return true;')
time.sleep(1)
click('system','document.querySelector("#rocketbar")')
checks['home-first-click']=bool(wait('system','return core.appCore.rocketbar.active && !document.querySelector("#vulpes-gaia-keyboard")?.hidden;'))
ev('system','core.appCore.rocketbar.handleCancel();return true;');time.sleep(.5)
ev('system',"new MozActivity({name:'view',data:{type:'url',url:"+json.dumps(url)+"}});return true;")
time.sleep(2)
wait('web','return !!document.querySelector("#text");')
click('web','document.querySelector("#text")')
checks['web-input-first-click']=bool(wait('system','return !document.querySelector("#vulpes-gaia-keyboard")?.hidden;'))
ev('keyboard',"await navigator.mozInputMethod.inputcontext.sendKey('a',0);return true;")
checks['web-input-edits']=ev('web','return document.querySelector("#text").value==="a";')
click('web','document.querySelector("#select")')
time.sleep(.3)
checks['web-select-opens']=c.execute("return Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('ContentSelectDropdownPopup')?.state==='open';")
print(json.dumps(checks),flush=True)
if checks['web-select-opens']:
c.execute("const p=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('ContentSelectDropdownPopup');p.querySelectorAll('menuitem')[1].doCommand();p.hidePopup();return true;")
checks['web-select-changes']=bool(wait('web','return document.querySelector("#select").value==="two";'))
click('web','document.querySelector("#editor span")')
ev('keyboard',"await navigator.mozInputMethod.inputcontext.sendKey('b',0);return true;")
checks['nested-contenteditable']=ev('web','return document.querySelector("#editor").textContent.includes("b");')
click('web','document.querySelector("#shadow").shadowRoot.querySelector("input")')
ev('keyboard',"await navigator.mozInputMethod.inputcontext.sendKey('c',0);return true;")
checks['shadow-input']=ev('web','return document.querySelector("#shadow").shadowRoot.querySelector("input").value==="c";')
ev('system','Service.query("getActiveApp").appChrome.overflowMenu.show();return true;')
time.sleep(.3)
click('system','Service.query("getActiveApp").appChrome.shareButton')
checks['share-choices']=wait('system','return document.querySelector("#action-menu")?.checkVisibility() && document.querySelectorAll("#action-menu-list button[data-value]").length===2;')
click('system','document.querySelector("#action-menu-list button")')
time.sleep(1)
checks['share-url-to-sms']=bool(wait('sms','return document.querySelector("#messages-input")?.textContent.includes('+json.dumps(url)+');'))
print(json.dumps(checks,indent=2),flush=True)
assert all(checks.values()),checks
ev('system','await VulpesCompat.call("activities.cancel",{});dispatchEvent(new CustomEvent("home"));return true;')
finally:server.shutdown()