Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

72
tools/audit-apps.py Normal file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Launch genuine user apps in the explicitly enabled test profile and record diagnostics."""
import json
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
with Client(2857) as c:
assert "/host-" in c.execute("return PathUtils.profileDir;")
c.execute(
"try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}"
)
def ev(app, code, path="/"):
s = """const done=arguments[arguments.length-1];const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;
const bc=(function f(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const child of b.children){const r=f(child);if(r)return r;}})(root);
if(!bc){done(null);return;}bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(done,e=>done({error:String(e)}));""".replace(
"PREFIX", json.dumps("http://" + app + ".localhost:8765" + path)
).replace(
"CODE", json.dumps(code)
)
return c.call(
"WebDriver:ExecuteAsyncScript",
{
"script": s,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
results = {}
for app, entry in [
("communications", "dialer"),
("communications", "contacts"),
("sms", None),
("email", None),
("gallery", None),
("music", None),
("video", None),
("clock", None),
("costcontrol", None),
("camera", None),
("calendar", None),
("settings", None),
("search", None),
("fm", None),
]:
params = {
"manifestURL": f"http://{app}.localhost:8765/manifest.webapp",
"entryPoint": entry,
}
ev(
"system",
'await VulpesCompat.call("apps.launch",' + json.dumps(params) + ");return true;",
)
path = "/" + entry + "/" if entry else "/"
for _ in range(100):
if ev(app, "return document.readyState==='complete';", path):
break
time.sleep(0.1)
time.sleep(1)
result = ev(
app,
"return {url:location.href,text:document.body?.innerText.slice(0,1800),diagnostics:window.__vulpesDiagnostics};",
path,
)
results[app + ("/" + entry if entry else "")] = result
print(app, json.dumps(result, ensure_ascii=False), flush=True)
(ROOT / "logs/app-audit.json").write_text(json.dumps(results, ensure_ascii=False, indent=2))

97
tools/audit-repository.py Normal file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Review files eligible for publication without changing the Git index."""
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
EXCLUDED = [
"profiles/",
"engines/",
"downloads/",
"logs/",
"node_modules/",
".venv/",
"android/toolchain/",
"android/keys/",
"android/.gradle/",
"android/build/",
"android/app/build/",
"android/app/src/main/assets/",
"android/dist/",
"android/logs/",
]
GENERATED = {
"assets/registry.json",
"assets/build-info.json",
"assets/settings-current-defaults.json",
"assets/webapps/webapps.json",
"android/local.properties",
}
def publication_files():
output = subprocess.check_output(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
cwd=ROOT,
)
return sorted(set(output.decode().split("\0")) - {""})
def audit():
files = publication_files()
errors = []
size = 0
for name in files:
path = ROOT / name
if name in GENERATED or any(name.startswith(prefix) for prefix in EXCLUDED):
errors.append("Generated/private file eligible for publication: " + name)
if path.suffix.lower() in {".keystore", ".jks", ".p12", ".pfx"} or path.name in {
".env",
"id_rsa",
"id_ed25519",
}:
errors.append("Possible credential file: " + name)
if path.is_symlink() and not path.resolve().is_relative_to(ROOT):
errors.append("Symlink outside the repository: " + name)
if not path.exists():
errors.append("Tracked file missing: " + name)
continue
if path.is_file():
size += path.stat().st_size
if (
name.startswith(("tools/", "host/", "services/", "adapters/", "android/tools/"))
and path.suffix in {".py", ".js", ".mjs", ".sh"}
and path.name != Path(__file__).name
):
text = path.read_text()
if (
"/mnt/BIGDATATWO/" in text
or "/home/archiviste/" in text
or "versions/2.7-gecko52/" in text
):
errors.append("Private workspace dependency: " + name)
ignored_probes = [
"android/keys/preview.keystore",
"android/local.properties",
"profiles/example/cookies.sqlite",
"android/dist/example.apk",
"logs/example.log",
]
for name in ignored_probes:
result = subprocess.run(["git", "check-ignore", "-q", name], cwd=ROOT)
if result.returncode != 0:
errors.append("Missing ignore rule: " + name)
report = {
"eligibleFiles": len(files),
"sizeMiB": round(size / 1024**2, 1),
"errors": errors,
"scope": "Working tree paths and first-party code; not a full history secret scan.",
}
print(json.dumps(report, indent=2))
return bool(errors)
if __name__ == "__main__":
sys.exit(audit())

173
tools/check-desktop.py Normal file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Run desktop integration and restart checks on a private X11 display."""
import argparse
import importlib.util
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="store_true")
parser.add_argument(
"--phase",
action="append",
choices=["first", "feedback", "apps", "platform", "languages", "restart"],
help="Run only the selected phase(s)",
)
args = parser.parse_args()
for command in ["Xvfb", "xdotool", "import"]:
if not shutil.which(command):
sys.exit("Dépendance de test absente : " + command)
extra = ["--candidate"] if args.candidate else []
for port in [2857, 8765]:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
except OSError:
sys.exit(f"Port {port} occupé : fermer le laboratoire actuel avant les tests.")
(ROOT / "logs").mkdir(exist_ok=True)
with tempfile.TemporaryFile() as display_file, (ROOT / "logs/xvfb.log").open("w") as log:
xserver = subprocess.Popen(
[
"Xvfb",
"-displayfd",
str(display_file.fileno()),
"-screen",
"0",
"800x1000x24",
"-nolisten",
"tcp",
],
pass_fds=(display_file.fileno(),),
stdout=log,
stderr=log,
)
try:
import time
for _ in range(100):
display_file.seek(0)
display = display_file.read().decode().strip()
if display:
break
if xserver.poll() is not None:
sys.exit("Xvfb a échoué : logs/xvfb.log")
time.sleep(0.05)
else:
sys.exit("Xvfb ne répond pas")
env = os.environ.copy()
env.update(DISPLAY=":" + display, GDK_BACKEND="x11", MOZ_ENABLE_WAYLAND="0")
for phase in args.phase or [
"first",
"feedback",
"apps",
"platform",
"languages",
"restart",
]:
with (ROOT / "logs" / ("desktop-" + phase + ".log")).open("w") as output:
host = subprocess.Popen(
[sys.executable, str(ROOT / "tools/run-host.py"), "--test", *extra],
env=env,
stdout=output,
stderr=subprocess.STDOUT,
)
connected = False
try:
# Wait for Marionette initialization before opening the suite.
with Client(2857) as client:
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)
probe.register(client)
for _ in range(200):
ready = probe.evaluate(client, "system", "return document.body?.getAttribute('ready-state')==='fullyLoaded';")
if ready.get("value"):
break
time.sleep(.1)
else:
raise RuntimeError("Gaia did not finish loading")
# Wake before unlocking: waking can activate the lockscreen.
probe.evaluate(client, "system", "ScreenManager.turnScreenOn(true);return true;")
time.sleep(.5)
probe.evaluate(client, "system", "lockScreen.unlock(true);return true;")
time.sleep(.5)
connected = True
# The ownership test deliberately removes Gaia's iframe.
# Start the UI regressions in a fresh process, preserving data.
if phase in ["first", "restart"]:
subprocess.run(
[
sys.executable,
str(ROOT / "tools/test-host.py"),
*extra,
*(["--restart-check"] if phase == "restart" else []),
],
env=env,
check=True,
)
if phase in ["feedback", "restart"]:
subprocess.run(
[
sys.executable,
str(ROOT / "tools/test-feedback.py"),
*(["--restart-check"] if phase == "restart" else []),
],
env=env,
check=True,
)
if phase in ["apps", "restart"]:
subprocess.run(
[
sys.executable,
str(ROOT / "tools/test-apps.py"),
*(["--restart-check"] if phase == "restart" else []),
],
env=env,
check=True,
)
if phase == "platform":
subprocess.run(
[sys.executable, str(ROOT / "tools/test-camera.py")],
env=env,
check=True,
)
subprocess.run(
[sys.executable, str(ROOT / "tools/test-platform.py")],
env=env,
check=True,
)
if phase in ["languages", "restart"]:
subprocess.run(
[
sys.executable,
str(ROOT / "tools/test-languages.py"),
*(["--restart-check"] if phase == "restart" else []),
],
env=env,
check=True,
)
finally:
try:
if connected:
with Client(2857) as client:
client.call("Marionette:Quit", {"flags": ["eAttemptQuit"]})
finally:
try:
host.wait(timeout=15)
except subprocess.TimeoutExpired:
host.terminate()
host.wait(timeout=10)
if host.returncode:
sys.exit("Le lanceur a échoué, consulter logs/desktop-" + phase + ".log")
finally:
xserver.terminate()
xserver.wait(timeout=10)

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Run archived app observations and restart in an isolated profile/display."""
import os
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
(ROOT / "logs").mkdir(exist_ok=True)
for port in [8765, 2857]:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
except OSError:
sys.exit("Fermer Vulpes avant ces tests (port " + str(port) + " occupé).")
with tempfile.TemporaryFile() as display_file, (ROOT / "logs/legacy-xvfb.log").open("w") as log:
xserver = subprocess.Popen(
[
"Xvfb",
"-displayfd",
str(display_file.fileno()),
"-screen",
"0",
"800x1000x24",
"-nolisten",
"tcp",
],
pass_fds=(display_file.fileno(),),
stdout=log,
stderr=log,
)
try:
for _ in range(100):
display_file.seek(0)
display = display_file.read().decode().strip()
if display:
break
time.sleep(0.05)
else:
raise RuntimeError("Xvfb failed")
env = os.environ.copy()
env.update(DISPLAY=":" + display, GDK_BACKEND="x11", MOZ_ENABLE_WAYLAND="0")
for phase in ["first", "restart"]:
with (ROOT / "logs" / ("legacy-host-" + phase + ".log")).open("w") as output:
host = subprocess.Popen(
[sys.executable, str(ROOT / "tools/run-host.py"), "--test", "--legacy-apps"],
env=env,
stdout=output,
stderr=subprocess.STDOUT,
)
connected = False
try:
with Client(2857):
pass
connected = True
subprocess.run(
[
sys.executable,
str(ROOT / "tools/test-legacy-apps.py"),
*(["--restart-check"] if phase == "restart" else []),
],
env=env,
check=True,
)
finally:
try:
if connected:
with Client(2857) as client:
client.call("Marionette:Quit", {"flags": ["eAttemptQuit"]})
finally:
try:
host.wait(timeout=15)
except subprocess.TimeoutExpired:
host.terminate()
host.wait(timeout=10)
if host.returncode:
raise RuntimeError("Host failed, see logs/legacy-host-" + phase + ".log")
finally:
xserver.terminate()
xserver.wait(timeout=10)

