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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
72
tools/audit-apps.py
Normal file
72
tools/audit-apps.py
Normal 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
97
tools/audit-repository.py
Normal 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
173
tools/check-desktop.py
Normal 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)
|
||||
85
tools/check-legacy-apps.py
Normal file
85
tools/check-legacy-apps.py
Normal 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
106
tools/check-parity.py
Normal 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
135
tools/fetch-engine.py
Normal 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
47
tools/inspect-host.py
Normal 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
47
tools/inventory-gaia.py
Normal 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",
|
||||
)
|
||||
70
tools/marionette_client.py
Normal file
70
tools/marionette_client.py
Normal 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
121
tools/modernize-syntax.py
Normal 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
6
tools/prepare-assets.py
Normal 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")
|
||||
55
tools/prepare-legacy-apps.py
Normal file
55
tools/prepare-legacy-apps.py
Normal 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)
|
||||
82
tools/prepare-publication.py
Normal file
82
tools/prepare-publication.py
Normal 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
46
tools/probe-desktop.py
Normal 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
88
tools/probe-engine.py
Normal 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
67
tools/project.py
Normal 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
|
||||
21
tools/refresh-settings-panels.py
Normal file
21
tools/refresh-settings-panels.py
Normal 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))
|
||||
59
tools/restore-density-assets.py
Normal file
59
tools/restore-density-assets.py
Normal 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
164
tools/run-host.py
Normal 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
206
tools/serve-gaia.py
Normal 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
102
tools/test-app-workflows.py
Normal 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
405
tools/test-apps.py
Normal 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
90
tools/test-call-screen.py
Normal 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
137
tools/test-camera.py
Normal 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))
|
||||
49
tools/test-device-information.py
Normal file
49
tools/test-device-information.py
Normal 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))
|
||||
61
tools/test-feedback-preview10.py
Normal file
61
tools/test-feedback-preview10.py
Normal 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
351
tools/test-feedback.py
Normal 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")
|
||||
82
tools/test-fetch-engine.py
Normal file
82
tools/test-fetch-engine.py
Normal 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
114
tools/test-gaia-keyboard.py
Normal 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))
|
||||
55
tools/test-gaia-restoration.py
Normal file
55
tools/test-gaia-restoration.py
Normal 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))
|
||||
68
tools/test-home-surface.py
Normal file
68
tools/test-home-surface.py
Normal 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
448
tools/test-host.py
Normal 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
223
tools/test-languages.py
Normal 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
253
tools/test-legacy-apps.py
Normal 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
78
tools/test-platform.py
Normal 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
122
tools/test-services.py
Normal 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()
|
||||
34
tools/test-sms-conversation.py
Normal file
34
tools/test-sms-conversation.py
Normal 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;")
|
||||
41
tools/test-sms-delivery.py
Normal file
41
tools/test-sms-delivery.py
Normal 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
124
tools/test-update-gecko.py
Normal 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()
|
||||
37
tools/test-welcome-languages.py
Normal file
37
tools/test-welcome-languages.py
Normal 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
288
tools/update-gecko.py
Normal 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.")
|
||||
Loading…
Reference in a new issue