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
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
38
tools/prepare-language-fonts.py
Normal file
38
tools/prepare-language-fonts.py
Normal 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')
|
||||
|
|
@ -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(_){}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
56
tools/test-browser-feedback.py
Normal file
56
tools/test-browser-feedback.py
Normal 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')
|
||||
92
tools/test-compact-layout.py
Normal file
92
tools/test-compact-layout.py
Normal 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))
|
||||
68
tools/test-compact-phone.py
Normal file
68
tools/test-compact-phone.py
Normal 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;")
|
||||
74
tools/test-keypad-workflows.py
Normal file
74
tools/test-keypad-workflows.py
Normal 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))
|
||||
33
tools/test-photo-orientation.py
Normal file
33
tools/test-photo-orientation.py
Normal 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))
|
||||
32
tools/test-unix-control.py
Normal file
32
tools/test-unix-control.py
Normal 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))
|
||||
91
tools/test-web-controls.py
Normal file
91
tools/test-web-controls.py
Normal 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()
|
||||
Loading…
Reference in a new issue