106
tools/check-parity.py Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Reject stale Android payloads and inconsistent common release metadata."""
import argparse, hashlib, io, json, re, zipfile
from project import ROOT, release_info
p = argparse.ArgumentParser()
p.add_argument("--apk", type=str)
args = p.parse_args()
r = release_info()
apk = ROOT / (args.apk or f'android/dist/vulpes-os-preview-{r["version"]}.apk')
pattern = re.compile(
r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org|http://([a-zA-Z0-9_-]+)\.localhost:8765"
)
def translate(s):
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:18765", s)
results = {}
with zipfile.ZipFile(apk) as archive:
metadata = json.loads(archive.read("assets/build-info.json"))
packaged = json.loads(archive.read("assets/bridge/release.json"))
assert metadata["version"] == r["version"], metadata
assert metadata["geckoview"] == r["geckoview"], metadata
assert packaged == r, (packaged, r)
with zipfile.ZipFile(io.BytesIO(archive.read("assets/gaia.zip"))) as gaia:
for name in [
"platform-ui.js",
"gaia-camera.js",
"gaia-compat.js",
"gaia-services.js",
"gaia-styles.js",
"gaia-restoration.js",
"gaia-wifi.js",
"desktop.css",
]:
expected = translate((ROOT / "host" / name).read_text()).encode()
actual = gaia.read("_vulpes/" + name)
assert actual == expected, "Stale Android shared UI: " + name
results[name] = hashlib.sha256(actual).hexdigest()
wifi = translate((ROOT / "overrides/system.gaiamobile.org/js/wifi.js").read_text()).encode()
assert gaia.read("system/js/wifi.js") == wifi, "Stale Android Gaia Wi-Fi timer"
results["system/js/wifi.js"] = hashlib.sha256(wifi).hexdigest()
for app, path in [
("settings", "js/panels/display/panel.js"),
("settings", "js/panels/wifi/panel.js"),
("system", "js/screen_manager.js"),
("system", "js/notification_screen.js"),
("system", "style/system/system.css"),
("communications", "dialer/style/call_log.css"),
]:
expected = translate((ROOT / "overrides" / (app + ".gaiamobile.org") / path).read_text())
for old, new in [("-moz-calc(", "calc("), ("-moz-box-sizing:", "box-sizing:"),
("-moz-plaintext", "plaintext"), (":-moz-dir(", ":dir("),
("offset-inline-start:", "inset-inline-start:"),
("offset-inline-end:", "inset-inline-end:")]:
expected = expected.replace(old, new)
actual = gaia.read(app + "/" + path)
assert actual == expected.encode(), "Stale Android Gaia panel: " + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
for app, path, source in [
("calendar", "gaia_build_defer_index.js", "overrides"),
("calendar", "js/bundle.js", "overrides"),
("gallery", "js/metadata_scripts.js", "assets/webapps"),
("gallery", "js/ImageEditor.js", "assets/webapps"),
]:
expected = translate((ROOT / source / (app + ".gaiamobile.org") / path).read_text())
for old, new in [("-moz-calc(", "calc("), ("-moz-box-sizing:", "box-sizing:"),
("-moz-plaintext", "plaintext"), (":-moz-dir(", ":dir("),
("offset-inline-start:", "inset-inline-start:"),
("offset-inline-end:", "inset-inline-end:")]:
expected = expected.replace(old, new)
expected = expected.encode()
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
for path in (ROOT / "overrides/search.gaiamobile.org/home").rglob("*"):
if path.is_file():
relative = path.relative_to(ROOT / "overrides/search.gaiamobile.org")
assert gaia.read("search/" + relative.as_posix()) == path.read_bytes(), str(relative)
assert b"vulpes-local-home" in gaia.read("search/newtab.html")
assert archive.read("assets/bridge/Messages.mjs") == (ROOT / "host/Messages.sys.mjs").read_bytes()
for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs"]:
assert (
archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes()
), name
assert (
r["androidEngine"].split(".")[0]
== r["desktopEngine"].split(".")[0]
== r["engineAlignment"]["family"]
)
report = {
"version": r["version"],
"gaia": r["gaia"],
"desktopEngine": r["desktopEngine"],
"androidEngine": r["androidEngine"],
"exactEngineMatch": r["desktopEngine"] == r["androidEngine"],
"sharedFiles": results,
"apk": str(apk),
"scope": "Payload and metadata parity; hardware behavior requires separate integration tests.",
}
(ROOT / "logs/parity-payload.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))

135
tools/fetch-engine.py Normal file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Download a checked engine archive into an isolated installation."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tarfile
import tempfile
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
def read_url(url):
with urllib.request.urlopen(url, timeout=30) as response:
return response.read()
def install(lock, offline=False):
version = lock["version"]
archive = ROOT / "downloads" / Path(lock["archive"]).name
target = ROOT / "engines" / version
if target.exists():
metadata_path = target / "engine.json"
if not metadata_path.is_file() or not (target / "firefox/firefox").is_file():
raise RuntimeError(f"Incomplete engine installation left untouched: {target}")
existing = json.loads(metadata_path.read_text())
if existing.get("version") != version or existing.get("sha512") != lock["sha512"]:
raise RuntimeError(f"Existing engine metadata differs from the lock: {target}")
return
if not archive.exists():
if offline:
raise RuntimeError(f"Cached archive missing: {archive}")
partial = archive.with_suffix(archive.suffix + ".partial")
with (
urllib.request.urlopen(lock["archive"], timeout=60) as response,
partial.open("wb") as out,
):
while chunk := response.read(1024 * 1024):
out.write(chunk)
partial.replace(archive)
with archive.open("rb") as stream:
digest = hashlib.file_digest(stream, "sha512").hexdigest()
if digest != lock["sha512"]:
raise RuntimeError(f"SHA512 mismatch; archive not extracted: {archive}")
with tempfile.TemporaryDirectory(prefix=".fetch-", dir=ROOT / "engines") as temporary:
stage = Path(temporary)
with tarfile.open(archive) as package:
package.extractall(stage, filter="data")
if not (stage / "firefox/firefox").is_file():
raise RuntimeError("Firefox executable missing from the archive")
distribution = stage / "firefox/distribution"
distribution.mkdir(exist_ok=True)
policies = {"policies": {"DisableAppUpdate": True, "DontCheckDefaultBrowser": True}}
(distribution / "policies.json").write_text(json.dumps(policies, indent=2) + "\n")
(stage / "engine.json").write_text(json.dumps(lock, indent=2) + "\n")
stage.rename(target)
def main():
parser = argparse.ArgumentParser()
choice = parser.add_mutually_exclusive_group()
choice.add_argument(
"--locked",
action="store_true",
help="Install engine-lock.json without changing the candidate",
)
choice.add_argument(
"--version", help="Exact candidate release; otherwise fetch the latest stable release"
)
parser.add_argument(
"--offline", action="store_true", help="With --locked, use the local archive cache only"
)
args = parser.parse_args()
if args.offline and not args.locked:
parser.error("--offline requires --locked")
for name in ["downloads", "engines"]:
(ROOT / name).mkdir(exist_ok=True)
if args.locked:
lock = json.loads((ROOT / "engine-lock.json").read_text())
else:
version = args.version
if not version:
metadata = json.loads(
read_url("https://product-details.mozilla.org/1.0/firefox_versions.json")
)
version = metadata["LATEST_FIREFOX_VERSION"]
(ROOT / "downloads/firefox_versions.json").write_text(
json.dumps(metadata, indent=2) + "\n"
)
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?(?:esr)?", version):
raise RuntimeError("Unsupported release identifier")
base = "https://archive.mozilla.org/pub/firefox/releases/" + version + "/"
relative = f"linux-x86_64/en-US/firefox-{version}.tar.xz"
sums = read_url(base + "SHA512SUMS").decode()
(ROOT / "downloads" / ("SHA512SUMS-" + version)).write_text(sums)
expected = next(
(
line.split()[0]
for line in sums.splitlines()
if len(line.split()) == 2 and line.split()[1].lstrip("*") == relative
),
None,
)
if not expected:
raise RuntimeError("Archive not listed in official SHA512SUMS")
lock = {
"version": version,
"runtime": f"engines/{version}/firefox",
"archive": base + relative,
"sha512": expected,
"checksumSource": base + "SHA512SUMS",
"channel": "release",
"sourceArchive": base + f"source/firefox-{version}.source.tar.xz",
"localChanges": ["distribution/policies.json: pin engine; no automatic update"],
"gaiaPorted": False,
}
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?(?:esr)?", lock["version"]):
raise RuntimeError("Unsupported release identifier in lock")
if lock["runtime"] != f'engines/{lock["version"]}/firefox':
raise RuntimeError("Runtime path does not match the locked version")
install(lock, args.offline)
if not args.locked:
with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False) as output:
json.dump(lock, output, indent=2)
output.write("\n")
temporary = output.name
os.replace(temporary, ROOT / "candidate-engine.json")
print(json.dumps(lock, indent=2), flush=True)
if __name__ == "__main__":
main()

47
tools/inspect-host.py Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Inspect a test host. Requires its explicitly enabled Marionette port."""
import argparse
import json
import sys
from marionette_client import Client
parser = argparse.ArgumentParser()
parser.add_argument("--app", default="system")
args = parser.parse_args()
with Client(2857) as client:
client.execute(
"""
try {ChromeUtils.registerWindowActor('VulpesProbe', {child: {esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'}, allFrames:true,safeForUntrustedWebProcess:true});} catch(e) {}
"""
)
script = (
sys.stdin.read()
or """return {url:location.href, title:document.title, text:document.body?.innerText.slice(0,500),
api:typeof navigator.vulpesRequest, frames:Array.from(document.querySelectorAll('iframe'),f=>({src:f.src,id:f.id})),
booted:!!window.app, appsReady:!!window.applications?.ready};"""
)
result = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": """
const done=arguments[arguments.length-1];
const browser=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system');
const deadline=Date.now()+10000;
(function inspect(){
const context=
(function find(bc){if(bc.currentWindowGlobal?.documentURI.spec.startsWith("http://"""
+ args.app
+ """.localhost:8765/"))return bc;for(const c of bc.children){const found=find(c);if(found)return found;}return null;})(browser.browsingContext);
if(!context){if(Date.now()<deadline){setTimeout(inspect,100);return;}done({error:'Application is not open'});return;}
context.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate', """
+ json.dumps(script)
+ """).then(v=>done({result:v}),e=>done({error:String(e),stack:e.stack}));
})();
""",
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)
print(json.dumps(result, ensure_ascii=False, indent=2))

47
tools/inventory-gaia.py Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Static inventory of the preserved, actually launched Gaia profile."""
from pathlib import Path
import json
import re
root = Path(__file__).resolve().parents[1]
apps = root / "assets/webapps"
report = {
"source": "assets/webapps",
"note": "Static references, including definitions: not a count of mandatory calls.",
"navigatorAPIs": {},
"legacyCustomElementFiles": [],
"apps": {},
}
for f in apps.rglob("*"):
if f.suffix not in (".js", ".html", ".webapp") or not f.is_file():
continue
s = f.read_text(errors="replace")
rel = str(f.relative_to(apps))
for api in set(re.findall(r"navigator\.(moz\w+|getDataStores)", s)):
info = report["navigatorAPIs"].setdefault(api, {"fileCount": 0, "examples": []})
info["fileCount"] += 1
if len(info["examples"]) < 3:
info["examples"].append(rel)
if "registerElement(" in s:
report["legacyCustomElementFiles"].append(rel)
if f.name == "manifest.webapp" and f.parent.parent == apps:
try:
d = json.loads(s)
report["apps"][f.parent.name] = {
"name": d.get("name"),
"permissions": d.get("permissions", {}),
"activities": list(d.get("activities", {})),
}
except ValueError:
pass
out = root / "docs/legacy-dependencies.json"
out.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
print(
len(report["navigatorAPIs"]),
"APIs referenced;",
len(report["apps"]),
"apps;",
len(report["legacyCustomElementFiles"]),
"files referencing Custom Elements v0",
)

View file

@ -0,0 +1,70 @@
"""Small client for the Marionette protocol shipped with current Firefox."""
import json
import socket
import time
class Client:
def __init__(self, port=2856):
deadline = time.monotonic() + 30
while True:
try:
self.socket = socket.create_connection(("127.0.0.1", port), timeout=20)
break
except ConnectionRefusedError:
if time.monotonic() >= deadline:
raise
time.sleep(0.2)
self.sequence = 0
self.quit_requested = False
self.receive()
self.call("WebDriver:NewSession", {"capabilities": {}})
self.call("Marionette:SetContext", {"value": "chrome"})
def receive(self):
length = bytearray()
while not length.endswith(b":"):
chunk = self.socket.recv(1)
if not chunk:
raise ConnectionError("Marionette disconnected")
length += chunk
remaining = int(length[:-1])
payload = bytearray()
while len(payload) < remaining:
chunk = self.socket.recv(remaining - len(payload))
if not chunk:
raise ConnectionError("Marionette disconnected")
payload += chunk
return json.loads(payload)
def call(self, command, arguments=None):
self.sequence += 1
payload = json.dumps([0, self.sequence, command, arguments or {}]).encode()
self.socket.sendall(str(len(payload)).encode() + b":" + payload)
response = self.receive()
if response[2]:
raise RuntimeError(response[2])
if command == "Marionette:Quit":
self.quit_requested = True
return response[3]
def execute(self, script):
result = self.call(
"WebDriver:ExecuteScript",
{"script": script, "args": [], "newSandbox": True, "sandbox": "system"},
)
return result.get("value", result)
def close(self):
try:
if not self.quit_requested:
self.call("WebDriver:DeleteSession")
finally:
self.socket.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()

121
tools/modernize-syntax.py Normal file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Explicit, idempotent syntax repairs for sources and prebuilt migration assets."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
replacements = {
"timestamp: String(message.timestamp)": "timestamp: String(+message.timestamp)",
"timestamp:String(message.timestamp)": "timestamp:String(+message.timestamp)",
"if (l10nAttrs.raw) {": 'if (typeof l10nAttrs.raw === "string") {',
"if(l10nAttrs.raw){": 'if(typeof l10nAttrs.raw==="string"){',
"this.startupMetadata.splice(entry, 1);\n icon.updateName();": "this.startupMetadata.splice(entry, 1);\n icon.updateName();\n if (window.VulpesCompat && icon.app) icon.refresh(); // Replace legacy rasterized cache with original app assets.",
"this.startupMetadata.splice(entry,1);icon.updateName();": "this.startupMetadata.splice(entry,1);icon.updateName();if(window.VulpesCompat&&icon.app)icon.refresh();",
"menuStatus = document.getElementById('fxa-desc');": "menuStatus = document.getElementById('fxa-desc');\n if(window.VulpesCompat){menuStatus.removeAttribute('data-l10n-id');menuStatus.textContent=(document.documentElement.lang||navigator.language).startsWith('fr')?'Service non disponible sur ce bureau':'Service unavailable on this desktop';return;}",
"menuStatus=document.getElementById('fxa-desc');": "menuStatus=document.getElementById('fxa-desc');if(window.VulpesCompat){menuStatus.removeAttribute('data-l10n-id');menuStatus.textContent=(document.documentElement.lang||navigator.language).startsWith('fr')?'Service non disponible sur ce bureau':'Service unavailable on this desktop';return;}",
"var onImageLoad = (evt) => {\n window.requestAnimationFrame(() => {\n var newActiveImage = evt.target.closest('img:not(.active)');": "var onImageLoad = (evt) => {\n const image = evt.target;\n window.requestAnimationFrame(() => {\n var newActiveImage = image.closest('img:not(.active)');",
"var onImageLoad=(evt)=>{window.requestAnimationFrame(()=>{var newActiveImage=evt.target.closest('img:not(.active)');": "var onImageLoad=(evt)=>{const image=evt.target;window.requestAnimationFrame(()=>{var newActiveImage=image.closest('img:not(.active)');",
"var sdcard = navigator.getDeviceStorage('sdcard');\n var request = sdcard.get(captionsFilename);": "var sdcard = navigator.getDeviceStorage('sdcard');\n if (!sdcard) return; // Captions are optional on desktop.\n var request = sdcard.get(captionsFilename);",
"var sdcard=navigator.getDeviceStorage('sdcard');var request=sdcard.get(captionsFilename);": "var sdcard=navigator.getDeviceStorage('sdcard');if(!sdcard)return;var request=sdcard.get(captionsFilename);",
"this.activeAlarm.snoozeAlarm(alert.id);\n window.close();": "this.activeAlarm.snoozeAlarm(alert.id).then(() => window.close()).catch(console.error);",
"this.activeAlarm.close(alert.type, alert.id);\n window.close();": "this.activeAlarm.close(alert.type, alert.id).then(() => window.close()).catch(console.error);",
"_messageHandler: function(evt) {\n var data = evt.data;": "_messageHandler: function(evt) {\n if (!evt.source || evt.origin !== window.location.origin || !evt.data) return;\n var data = evt.data;",
"offscreenImage.src = '';": "offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');",
"offscreenImage.src='';": "offscreenImage.onload=offscreenImage.onerror=null;offscreenImage.removeAttribute('src');",
"exports.scheduler = exports.scheduler || new Scheduler();": "if (!exports.scheduler || typeof exports.scheduler.mutation !== 'function') Object.defineProperty(exports, 'scheduler', {value:new Scheduler(),configurable:true,writable:true});",
"exports.scheduler=exports.scheduler||new Scheduler();": "if(!exports.scheduler||typeof exports.scheduler.mutation!=='function')Object.defineProperty(exports,'scheduler',{value:new Scheduler(),configurable:true,writable:true});",
"function postMessageSync(win, data, transfer) {": "function postMessageSync(win, data, transfer) { try { void win.document; } catch (_) { win.postMessage(data, location.origin, transfer || []); return; }",
"function postMessageSync(win,data,transfer){": "function postMessageSync(win,data,transfer){try{void win.document;}catch(_){win.postMessage(data,location.origin,transfer||[]);return;}",
"this.childWindow = window.open(this.url, '_blank', 'attention');": "this.childWindow = window.VulpesCompat ? VulpesCompat.openAttention(this.url) : window.open(this.url, '_blank', 'attention');",
"style: function(baseUrl) {": "style: function(baseUrl) { if (window.VulpesCompat) { window.VulpesCompat.componentStyle(this, baseUrl).catch(console.error); return; }",
"style:function(baseUrl){": "style:function(baseUrl){if(window.VulpesCompat){window.VulpesCompat.componentStyle(this,baseUrl).catch(console.error);return;}",
"shimHostIframe.contentDocument.readyState === 'complete' ?": "typeof shimHostIframe.contentWindow.bootstrap === 'function' ?",
"shimHostIframe.contentDocument.readyState==='complete'?": "typeof shimHostIframe.contentWindow.bootstrap==='function'?",
"hardwareHomeButton: '(-moz-physical-home-button)'": "hardwareHomeButton: window.VulpesCompat ? '(min-width: 0px)' : '(-moz-physical-home-button)'",
"hardwareHomeButton:'(-moz-physical-home-button)'": "hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'",
"new AudioContext(this._channel)": "new AudioContext()",
"var doc = (parent.location === window.location) ?\n document : parent.document;": 'var doc = document; try { if (!document.getElementById("confirmation-message")) doc = parent.document; } catch (_) {}',
"var doc=(parent.location===window.location)?document:parent.document;": 'var doc=document;try{if(!document.getElementById("confirmation-message"))doc=parent.document;}catch(_){}',
"window.scheduler || schedulerShim()": '(window.scheduler && typeof window.scheduler.mutation === "function" ? window.scheduler : schedulerShim())',
"window.scheduler||schedulerShim()": '(window.scheduler&&typeof window.scheduler.mutation==="function"?window.scheduler:schedulerShim())',
"div.querySelector(':host'); return true;": "div.querySelector(':host'); div.querySelector('::content'); return true;",
"div.querySelector(':host');return true;": "div.querySelector(':host');div.querySelector('::content');return true;",
"this._screenBrightnessTransition &&\n this._screenBrightnessTransition.transitionTo(this._targetBrightness);": "if (this._screenBrightnessTransition) {\n this._screenBrightnessTransition.transitionTo(this._targetBrightness);\n } else {\n power.screenBrightness = this._targetBrightness;\n }",
"this._screenBrightnessTransition&&this._screenBrightnessTransition.transitionTo(this._targetBrightness);": "this._screenBrightnessTransition?this._screenBrightnessTransition.transitionTo(this._targetBrightness):(power.screenBrightness=this._targetBrightness);",
"window.open(url, '_blank', 'remote=true');": "new MozActivity({name:'view',data:{type:'url',url:url}});",
"window.open(url,'_blank','remote=true');": "new MozActivity({name:'view',data:{type:'url',url:url}});",
"newDate.toLocaleFormat('%p')": "new Intl.DateTimeFormat(navigator.language, {hour:'numeric', hour12:true}).formatToParts(newDate).find(part => part.type === 'dayPeriod').value",
"gaia-container { position: relative; display: block; }": " :host { position: relative; display: block; }",
"[for (v of voices) v.lang.split('-')[0]]": "voices.map(v => v.lang.split('-')[0])",
"[for (p of pat) new SingleMatchPattern(p)]": "Array.from(pat, p => new SingleMatchPattern(p))",
"[for(p of pat)new SingleMatchPattern(p)]": "Array.from(pat, p => new SingleMatchPattern(p))",
"window.scheduler || {": '(window.scheduler && typeof window.scheduler.mutation === "function" ? window.scheduler : null) || {',
"window.scheduler||{": '(window.scheduler&&typeof window.scheduler.mutation==="function"?window.scheduler:null)||{',
"this._pendingIconUrl = 'app-icon';": "this._pendingIconUrl = 'app-icon';\n if (window.VulpesCompat) { this._vulpesSourceIcon = blob; this._hasUserSetIcon = true; } // Keep original app pixels; skip the legacy canvas pass.",
"this._pendingIconUrl='app-icon';": "this._pendingIconUrl='app-icon';if(window.VulpesCompat){this._vulpesSourceIcon=blob;this._hasUserSetIcon=true;}",
"Object.defineProperty(proto, 'icon', {\n get: function() {\n return new Promise((resolve, reject) => {": "Object.defineProperty(proto, 'icon', {\n get: function() {\n if (window.VulpesCompat && this.app && this._vulpesSourceIcon) return Promise.resolve(this._vulpesSourceIcon);\n return new Promise((resolve, reject) => {",
"Object.defineProperty(proto,'icon',{get:function(){return new Promise((resolve,reject)=>{": "Object.defineProperty(proto,'icon',{get:function(){if(window.VulpesCompat&&this.app&&this._vulpesSourceIcon)return Promise.resolve(this._vulpesSourceIcon);return new Promise((resolve,reject)=>{",
"if (window.VulpesCompat && appOrBookmark.manifest?.icons?.['512']?.includes('vulpes-browser.png')) icon.refresh();": "if (window.VulpesCompat && icon.app) icon.refresh(); // Replace legacy rasterized cache with original app assets.",
"if(window.VulpesCompat&&appOrBookmark.manifest?.icons?.['512']?.includes('vulpes-browser.png'))icon.refresh();": "if(window.VulpesCompat&&icon.app)icon.refresh();",
}
for name in ["gaia", "assets/webapps"]:
count = 0
for path in (ROOT / name).rglob("*.js"):
if not path.is_file() or "/test/" in str(path):
continue
try:
original = path.read_text()
except UnicodeDecodeError:
continue
text = original
if path.name == "main.js" and "camera.gaiamobile.org" in str(path):
text = (
text.replace(
"require(['main']);",
"if (!window.VulpesCompat || navigator.mozCameras) require(['main']);",
)
if "if (!window.VulpesCompat || navigator.mozCameras)" not in text
else text
)
if path.name == "fxa_iac_client.js":
text = text.replace(
"callback&&callback();});",
"callback&&callback();}).catch(function(error){callback&&callback(error);});",
)
text = text.replace(
"connect(function(){_sendMessage(message,successCb,errorCb);",
"connect(function(error){if(error){errorCb&&errorCb(error);return;}_sendMessage(message,successCb,errorCb);",
)
for old, new in replacements.items():
if new not in text:
text = text.replace(old, new)
if text != original:
path.write_text(text)
count += 1
print(name, count, "files updated")
# Native boot overlays keep the immutable Gaia system image. Include both SMS
# entry points: the old architecture lazy-loads conversation.js separately.
for relative in ("views/conversation/js/conversation.js", "views/conversation/gaia_build_defer_index.js"):
source = ROOT / "assets/webapps/sms.gaiamobile.org" / relative
target = ROOT / "overrides/sms.gaiamobile.org" / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(source.read_bytes())
# Preserve bundled item definitions, replacing only the root dependency list.
# Unsupported hardware modules must not execute and break unrelated Settings UI.
path = ROOT / "assets/webapps/settings.gaiamobile.org/js/panels/root/low_priority_items.js"
if path.is_file():
text = path.read_text()
marker = "/* VULPES_ROOT_CAPABILITIES */"
old = "define('panels/root/low_priority_items',"
start = text.find(marker) if marker in text else text.find(old)
if start < 0:
raise RuntimeError("Settings root bundle has changed; review capability adapter")
replacement = (
text[:start]
+ marker
+ "\n"
+ (ROOT / "gaia/apps/settings/js/panels/root/low_priority_items.js").read_text()
)
if replacement != text:
path.write_text(replacement)

6
tools/prepare-assets.py Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Check that the installed Gaia payload is present in this checkout."""
from project import validate_assets
if __name__ == "__main__":
print(f"Gaia: {validate_assets()} applications available in assets/webapps")

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Fetch three pinned historical packages and extract byte-identical test copies."""
import hashlib
import json
from pathlib import Path
import stat
import tempfile
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parents[1]
CATALOG = json.loads((ROOT / "tests/legacy-apps.json").read_text())
BASE = ROOT / "downloads/legacy-apps"
BASE.mkdir(parents=True, exist_ok=True)
(BASE / "apps").mkdir(exist_ok=True)
for app in CATALOG:
archive = BASE / app["archive"]
if not archive.exists():
with urllib.request.urlopen(app["url"], timeout=30) as response:
data = response.read(10 * 1024 * 1024 + 1)
if len(data) > 10 * 1024 * 1024:
raise RuntimeError("Archive too large")
if hashlib.sha256(data).hexdigest() != app["sha256"]:
raise RuntimeError("Archive checksum mismatch: " + app["id"])
archive.write_bytes(data)
if hashlib.sha256(archive.read_bytes()).hexdigest() != app["sha256"]:
raise RuntimeError("Archive checksum mismatch: " + app["id"])
destination = BASE / "apps" / app["id"]
with zipfile.ZipFile(archive) as package:
if sum(info.file_size for info in package.infolist()) > 50 * 1024 * 1024:
raise RuntimeError("Extracted package too large")
manifest = json.loads(package.read("manifest.webapp"))
if manifest.get("type", "web") not in ("web", "privileged"):
raise RuntimeError("Only web and reviewed privileged packages are allowed")
for info in package.infolist():
name = Path(info.filename)
if (
name.is_absolute()
or ".." in name.parts
or "\\" in info.filename
or stat.S_ISLNK(info.external_attr >> 16)
):
raise RuntimeError("Unsafe archive entry: " + info.filename)
if not destination.exists():
with tempfile.TemporaryDirectory(dir=BASE / "apps") as temporary:
stage = Path(temporary) / "package"
stage.mkdir()
package.extractall(stage)
stage.rename(destination)
for info in package.infolist():
if not info.is_dir() and (destination / info.filename).read_bytes() != package.read(
info
):
raise RuntimeError("Extracted app was modified: " + app["id"] + "/" + info.filename)
print(app["id"] + ": archive SHA256 and extracted files verified", flush=True)

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Build a separate publication index; never change the working index or push."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIRS = {'adapters','android','assets','contracts','gaia','host','overrides','services','tests','tools','tundra'}
ROOT_FILES = {'.gitignore','.nvmrc','.prettierignore','.prettierrc.json','LICENSE','NOTICE',
'README.md','README.fr.md','engine-lock.json','package.json','package-lock.json',
'pyproject.toml','requirements-dev.txt','release.json','run.sh','setup.sh'}
INTERNAL = {'changelog.json','VALIDATION.json','candidate-engine.json',
'tools/export-changelog.py','tools/sync-forgejo-changelog.py','tests/test_changelog.py',
'tools/check-checkout.py'}
MEDIA = {'docs/media/vulpes-logo.png','docs/media/vulpes-desktop.png','docs/media/vulpes-gecko.png'}
PRIVATE_PARTS = {'.git','.ssh','profiles','logs','out','downloads','engines','toolchain','keys','.gradle','node_modules','__pycache__'}
def target_name(name):
p=Path(name)
if name in INTERNAL or any(x in PRIVATE_PARTS for x in p.parts):return None
if name in MEDIA:return name
if p.parts[0] not in SOURCE_DIRS and name not in ROOT_FILES:return None
if 'docs' in p.parts or p.name=='VALIDATION.json':return None
if p.suffix.lower()=='.md' and name not in {'README.md','README.fr.md'}:
# License notices are retained verbatim, without publishing internal Markdown.
if p.name.lower() in {'license.md','copying.md','notice.md'}:return str(p.with_suffix(''))
return None
if p.name.lower() in {'roadmap','todo','roadmap.txt','todo.txt'}:return None
if name.startswith(('android/app/build/','android/build/','android/dist/','android/app/src/main/assets/','tundra/sources/')):return None
if p.suffix.lower() in {'.apk','.img','.ext4','.keystore','.jks','.p12','.pfx','.pyc'}:return None
if p.name in {'local.properties','client-key','known_hosts','authorized_keys','id_rsa','id_ed25519'}:return None
return name
def select():
paths=subprocess.check_output(['git','ls-files','--cached','--others','--exclude-standard','-z'],cwd=ROOT).decode().split('\0')
selected={}
for name in sorted(set(paths)-{''}):
dest=target_name(name)
if dest is None:continue
p=ROOT/name
if p.is_symlink() and not p.resolve().is_relative_to(ROOT):raise ValueError('External symlink: '+name)
if not p.is_file():raise ValueError('Missing publication file: '+name)
if dest in selected:raise ValueError('Duplicate destination: '+dest)
selected[dest]=name
if not {'LICENSE','NOTICE','README.md','README.fr.md'}<=selected.keys():raise ValueError('Missing publication metadata')
return selected
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--index',type=Path,required=True)
parser.add_argument('--report',type=Path,required=True)
args=parser.parse_args()
index=args.index.resolve();normal=Path(subprocess.check_output(['git','rev-parse','--git-path','index'],cwd=ROOT,text=True).strip()).resolve()
if index==normal:raise ValueError('Refusing to replace working index')
selected=select();env=dict(os.environ,GIT_INDEX_FILE=str(index))
index.parent.mkdir(parents=True,exist_ok=True)
subprocess.run(['git','read-tree','--empty'],cwd=ROOT,env=env,check=True)
ordinary=[name for name in selected.values() if not (ROOT/name).is_symlink()]
encoded=''.join(json.dumps(name,ensure_ascii=False)+'\n' for name in ordinary).encode()
hashes=subprocess.check_output(['git','hash-object','-w','--no-filters','--stdin-paths'],input=encoded,cwd=ROOT).decode().splitlines()
if len(hashes)!=len(ordinary):raise ValueError('Blob count mismatch')
blobs=dict(zip(ordinary,hashes))
entries=[];manifest={};warnings=[]
for dest,source in selected.items():
p=ROOT/source;data=os.readlink(p).encode() if p.is_symlink() else p.read_bytes()
if re.search(rb'-----BEGIN (?:OPENSSH|RSA|EC|DSA)? ?PRIVATE KEY-----',data):warnings.append(source)
blob=blobs.get(source) or subprocess.check_output(['git','hash-object','-w','--stdin'],input=data,cwd=ROOT).decode().strip()
mode='120000' if p.is_symlink() else ('100755' if p.stat().st_mode&0o111 else '100644')
entries.append((mode+' '+blob+'\t'+dest+'\0').encode())
manifest[dest]={'source':source,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data)}
if warnings:raise ValueError('Private-key material requires review: '+', '.join(warnings))
subprocess.run(['git','update-index','-z','--index-info'],input=b''.join(entries),cwd=ROOT,env=env,check=True)
tree=subprocess.check_output(['git','write-tree'],cwd=ROOT,env=env,text=True).strip()
report={'tree':tree,'files':len(manifest),'bytes':sum(v['bytes'] for v in manifest.values()),'markdown':[x for x in manifest if x.lower().endswith('.md')],'manifest':manifest}
args.report.parent.mkdir(parents=True,exist_ok=True);args.report.write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({k:v for k,v in report.items() if k!='manifest'},indent=2))
if __name__=='__main__':main()

46
tools/probe-desktop.py Normal file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Evaluate diagnostics in Gaia on the test desktop, never a normal profile."""
import json, sys, time
from marionette_client import Client
def evaluate(client, app, code, path="/"):
prefix = f"http://{app}.localhost:8765{path}"
script = """const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
const bc=w?.document.getElementById('system')?.browsingContext;
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const c of b.children){const f=find(c);if(f)return f;}return null;}
const target=bc&&find(bc);if(!target){done({missing:true});return;}
target.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(value=>done({value}),error=>done({error:String(error)}));""".replace(
"PREFIX", json.dumps(prefix)
).replace(
"CODE", json.dumps(code)
)
return client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": script,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 25000,
},
)["value"]
def register(client):
assert client.execute("return PathUtils.profileDir;").split("/")[-1].startswith("host-")
client.execute(
"try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}"
)
if __name__ == "__main__":
with Client(2857) as c:
register(c)
for _ in range(50):
result = evaluate(c, sys.argv[1], sys.argv[2])
if not result.get("missing"):
break
time.sleep(0.2)
print(json.dumps(result, indent=2, ensure_ascii=False))

88
tools/probe-engine.py Normal file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Audit the official engine in isolation. This does not launch Gaia."""
import argparse
import json
import os
import subprocess
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
(ROOT / "logs").mkdir(exist_ok=True)
(ROOT / "profiles").mkdir(exist_ok=True)
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="store_true")
args = parser.parse_args()
lock = json.loads(
(ROOT / ("candidate-engine.json" if args.candidate else "engine-lock.json")).read_text()
)
runtime = ROOT / lock["runtime"]
profile = ROOT / "profiles" / ("probe-" + lock["version"])
profile.mkdir(parents=True, exist_ok=True)
prefs = {
"marionette.port": 2856,
"browser.shell.checkDefaultBrowser": False,
"browser.startup.homepage_override.mstone": "ignore",
"datareporting.policy.dataSubmissionEnabled": False,
"toolkit.telemetry.reportingpolicy.firstRun": False,
"browser.aboutwelcome.enabled": False,
}
(profile / "user.js").write_text(
"".join("user_pref(" + json.dumps(k) + "," + json.dumps(v) + ");\n" for k, v in prefs.items())
)
env = os.environ.copy()
for name in list(env):
if name.startswith("MOZ_DISABLE_") or name == "B2G_HOMESCREEN":
env.pop(name)
with (ROOT / "logs/engine-probe.log").open("w") as log:
proc = subprocess.Popen(
[
str(runtime / "firefox"),
"-no-remote",
"-headless",
"-marionette",
"--remote-allow-system-access",
"-profile",
str(profile),
"about:blank",
],
env=env,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
with Client() as client:
result = client.execute(
"""
const w=Services.wm.getMostRecentWindow('navigator:browser');
const f=w.document.createElementNS('http://www.w3.org/1999/xhtml','iframe');
const b=w.document.createXULElement('browser');
const contracts={};
for(const name of ['@mozilla.org/AppsService;1','@mozilla.org/settingsService;1','@mozilla.org/network/protocol;1?name=app'])contracts[name]=name in Components.classes;
const methods={};
for(const name of ['setVisible','getScreenshot','sendTouchEvent','setInputMethodActive','getCanGoBack'])methods[name]=typeof f[name];
const modules={};
for(const name of ['Downloads','Services','AppConstants']){try{ChromeUtils.importESModule('resource://gre/modules/'+name+'.sys.mjs');modules[name]=true;}catch(e){modules[name]=false;}}
return {version:Services.appinfo.platformVersion,build:Services.appinfo.platformBuildID,
updatesDisabled:!Services.policies.isAllowed('appUpdate'),contracts,modules,iframeMethods:methods,
registerElement:typeof w.document.registerElement,customElements:typeof w.customElements,
xulBrowser:{loadURI:typeof b.loadURI,tag:b.localName},fission:Services.appinfo.fissionAutostart,
contentSandboxLevel:Services.prefs.getIntPref('security.sandbox.content.level'),gaiaPorted:false};
"""
)
assert result["version"] == lock["version"], result
assert result["updatesDisabled"] is True, result
assert result["contentSandboxLevel"] > 0, result
(ROOT / "logs/engine-capabilities.json").write_text(json.dumps(result, indent=2) + "\n")
(ROOT / "logs" / ("engine-capabilities-" + lock["version"] + ".json")).write_text(
json.dumps(result, indent=2) + "\n"
)
print(json.dumps(result, indent=2), flush=True)
client.call("Marionette:Quit", {"flags": ["eAttemptQuit"]})
finally:
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=10)

67
tools/project.py Normal file
View file

@ -0,0 +1,67 @@
"""Paths and metadata shared by the desktop and Android build tools."""
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def validate_assets():
"""Require the checked-in Gaia payload instead of a private profile copy."""
apps = ROOT / "assets/webapps"
required = [
ROOT / "assets/settings-defaults.json",
apps / "system.gaiamobile.org/index.html",
apps / "homescreen.gaiamobile.org/index.html",
apps / "default_theme.gaiamobile.org/manifest.webapp",
]
missing = [str(path.relative_to(ROOT)) for path in required if not path.is_file()]
if missing:
raise RuntimeError("Incomplete checkout; missing Gaia files: " + ", ".join(missing))
manifests = sorted(apps.glob("*/manifest.webapp"))
if not manifests:
raise RuntimeError("No installed Gaia manifests in assets/webapps")
for manifest in manifests:
json.loads(manifest.read_text())
json.loads((ROOT / "assets/settings-defaults.json").read_text())
return len(manifests)
def build_info():
"""Read this checkout's revision; archives without .git report it as unknown."""
unknown = {"revision": "unknown", "timestamp": 0, "dirty": None}
if not (ROOT / ".git").exists():
return unknown
try:
revision, timestamp = (
subprocess.check_output(
["git", "show", "-s", "--format=%H%n%ct", "HEAD"],
cwd=ROOT,
text=True,
stderr=subprocess.DEVNULL,
)
.strip()
.splitlines()
)
changes = subprocess.check_output(
["git", "status", "--porcelain", "--untracked-files=normal"],
cwd=ROOT,
text=True,
)
except (OSError, subprocess.CalledProcessError, ValueError):
return unknown
return {"revision": revision, "timestamp": int(timestamp), "dirty": bool(changes)}
def release_info():
"""One product revision, with independently verified platform engine packages."""
release = json.loads((ROOT / "release.json").read_text())
release.setdefault("version", f'{release["gaia"]}-{release["channel"]}.{release["revision"]}')
release["desktopEngine"] = json.loads((ROOT / "engine-lock.json").read_text())["version"]
engine, build_id = release["geckoview"].rsplit(".", 1)
release["androidEngine"] = engine
release["androidEngineBuildID"] = build_id
if not release["androidVersionCode"] > 4:
raise ValueError("Android versionCode must preserve upgrades from Preview 4")
return release

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Refresh selected AMD modules inside Gaia's existing Settings panel bundles."""
from pathlib import Path
import re
ROOT=Path(__file__).resolve().parents[1]
for panel, module in [('display','slider_handler'),('wifi','wifi_network_list')]:
name=f'panels/{panel}/{module}'
relative=f'js/panels/{panel}/panel.js'
original=ROOT/'assets/webapps/settings.gaiamobile.org'/relative
source=ROOT/'overrides/settings.gaiamobile.org'/f'js/{name}.js'
text=source.read_text()
dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text)))
import json
text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1)
bundle=original.read_text()
start=bundle.index("define('"+name+"'")
end=bundle.find("define('",start+8)
if end<0:end=len(bundle)
target=ROOT/'overrides/settings.gaiamobile.org'/relative
target.write_text(bundle[:start]+text+'\n'+bundle[end:])
print(target.relative_to(ROOT))

