#!/usr/bin/env python3
"""Integration checks in real Gaia and isolated native Gecko views.
Run alongside `run-host.py --test`, preferably on a dedicated Xvfb display.
Only this test profile is used. No tests are injected by the normal launcher.
"""
import argparse
import http.client
import json
import os
from pathlib import Path
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--restart-check", action="store_true")
parser.add_argument("--candidate", action="store_true")
args = parser.parse_args()
class Page(BaseHTTPRequestHandler):
def do_GET(self):
page = (
f'
Vulpes test {self.path} Real Gecko navigation '
'Next page '
'Scroll test
'
).encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Length", str(len(page)))
self.end_headers()
self.wfile.write(page)
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 = {}
def expect(name, value):
checks[name] = value
print(name + ": " + json.dumps(value, ensure_ascii=False), flush=True)
if value is not True:
raise AssertionError(name + ": " + repr(value))
try:
with Client(2857) as client:
client.execute(
"""try {ChromeUtils.registerWindowActor('VulpesProbe', {
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
allFrames:true,safeForUntrustedWebProcess:true});} catch (_) {}"""
)
def evaluate(app, code):
# Test actor evaluates under the document's content principal.
# `web` selects the isolated native view; all others select Gaia.
selector = """const shell=Services.wm.getMostRecentWindow('vulpes:host');
if(!shell?.document.getElementById('system')){done({missing:true});return;}
const browsers=[...shell.document.querySelectorAll('browser')];
let bc;
if (APP === 'web') bc=browsers.find(b=>b.id !== 'system')?.browsingContext;
else bc=(function find(b){
if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://'+APP+'.localhost:8765/'))return b;
for(const c of b.children){const result=find(c);if(result)return result;}return null;
})(shell.document.getElementById('system').browsingContext);
if(!bc) {done({missing:true});return;}
bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate', CODE)
.then(value=>done({result:value}),error=>done({error:String(error),stack:error.stack}));
""".replace(
"APP", json.dumps(app)
).replace(
"CODE", json.dumps(code)
)
response = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": "const done=arguments[arguments.length-1];" + selector,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
if response.get("missing"):
return None
if "error" in response:
raise AssertionError(response)
return response.get("result")
def wait(app, code, timeout=15):
last = None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
last = evaluate(app, code)
except AssertionError as error:
# Navigation can replace the document while its read-only probe is pending.
detail = error.args[0] if error.args else None
if not isinstance(detail, dict) or not detail.get("error", "").startswith(
"AbortError: Actor 'VulpesProbe' destroyed before query"
):
raise
last = None
if last:
return last
time.sleep(0.15)
raise AssertionError(f"Timeout {app}: {last}")
expect(
"engine-pinned",
client.execute("return Services.appinfo.platformVersion;")
== json.loads(
(
ROOT / ("candidate-engine.json" if args.candidate else "engine-lock.json")
).read_text()
)["version"],
)
expect(
"sandbox-and-fission",
client.execute(
"return Services.prefs.getIntPref('security.sandbox.content.level')>=6 && Services.appinfo.fissionAutostart;"
),
)
expect(
"real-gaia-boot",
wait(
"system",
"return document.body?.getAttribute('ready-state')==='fullyLoaded';",
timeout=60,
),
)
# New profiles now start on the original Gaia lockscreen. Home's
# lazy icon images load only after the screen becomes visible.
evaluate("system", "ScreenManager.turnScreenOn(true);return true;")
time.sleep(.5)
evaluate("system", "lockScreen.unlock(true);return true;")
wait("system", "return !lockScreen.locked;")
expect(
"home-icons",
wait(
"homescreen",
"const icons=[...document.querySelectorAll('gaia-app-icon')];"
"return icons.length>10 && icons.slice(0,6).every(icon=>{"
"const image=icon.shadowRoot?.querySelector('img');"
"return image?.complete && image.naturalWidth>0;});",
),
)
expect(
"screen-brightness-restored",
client.execute(
"return Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('screen').style.filter==='brightness(1)';"
),
)
if args.restart_check:
expect(
"settings-survive-restart",
evaluate(
"system",
"const r=await navigator.vulpesRequest({version:1,id:1,method:'settings.get',params:{key:'vulpes.integration.persist'}});return r.result.value==='survives-restart';",
),
)
expect(
"blob-survives-restart",
evaluate(
"homescreen",
"const s=(await navigator.getDataStores('icons'))[0];const v=await s.get('vulpes-integration');return await v.icon.text()==='Vulpes integration';",
),
)
raise SystemExit(0)
expect(
"localized-app-manifest",
evaluate(
"homescreen",
"const i=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.app?.origin.includes('settings.'));return await i.app.getLocalizedValue('name','fr')==='Paramètres';",
),
)
expect(
"app-inline-script-blocked",
evaluate(
"homescreen",
"""window.__injectedScriptRan=false;const s=document.createElement('script');s.textContent='window.__injectedScriptRan=true';document.head.append(s);s.remove();await new Promise(r=>setTimeout(r,100));return window.__injectedScriptRan===false;""",
),
)
expect(
"forged-settings-permission-denied",
evaluate(
"homescreen",
"""const r=await navigator.vulpesRequest({version:1,id:1,method:'settings.set',identity:'system',permissions:['settings.write'],params:{values:{'vulpes.test.denied':true}}});return r.error?.code==='PERMISSION_DENIED';""",
),
)
expect(
"read-only-datastore-denied",
evaluate(
"homescreen",
"const s=(await navigator.getDataStores('bookmarks_store'))[0];try{await s.put({},'vulpes-test');return false;}catch(e){return e.name==='DATASTORE_READ_ONLY';}",
),
)
expect(
"datastore-blob-roundtrip",
evaluate(
"homescreen",
"""const s=(await navigator.getDataStores('icons'))[0];await s.put({icon:new Blob(['Vulpes integration'])},'vulpes-integration');const v=await s.get('vulpes-integration');return v.icon instanceof Blob && await v.icon.text()==='Vulpes integration';""",
),
)
# Start from a settled, unlocked Home even after other app suites.
evaluate("system", "lockScreen.unlock(true);ScreenManager.turnScreenOn(true);return true;")
time.sleep(1)
client.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();return true;")
wait("system", "return !!document.querySelector('.homescreen.active[transition-state=opened]');")
time.sleep(.5)
client.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;")
# Trusted X11 input verifies mouse-to-touch, not dispatched DOM events.
evaluate(
"homescreen",
"window.__touchCount=0;addEventListener('touchstart',()=>window.__touchCount++);return true;",
)
subprocess.run(
["xdotool", "search", "--name", "^Vulpes", "windowraise", "windowfocus"],
check=True,
stdout=subprocess.DEVNULL,
)
subprocess.run(
[
"xdotool",
"mousemove",
"245",
"630",
"mousedown",
"1",
"mousemove",
"245",
"530",
"mousemove",
"245",
"430",
"mouseup",
"1",
],
check=True,
)
expect(
"trusted-touch-drag",
wait(
"homescreen",
"return window.__touchCount>0 && document.querySelector('#apps-panel > .scrollable').scrollTop>0;",
),
)
# Position the real Settings icon, then physically tap it.
pos = evaluate(
"homescreen",
"""const e=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.app?.origin.includes('settings.'));e.scrollIntoView({block:'center'});await new Promise(r=>setTimeout(r,150));const b=e.getBoundingClientRect();return {x:mozInnerScreenX+b.x+b.width/2,y:mozInnerScreenY+b.y+b.height/2};""",
)
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
expect(
"settings-physical-tap",
wait("settings", "return document.body?.textContent.includes('Paramètres');"),
)
expect(
"version-visible-setting",
evaluate(
"settings",
"const caps=await VulpesCompat.call('platform.capabilities',{});return await new Promise((resolve,reject)=>{const r=navigator.mozSettings.createLock().get('deviceinfo.os');r.onsuccess=()=>resolve(r.result['deviceinfo.os']===caps.gaia);r.onerror=()=>reject(r.error);});",
),
)
expect(
"settings-persist-write",
evaluate(
"settings",
"return await new Promise((resolve,reject)=>{const r=navigator.mozSettings.createLock().set({'vulpes.integration.persist':'survives-restart'});r.onsuccess=()=>resolve(true);r.onerror=()=>reject(r.error);});",
),
)
evaluate(
"system",
"dispatchEvent(new CustomEvent('activity-view',{detail:{source:{data:{type:'url',url:"
+ json.dumps(url + "/one")
+ "}}}}));return true;",
)
expect(
"csp-protected-page-native-view",
wait(
"web",
"return document.readyState==='complete' && document.title==='Vulpes test /one' && window.top===window;",
),
)
evaluate(
"system", "document.querySelector('#tracking-notice-confirm')?.click();return true;"
)
expect(
"website-no-privileged-api",
evaluate(
"web",
"return typeof navigator.vulpesRequest==='undefined' && typeof navigator.mozApps==='undefined';",
),
)
for _ in range(60):
visible = client.execute(
"return [...Services.wm.getMostRecentWindow('vulpes:host').document.querySelectorAll('browser')].some(b=>b.id!=='system' && b.style.visibility==='visible');"
)
if visible:
break
time.sleep(0.15)
expect("native-view-visible", visible)
# Use a real user navigation. Script redirects before user interaction
# can replace/skip an entry under current Gecko history heuristics.
pos = evaluate(
"web",
"const r=document.querySelector('a').getBoundingClientRect();return {x:mozInnerScreenX+r.x+r.width/2,y:mozInnerScreenY+r.y+r.height/2};",
)
client.execute("Services.wm.getMostRecentWindow('vulpes:host').focus();return true;")
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
expect(
"native-navigation",
wait(
"web",
"return document.readyState==='complete' && document.title==='Vulpes test /two';",
),
)
for _ in range(80):
back_ready = client.execute(
"return [...Services.wm.getMostRecentWindow('vulpes:host').document.querySelectorAll('browser')].some(b=>b.id!=='system' && b.canGoBack);"
)
if back_ready:
break
time.sleep(0.1)
expect("native-history-ready", back_ready)
client.execute(
"const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');for(const [id,e] of Views.entries)Views.request(e.actor,{contextId:id,operation:'back'});return true;"
)
expect("native-history-back", wait("web", "return document.title==='Vulpes test /one';"))
expect(
"native-screenshot-blob",
evaluate(
"system",
"""const f=[...document.querySelectorAll('iframe')].find(f=>f.src.startsWith('http://127.0.0.1:'));return await new Promise((resolve,reject)=>{const r=f.getScreenshot();r.onsuccess=()=>resolve(r.result instanceof Blob && r.result.size>100);r.onerror=()=>reject(r.error);});""",
),
)
# Physical keyboard typing in the isolated real page.
pos = evaluate(
"web",
"const e=document.querySelector('input');e.scrollIntoView();const r=e.getBoundingClientRect();return {x:mozInnerScreenX+r.x+r.width/2,y:mozInnerScreenY+r.y+r.height/2};",
)
subprocess.run(
[
"xdotool",
"mousemove",
str(round(pos["x"])),
str(round(pos["y"])),
"click",
"1",
"type",
"--clearmodifiers",
"Vulpes",
],
check=True,
)
expect(
"native-keyboard-input",
wait("web", "return document.querySelector('input').value==='Vulpes';"),
)
client.execute(
"Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();return true;"
)
for _ in range(60):
hidden = client.execute(
"return [...Services.wm.getMostRecentWindow('vulpes:host').document.querySelectorAll('browser')].filter(b=>b.id!=='system').every(b=>b.style.visibility==='hidden');"
)
if hidden:
break
time.sleep(0.15)
expect("home-hides-native-view", hidden)
expect(
"home-remains-usable",
evaluate("homescreen", "return document.querySelectorAll('gaia-app-icon').length>10;"),
)
evaluate(
"web",
"location.href='http://settings.localhost:8765/vulpes-not-a-page.html';return true;",
)
wait(
"web",
"return location.hostname==='settings.localhost' && document.readyState==='complete' && typeof navigator.vulpesRequest==='function';",
)
expect(
"gaia-origin-in-web-view-denied",
evaluate(
"web",
"const r=await navigator.vulpesRequest({version:1,id:1,method:'settings.get',params:{key:'deviceinfo.os'}});return r.error?.code==='PERMISSION_DENIED';",
),
)
view_ids = client.execute(
"return [...ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs').Views.entries.keys()];"
)
expect(
"owner-frame-removed",
evaluate(
"system",
"let count=0;for(const id of "
+ json.dumps(view_ids)
+ "){const f=navigator.vulpesFrameForContext(id);if(f){f.remove();count++;}}return count==="
+ str(len(view_ids))
+ ";",
),
)
for _ in range(40):
removed = client.execute(
"return ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs').Views.entries.size===0;"
)
if removed:
break
time.sleep(0.1)
expect("view-removed-with-owner", removed)
for name, host, path, status in [
("http-host-allowlist", "evil.localhost:8765", "/", 421),
("http-traversal-denied", "system.localhost:8765", "/%2e%2e/host/Runtime.sys.mjs", 400),
(
"privileged-source-not-served",
"system.localhost:8765",
"/_vulpes/Runtime.sys.mjs",
404,
),
]:
connection = http.client.HTTPConnection("127.0.0.1", 8765)
connection.request("GET", path, headers={"Host": host})
response = connection.getresponse()
expect(name, response.status == status)
response.read()
connection.close()
checks["system-diagnostics"] = evaluate("system", "return window.__vulpesDiagnostics;")
checks["settings-diagnostics"] = evaluate("settings", "return window.__vulpesDiagnostics;")
expect(
"settings-no-uncaught-errors",
not any(e["kind"] in ["error", "rejection"] for e in checks["settings-diagnostics"]),
)
finally:
server.shutdown()
(
ROOT / "logs" / ("host-restart-results.json" if args.restart_check else "host-results.json")
).write_text(json.dumps(checks, ensure_ascii=False, indent=2) + "\n")