View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Restore original Gaia density variants discarded by the 1x packaged build."""
import json
from pathlib import Path
import re
import shutil
ROOT = Path(__file__).resolve().parents[1]
URL = re.compile(r'url\([\'\"]?([^\s)\'\"]+)[\'\"]?\)')
PROPERTY = re.compile(r'background(?:-image)?\s*:[^;{}]+;')
def main():
report = []
for packaged in sorted((ROOT/'assets/webapps').glob('**/*.css')):
rel = packaged.relative_to(ROOT/'assets/webapps')
app, *parts = rel.parts
override = ROOT/'overrides'/rel
text = (override if override.exists() else packaged).read_text()
def declaration(match):
value = match[0]
if 'image-set(' in value:
return value
def image(found):
name = found[1]
if name.startswith(('data:', 'http:', 'https:')) or '@' in name:
return found[0]
target = (packaged.parent/name).resolve() if not name.startswith('/') else ROOT/'assets/webapps'/app/name.lstrip('/')
try:
asset = target.relative_to(ROOT/'assets/webapps'/app)
except ValueError:
return found[0]
source = ROOT/'gaia/apps'/app.removesuffix('.gaiamobile.org')/asset
if asset.parts[0] == 'shared':
source = ROOT/'gaia'/asset
variants = []
for density in ('1.5', '2', '2.25'):
candidate = source.with_name(source.stem+'@'+density+'x'+source.suffix)
if candidate.is_file() and candidate.suffix == '.png':
dest = ROOT/'overrides'/app/asset.parent/candidate.name
dest.parent.mkdir(parents=True,exist_ok=True)
shutil.copyfile(candidate,dest)
url = str(Path(name).with_name(candidate.name))
variants.append(f'url("{url}") {density}x')
if not variants:
return found[0]
report.append({'stylesheet':str(rel),'image':name,'densities':len(variants)})
return 'image-set('+f'url("{name}") 1x, '+', '.join(variants)+')'
return URL.sub(image,value)
updated = PROPERTY.sub(declaration,text)
if updated != text:
override.parent.mkdir(parents=True,exist_ok=True)
override.write_text(updated)
(ROOT/'logs/density-restoration.json').write_text(json.dumps(report,indent=2)+'\n')
print(f'{len(report)} CSS image references restored from original Gaia variants')
if __name__ == '__main__':
main()

164
tools/run-host.py Normal file
View file

@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Prepare and run the independent Gaia desktop on a pinned Mozilla runtime."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
from project import validate_assets
def prepare(lock, test=False):
runtime = ROOT / lock["runtime"]
if not (runtime / "firefox").is_file():
raise RuntimeError(
"Moteur absent. Exécuter ./setup.sh pour installer le moteur verrouillé."
)
validate_assets()
for name in ["host", "services", "adapters"] + (["tests"] if test else []):
shutil.copytree(ROOT / name, runtime / "vulpes" / name, dirs_exist_ok=True)
(runtime / "defaults/pref").mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / "host/bootstrap/vulpes.cfg", runtime / "vulpes.cfg")
shutil.copy2(
ROOT / "host/bootstrap/vulpes-autoconfig.js", runtime / "defaults/pref/vulpes-autoconfig.js"
)
return runtime
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="store_true")
parser.add_argument(
"--legacy-apps", action="store_true", help="Trois paquets archivés, profil séparé"
)
parser.add_argument(
"--test", action="store_true", help="Marionette 2857, profil de test et fenêtre auxiliaire"
)
parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
lock = json.loads(
(ROOT / ("candidate-engine.json" if args.candidate else "engine-lock.json")).read_text()
)
(ROOT / "logs").mkdir(exist_ok=True)
# One host owns the loopback origins/registry. Never attach silently to an
# unknown HTTP listener or to another application's profile.
with (ROOT / "logs/host.lock").open("w") as guard:
try:
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError(
"Un hôte Gecko actuel est déjà ouvert. Fermer sa fenêtre avant de relancer."
)
if args.legacy_apps:
subprocess.run([sys.executable, str(ROOT / "tools/prepare-legacy-apps.py")], check=True)
runtime = prepare(lock, args.test)
if args.prepare_only:
print("Adaptateur synchronisé :", runtime)
return
probe = socket.socket()
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", 8765))
except OSError:
raise RuntimeError("Port Gaia 8765 déjà occupé. Fermer le précédent hôte/serveur Gaia.")
finally:
probe.close()
profile = (
ROOT
/ "profiles"
/ (
("host-" if args.test else "desktop-")
+ ("legacy-" if args.legacy_apps else "")
+ lock["version"]
)
)
profile.mkdir(parents=True, exist_ok=True)
prefs = {
"browser.shell.checkDefaultBrowser": False,
"browser.dom.window.dump.enabled": True,
"browser.startup.homepage_override.mstone": "ignore",
"browser.aboutwelcome.enabled": False,
"font.default.x-western": "sans-serif",
"font.name.sans-serif.x-western": "Fira Sans",
"marionette.enabled": args.test,
"datareporting.policy.dataSubmissionEnabled": False,
}
if args.test:
prefs["marionette.port"] = 2857
(profile / "user.js").write_text(
"\n".join(f"user_pref({json.dumps(k)}, {json.dumps(v)});" for k, v in prefs.items())
+ "\n"
)
env = os.environ.copy()
for name in list(env):
if name.startswith("MOZ_DISABLE_") or name in (
"B2G_HOMESCREEN",
"MOZ_MARIONETTE",
"MOZ_HEADLESS",
):
env.pop(name)
env.update(VULPES_HOST_ROOT=str(runtime / "vulpes/host"), VULPES_WORKSPACE_ROOT=str(ROOT))
command = [
str(runtime / "firefox"),
"-no-remote",
"-purgecaches",
"-profile",
str(profile),
"-chrome",
"chrome://vulpes/content/shell.xhtml",
]
if args.test:
command += ["-marionette", "--remote-allow-system-access", "-new-window", "about:blank"]
proc = None
with (ROOT / "logs/gaia-server.log").open("w") as output:
server = subprocess.Popen(
[
sys.executable,
str(ROOT / "tools/serve-gaia.py"),
*(["--legacy-apps"] if args.legacy_apps else []),
],
stdout=output,
stderr=subprocess.STDOUT,
)
try:
for _ in range(900):
if server.poll() is not None:
raise RuntimeError("Échec du serveur Gaia : logs/gaia-server.log")
try:
with socket.create_connection(("127.0.0.1", 8765), timeout=0.2):
break
except OSError:
time.sleep(0.05)
else:
raise RuntimeError("Le serveur Gaia ne répond pas.")
print(f'Vulpes / Gecko {lock["version"]} — profil {profile}', flush=True)
proc = subprocess.Popen(command, env=env)
result = proc.wait()
if result:
raise RuntimeError(f"Gecko a quitté avec le code {result}")
finally:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
server.terminate()
server.wait(timeout=10)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
except (RuntimeError, OSError) as error:
sys.exit(str(error))

206
tools/serve-gaia.py Normal file
View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Serve packaged Gaia with a distinct loopback origin for every installed app."""
import argparse
import json
import mimetypes
import os
import re
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlsplit
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--legacy-apps", action="store_true")
args = parser.parse_args()
from project import build_info, validate_assets, release_info
validate_assets()
info = build_info()
info["release"] = release_info()
revision, commit_time = info["revision"], str(info["timestamp"])
(ROOT / "assets/build-info.json").write_text(json.dumps(info))
APPS = ROOT / "assets/webapps"
ORIGINS = {}
REGISTRY = []
pattern = re.compile(r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org")
def translate(text):
return pattern.sub(lambda m: f"http://{m[1]}.localhost:{args.port}", text)
manifests = sorted(APPS.glob("*/manifest.webapp"))
LEGACY_ROOTS = set()
if args.legacy_apps:
for entry in json.loads((ROOT / "tests/legacy-apps.json").read_text()):
directory = ROOT / "downloads/legacy-apps/apps" / entry["id"]
manifests.append(directory / "manifest.webapp")
LEGACY_ROOTS.add(directory.resolve())
for manifest_path in manifests:
name = manifest_path.parent.name.removesuffix(".gaiamobile.org")
if not re.fullmatch("[a-z0-9_-]+", name):
continue
origin = f"http://{name}.localhost:{args.port}"
manifest = json.loads(translate(manifest_path.read_text()))
ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve()
REGISTRY.append(
{
"id": name,
"origin": origin,
"manifestURL": origin + "/manifest.webapp",
"manifest": manifest,
}
)
ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"]
(ROOT / "assets/registry.json").write_text(
json.dumps(REGISTRY, ensure_ascii=False, indent=2) + "\n"
)
(ROOT / "assets/settings-current-defaults.json").write_text(
translate((ROOT / "assets/settings-defaults.json").read_text())
)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
root = ORIGINS.get(self.headers.get("Host", "").lower())
if root is None:
self.send_error(421, "Unregistered application host")
return
path = unquote(urlsplit(self.path).path)
if "\x00" in path or "\\" in path or ".." in Path(path).parts:
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()
self.send_response(200)
self.send_header("Content-Type", "text/javascript")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
if path == "/resources/gaia_commit.txt":
data = (revision + "\n" + commit_time + "\n").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
return
# Only this non-privileged compatibility layer is exposed, never host modules.
if path in [
"/_vulpes/" + name
for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
]:
file = ROOT / "host" / path.rsplit("/", 1)[1]
else:
file = (root / path.lstrip("/")).resolve()
if file.is_dir():
file = file / "index.html"
if not file.is_relative_to(root):
self.send_error(403)
return
overrides = {
"system.gaiamobile.org": [
"js/launcher.js",
"js/gaia_scheduler.js",
"js/keyboard_manager.js",
"js/core.js",
"js/rocketbar.js",
]
}
if path.lstrip("/") in overrides.get(root.name, []):
file = (
ROOT
/ "gaia/apps"
/ root.name.removesuffix(".gaiamobile.org")
/ path.lstrip("/")
)
overlay_root = (ROOT / "overrides" / root.name).resolve()
overlay = (overlay_root / path.lstrip("/")).resolve()
if (
path != "/manifest.webapp"
and overlay.is_relative_to(overlay_root)
and overlay.is_file()
):
file = overlay
if path.startswith("/shared/"):
common = (ROOT / "overrides" / path.lstrip("/")).resolve()
if common.is_relative_to((ROOT / "overrides/shared").resolve()) and common.is_file():
file = common
if not file.is_file() and path.startswith("/shared/"):
shared = (ROOT / "gaia" / path.lstrip("/")).resolve()
if shared.is_relative_to((ROOT / "gaia/shared").resolve()):
file = shared
if not file.is_file():
self.send_error(404)
return
data = file.read_bytes()
mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
if file.suffix in (".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"):
text = translate(data.decode("utf-8"))
if file.suffix in (".css", ".js", ".html"):
text = (
text.replace("-moz-calc(", "calc(")
.replace("-moz-box-sizing:", "box-sizing:")
.replace("-moz-plaintext", "plaintext")
.replace(":-moz-dir(", ":dir(")
.replace("offset-inline-start:", "inset-inline-start:")
.replace("offset-inline-end:", "inset-inline-end:")
)
data = text.encode("utf-8")
if root.name == "music.gaiamobile.org" and file.suffix == ".js":
data = data.replace(b"audio.src=null;", b"audio.removeAttribute('src');")
if file.suffix == ".html" and root in LEGACY_ROOTS:
data = data.replace(
b"<head>",
b'<head><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script>',
1,
)
elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")):
data = data.replace(
b"<head>",
b'<head><script src="/_vulpes/platform-config.js"></script><link rel="stylesheet" href="/_vulpes/desktop.css"><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>',
1,
)
if (
file.suffix == ".html"
and root.name == "communications.gaiamobile.org"
and "/dialer/" in path
):
data = data.replace(
b"<head>", b'<head><script src="/shared/js/accessibility_helper.js"></script>', 1
)
self.send_response(200)
self.send_header(
"Content-Type", mime + ("; charset=utf-8" if mime.startswith("text/") else "")
)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
# Installed Gaia code may use its legacy module compiler, but must not
# gain privileged host rights through inline or remote script injection.
self.send_header(
"Content-Security-Policy",
"script-src 'self' 'unsafe-eval'; object-src 'none'; "
"base-uri 'self'; worker-src 'self' blob:; "
f"frame-ancestors http://*.localhost:{args.port}",
)
origin = self.headers.get("Origin", "")
if urlsplit(origin).netloc in ORIGINS and origin.startswith("http://"):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *values):
if values and str(values[1]) not in ("200", "304"):
super().log_message(fmt, *values)
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"Gaia: {len(REGISTRY)} applications; 127.0.0.1:{args.port}", flush=True)
server.serve_forever()

102
tools/test-app-workflows.py Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Exercise original Gallery, Video and Calendar UI in the desktop test profile."""
import base64
import importlib.util
import json
import subprocess
import tempfile
import time
from pathlib import Path
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)
results = {}
stem = 'vulpes-workflow-' + str(time.time_ns())
with Client(2857) as client:
probe.register(client)
def ev(app, code):
result = probe.evaluate(client, app, code)
if 'error' in result:
raise AssertionError(result)
return result.get('value')
def wait(app, code):
for _ in range(300):
value = ev(app, code)
if value:
return value
time.sleep(.15)
raise AssertionError((app, code))
def click(app, selector):
wait(app, 'return !!document.querySelector(' + json.dumps(selector) + ');')
ev(app, 'document.querySelector(' + json.dumps(selector) + ').click();return true;')
time.sleep(.25)
def launch(app):
ev('system', 'ScreenManager.turnScreenOn(true);lockScreen.unlock(true);await VulpesCompat.call("apps.launch",{manifestURL:"http://' + app + '.localhost:8765/manifest.webapp"});return true;')
wait(app, "return document.readyState==='complete';")
wait('system', 'return !!window.ScreenManager && !!window.lockScreen && !!window.VulpesCompat;')
ev('system', 'ScreenManager.turnScreenOn(true);return true;')
time.sleep(.5)
ev('system', 'lockScreen.unlock(true);return true;')
time.sleep(.5)
launch('video')
with tempfile.TemporaryDirectory(prefix='vulpes-video-') as tmp:
file = Path(tmp) / 'test.webm'
subprocess.run(['ffmpeg','-hide_banner','-loglevel','error','-f','lavfi','-i','testsrc2=size=320x240:rate=15','-f','lavfi','-i','sine=frequency=440:sample_rate=44100','-t','3','-c:v','libvpx','-b:v','180k','-c:a','libvorbis',str(file)],check=True)
encoded = json.dumps(base64.b64encode(file.read_bytes()).decode())
ev('video', 'const b=Uint8Array.from(atob('+encoded+'),c=>c.charCodeAt(0));await navigator.getDeviceStorage("videos").addNamed(new Blob([b],{type:"video/webm"}),'+json.dumps(stem+'.webm')+');return true;')
wait('video', 'return [...document.querySelectorAll(".thumbnail .title")].some(e=>e.textContent.toLowerCase()==='+json.dumps(stem)+');')
ev('video', '[...document.querySelectorAll(".thumbnail .title")].find(e=>e.textContent.toLowerCase()==='+json.dumps(stem)+').closest(".thumbnail").click();return true;')
results['video_playback'] = wait('video','const p=document.querySelector("video");return p && !p.paused && p.currentTime>0 && p.duration>2 && !p.error;')
results['video_seek'] = ev('video','const p=document.querySelector("video");p.pause();p.currentTime=1.5;await new Promise(r=>setTimeout(r,250));return Math.abs(p.currentTime-1.5)<.1 && !p.error;')
click('video','#options');click('video','.single-delete-button');click('video','#confirm-ok')
results['video_delete'] = wait('video','try{await navigator.getDeviceStorage("videos").get('+json.dumps(stem+'.webm')+');return false;}catch(e){return e.name==="NotFoundError";}')
launch('gallery')
ev('gallery','location.reload();return true;')
time.sleep(1)
wait('gallery','return document.body.classList.contains("thumbnailListView") && typeof photodb!=="undefined";')
ev('gallery','const c=document.createElement("canvas");c.width=320;c.height=240;const x=c.getContext("2d");x.fillStyle="orange";x.fillRect(0,0,320,240);await navigator.getDeviceStorage("pictures").addNamed(await new Promise(r=>c.toBlob(r,"image/jpeg")),'+json.dumps(stem+'.jpg')+');return true;')
selector='.thumbnailImage[data-filename="/sdcard/'+stem+'.jpg"]'
wait('gallery','return !!document.querySelector('+json.dumps(selector)+');')
ev('gallery','document.querySelector('+json.dumps(selector)+').closest(".thumbnail").click();return true;')
wait('gallery','return document.body.classList.contains("fullscreenView");')
click('gallery','#fullscreen-edit-button-tiny')
wait('gallery','return document.body.classList.contains("editView") && !!document.querySelector("#edit-preview-area canvas");')
wait('gallery','return !!window.imageEditor?.previewImageData?.width && !imageEditor.editing;')
click('gallery','#edit-effect-button');click('gallery','#edit-effect-bw')
wait('gallery','return !imageEditor.editing;')
click('gallery','#edit-tool-apply-button')
wait('gallery','return !document.querySelector("#edit-save-button").hidden && !document.querySelector("#edit-save-button").disabled;')
click('gallery','#edit-save-button')
wait('gallery','return document.body.classList.contains("fullscreenView");')
results['gallery_edit_saved'] = wait('gallery','try{const f=await navigator.getDeviceStorage("pictures").get('+json.dumps(stem+'.edit1.jpg')+');const b=await createImageBitmap(f),c=document.createElement("canvas");c.width=b.width;c.height=b.height;const x=c.getContext("2d");x.drawImage(b,0,0);const p=x.getImageData(20,20,1,1).data;return b.width===320 && b.height===240 && Math.abs(p[0]-p[1])<3 && Math.abs(p[1]-p[2])<3;}catch(e){return false;}')
# Leave the full-screen view before deleting its current file.
click('gallery','#fullscreen-back-button-tiny')
wait('gallery','return document.body.classList.contains("thumbnailListView");')
ev('gallery','const s=navigator.getDeviceStorage("pictures");await s.delete('+json.dumps(stem+'.edit1.jpg')+');await s.delete('+json.dumps(stem+'.jpg')+');return true;')
launch('calendar')
ev('calendar','location.href="/index.html";return true;')
time.sleep(.8)
wait('calendar', 'return !!window.require && performance.getEntriesByName("fullyLoaded").length>0;')
ev('calendar','window.workflowErrors=[];addEventListener("unhandledrejection",e=>workflowErrors.push(String(e.reason)));return true;')
click('calendar','[data-l10n-id=new-event-link]')
wait('calendar','return !!document.querySelector("[name=title]");')
ev('calendar','const e=document.querySelector("[name=title]");e.value='+json.dumps(stem)+';e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
click('calendar','#modify-event-view .save')
results['calendar_saved'] = wait('calendar','return [...document.querySelectorAll("a.event")].some(e=>e.textContent.includes('+json.dumps(stem)+'));')
ev('calendar','[...document.querySelectorAll("a.event")].find(e=>e.textContent.includes('+json.dumps(stem)+')).click();return true;')
wait('calendar','return document.querySelector("#event-view")?.textContent.includes('+json.dumps(stem)+');')
time.sleep(.5)
click('calendar','#event-view .edit')
wait('calendar','return document.querySelector("[name=title]")?.value==='+json.dumps(stem)+';')
time.sleep(.5)
click('calendar','#modify-event-view .delete-record')
results['calendar_deleted'] = wait('calendar','return location.hash.includes("/month/") && ![...document.querySelectorAll("a.event")].some(e=>e.textContent.includes('+json.dumps(stem)+'));')
results['calendar_no_rejection'] = ev('calendar','return workflowErrors.length===0;')
results['gallery_no_diagnostics'] = ev('gallery','return !window.__vulpesDiagnostics?.length;')
results['calendar_no_diagnostics'] = ev('calendar','return !window.__vulpesDiagnostics?.length;')
assert all(value is True for value in results.values()), results
(ROOT/'logs/app-workflows.json').write_text(json.dumps(results,indent=2)+'\n')
print(json.dumps(results,indent=2))

405
tools/test-apps.py Normal file
View file

@ -0,0 +1,405 @@
#!/usr/bin/env python3
"""Media, alarms and visual regressions in genuine Gaia user applications."""
import argparse
import base64
import json
import os
import subprocess
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--restart-check", action="store_true")
args = parser.parse_args()
results = {}
alarm_label = "Vulpes UI regression " + str(time.time_ns())
# A fresh filename avoids an earlier asynchronous MediaDB deletion being
# mistaken for this import. Keep the name for the restart assertions.
fixture_path = ROOT / "logs/apps-media-fixture.json"
media_fixture = (
json.loads(fixture_path.read_text())
if args.restart_check
else "vulpes-regression-" + str(time.time_ns()) + ".png"
)
if not args.restart_check:
fixture_path.write_text(json.dumps(media_fixture))
with Client(2857) as client:
engine = client.execute("return Services.appinfo.platformVersion;")
assert (
client.execute("return PathUtils.profileDir;").split("/")[-1].startswith("host-")
), "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, path="/"):
code = code.replace("vulpes-regression.png", media_fixture)
prefix = "http://" + app + ".localhost:8765" + path
script = """const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
if(!w?.document.getElementById('system')){done({missing:true});return;}
const bc=(function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const c of b.children){const f=find(c);if(f)return f;}return null;})(w.document.getElementById('system').browsingContext);
if(!bc){done({missing:true});return;}bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(result=>done({result}),error=>done({error:String(error)}));""".replace(
"PREFIX", json.dumps(prefix)
).replace(
"CODE", json.dumps(code)
)
r = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": script,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
if "error" in r:
raise AssertionError(r)
return r.get("result")
def wait(app, code, path="/"):
for _ in range(70):
r = ev(app, code, path)
if r:
return r
time.sleep(0.15)
raise AssertionError("Timeout: " + app + " " + code)
def check(name, condition):
results[name] = condition
print(name + ": " + json.dumps(condition, ensure_ascii=False), flush=True)
assert condition is True, (name, condition)
def launch(app, entry=None):
ev(
"system",
'await VulpesCompat.call("apps.launch",'
+ json.dumps(
{"manifestURL": f"http://{app}.localhost:8765/manifest.webapp", "entryPoint": entry}
)
+ ");return true;",
)
wait(app, "return document.readyState==='complete';")
if app == "clock":
wait(app, "return performance.getEntriesByName('fullyLoaded').length>0;")
wait(
"system",
"return [...document.querySelectorAll('.appWindow.active[transition-state=opened] iframe')].some(f=>f.src.startsWith('http://"
+ app
+ ".localhost:8765/'));",
)
time.sleep(0.5)
def focus():
client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;"
)
subprocess.run(
["xdotool", "search", "--name", "^Vulpes", "windowraise", "windowfocus"],
check=True,
stdout=subprocess.DEVNULL,
)
def tap(app, selector, path="/"):
focus()
pos = ev(
app,
"const e="
+ selector
+ ';e.scrollIntoView({block:"nearest"});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};',
path,
)
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
time.sleep(0.4)
def shot(name):
data = client.call("WebDriver:ExecuteAsyncScript", {
"script": """const done=arguments[arguments.length-1];
const w=Services.wm.getMostRecentWindow('vulpes:host'),b=w.document.getElementById('system');
b.browsingContext.currentWindowGlobal.drawSnapshot(new DOMRect(0,0,b.clientWidth,b.clientHeight),1,'white').then(bitmap=>{
const canvas=w.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
canvas.width=bitmap.width;canvas.height=bitmap.height;canvas.getContext('2d').drawImage(bitmap,0,0);
done(canvas.toDataURL());}).catch(e=>done({error:String(e)}));""",
"args": [], "newSandbox": True, "sandbox": "system", "scriptTimeout": 25000
})["value"]
if isinstance(data, str) and data.startswith("data:image/"):
(ROOT / "logs" / ("feedback-" + name + ".png")).write_bytes(base64.b64decode(data.split(",",1)[1]))
else:
print("Screenshot unavailable:", data, flush=True)
try:
wait("system", "return document.body?.getAttribute('ready-state')==='fullyLoaded';")
ev("system", "ScreenManager.turnScreenOn(true);return true;")
time.sleep(.5)
ev("system", "lockScreen.unlock(true);return true;")
time.sleep(.5)
wait("system", "return !document.getElementById('screen').classList.contains('locked');")
launch("gallery")
if args.restart_check:
check(
"photo-survives-restart",
ev(
"gallery",
"const f=await navigator.getDeviceStorage('pictures').get('vulpes-regression.png');return f.size>0 && f.type==='image/png';",
),
)
check(
"gallery-index-survives-restart",
wait("gallery", "return !!document.querySelector('.thumbnail');"),
)
launch("clock")
check(
"alarm-survives-restart",
ev(
"clock",
"return (await navigator.mozAlarms.getAll()).some(a=>a.data?.vulpesRegression==='persistence');",
),
)
raise SystemExit(0)
check(
"photo-import",
ev(
"gallery",
"""const c=document.createElement('canvas');c.width=128;c.height=96;const x=c.getContext('2d');x.fillStyle='#1686ae';x.fillRect(0,0,128,96);const blob=await new Promise(r=>c.toBlob(r,'image/png'));const s=navigator.getDeviceStorage('pictures');await s.delete('vulpes-regression.png');for(let n=0;n<100 && [...document.querySelectorAll('.thumbnailImage')].some(i=>i.dataset.filename==='/sdcard/vulpes-regression.png');n++)await new Promise(r=>setTimeout(r,50));await s.addNamed(blob,'vulpes-regression.png');const f=await s.get('vulpes-regression.png');return f.size===blob.size && f.type==='image/png' && Object.prototype.toString.call(f.lastModifiedDate)==='[object Date]';""",
),
)
check(
"gallery-thumbnail",
wait(
"gallery",
"return [...document.querySelectorAll('.thumbnailImage')].some(i=>i.dataset.filename==='/sdcard/vulpes-regression.png');",
),
)
time.sleep(0.6)
tap(
"gallery",
"[...document.querySelectorAll('.thumbnailImage')].find(i=>i.dataset.filename==='/sdcard/vulpes-regression.png').closest('.thumbnail')",
)
check(
"gallery-open-photo",
wait("gallery", "return document.body.classList.contains('fullscreenView');"),
)
shot("gallery-open")
check(
"media-path-traversal-denied",
ev(
"gallery",
"try{await navigator.getDeviceStorage('pictures').get('../vulpes-settings-v1.json');return false;}catch(e){return e.name==='SecurityError';}",
),
)
check(
"media-permissions-enforced",
ev(
"homescreen",
"try{await VulpesCompat.call('media.request',{type:'pictures',operation:'get',name:'vulpes-regression.png'});return false;}catch(e){return e.name==='PERMISSION_DENIED';}",
),
)
launch("music")
check(
"music-import",
ev(
"music",
"const s=navigator.getDeviceStorage('music');await s.delete('Vulpes-test.opus');const r=await fetch('http://clock.localhost:8765/shared/resources/media/alarms/ac_awake.opus');if(!r.ok)return false;await s.addNamed(await r.blob(),'Vulpes-test.opus');return true;",
),
)
tap("music", "document.querySelector('button[value=\"songs\"]')")
time.sleep(0.5)
if not ev("music", "return location.hash==='#/songs';"):
tap("music", "document.querySelector('button[value=\"songs\"]')")
check(
"music-songs-tab",
wait(
"music",
"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('/songs/'));",
),
)
check(
"music-song-indexed",
wait(
"music",
"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('/songs/') && f.contentDocument?.body?.innerText.includes('Vulpes-test'));",
),
)
check(
"music-toolbar-styled",
ev(
"music",
"const b=document.querySelector('button[value=songs]');return b.getBoundingClientRect().width>=80 && getComputedStyle(b).backgroundColor==='rgba(0, 0, 0, 0)';",
),
)
shot("music-songs")
tap("music", "document.querySelector('.gfl-item')", "/views/songs/")
check(
"music-plays-audio",
wait(
"music",
"const a=document.querySelector('audio');return !a.paused && a.currentTime>0;",
),
)
ev("music", "document.querySelector('audio').pause();return true;")
launch("clock")
tap("clock", "document.querySelector('#alarm-new')")
check(
"alarm-editor-opens",
wait(
"clock",
"return location.hash==='#alarm-edit-panel' && !!document.querySelector('#alarm-name');",
),
)
ev(
"clock",
"document.querySelector('#alarm-name').value="
+ json.dumps(alarm_label)
+ ";return true;",
)
tap("clock", "document.querySelector('#alarm-done')")
check(
"alarm-saved-through-ui",
wait(
"clock",
"const db=require('alarm_database');const alarm=(await db.getAll()).find(a=>a.label==="
+ json.dumps(alarm_label)
+ ");return !!alarm?.registeredAlarms.normal && document.body.innerText.includes("
+ json.dumps(alarm_label)
+ ");",
),
)
check(
"alarm-delivery",
ev(
"clock",
"""const list=await navigator.mozAlarms.getAll();const saved=(await require('alarm_database').getAll()).find(a=>a.label===ALARM_LABEL);const alarm=list.find(a=>a.data?.id===saved?.id && a.data?.type==='normal');if(!alarm)return false;await navigator.mozAlarms.add(new Date(Date.now()+700),'honorTimezone',alarm.data);return true;""".replace(
"ALARM_LABEL", json.dumps(alarm_label)
),
),
)
check(
"alarm-ring-view",
wait(
"clock",
"return !!document.querySelector('iframe[data-vulpes-attention]')?.contentDocument?.querySelector('#ring-button-stop');",
),
)
shot("alarm-ring")
check(
"alarm-plays-audio",
wait(
"clock",
"const w=document.querySelector('iframe[data-vulpes-attention]')?.contentWindow;return w?.ringView?.ringtonePlayer?._audio?.paused===false;",
),
)
tap("clock", "document.querySelector('#ring-button-stop')", "/onring.html")
check(
"alarm-stop-button",
wait("clock", "return !document.querySelector('iframe[data-vulpes-attention]');"),
)
check(
"alarm-no-uncaught-errors",
ev(
"clock",
"return !window.__vulpesDiagnostics.some(d=>d.kind==='error'||d.kind==='rejection');",
),
)
check(
"alarm-persistence-scheduled",
ev(
"clock",
"await navigator.mozAlarms.add(new Date(Date.now()+86400000),'honorTimezone',{vulpesRegression:'persistence'});return true;",
),
)
launch("settings")
check(
"settings-switch-type-size",
ev(
"settings",
"return [...document.querySelectorAll('#root gaia-switch label')].filter(e=>e.getBoundingClientRect().height).every(e=>parseFloat(getComputedStyle(e).fontSize)>=19);",
),
)
shot("settings-switches")
launch("email")
check(
"email-field-appearance",
wait(
"email",
"const e=document.querySelector('input[type=email]');return e && getComputedStyle(e).appearance==='none';",
),
)
for app in ["costcontrol", "fm"]:
launch(app)
check(
app + "-desktop-capability",
ev(
app,
"return !document.querySelector('.vulpes-capability') && document.body.innerText.length>0 && !window.__vulpesDiagnostics.some(d=>d.kind==='error'||d.kind==='rejection');",
),
)
focus()
subprocess.run(["xdotool", "mousemove", "240", "820", "mousedown", "1"], check=True)
time.sleep(0.85)
subprocess.run(["xdotool", "mouseup", "1"], check=True)
check(
"long-home-task-manager",
wait(
"system",
"return document.querySelector('#screen').classList.contains('cards-view');",
),
)
shot("task-manager")
before = ev(
"system", "return [...document.querySelectorAll('.appWindow iframe')].map(f=>f.src);"
)
tap(
"system",
"[...document.querySelectorAll('.close-button')].find(e=>{const r=e.getBoundingClientRect();return r.x>=0&&r.right<480;})",
)
check(
"task-manager-closes-app",
wait(
"system",
"const before="
+ json.dumps(before)
+ ";const after=[...document.querySelectorAll('.appWindow iframe')].map(f=>f.src);return before.some(url=>!after.includes(url));",
),
)
ev("system", "dispatchEvent(new CustomEvent('home'));return true;")
time.sleep(0.5)
check(
"home-transparent-chrome",
ev(
"system",
"return getComputedStyle(document.querySelector('#homescreen > .chrome')).backgroundColor==='rgba(0, 0, 0, 0)';",
),
)
check(
"browser-localized-name",
ev(
"system",
"const a=(await navigator.mozApps.mgmt.getAll()).find(a=>a.origin.includes('search.localhost'));return a.manifest.locales.fr.name==='Navigateur' && Object.values(a.manifest.icons).every(i=>i.includes('vulpes-browser.png'));",
),
)
shot("home-transparent")
except Exception:
shot("apps-failure")
print(
"FAILURE CLOCK",
json.dumps(
ev(
"clock",
"return {body:document.body.innerText,hash:location.hash,errors:window.__vulpesDiagnostics,button:document.querySelector('#alarm-new')?.getBoundingClientRect().toJSON()};",
)
),
flush=True,
)
raise
finally:
(
ROOT
/ "logs"
/ ("apps-restart-results.json" if args.restart_check else "apps-results.json")
).write_text(json.dumps(results, indent=2) + "\n")

90
tools/test-call-screen.py Normal file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Test original Gaia call/power UI with a simulated modem in the test profile.
Start with VULPES_TEST_TELEPHONY=1 python3 tools/run-host.py --test.
No real calls, poweroff or host shutdown are performed.
"""
import sys, importlib.util, time, json
sys.path.insert(0,'tools')
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe','tools/probe-desktop.py')
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
with Client(2857) as c:
p.register(c)
def ev(app,code):
r=p.evaluate(c,app,code,"/dialer/" if app == "communications" else "/")
if r.get('missing'):return None
assert 'error' not in r,r
return r.get('value')
def wait(app,code):
for _ in range(150):
result=ev(app,code)
if result:return result
time.sleep(.2)
raise AssertionError((app,code))
wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';")
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
time.sleep(1)
assert ev('system','return !!navigator.mozTelephony;'), 'Set VULPES_TEST_TELEPHONY=1 for this test'
c.execute("""const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
const original=Runtime.platform.request.bind(Runtime.platform);
Runtime.testCalls=[];Runtime.testCommands=[];Runtime.testHistory=[];
Runtime.platform.request=async (actor,app,op,params)=>{
if(op==='capabilities')return {...await original(actor,app,op,params),callControl:true};
if(op==='calls')return Runtime.testCalls;
if(op==='callAudio')return {muted:false,speaker:false,volume:1};
if(op==='callHistory')return Runtime.testHistory;
if(op==='ackCallHistory'){Runtime.testHistory=Runtime.testHistory.filter(i=>!params.ids.includes(i.id));return null;}
if(['answer','hangup'].includes(op)){Runtime.testCommands.push({op,params});
if(Runtime.failCommand)throw Error('RADIO_BUSY');
if(op==='answer')Runtime.testCalls[0].state='active';else Runtime.testCalls=[];return {};}
return original(actor,app,op,params);
};return true;""")
def calls(state):
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.testCalls="+json.dumps([{'path':'/ril_0/voicecall01','number':'0123456789','state':state}] if state else [])+";return true;")
calls('dialing')
wait('callscreen','return document.body?.innerText.includes("0123456789");')
assert wait('system','return document.querySelector(".callscreenWindow")?.classList.contains("active");')
for state,label in [('alerting','alerting'),('active','connected'),('held','held')]:
calls(state);wait('callscreen',f'return navigator.mozTelephony?.calls[0]?.state==={json.dumps(label)};')
calls('active')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="connected";')
ev('callscreen','document.getElementById("callbar-hang-up").click();return true;')
wait('system','return navigator.mozTelephony.calls.length===0;')
wait('system','return !document.querySelector(".callscreenWindow")?.classList.contains("active");')
calls('incoming')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="incoming" && document.body?.innerText.includes("0123456789");')
assert c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');return w.document.getElementById('screen').getBoundingClientRect().bottom<=w.document.getElementById('home').getBoundingClientRect().top+1;"), 'Home overlaps incoming-call controls'
ev('callscreen','document.getElementById("callbar-answer").click();return true;')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="connected";')
calls(None)
wait('system','return !document.querySelector(".callscreenWindow")?.classList.contains("active");')
print('PASS: original outgoing/incoming Gaia UI, answer, hangup, automatic close.',flush=True)
# Journal is imported into the original Gaia call log and acknowledged once.
stamp=int(time.time()*1000)
entry={'id':str(stamp),'date':stamp,'number':'0123456789','duration':4000,'direction':'outgoing','connectedAt':stamp}
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.testHistory="+json.dumps([entry])+";return true;")
ev('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://communications.localhost:8765/manifest.webapp",entryPoint:"dialer"});return true;')
wait('communications','return document.readyState==="complete" && document.getElementById("edit-mode-header") && window.Navigation?.currentView==="keypad";')
ev('communications','document.getElementById("option-recents").click();return true;')
wait('communications','return document.body?.innerText.includes("0123456789");')
for _ in range(30):
if c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');return Runtime.testHistory.length;")==0:break
time.sleep(.2)
else:raise AssertionError('Call history was not acknowledged')
print('PASS: original call log imports the completed call.',flush=True)
ev('system','dispatchEvent(new Event("home"));return true;')
def key(edge):ev('system','dispatchEvent(new CustomEvent("vulpes-service-event",{detail:{type:"hardware-key",data:{type:'+json.dumps(edge)+'}}}));return true;')
key('sleep-button-press');time.sleep(.15);key('sleep-button-release')
wait('system','return navigator.mozPower.screenEnabled===false;')
key('sleep-button-press');time.sleep(.15);key('sleep-button-release')
wait('system','return navigator.mozPower.screenEnabled===true;')
key('sleep-button-press');time.sleep(1)
assert ev('system','return document.getElementById("sleep-menu").classList.contains("visible");')
key('sleep-button-release');ev('system','dispatchEvent(new Event("home"));return true;')
key('volume-up-button-press');time.sleep(.15);key('volume-up-button-release')
assert ev('system','return document.getElementById("volume").classList.contains("visible");')
ev('system','navigator.battery.level=.73;navigator.battery.charging=true;navigator.battery.dispatchEvent(new Event("levelchange"));return true;')
assert ev('system','const i=document.getElementById("statusbar-battery");return !i.hidden && i.dataset.level==="70" && i.dataset.charging==="true";')
print('PASS: short Power press sleeps/wakes, long press shows Gaia menu, Gaia volume and battery icons.',flush=True)
print('No real modem operations or poweroff performed.')
c.execute('Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);return true;')

137
tools/test-camera.py Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Exercise the original Gaia Camera and Gallery with a simulated desktop camera."""
import importlib.util
import json
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
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)
def ev(app, code):
reply = probe.evaluate(c, app, code)
if "error" in reply:
raise AssertionError(reply)
return reply.get("value")
def wait(app, code):
for _ in range(120):
result = ev(app, code)
if result:
return result
time.sleep(0.15)
raise TimeoutError(app + ": " + code)
def launch(app):
ev(
"system",
'await VulpesCompat.call("apps.launch",{manifestURL:"http://'
+ app
+ '.localhost:8765/manifest.webapp"});return true;',
)
wait(app, 'return document.readyState==="complete" && !document.hidden;')
wait("system", 'return document.body?.getAttribute("ready-state")==="fullyLoaded";')
c.execute(
'const w=Services.wm.getMostRecentWindow("vulpes:host");w.focus();w.document.getElementById("system").focus();return true;'
)
c.execute(
'Services.prefs.setBoolPref("media.navigator.streams.fake",true);Services.prefs.setBoolPref("media.navigator.permission.fake",true);const p=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI("http://camera.localhost:8765"),{});Services.perms.addFromPrincipal(p,"camera",Ci.nsIPermissionManager.ALLOW_ACTION);return true;'
)
try:
ev("system", 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
launch("camera")
ev("camera", "setTimeout(()=>location.reload(),0);return true;")
time.sleep(2)
wait(
"camera",
'return !!window.app?.loaded && !!window.app?.camera?.mozCamera && !app.camera.isBusy && !!document.querySelector(".viewfinder video")?.videoWidth;',
)
results["original_gaia_ui"] = ev(
"camera",
'return !!window.app && !!document.querySelector(".js-capture") && !document.querySelector(".vulpes-capability");',
)
assert results["original_gaia_ui"]
results["preview"] = ev(
"camera",
'const v=document.querySelector(".viewfinder video");return {width:v.videoWidth,height:v.videoHeight};',
)
results["unavailable_controls"] = ev(
"camera",
'return [".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true");',
)
assert results["unavailable_controls"]
ev(
"camera",
'window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;document.querySelector(".js-flash").click();document.querySelector(".mode-switch .inner").click();return true;',
)
assert ev(
"camera",
'return app.camera.mode==="picture" && app.camera.mozCamera.flashMode==="off";',
)
# Native touch injection targets the camera frame even on high-DPI desktops.
point = ev("camera", 'const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};')
script = """const done=arguments[arguments.length-1];
const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://camera.localhost:8765/'))return b;for(const child of b.children){const result=find(child);if(result)return result;}}
find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',POINT).then(()=>done(true),e=>done({error:String(e)}));""".replace('POINT',json.dumps(point))
assert c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],
'newSandbox':True,'sandbox':'system','scriptTimeout':10000})['value'] is True
photo = wait(
"camera",
'return (await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).find(f=>!window.__beforeCapture.includes(f.name));',
)
assert photo["type"] == "image/jpeg" and photo["size"] > 0, photo
results["photo"] = photo
launch("gallery")
results["camera_stops_when_hidden"] = wait(
"camera", 'return window.__streamBefore.getTracks().every(t=>t.readyState==="ended");'
)
photo_name = json.dumps(photo["name"])
results["gallery_indexes_photo"] = wait(
"gallery",
'return typeof files!=="undefined" && files.some(f=>f.name==='
+ photo_name
+ " && f.metadata.width>0 && f.metadata.thumbnail);",
)
assert results["gallery_indexes_photo"]
ev(
"gallery",
'const e=[...document.querySelectorAll(".thumbnailImage")].find(e=>e.dataset.filename==='
+ photo_name
+ ");e.click();return true;",
)
results["gallery_opens_photo"] = wait(
"gallery", 'return document.body.classList.contains("fullscreenView");'
)
results["gallery_import_button"] = ev(
"gallery", 'return !!document.querySelector("gaia-header [data-vulpes-import]") && !document.querySelector(".vulpes-import");'
)
assert results["gallery_import_button"]
ev("gallery", "launchCameraApp();return true;")
wait("camera", "return !document.hidden;")
results["camera_resumes"] = wait(
"camera",
'return !!app.camera.mozCamera && app.camera.mozCamera!==window.__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy;',
)
results["camera_errors"] = ev(
"camera",
'return __vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection");',
)
assert not results["camera_errors"], results
launch("gallery")
finally:
c.execute(
'Services.prefs.clearUserPref("media.navigator.streams.fake");Services.prefs.clearUserPref("media.navigator.permission.fake");return true;'
)
(ROOT / "logs/camera-desktop-results.json").write_text(
json.dumps(results, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(results, indent=2, ensure_ascii=False))

View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Check the shared Information panel in a real Gecko test host."""
import importlib.util
import json
from pathlib import Path
import time
from marionette_client import Client
from project import ROOT, release_info
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)
release = release_info()
with Client(2857) as client:
probe.register(client)
def evaluate(app, code):
response = probe.evaluate(client, app, code)
if 'error' in response:
raise AssertionError(response)
return response.get('value')
def wait(app, code):
for _ in range(100):
value = evaluate(app, code)
if value:
return value
time.sleep(.15)
raise TimeoutError(code)
wait('system', 'return !!window.VulpesCompat;')
evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
wait('settings', 'return document.readyState === "complete";')
evaluate('settings', """document.querySelector('#root a[href="#about"]').click();return true;""")
wait('settings', 'return !!document.querySelector("#about.current #vulpes-system-info");')
rows = evaluate('settings', 'return Object.fromEntries(Array.from(document.querySelectorAll("#vulpes-system-info > li"),li=>[li.dataset.component, {label:li.querySelector("span").textContent,value:li.querySelector("small").textContent}]));')
assert rows['vulpes']['value'] == release['version'], rows
assert rows['gaia']['value'] == release['gaia'], rows
assert rows['gecko']['value'] == client.execute('return Services.appinfo.platformVersion;'), rows
assert rows['adapter']['value'] == release['adapters']['desktop']+'-desktop', rows
assert 'tundra' not in rows, rows
assert rows['model']['label'] == 'Modèle', rows
assert evaluate('settings', """return ['deviceinfo.product_model','deviceinfo.software'].every(key=>getComputedStyle(document.querySelector('#about [data-name="'+key+'"]').closest('li')).display==='none');""")
evaluate('settings', 'document.documentElement.lang="en-US";return true;')
wait('settings', 'return document.querySelector("[data-component=model] span").textContent==="Model";')
assert evaluate('settings', 'return document.querySelector("[data-component=adapter] span").textContent;') == 'Vulpes adapter'
evaluate('settings', 'document.documentElement.lang="fr";return true;')
wait('settings', 'return document.querySelector("[data-component=model] span").textContent==="Modèle";')
report = {'passed':True,'rows':rows,'localization':['fr','en-US'],'duplicateLegacyRowsHidden':True}
(ROOT/'logs/device-information-test.json').write_text(json.dumps(report,indent=2,ensure_ascii=False)+'\n')
print(json.dumps(report,indent=2,ensure_ascii=False))

View file

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Check reported Gaia layout, brightness, local homepage and navigation regressions."""
import sys,importlib.util,json,time
from pathlib import Path
sys.path.insert(0,str(Path.cwd()/'tools'))
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe','tools/probe-desktop.py');p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
results={}
with Client(2857) as c:
p.register(c)
def ev(app,code,path='/'):
r=p.evaluate(c,app,code,path);assert 'error' not in r,r;return r.get('value')
def wait(app,code,path='/'):
for _ in range(80):
r=ev(app,code,path)
if r:return r
time.sleep(.15)
raise AssertionError((app,code))
def launch(app,entry=None):
ev('system','await VulpesCompat.call("apps.launch",'+json.dumps({'manifestURL':f'http://{app}.localhost:8765/manifest.webapp',**({'entryPoint':entry} if entry else {})})+');return true;')
wait(app,'return document.readyState==="complete" && !!window.VulpesGaiaUI;')
wait('system','return document.body?.getAttribute("ready-state")==="fullyLoaded";')
ev('system','lockScreen.unlock(true);ScreenManager.turnScreenOn(true);navigator.mozSettings.createLock().set({"screen.timeout":0});return true;')
launch('communications','dialer')
for tab in ['recents','contacts','keypad']:
ident={'recents':'option-recents','contacts':'option-contacts','keypad':'option-keypad'}[tab]
ev('communications',f'document.getElementById("{ident}").click();return true;')
time.sleep(.5)
r=ev('communications','return {scrollY,header:[...document.querySelectorAll("gaia-header")].filter(e=>e.getBoundingClientRect().width>0).map(e=>e.getBoundingClientRect().top)};')
assert r['scrollY']==0,r
results[tab]=r
launch('settings');wait('settings','return typeof require==="function";')
ev('settings','require(["modules/settings_service"],s=>s.navigate("display"));return true;')
wait('settings','return !!document.querySelector("#display.current .brightness-manual input");')
ev('settings','const e=document.querySelector("#display .brightness-manual input");e.value="0.4";e.dispatchEvent(new Event("input",{bubbles:true}));e.dispatchEvent(new Event("change",{bubbles:true}));return true;')
results['brightness']=wait('system','return typeof ScreenManager._userBrightness==="number" && Math.abs(navigator.mozPower.screenBrightness-0.4)<.02 ? {user:ScreenManager._userBrightness,power:navigator.mozPower.screenBrightness}:null;')
results['select']=ev('settings','const e=document.querySelector("#display select"),s=getComputedStyle(e);return {top:s.top,lineHeight:s.lineHeight,padding:s.padding,appearance:s.appearance};')
assert results['select']['top']=='0px',results['select']
launch('video')
results['video']=wait('video','const e=document.querySelector("#overlay-text"),r=e?.getBoundingClientRect();return r?.width>0 ? {left:r.left,right:r.right,width:innerWidth,overflow:e.scrollWidth>e.clientWidth}:null;')
assert results['video']['left']>=0 and results['video']['right']<=results['video']['width'] and not results['video']['overflow'],results['video']
launch('search')
results['browser']=wait('search','return document.querySelector("#vulpes-local-home")?.contentDocument?.querySelector("h1")?.textContent;','/newtab.html')
assert results['browser'] in ['Le Web vous attend.', 'The Web is yours.']
# App layouts must reserve navigation space; Home alone may overlay wallpaper.
for w,h in [(480,844),(844,480)]:
c.execute(f"Services.wm.getMostRecentWindow('vulpes:host').resizeTo({w},{h});")
time.sleep(.3)
launch('settings')
time.sleep(.8)
r=c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host'),s=w.document.getElementById('screen').getBoundingClientRect(),h=w.document.getElementById('home').getBoundingClientRect();return {s:s.toJSON(),h:h.toJSON(),landscape:w.innerWidth>w.innerHeight};")
assert r['s']['right']<=r['h']['left']+1 if r['landscape'] else r['s']['bottom']<=r['h']['top']+1,r
results['navigation-'+str(w)]=r
c.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();")
time.sleep(.6)
r=c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host'),d=w.document,s=d.getElementById('screen').getBoundingClientRect();return {active:d.documentElement.hasAttribute('home-active'),width:s.width,height:s.height,innerWidth:w.innerWidth,innerHeight:w.innerHeight};")
assert r['active'] and r['width']==r['innerWidth'] and r['height']==r['innerHeight'],r
results['wallpaper-'+str(w)]=r
c.execute("Services.wm.getMostRecentWindow('vulpes:host').resizeTo(480,844);")
Path('logs/feedback-preview10-checks.json').write_text(json.dumps(results,indent=2)+'\n')
print(json.dumps(results,indent=2))

351
tools/test-feedback.py Normal file
View file

@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""Regression checks for the September desktop feedback, in actual Gaia UI."""
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--restart-check", action="store_true")
args = parser.parse_args()
results = {}
with Client(2857) as client:
engine = client.execute("return Services.appinfo.platformVersion;")
assert (
client.execute("return PathUtils.profileDir;").split("/")[-1].startswith("host-")
), "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, path="/"):
prefix = "http://" + app + ".localhost:8765" + path
script = """const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
if(!w?.document.getElementById('system')){done({missing:true});return;}
const bc=(function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const c of b.children){const f=find(c);if(f)return f;}return null;})(w.document.getElementById('system').browsingContext);
if(!bc){done({missing:true});return;}bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(result=>done({result}),error=>done({error:String(error)}));""".replace(
"PREFIX", json.dumps(prefix)
).replace(
"CODE", json.dumps(code)
)
r = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": script,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
if "error" in r:
raise AssertionError(r)
return r.get("result")
def wait(app, code, path="/"):
for _ in range(70):
r = ev(app, code, path)
if r:
return r
time.sleep(0.15)
raise AssertionError("Timeout: " + app + " " + code)
def check(name, condition):
results[name] = condition
print(name + ": " + json.dumps(condition, ensure_ascii=False), flush=True)
assert condition is True, (name, condition)
def launch(app, entry=None):
ev(
"system",
'await VulpesCompat.call("apps.launch",'
+ json.dumps(
{"manifestURL": f"http://{app}.localhost:8765/manifest.webapp", "entryPoint": entry}
)
+ ");return true;",
)
wait(app, "return document.readyState==='complete';")
time.sleep(0.5)
def focus():
client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;"
)
subprocess.run(
["xdotool", "search", "--name", "^Vulpes", "windowraise", "windowfocus"],
check=True,
stdout=subprocess.DEVNULL,
)
def tap(app, selector, path="/"):
pos = ev(
app,
"const e="
+ selector
+ ';e.scrollIntoView({block:"nearest"});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};',
path,
)
focus()
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
time.sleep(0.4)
def shot(name):
focus()
subprocess.run(
["import", "-window", "root", str(ROOT / "logs" / ("feedback-" + name + ".png"))],
check=True,
)
try:
wait(
"system",
"return document.body?.getAttribute('ready-state')==='fullyLoaded' && !!document.querySelector('#statusbar-time');",
)
ev("system", "ScreenManager.turnScreenOn(true);return true;")
time.sleep(.5)
ev("system", "lockScreen.unlock(true);return true;")
wait("system", "return !lockScreen.locked;")
time.sleep(.5)
if args.restart_check:
launch("communications", "contacts")
check(
"contacts-survive-restart",
ev(
"communications",
"const c=await navigator.mozContacts.find({filterBy:['name'],filterValue:'Vulpes Regression',filterOp:'equals'});return c.length===1 && c[0].tel[0].value==='0612345678' && Object.prototype.toString.call(c[0].updated)==='[object Date]' && Number.isFinite(c[0].updated.getTime());",
"/contacts/",
),
)
check(
"passcode-survives-restart",
ev(
"system",
"await LazyLoader.load('/shared/js/passcode_helper.js');return await PasscodeHelper.check('9876');",
),
)
raise SystemExit(0)
check(
"clock-at-right",
ev(
"system",
"const b=document.querySelector('#statusbar-time').getBoundingClientRect();return b.right>innerWidth-15 && b.left>innerWidth/2;",
),
)
check(
"status-tray-icons",
ev(
"system",
"UtilityTray.show(true);await new Promise(r=>setTimeout(r,300));return [...document.querySelectorAll('#quick-settings a')].filter(e=>e.getBoundingClientRect().width>0 && e.getBoundingClientRect().top>0).length>=4;",
),
)
ev("system", "UtilityTray.hide(true);return true;")
launch("communications", "dialer")
tap("communications", "document.querySelector('[data-value=\"1\"]')", "/dialer/")
tap("communications", "document.querySelector('[data-value=\"2\"]')", "/dialer/")
check(
"dialpad-physical-taps",
ev("communications", "return KeypadManager._phoneNumber.endsWith('12');", "/dialer/"),
)
tap("communications", "document.getElementById('option-recents')", "/dialer/")
check(
"dialer-recents-tab",
wait(
"communications",
"return document.body.innerText.includes('Journal d’appels');",
"/dialer/",
),
)
tap("communications", "document.getElementById('option-contacts')", "/dialer/")
check(
"dialer-contacts-tab",
wait(
"communications",
"return document.readyState==='complete' && document.body.innerText.includes('Contacts');",
"/contacts/",
),
)
shot("contacts")
check(
"contact-store-roundtrip",
ev(
"communications",
"""const old=await navigator.mozContacts.find({filterBy:['name'],filterValue:'Vulpes Regression',filterOp:'equals'});for(const c of old)await navigator.mozContacts.remove(c);const c=new mozContact({name:['Vulpes Regression'],givenName:['Vulpes'],familyName:['Regression'],tel:[{value:'0612345678',type:['mobile']}]});await navigator.mozContacts.save(c);window.__testContactId=c.id;const list=await navigator.mozContacts.find({filterBy:['id'],filterValue:c.id,filterOp:'equals'});return list.length===1 && list[0].name[0]==='Vulpes Regression';""",
"/contacts/",
),
)
check(
"contact-list-updates",
wait(
"communications",
"return document.body.innerText.includes('Regression');",
"/contacts/",
),
)
check(
"contacts-permission-isolation",
ev(
"homescreen",
"const r=await navigator.vulpesRequest({version:1,id:500,method:'contacts.request',params:{operation:'all'}});return r.error?.code==='PERMISSION_DENIED';",
),
)
launch("sms")
check(
"sms-empty-view",
wait("sms", "return document.body.innerText.includes('Aucun message');"),
)
shot("sms")
tap("sms", "document.querySelector('#threads-composer-link')")
check(
"sms-composer-ready",
wait(
"sms",
"return location.hash==='#/composer' && !!document.querySelector('#messages-input');",
),
)
tap("sms", "document.querySelector('#messages-input')")
subprocess.run(["xdotool", "type", "--clearmodifiers", "Brouillon Vulpes"], check=True)
check(
"sms-composer-typing",
wait(
"sms",
"return document.querySelector('#messages-input').textContent.includes('Brouillon Vulpes');",
),
)
check(
"sms-no-fake-send",
ev(
"sms",
"try{await navigator.mozMobileMessage.send('0612345678','test');return false;}catch(e){return e.name==='RadioDisabledError';}",
),
)
check(
"sms-segment-count",
ev(
"sms",
"const s=await navigator.mozMobileMessage.getSegmentInfoForText('a'.repeat(161));return s.segments===2 && s.charsPerSegment===153;",
),
)
launch("settings")
ev(
"settings",
"await new Promise(r=>require(['modules/settings_service'],s=>{s.navigate('root');r();}));return true;",
)
wait("settings", "return !!document.querySelector('#root.current');")
time.sleep(0.5)
tap("settings", "document.querySelector('a[href=\"#about\"]')")
wait("settings", "return !!document.querySelector('#about.current');")
check(
"back-arrow-visible",
ev(
"settings",
"const h=document.querySelector('#about gaia-header'),b=h.shadowRoot.querySelector('button');return getComputedStyle(h).display==='block' && getComputedStyle(b,'::before').content==='\"left\"' && b.getBoundingClientRect().width===50;",
),
)
tap("settings", "document.querySelector('a[href=\"#about-moreInfo\"]')")
check(
"actual-versions-in-panel",
wait(
"settings",
"return document.querySelector('#about-moreInfo.current') && document.body.innerText.includes("
+ json.dumps(engine)
+ ") && document.body.innerText.includes('2.7');",
),
)
shot("versions")
check(
"passcode-byte-roundtrip",
ev(
"system",
"await LazyLoader.load('/shared/js/passcode_helper.js');await PasscodeHelper.set('9876');const yes=await PasscodeHelper.check('9876'),no=await PasscodeHelper.check('1111');return yes && !no;",
),
)
check(
"iac-forged-route-denied",
ev(
"homescreen",
"try{await VulpesCompat.call('iac.connect',{keyword:'search'});return false;}catch(e){return e.name==='CONNECTION_DENIED';}",
),
)
launch("search")
check(
"firefox-newtab-ready",
wait(
"search",
"return document.readyState==='complete' && !!document.querySelector('#top-sites');",
"/newtab.html",
),
)
tap("system", "document.querySelector('.appWindow.active .urlbar-hit-area')")
wait("system", "return document.querySelector('#rocketbar').classList.contains('active');")
tap("system", "document.querySelector('#rocketbar-input')")
subprocess.run(
[
"xdotool",
"key",
"ctrl+a",
"type",
"--clearmodifiers",
"--delay",
"40",
"https://example.com",
],
check=True,
)
check(
"rocketbar-physical-typing",
ev(
"system",
"return document.querySelector('#rocketbar-input').value==='https://example.com';",
),
)
subprocess.run(["xdotool", "key", "Return"], check=True)
check(
"rocketbar-submit-creates-browser",
wait(
"system",
"return [...document.querySelectorAll('iframe')].some(f=>f.src==='https://example.com/');",
),
)
time.sleep(1)
shot("browser")
client.execute(
"Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();return true;"
)
time.sleep(0.7)
check(
"browser-return-home",
client.execute(
"return [...Services.wm.getMostRecentWindow('vulpes:host').document.querySelectorAll('browser')].filter(b=>b.id!=='system').every(b=>b.style.visibility==='hidden');"
),
)
check(
"apps-fill-height",
ev(
"system",
"return !document.querySelector('#screen').classList.contains('software-button-enabled');",
),
)
print(
"APP DIAGNOSTICS",
json.dumps(
{
a: ev(a, "return window.__vulpesDiagnostics;")
for a in ["system", "communications", "sms", "settings", "search"]
},
ensure_ascii=False,
),
flush=True,
)
finally:
(
ROOT
/ "logs"
/ ("feedback-restart-results.json" if args.restart_check else "feedback-results.json")
).write_text(json.dumps(results, indent=2) + "\n")

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Exercise a locked installation using a small local archive, without network access."""
import hashlib
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
class LockedEngineTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
(self.root / "tools").mkdir()
(self.root / "downloads").mkdir()
shutil.copy2(
Path(__file__).with_name("fetch-engine.py"), self.root / "tools/fetch-engine.py"
)
self.archive = self.root / "downloads/firefox-1.0.tar.xz"
with tarfile.open(self.archive, "w:xz") as package:
content = b"#!/bin/sh\nexit 0\n"
member = tarfile.TarInfo("firefox/firefox")
member.mode = 0o755
member.size = len(content)
package.addfile(member, io.BytesIO(content))
self.lock = {
"version": "1.0",
"runtime": "engines/1.0/firefox",
"archive": "https://example.invalid/firefox-1.0.tar.xz",
"sha512": hashlib.sha512(self.archive.read_bytes()).hexdigest(),
}
(self.root / "engine-lock.json").write_text(json.dumps(self.lock))
(self.root / "candidate-engine.json").write_text("existing candidate\n")
def run_installer(self):
return subprocess.run(
[sys.executable, "tools/fetch-engine.py", "--locked", "--offline"],
cwd=self.root,
capture_output=True,
text=True,
)
def test_install_and_repeat_preserve_candidate_and_runtime(self):
self.assertEqual(self.run_installer().returncode, 0)
binary = self.root / "engines/1.0/firefox/firefox"
self.assertTrue(binary.is_file())
self.assertTrue(binary.stat().st_mode & 0o111)
before = binary.stat().st_mtime_ns
self.assertEqual(self.run_installer().returncode, 0)
self.assertEqual(binary.stat().st_mtime_ns, before)
self.assertEqual((self.root / "candidate-engine.json").read_text(), "existing candidate\n")
def test_wrong_checksum_never_extracts(self):
self.archive.write_bytes(b"invalid")
result = self.run_installer()
self.assertNotEqual(result.returncode, 0)
self.assertIn("SHA512 mismatch", result.stderr)
self.assertFalse((self.root / "engines/1.0").exists())
def test_missing_offline_archive_fails_without_downloading(self):
self.archive.unlink()
result = self.run_installer()
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cached archive missing", result.stderr)
def test_incomplete_engine_is_preserved(self):
target = self.root / "engines/1.0"
target.mkdir(parents=True)
sentinel = target / "keep.txt"
sentinel.write_text("local work")
self.assertNotEqual(self.run_installer().returncode, 0)
self.assertEqual(sentinel.read_text(), "local work")
if __name__ == "__main__":
unittest.main()

114
tools/test-gaia-keyboard.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Exercise Gaia's keyboard in an isolated desktop profile. Never send a message."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import subprocess
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)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--touch-events', action='store_true')
parser.add_argument('--dpr', type=int, choices=(1, 3), default=1)
parser.add_argument('--rotation', action='store_true')
args = parser.parse_args()
if args.dpr != 1 and not args.touch_events:
parser.error('--dpr 3 requires --touch-events')
with Client(2857) as client:
probe.register(client)
def evaluate(app, code):
result = probe.evaluate(client, app, code)
if 'value' not in result:
raise RuntimeError(result)
return result['value']
def wait(app, code):
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
result=probe.evaluate(client, app, code)
if result.get('value'):
return
if 'error' in result:
raise RuntimeError(result['error'])
time.sleep(.15)
raise TimeoutError(code)
def tap(app, selector):
if app == 'keyboard':
selector = '.keyboard-type-container[data-active] ' + selector
rect = evaluate(app, 'return document.querySelector(' + json.dumps(selector) + ').getBoundingClientRect().toJSON();')
point = {'x': rect['x'] + rect['width'] / 2, 'y': rect['y'] + rect['height'] / 2}
if args.touch_events:
script = '''const done=arguments[arguments.length-1];
const w=Services.wm.getMostRecentWindow('vulpes:host');
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;
for(const c of b.children){const f=find(c);if(f)return f;}}
find(w.document.getElementById('system').browsingContext).currentWindowGlobal
.getActor('VulpesProbe').sendQuery('Tap',POINT).then(done,e=>done({error:String(e)}));'''
script = script.replace('PREFIX', json.dumps('http://' + app + '.localhost:8765/')).replace('POINT', json.dumps(point))
result = client.call('WebDriver:ExecuteAsyncScript', {
'script': script, 'args': [], 'newSandbox': True, 'sandbox': 'system',
})['value']
assert result is True, result
else:
frame_selector = '#vulpes-gaia-keyboard' if app == 'keyboard' else 'iframe[src^="http://' + app + '.localhost:8765/"]'
frame = evaluate('system', 'return document.querySelector(' + json.dumps(frame_selector) + ').getBoundingClientRect().toJSON();')
subprocess.run(['xdotool', 'search', '--name', '^Vulpes', 'windowraise',
'windowfocus', 'mousemove', '--window', '%1',
str(round(frame['x'] + point['x'])), str(round(frame['y'] + point['y'])),
'click', '1'], env=os.environ, check=True)
time.sleep(.35)
def size(width, height):
client.execute(f"const w=Services.wm.getMostRecentWindow('vulpes:host');w.document.documentElement.style.minWidth='0';w.document.documentElement.style.minHeight='0';w.resizeTo({width},{height});return true;")
time.sleep(.6)
wait('system', 'return document.body?.getAttribute("ready-state")==="fullyLoaded";')
client.execute(f"Services.prefs.setCharPref('layout.css.devPixelsPerPx','{args.dpr}');return true;")
size(360, 740)
evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
time.sleep(1)
evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
wait('sms', 'return !!window.Navigation;')
evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;')
tap('sms', '.recipient[contenteditable]')
wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");')
tap('keyboard', 'button[aria-label="2"]')
tap('keyboard', 'button[aria-label="3"]')
number = evaluate('sms', 'return document.querySelector(".recipient[contenteditable]").textContent;')
assert number.strip() == '23', number
numeric_height = evaluate('keyboard', 'return document.querySelector("button[aria-label=\\"2\\"]").getBoundingClientRect().height;')
assert numeric_height >= 44, numeric_height
tap('sms', '#messages-input')
wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");')
tap('keyboard', 'button[aria-label="a"]')
text = evaluate('sms', 'return document.querySelector("#messages-input").textContent;')
assert text.lower() == 'a', text
assert evaluate('sms', 'return document.querySelector(".recipient[contenteditable]").textContent;') == number, 'Letter changed the recipient'
evaluate('sms', 'getSelection().setPosition(document.querySelector("#messages-input").firstChild,0);return true;')
tap('keyboard', 'button[aria-label="b"]')
assert evaluate('sms', 'return document.querySelector("#messages-input").textContent.toLowerCase();') == 'ba'
tap('keyboard', 'button[data-keycode="8"]')
assert evaluate('sms', 'return document.querySelector("#messages-input").textContent.toLowerCase();') == 'a'
report = {'passed': True, 'sent': False, 'dpr': args.dpr,
'recipientUnchanged': True, 'messageTyped': True, 'caretAndBackspace': True,
'numericKeyHeight': numeric_height}
if args.rotation:
size(740, 360)
wait('keyboard', 'return !app.viewManager.screenInPortraitMode();')
report['landscape'] = evaluate('keyboard', 'return {width:innerWidth,height:innerHeight,rem:app.viewManager.getRemToPx(),font:parseFloat(getComputedStyle(document.documentElement).fontSize)};')
assert abs(report['landscape']['font'] - report['landscape']['width'] / 64) < .05
size(360, 740)
wait('keyboard', 'return app.viewManager.screenInPortraitMode();')
report['portrait'] = evaluate('keyboard', 'return {height:innerHeight,key:document.querySelector("button[aria-label=\\"a\\"]").getBoundingClientRect().height,font:parseFloat(getComputedStyle(document.documentElement).fontSize)};')
assert report['portrait']['key'] >= 44, report
print(json.dumps(report))

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Check original Gaia panels and navigation in the 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'))
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
results={}
with Client(2857) as c:
p.register(c)
def ev(app,code):
r=p.evaluate(c,app,code,'/contacts/' if app=='communications' else '/')
if r.get('missing'):return None
assert 'error' not in r,r
return r.get('value')
def wait(app,code):
for _ in range(100):
r=ev(app,code)
if r:return r
time.sleep(.15)
raise AssertionError((app,code))
def launch(app,entry=None):
ev('system','await VulpesCompat.call("apps.launch",'+json.dumps({'manifestURL':f'http://{app}.localhost:8765/manifest.webapp',**({'entryPoint':entry} if entry else {})})+');return true;')
wait(app,'return document.readyState==="complete" && !!window.VulpesGaiaUI;')
wait('system','return document.body?.getAttribute("ready-state")==="fullyLoaded";')
results['lockscreen_original_icons']=wait('system','return getComputedStyle(document.querySelector("#lockscreen-area-unlock > div")).backgroundImage.includes("lockscreen_unlock.png");')
ev('system','lockScreen.unlock(true);return true;')
wait('system','return !document.querySelector("#screen").classList.contains("locked");')
results['tray_date']=wait('system','return document.querySelector("#statusbar-operator")?.textContent;')
launch('settings')
time.sleep(.8)
results['navigation_reserved']=c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host'),s=w.document.getElementById('screen').getBoundingClientRect(),h=w.document.getElementById('home').getBoundingClientRect();return s.bottom<=h.top+1;")
assert results['navigation_reserved']
results['no_provisional_root']=ev('settings','return !document.getElementById("vulpes-platform-settings") && !document.querySelector(".vulpes-import");')
assert results['no_provisional_root']
results['restored_menus']=wait('settings','return ["wifi","bluetooth","carrier","call","messaging","sim-manager","simpin","findmydevice","addons","mediaStorage","applicationStorage"].every(id=>{const a=document.querySelector("#root a[href=\\"#"+id+"\\"]");return a && !a.closest("li").hidden;});')
ev('settings','document.querySelector("a[href=\\"#battery\\"]").click();return true;')
wait('settings','return !!document.querySelector("#battery-level");')
ev('settings','navigator.battery.level=.63;navigator.battery.dispatchEvent(new Event("levelchange"));return true;')
results['battery_interpolation']=wait('settings','return document.querySelector("#battery-level").textContent.includes("63") && !document.querySelector("#battery-level").textContent.includes("{{");')
ev('settings','require(["modules/settings_service"],s=>s.navigate("wifi"));return true;')
results['wifi_original_unavailable']=wait('settings','return !!document.querySelector("#wifi .wifi-availableNetworks") && document.querySelector("#wifi [data-vulpes-unavailable]")?.textContent;')
launch('communications','contacts')
wait('communications','return !!document.getElementById("settings-button") && !!document.getElementById("add-contact-button");')
ev('communications','document.getElementById("settings-button").click();return true;')
wait('communications','return !!document.querySelector("#importContacts button");')
ev('communications','document.querySelector("#importContacts button").click();return true;')
results['contact_import_original']=wait('communications','return document.getElementById("import-sd-option")?.querySelector("button")?.disabled===false && !!document.getElementById("import-sim-option-unavailable");')
results['contact_errors']=ev('communications','return __vulpesDiagnostics.filter(x=>/TypeError|ReferenceError/.test(x.message));')
assert not results['contact_errors'],results['contact_errors']
launch('gallery')
results['media_import_header']=wait('gallery','return !!document.querySelector("gaia-header [data-vulpes-import]") && !document.querySelector(".vulpes-import");')
ev('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://homescreen.localhost:8765/manifest.webapp"});return true;')
Path('logs/gaia-restoration-tests.json').write_text(json.dumps(results,indent=2,ensure_ascii=False)+'\n')
print(json.dumps(results,indent=2,ensure_ascii=False))

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Check the native navigation surface follows Gaia's active application."""
import importlib.util
import json
from pathlib import Path
import time
from marionette_client import Client
from project import ROOT
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)
with Client(2857) as c:
probe.register(c)
def state():
return c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host'),d=w.document,b=d.getElementById('home');return {home:d.documentElement.hasAttribute('home-active'),bottom:d.documentElement.hasAttribute('home-at-bottom'),height:w.innerHeight,screen:d.getElementById('screen').clientHeight,bar:b.clientHeight,background:w.getComputedStyle(b).backgroundImage,color:w.getComputedStyle(b).backgroundColor};")
def wait(home):
for _ in range(100):
s=state()
if s['home']==home and s['screen']==s['height']-(0 if home else s['bar']):return s
time.sleep(.15)
raise AssertionError(s)
c.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();")
home=wait(True);assert 'linear-gradient' in home['background'],home
c.execute("const b=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home');InspectorUtils.addPseudoClassLock(b,':active');")
pressed=state();assert pressed['background']==home['background'] and pressed['color']==home['color'],pressed
c.execute("InspectorUtils.removePseudoClassLock(Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home'),':active');")
probe.evaluate(c,'homescreen',"const p=document.querySelector('#apps-panel > .scrollable');p.scrollTop=p.scrollHeight;return true;")
for _ in range(100):
bottom=state()
if bottom['bottom']:break
time.sleep(.1)
assert bottom['background']=='none' and bottom['color']=='rgba(0, 0, 0, 0)',bottom
result=probe.evaluate(c,'system','await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
assert result.get('value'),result
app=wait(False);assert app['background']=='none' and app['color']=='rgb(18, 35, 50)',app
denied=probe.evaluate(c,'settings','try {await VulpesCompat.call("desktop.navigation",{home:true});return false;} catch(e) {return true;}')
assert denied.get('value'),denied
deniedScroll=probe.evaluate(c,'settings','try {await VulpesCompat.call("desktop.homeScroll",{bottom:true});return false;} catch(e) {return true;}')
assert deniedScroll.get('value'),deniedScroll
c.execute("Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('home').click();")
wait(True)
probe.evaluate(c,'homescreen',"document.querySelector('#apps-panel > .scrollable').scrollTop=0;return true;")
for _ in range(100):
top=state()
if not top['bottom']:break
time.sleep(.1)
assert 'linear-gradient' in top['background'],top
iconRecovery=probe.evaluate(c,'homescreen', '''
const source=document.querySelectorAll('gaia-app-icon')[1],box=document.createElement('div');
box.style.cssText='display:none;position:fixed;top:0;left:0;width:100px';
document.body.append(box);
const icon=document.createElement('gaia-app-icon');icon.style.width='100px';box.append(icon);icon.size=icon.clientWidth;icon.app=source.app;
try {
await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
const initial=icon.size;
box.style.display='block';
for(let i=0;i<100;i++){
const image=icon.shadowRoot?.querySelector('img');
if(image?.complete && image.naturalWidth>0)return {initial,width:icon.size,naturalWidth:image.naturalWidth};
await new Promise(r=>setTimeout(r,50));
}
return {initial,width:icon.size,failed:true};
} finally {box.remove();}
''')
assert iconRecovery.get('value',{}).get('initial')==0 and iconRecovery['value'].get('naturalWidth',0)>0,iconRecovery
report={'passed':True,'home':home,'pressed':pressed,'bottom':bottom,'application':app,'returnHome':top,'nonSystemCallerDenied':True,'nonHomeScrollCallerDenied':True,'zeroWidthIconRecovery':iconRecovery['value']}
(ROOT/'logs/home-surface-test.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))

448
tools/test-host.py Normal file
View file

@ -0,0 +1,448 @@
#!/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'<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vulpes test {self.path}</title><h1>Real Gecko navigation</h1>'
'<input id="text" autofocus><a href="/two">Next page</a>'
'<div style="height:2000px">Scroll test</div>'
).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:
last = evaluate(app, code)
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")

223
tools/test-languages.py Normal file
View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Native language select: physical clicks, keyboard, translation and restart."""
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--restart-check", action="store_true")
args = parser.parse_args()
results = {}
with Client(2857) as client:
engine = client.execute("return Services.appinfo.platformVersion;")
assert (
client.execute("return PathUtils.profileDir;").split("/")[-1].startswith("host-")
), "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, path="/"):
prefix = "http://" + app + ".localhost:8765" + path
script = """const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
if(!w?.document.getElementById('system')){done({missing:true});return;}
const bc=(function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const c of b.children){const f=find(c);if(f)return f;}return null;})(w.document.getElementById('system').browsingContext);
if(!bc){done({missing:true});return;}bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(result=>done({result}),error=>done({error:String(error)}));""".replace(
"PREFIX", json.dumps(prefix)
).replace(
"CODE", json.dumps(code)
)
r = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": script,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
if "error" in r:
raise AssertionError(r)
return r.get("result")
def wait(app, code, path="/"):
for _ in range(70):
r = ev(app, code, path)
if r:
return r
time.sleep(0.15)
raise AssertionError("Timeout: " + app + " " + code)
def check(name, condition):
results[name] = condition
print(name + ": " + json.dumps(condition, ensure_ascii=False), flush=True)
assert condition is True, (name, condition)
def launch(app, entry=None):
ev(
"system",
'await VulpesCompat.call("apps.launch",'
+ json.dumps(
{"manifestURL": f"http://{app}.localhost:8765/manifest.webapp", "entryPoint": entry}
)
+ ");return true;",
)
wait(app, "return document.readyState==='complete';")
wait(
"system",
"return [...document.querySelectorAll('.appWindow.active[transition-state=opened] iframe')].some(f=>f.src.startsWith('http://"
+ app
+ ".localhost:8765/'));",
)
time.sleep(0.5)
def focus():
client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;"
)
subprocess.run(
["xdotool", "search", "--name", "^Vulpes", "windowraise", "windowfocus"],
check=True,
stdout=subprocess.DEVNULL,
)
def tap(app, selector, path="/"):
pos = ev(
app,
"const e="
+ selector
+ ";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};",
path,
)
focus()
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
time.sleep(0.4)
def shot(name):
subprocess.run(
["import", "-window", "root", str(ROOT / "logs" / ("feedback-" + name + ".png"))],
check=True,
)
try:
wait("system", "return document.body?.getAttribute('ready-state')==='fullyLoaded';")
launch("settings")
wait(
"settings",
"return typeof require==='function' && !!document.querySelector('#root gaia-header');",
)
ev(
"settings",
"require(['modules/settings_service'],s=>s.navigate('languages'));return true;",
)
wait(
"settings",
"return document.querySelector('select[name=\"language.current\"]')?.options.length>10;",
)
wait(
"settings",
"const r=document.querySelector('select[name=\"language.current\"]').getBoundingClientRect();return r.x>=0 && r.x<innerWidth/2;",
)
time.sleep(0.5)
def state():
return client.execute(
"return Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('ContentSelectDropdown')?.menupopup?.state;"
)
def open_menu():
tap("settings", "document.querySelector('select[name=\"language.current\"]')")
for _ in range(30):
if state() == "open":
return
time.sleep(0.1)
raise AssertionError("Native select did not open")
if args.restart_check:
check(
"language-survives-restart",
ev(
"settings",
"return document.documentElement.lang==='fr' && document.querySelector('select[name=\"language.current\"]').value==='fr';",
),
)
else:
original = ev(
"settings",
"return document.querySelector('select[name=\"language.current\"]').value;",
)
open_menu()
check(
"native-language-menu-styled",
client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');const item=w.document.querySelector('#ContentSelectDropdownPopup menuitem');return w.getComputedStyle(item.querySelector('.menu-highlightable-text')).display==='none' && w.getComputedStyle(item.querySelector('.menu-text')).display!=='none';"
),
)
shot("languages-native-menu")
subprocess.run(["xdotool", "key", "Escape"], check=True)
check(
"language-cancel-preserves-value",
ev(
"settings",
"return document.querySelector('select[name=\"language.current\"]').value==="
+ json.dumps(original)
+ ";",
),
)
open_menu()
# Native menu keyboard selection exercises Gecko's input/change route.
subprocess.run(["xdotool", "key", "Home", *(["Down"] * 8), "Return"], check=True)
check(
"language-english-applied",
wait(
"settings",
"return document.documentElement.lang==='en-US' && document.querySelector('select[name=\"language.current\"]').value==='en-US' && document.querySelector('#languages h1').textContent.includes('Language');",
),
)
check(
"language-english-home",
wait("homescreen", "return document.documentElement.lang==='en-US';"),
)
open_menu()
# Select French with a real pointer click on the native menu item.
position = client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');const e=[...w.document.querySelectorAll('#ContentSelectDropdownPopup menuitem')].find(e=>e.getAttribute('label').includes('Français'));e.scrollIntoView();const b=e.getBoundingClientRect();return {x:w.mozInnerScreenX+b.x+b.width/2,y:w.mozInnerScreenY+b.y+b.height/2};"
)
subprocess.run(
[
"xdotool",
"mousemove",
str(round(position["x"])),
str(round(position["y"])),
"click",
"1",
],
check=True,
)
check(
"language-french-applied",
wait(
"settings",
"return document.documentElement.lang==='fr' && document.querySelector('select[name=\"language.current\"]').value==='fr' && document.querySelector('#languages h1').textContent.includes('Langue');",
),
)
check(
"language-french-home",
wait("homescreen", "return document.documentElement.lang==='fr';"),
)
shot("languages-french")
finally:
(
ROOT
/ "logs"
/ ("languages-restart-results.json" if args.restart_check else "languages-results.json")
).write_text(json.dumps(results, indent=2) + "\n")

253
tools/test-legacy-apps.py Normal file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Observe original archived apps in real Gaia; failures stay visible in the report."""
import argparse
import json
import subprocess
import time
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
p = argparse.ArgumentParser()
p.add_argument("--restart-check", action="store_true")
args = p.parse_args()
results = {"engine": None, "restart": args.restart_check, "apps": {}}
with Client(2857) as client:
profile = client.execute("return PathUtils.profileDir;")
assert "/host-legacy-" in profile, profile
results["engine"] = client.execute("return Services.appinfo.platformVersion;")
client.execute(
"try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}"
)
def ev(app, code):
script = """const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');
const b=w?.document.getElementById('system');if(!b){done(null);return;}
const bc=(function f(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;for(const c of b.children){const found=f(c);if(found)return found;}return null;})(b.browsingContext);
if(!bc){done(null);return;}bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',CODE).then(v=>done({value:v}),e=>done({error:String(e)}));""".replace(
"PREFIX", json.dumps("http://" + app + ".localhost:8765/")
).replace(
"CODE", json.dumps(code)
)
r = client.call(
"WebDriver:ExecuteAsyncScript",
{
"script": script,
"args": [],
"newSandbox": True,
"sandbox": "system",
"scriptTimeout": 15000,
},
)["value"]
if r and "error" in r:
raise RuntimeError(r["error"])
return r.get("value") if r else None
def wait(app, code, timeout=30):
end = time.monotonic() + timeout
while time.monotonic() < end:
r = ev(app, code)
if r:
return r
time.sleep(0.2)
raise RuntimeError("Timeout: " + app + " " + code)
def focus():
client.execute(
"const w=Services.wm.getMostRecentWindow('vulpes:host');w.focus();w.document.getElementById('system').focus();return true;"
)
subprocess.run(
["xdotool", "search", "--name", "^Vulpes", "windowraise", "windowfocus"],
check=True,
stdout=subprocess.DEVNULL,
)
def point(app, selector):
return ev(
app,
"const e=document.querySelector("
+ json.dumps(selector)
+ ');e.scrollIntoView({block:"center"});const b=e.getBoundingClientRect();return {x:mozInnerScreenX+b.x+b.width/2,y:mozInnerScreenY+b.y+b.height/2};',
)
def tap(app, selector):
pos = point(app, selector)
focus()
subprocess.run(
["xdotool", "mousemove", str(round(pos["x"])), str(round(pos["y"])), "click", "1"],
check=True,
)
time.sleep(0.3)
def shot(app):
focus()
subprocess.run(
[
"import",
"-window",
"root",
str(
ROOT
/ "logs"
/ ("archive-" + app + ("-restart" if args.restart_check else "") + ".png")
),
],
check=True,
)
wait("system", "return document.body?.getAttribute('ready-state')==='fullyLoaded';", 60)
for app in json.loads((ROOT / "tests/legacy-apps.json").read_text()):
name = app["id"]
r = {}
results["apps"][name] = r
try:
ev(
"system",
'await VulpesCompat.call("apps.launch",'
+ json.dumps({"manifestURL": "http://" + name + ".localhost:8765/manifest.webapp"})
+ ");return true;",
)
wait(name, "return document.readyState==='complete';")
wait(
"system",
"return [...document.querySelectorAll('.appWindow.active[transition-state=opened] iframe')].some(f=>f.src.startsWith('http://"
+ name
+ ".localhost:8765/'));",
)
time.sleep(1)
r["loaded"] = True
r["text"] = ev(name, "return document.body.innerText.slice(0,1800);")
if name == "legacy-2048-for-firefox-os":
wait(name, "return document.querySelectorAll('.tile').length>0;")
r["initialState"] = ev(
name, "return JSON.parse(localStorage.getItem('gameState'));"
)
if args.restart_check:
expected = json.loads((ROOT / "logs/legacy-game-state.json").read_text())
r["stateSurvivesRestart"] = r["initialState"] == expected
else:
tap(name, ".game-container")
focus()
subprocess.run(
[
"xdotool",
"key",
"--delay",
"150",
"Left",
"Up",
"Right",
"Down",
"Left",
"Up",
],
check=True,
)
time.sleep(0.4)
r["keyboardState"] = ev(
name, "return JSON.parse(localStorage.getItem('gameState'));"
)
pos = point(name, ".game-container")
focus()
subprocess.run(
[
"xdotool",
"mousemove",
str(round(pos["x"] + 80)),
str(round(pos["y"])),
"mousedown",
"1",
],
check=True,
)
for i in range(1, 11):
subprocess.run(
[
"xdotool",
"mousemove",
str(round(pos["x"] + 80 - i * 16)),
str(round(pos["y"])),
],
check=True,
)
time.sleep(0.025)
subprocess.run(["xdotool", "mouseup", "1"], check=True)
time.sleep(0.4)
r["swipeState"] = ev(
name, "return JSON.parse(localStorage.getItem('gameState'));"
)
r["keyboardChangesGame"] = r["initialState"] != r["keyboardState"]
r["swipeChangesGame"] = r["keyboardState"] != r["swipeState"]
(ROOT / "logs/legacy-game-state.json").write_text(json.dumps(r["swipeState"]))
r["layout"] = ev(
name,
"const b=document.querySelector('.game-container').getBoundingClientRect();return {width:innerWidth,board:{left:b.left,right:b.right,width:b.width},fits:b.left>=0&&b.right<=innerWidth};",
)
elif name == "legacy-calculator-2":
for key in ["C", "1", "2", "+", "3", "="]:
tap(name, 'input[value="' + key + '"]')
r["display"] = ev(
name, "return document.querySelector('#display div').textContent;"
)
r["twelvePlusThree"] = r["display"].strip() == "15"
else:
wait(name, "return !!document.querySelector('a[href=\"#/podcast/new\"]');")
r["uiReady"] = True
r["text"] = ev(name, "return document.body.innerText;")
r["legacyXhr"] = ev(
name,
"const x=new XMLHttpRequest({mozSystem:true});x.open('GET','/');x.responseType='moz-chunked-arraybuffer';return {mozSystem:typeof x.mozSystem,chunkedType:x.responseType};",
)
tap(name, 'a[href="#/podcast/new"]')
wait(name, "return !!document.querySelector('input');")
r["addForm"] = ev(
name,
"return {text:document.body.innerText,inputs:[...document.querySelectorAll('input')].map(e=>({type:e.type,placeholder:e.placeholder})),buttons:[...document.querySelectorAll('button')].map(e=>({text:e.textContent,type:e.type}))};",
)
if not args.restart_check:
ev(
name,
"window.__archiveRequests=[];const original=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url,...args){const item={method,url:String(url)};window.__archiveRequests.push(item);this.addEventListener('loadend',()=>{item.status=this.status;item.finished=true;});return original.call(this,method,url,...args);};return true;",
)
tap(name, "input")
subprocess.run(
[
"xdotool",
"key",
"ctrl+a",
"type",
"--clearmodifiers",
"--delay",
"15",
"https://atp.fm/episodes?format=rss",
],
check=True,
)
tap(name, "button")
try:
wait(name, "return window.__archiveRequests.some(r=>r.finished);", 20)
except RuntimeError:
r["requestTimedOut"] = True
r["feedRequests"] = ev(name, "return window.__archiveRequests;")
r["afterAdd"] = ev(name, "return document.body.innerText;")
else:
r["afterRestart"] = ev(name, "return document.body.innerText;")
shot(name)
except Exception as e:
r["failure"] = str(e)
try:
shot(name)
except Exception:
pass
r["diagnostics"] = ev(name, "return window.__vulpesDiagnostics || [];")
r["engineConsole"] = client.execute(
"return Services.console.getMessageArray().map(e=>e.message).filter(m=>m.includes("
+ json.dumps(name)
+ ")).slice(-20);"
)
print(name, json.dumps(r, ensure_ascii=False), flush=True)
(
ROOT
/ "logs"
/ ("legacy-apps-restart-results.json" if args.restart_check else "legacy-apps-results.json")
).write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")

78
tools/test-platform.py Normal file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Exercise shared platform calls in Gaia with a simulated desktop webcam."""
import importlib.util, json, time
from pathlib import Path
from marionette_client import Client
from project import release_info
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)
ROOT = Path(__file__).resolve().parents[1]
results = {}
product_version = release_info()["version"]
with Client(2857) as c:
probe.register(c)
def ev(app, code):
result = probe.evaluate(c, app, code)
if "error" in result:
raise AssertionError(result)
return result.get("value")
def wait(app, code):
for _ in range(100):
result = ev(app, code)
if result:
return result
time.sleep(0.15)
raise TimeoutError(app + ": " + code)
def launch(app):
ev(
"system",
f'await VulpesCompat.call("apps.launch",{{manifestURL:"http://{app}.localhost:8765/manifest.webapp"}});return true;',
)
wait(app, 'return document.readyState==="complete" && !!window.VulpesCompat;')
wait(
"system",
'return document.readyState==="complete" && !!document.querySelector(".appWindow.active iframe");',
)
results["capabilities"] = ev(
"system", 'return await VulpesCompat.call("platform.capabilities",{});'
)
assert results["capabilities"]["version"] == product_version
launch("gallery")
results["permission_boundary"] = ev(
"gallery",
'try{await VulpesCompat.call("platform.settings",{panel:"root"});return false;}catch(e){return e.message==="PERMISSION_DENIED";}',
)
assert results["permission_boundary"]
launch("settings")
c.execute(
'const p=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI("http://settings.localhost:8765"),{});Services.perms.addFromPrincipal(p,"desktop-notification",Ci.nsIPermissionManager.ALLOW_ACTION);return true;'
)
results["notification_id"] = ev(
"settings",
'return await VulpesCompat.call("platform.notification",{operation:"show",title:"Vulpes — test",body:"Desktop notification",tag:"parity-test"});',
)
assert isinstance(results["notification_id"], int), results
results["notification_list"] = ev(
"settings", 'return await VulpesCompat.call("platform.notification",{operation:"list"});'
)
assert any(n["id"] == results["notification_id"] for n in results["notification_list"])
ev(
"settings",
f'await VulpesCompat.call("platform.notification",{{operation:"close",id:{results["notification_id"]}}});return true;',
)
ev("settings", "document.querySelector('a[href=\"#about\"]').click();return true;")
results["settings_version"] = wait(
"settings", 'return document.getElementById("vulpes-system-info")?.textContent;'
)
assert product_version in results["settings_version"]
(ROOT / "logs/platform-desktop-results.json").write_text(
json.dumps(results, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(results, indent=2, ensure_ascii=False))

122
tools/test-services.py Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Run service contracts in the pinned engine, then restart and check persistence."""
import argparse
import functools
import json
import os
import subprocess
import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from marionette_client import Client
ROOT = Path(__file__).resolve().parents[1]
(ROOT / "logs").mkdir(exist_ok=True)
(ROOT / "profiles").mkdir(exist_ok=True)
parser = argparse.ArgumentParser()
parser.add_argument("--candidate", action="store_true")
args = parser.parse_args()
lock = json.loads(
(ROOT / ("candidate-engine.json" if args.candidate else "engine-lock.json")).read_text()
)
profile = ROOT / "profiles" / ("contracts-" + lock["version"])
profile.mkdir(exist_ok=True)
(profile / "user.js").write_text(
"""user_pref("marionette.port",2856);
user_pref("browser.shell.checkDefaultBrowser",false);
user_pref("browser.aboutwelcome.enabled",false);
user_pref("datareporting.policy.dataSubmissionEnabled",false);
user_pref("toolkit.telemetry.reportingpolicy.firstRun",false);
"""
)
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
# Only test modules are served, never profiles, downloads or engine files.
target = Path(self.translate_path(self.path)).resolve()
if not any(
target.is_relative_to(ROOT / name) for name in ["tests", "services", "adapters"]
):
self.send_error(404)
return
return super().do_GET()
server = ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(Handler, directory=str(ROOT)))
threading.Thread(target=server.serve_forever, daemon=True).start()
url = "http://127.0.0.1:%d/tests/contracts.html" % server.server_port
results = {}
try:
for phase in ["first", "restart"]:
env = os.environ.copy()
for key in list(env):
if key.startswith("MOZ_DISABLE_") or key == "B2G_HOMESCREEN":
env.pop(key)
with (ROOT / "logs" / ("contracts-" + phase + ".log")).open("w") as log:
proc = subprocess.Popen(
[
str(ROOT / lock["runtime"] / "firefox"),
"-no-remote",
"-headless",
"-marionette",
"--remote-allow-system-access",
"-profile",
str(profile),
"about:blank",
],
env=env,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
with Client() as client:
actual = client.execute("return Services.appinfo.platformVersion;")
assert actual == lock["version"], actual
client.call("Marionette:SetContext", {"value": "content"})
client.call(
"WebDriver:Navigate",
{"url": url + ("?restart=1" if phase == "restart" else "")},
)
for _ in range(300):
raw = client.execute(
'return document.getElementById("result")?.textContent || "";'
)
result = json.loads(raw) if raw.startswith("{") else None
if result:
break
time.sleep(0.1)
else:
page = client.execute(
"return {url:location.href,ready:document.readyState,text:document.body?.innerText};"
)
client.call("Marionette:SetContext", {"value": "chrome"})
errors = client.execute(
"return Services.console.getMessageArray().map(e=>e.message).slice(-20);"
)
raise AssertionError(
"Service tests did not complete: " + repr({"page": page, "errors": errors})
)
results[phase] = result
assert all(v is True for v in result.values()), result
client.call("Marionette:Quit", {"flags": ["eAttemptQuit"]})
finally:
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=10)
results["engine"] = lock["version"]
(ROOT / "logs/services-results.json").write_text(json.dumps(results, indent=2) + "\n")
(ROOT / "logs" / ("services-results-" + lock["version"] + ".json")).write_text(
json.dumps(results, indent=2) + "\n"
)
print(json.dumps(results, indent=2))
finally:
server.shutdown()
server.server_close()

View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Render sent and received bubbles through original Gaia on an isolated test profile."""
import sys,importlib.util,json,time
from pathlib import Path
root=Path(__file__).resolve().parents[1];sys.path.insert(0,str(root/'tools'))
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',root/'tools/probe-desktop.py');p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
with Client(2857) as c:
p.register(c)
def ev(app,code):
r=p.evaluate(c,app,code)
if 'error' in r:raise RuntimeError(r)
return r.get('value')
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.__saved=[Runtime.messages.radio,Runtime.messages.radioStatus];Runtime.messages.radio=async()=>({state:'sent'});Runtime.messages.radioStatus=null;return true;")
try:
ev('sms','await Navigation.toPanel("composer");Compose.clear();ConversationView.recipients.length=0;ConversationView.recipients.add({name:"5550199",number:"5550199",source:"manual"});Compose.append("Outgoing conversation regression");ConversationView.sendMessage({serviceId:0});return true;')
time.sleep(2)
report=ev('sms','return {hash:location.hash,panel:Navigation.currentPanel,messages:[...document.querySelectorAll(".message")].map(e=>({id:e.id,text:e.querySelector(".message-content-body")?.textContent,time:e.querySelector("time")?.dataset.time})),errors:window.__vulpesDiagnostics};')
assert len(report['messages'])>=1 and not report['errors'], report
outgoing=report
c.call('WebDriver:ExecuteAsyncScript',{'script':"const done=arguments[arguments.length-1];const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.receive({receipt:'pc-conversation-regression',sender:'5550199',body:'Incoming conversation regression',timestamp:Date.now()}).then(m=>{Runtime.broadcast('sms-received',m,'sms');done(true);});",'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':10000})
time.sleep(2)
report=ev('sms','return {hash:location.hash,messages:[...document.querySelectorAll(".message")].map(e=>({id:e.id,text:e.querySelector(".message-content-body")?.textContent,time:e.querySelector("time")?.dataset.time})),errors:window.__vulpesDiagnostics};')
assert any(m.get('text')=='Outgoing conversation regression' for m in report['messages']),report
assert any(m.get('text')=='Incoming conversation regression' for m in report['messages']),report
assert all(m['time'].isdigit() for m in report['messages']),report
assert not report['errors'],report
result={'outgoingVisible':True,'incomingVisible':True,'numericBubbleDates':True,'errors':report['errors']}
(root/'logs/sms-conversation-pc.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,indent=2))
finally:
ev('sms', 'const ids=await new Promise(resolve=>{const a=[];const c=navigator.mozMobileMessage.getMessages({numbers:["5550199"]});c.onsuccess=()=>{if(c.done)resolve(a);else{a.push(c.result.id);c.continue();}};});if(ids.length)await navigator.mozMobileMessage.delete(ids);return true;')
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');[Runtime.messages.radio,Runtime.messages.radioStatus]=Runtime.messages.__saved;delete Runtime.messages.__saved;return true;")

View file

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Verify Gaia request completion against simulated terminal modem events on --test desktop."""
import sys,importlib.util,json,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',ROOT/'tools/probe-desktop.py');p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
with Client(2857) as c:
p.register(c)
def ev(app,code):
r=p.evaluate(c,app,code)
if 'error' in r:raise RuntimeError(r)
return r.get('value')
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
for _ in range(50):
if ev('sms','return !!navigator.mozMobileMessage;'):break
time.sleep(.2)
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.__saved=[Runtime.messages.radio,Runtime.messages.radioStatus];Runtime.messages.radio=async()=>({path:'/ril_0/messageabc',state:'pending'});Runtime.messages.radioStatus=async()=>({state:'pending'});return true;")
result={}
try:
for terminal in ['sent','failed']:
ev('sms', '''window.__terminal=[];window.__id=null;window.__requestState='waiting';
navigator.mozMobileMessage.addEventListener('sending',e=>window.__id=e.message.id,{once:true});
const r=navigator.mozMobileMessage.send('111','Vulpes isolated queued test');
r.onsuccess=()=>{window.__requestState='success';};r.onerror=()=>{window.__requestState='error';};return true;''')
time.sleep(.4)
assert ev('sms','return window.__requestState;')=='waiting'
code="const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.radioStatus=async()=>({state:"+json.dumps(terminal)+"});Runtime.messages.refreshOutgoing();return true;"
c.execute(code)
for _ in range(30):
state=ev('sms','return window.__requestState;')
if state!='waiting':break
time.sleep(.1)
assert state==('success' if terminal=='sent' else 'error'),state
result[terminal]={'pendingRequestStayedOpen':True,'finalRequestState':state}
ev('sms','await new Promise(resolve=>{const r=navigator.mozMobileMessage.delete(window.__id);r.onsuccess=r.onerror=resolve;});return true;')
(ROOT/'logs/sms-pending-pc-ui.json').write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,indent=2))
finally:
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');[Runtime.messages.radio,Runtime.messages.radioStatus]=Runtime.messages.__saved;delete Runtime.messages.__saved;return true;")

124
tools/test-update-gecko.py Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Exercise activation/rollback and fail-closed qualification without a real engine."""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"updater", Path(__file__).with_name("update-gecko.py")
)
u = importlib.util.module_from_spec(spec)
spec.loader.exec_module(u)
class UpdateTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.old = {"version": "1.0", "runtime": "engines/1.0/firefox"}
self.new = {"version": "2.0", "runtime": "engines/2.0/firefox"}
for lock in [self.old, self.new]:
binary = self.root / lock["runtime"] / "firefox"
binary.parent.mkdir(parents=True)
binary.touch()
(self.root / "profiles/desktop-1.0").mkdir(parents=True)
(self.root / "profiles/desktop-1.0/settings.json").write_text("user data")
(self.root / "profiles/desktop-1.0/cache2").mkdir()
self.report = self.root / "logs/updates/test"
self.report.mkdir(parents=True)
for name, value in [
("ROOT", self.root),
("ACTIVE", self.root / "engine-lock.json"),
("HISTORY", self.root / "logs/updates/last-activation.json"),
]:
patcher = patch.object(u, name, value)
patcher.start()
self.addCleanup(patcher.stop)
idle = patch.object(u, "require_idle")
idle.start()
self.addCleanup(idle.stop)
u.atomic_json(u.ACTIVE, self.old)
def test_activate_then_rollback_preserves_both_profiles(self):
u.activate(self.new, self.old, self.report)
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.new)
self.assertEqual(
(self.root / "profiles/desktop-2.0/settings.json").read_text(), "user data"
)
self.assertFalse((self.root / "profiles/desktop-2.0/cache2").exists())
(self.root / "profiles/desktop-2.0/settings.json").write_text("new data")
u.rollback()
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.old)
self.assertEqual(
(self.root / "profiles/desktop-1.0/settings.json").read_text(), "user data"
)
self.assertEqual((self.root / "profiles/desktop-2.0/settings.json").read_text(), "new data")
def test_stale_reboot_marker_does_not_block_copy(self):
source = self.root / "profiles/desktop-1.0"
(source / "lock").symlink_to("127.0.0.1:+99999999")
u.activate(self.new, self.old, self.report)
self.assertTrue((source / "lock").is_symlink())
self.assertFalse((self.root / "profiles/desktop-2.0/lock").is_symlink())
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.new)
def test_live_native_profile_lock_prevents_copy(self):
lock = self.root / "profiles/desktop-1.0/.parentlock"
code = "import fcntl,sys; f=open(sys.argv[1],'w'); fcntl.lockf(f,fcntl.LOCK_EX); print('locked',flush=True); sys.stdin.read()"
child = subprocess.Popen(
[sys.executable, "-c", code, str(lock)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
try:
self.assertEqual(child.stdout.readline().strip(), "locked")
with self.assertRaises(RuntimeError):
u.activate(self.new, self.old, self.report)
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.old)
self.assertFalse((self.root / "profiles/desktop-2.0").exists())
finally:
child.communicate(timeout=5)
def test_existing_destination_is_never_overwritten(self):
(self.root / "profiles/desktop-2.0").mkdir()
with self.assertRaises(RuntimeError):
u.activate(self.new, self.old, self.report)
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.old)
def test_changed_active_lock_is_not_overwritten(self):
u.atomic_json(u.ACTIVE, {"version": "other"})
with self.assertRaises(RuntimeError):
u.activate(self.new, self.old, self.report)
self.assertEqual(json.loads(u.ACTIVE.read_text()), {"version": "other"})
def test_failed_check_cannot_promote_or_reuse_stale_result(self):
(self.root / "logs/engine-capabilities.json").write_text('{"old":true}')
def run(command, **kwargs):
if command[1].endswith("fetch-engine.py"):
u.atomic_json(self.root / "candidate-engine.json", self.new)
return type("Result", (), {"returncode": 0})()
return type("Result", (), {"returncode": 1})()
with (
patch("sys.argv", ["update-gecko.py", "--apply"]),
patch.object(u.shutil, "which", return_value="/tool"),
patch.object(u.subprocess, "run", side_effect=run),
):
with self.assertRaises(RuntimeError):
u.main()
self.assertEqual(json.loads(u.ACTIVE.read_text()), self.old)
self.assertFalse((self.root / "logs/engine-capabilities.json").exists())
reports = list((self.root / "logs/updates").glob("*/report.json"))
self.assertEqual(json.loads(reports[0].read_text())["status"], "failed")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Verify welcome translations against Gaia settings in the isolated PC host."""
import importlib.util
import json
import time
from pathlib import Path
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)
results = {}
with Client(2857) as client:
probe.register(client)
def evaluate(code):
response = probe.evaluate(client, "search", code, "/newtab.html")
assert "error" not in response, response
return response.get("value")
probe.evaluate(client, "system", 'await VulpesCompat.call("apps.launch",{manifestURL:"http://search.localhost:8765/manifest.webapp"});return true;')
time.sleep(2)
previous = evaluate('return (await VulpesCompat.call("settings.get",{key:"language.current"})).value;')
try:
for language, expected in [("fr", "fr"), ("fr-BE", "fr"), ("fr-CA", "fr"), ("en-US", "en"), ("de", "en"), ("fr", "fr")]:
evaluate('await VulpesCompat.call("settings.set",{values:{"language.current":' + json.dumps(language) + '}});return true;')
for attempt in range(40):
state = evaluate('const d=document.getElementById("vulpes-local-home")?.contentDocument;return {language:d?.documentElement.lang,heading:d?.querySelector("h1")?.textContent};')
if state and state.get("language") == expected:
break
time.sleep(.1)
assert state["language"] == expected, (language, state)
results[language] = state
finally:
evaluate('await VulpesCompat.call("settings.set",{values:{"language.current":' + json.dumps(previous) + '}});return true;')
(ROOT / "logs/welcome-languages.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(results, ensure_ascii=False, indent=2))

288
tools/update-gecko.py Normal file
View file

@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Download, qualify and optionally activate an isolated Gecko release."""
import argparse
from contextlib import contextmanager
import datetime
import fcntl
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ACTIVE = ROOT / "engine-lock.json"
HISTORY = ROOT / "logs/updates/last-activation.json"
RESULTS = [
"engine-capabilities.json",
"services-results.json",
"host-results.json",
"host-restart-results.json",
"feedback-results.json",
"feedback-restart-results.json",
"apps-results.json",
"apps-restart-results.json",
"languages-results.json",
"languages-restart-results.json",
]
def atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as output:
temporary = Path(output.name)
json.dump(value, output, ensure_ascii=False, indent=2)
output.write("\n")
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
def require_idle():
for port in [8765, 2856, 2857]:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
except OSError:
raise RuntimeError(
f"Port {port} occupé. Fermer l’émulateur et les tests avant la mise à jour."
)
@contextmanager
def idle_host():
# Share the launcher's lock during profile/engine changes only. Tests need
# to acquire it themselves while qualifying the candidate.
with (ROOT / "logs/host.lock").open("w") as guard:
try:
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Un émulateur est encore actif; aucun basculement.")
require_idle()
yield
def validate_runtime(lock):
runtime = (ROOT / lock["runtime"]).resolve()
if (
not runtime.is_relative_to((ROOT / "engines").resolve())
or not (runtime / "firefox").is_file()
):
raise RuntimeError("Moteur absent ou chemin hors du dossier engines.")
return runtime
@contextmanager
def closed_profile(profile):
if not profile.exists():
yield
return
# Match nsProfileLock::LockWithFcntl (whole file, exclusive, nonblocking).
# Do not follow an unexpected symlink for the native lock file.
descriptor = os.open(profile / ".parentlock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "r+") as guard:
try:
fcntl.lockf(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Le profil source est encore ouvert : " + str(profile))
for name in ["lock", "parent.lock"]:
marker = profile / name
if marker.is_symlink() and ":+" not in os.readlink(marker):
raise RuntimeError("Verrou de profil ancien ou inconnu : " + str(marker))
yield
def activate(candidate, old, report_dir):
require_idle()
validate_runtime(candidate)
if json.loads(ACTIVE.read_text()) != old:
raise RuntimeError("Le moteur actif a changé pendant les tests; basculement annulé.")
source = ROOT / "profiles" / ("desktop-" + old["version"])
target = ROOT / "profiles" / ("desktop-" + candidate["version"])
if target.exists():
raise RuntimeError("Profil cible déjà présent; conservé sans écrasement : " + str(target))
# Firefox on Linux uses a POSIX record lock on .parentlock. A symbolic
# `lock` marker can survive a reboot and is not proof of a live process.
with closed_profile(source):
temporary = Path(tempfile.mkdtemp(prefix=".upgrade-", dir=ROOT / "profiles"))
try:
if source.exists():
shutil.copytree(
source,
temporary,
dirs_exist_ok=True,
symlinks=True,
ignore=shutil.ignore_patterns(
".parentlock",
"parent.lock",
"lock",
"cache2",
"startupCache",
"shader-cache",
),
)
temporary.rename(target)
finally:
if temporary.exists():
shutil.rmtree(temporary)
# Save the recovery record before the single atomic activation write.
recovery = {
"previous": old,
"activated": candidate,
"report": str(report_dir.relative_to(ROOT)),
"oldProfile": str(source.relative_to(ROOT)),
"newProfile": str(target.relative_to(ROOT)),
}
atomic_json(report_dir / "activation.json", recovery)
atomic_json(HISTORY, recovery)
atomic_json(ACTIVE, candidate)
def rollback():
require_idle()
recovery = json.loads(HISTORY.read_text())
current = json.loads(ACTIVE.read_text())
if current == recovery["previous"]:
print("Ancien moteur déjà actif.")
return
if current != recovery["activated"]:
raise RuntimeError(
"Le moteur actif ne correspond pas au dernier basculement; aucun changement."
)
validate_runtime(recovery["previous"])
atomic_json(ACTIVE, recovery["previous"])
print("Retour au moteur " + recovery["previous"]["version"] + " et à son profil conservé.")
print("Les données créées dans le nouveau profil restent dans " + recovery["newProfile"])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--retest", action="store_true", help="Retester même si la version est déjà active"
)
parser.add_argument("--version", help="Version stable exacte; sinon dernière stable Mozilla")
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--apply", action="store_true", help="Activer après tous les tests, en copiant le profil"
)
mode.add_argument(
"--rollback",
action="store_true",
help="Revenir au moteur/profil précédents sans supprimer le nouveau",
)
args = parser.parse_args()
(ROOT / "logs/updates").mkdir(parents=True, exist_ok=True)
with (ROOT / "logs/updates/update.lock").open("w") as guard:
try:
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Une mise à jour est déjà en cours.")
if args.rollback:
if args.version or args.retest:
raise RuntimeError("--version/--retest ne s’appliquent pas au retour arrière.")
with idle_host():
rollback()
return
require_idle()
for command in ["Xvfb", "xdotool", "import"]:
if not shutil.which(command):
raise RuntimeError("Dépendance absente : " + command)
old = json.loads(ACTIVE.read_text())
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
report_dir = ROOT / "logs/updates" / stamp
report_dir.mkdir()
atomic_json(report_dir / "previous-engine.json", old)
report = {
"status": "running",
"previousVersion": old["version"],
"steps": [],
"scope": "desktop automation; full Firefox OS parity and mobile hardware not certified",
}
def save():
atomic_json(report_dir / "report.json", report)
def step(name, script, *arguments):
print("→ " + name, flush=True)
record = {"name": name, "log": name + ".log", "status": "running"}
report["steps"].append(record)
save()
with (report_dir / record["log"]).open("w") as log:
result = subprocess.run(
[sys.executable, str(ROOT / "tools" / script), *arguments],
cwd=ROOT,
stdout=log,
stderr=subprocess.STDOUT,
)
record.update(
status="passed" if result.returncode == 0 else "failed", exitCode=result.returncode
)
save()
if result.returncode:
raise RuntimeError("Échec de " + name + " : " + str(report_dir / record["log"]))
print(" OK", flush=True)
try:
step(
"download",
"fetch-engine.py",
*(["--version", args.version] if args.version else []),
)
candidate = json.loads((ROOT / "candidate-engine.json").read_text())
report["candidateVersion"] = candidate["version"]
save()
print(f"Gecko {old['version']} → candidat {candidate['version']}", flush=True)
if candidate["version"] == old["version"] and not args.retest:
report["status"] = "up-to-date"
print(
"Cette version est déjà active. Utiliser --retest pour refaire les contrôles.",
flush=True,
)
return
# Old result files must never qualify a failed or incomplete run.
for name in RESULTS:
path = ROOT / "logs" / name
if path.exists():
shutil.copy2(path, report_dir / ("before-" + name))
path.unlink()
step("capabilities", "probe-engine.py", "--candidate")
step("services", "test-services.py", "--candidate")
step("desktop", "check-desktop.py", "--candidate")
for name in RESULTS:
path = ROOT / "logs" / name
if not path.is_file():
raise RuntimeError("Rapport de test absent : " + name)
shutil.copy2(path, report_dir / name)
candidate.update(
gaiaBoots=True,
gaiaPorted=False,
qualificationReport=str((report_dir / "report.json").relative_to(ROOT)),
)
if args.apply and candidate["version"] != old["version"]:
with idle_host():
activate(candidate, old, report_dir)
report["status"] = "activated"
print("Moteur activé; ancien moteur et ancien profil conservés.", flush=True)
else:
report["status"] = "qualified"
print("Tests réussis. Moteur actif conservé.", flush=True)
except BaseException as error:
report.update(status="failed", error=str(error))
save()
raise
finally:
save()
print("Rapport : " + str(report_dir / "report.json"), flush=True)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError, KeyError) as error:
sys.exit(str(error))
except KeyboardInterrupt:
sys.exit("Mise à jour interrompue; consulter le rapport.")