Update shared Gaia workflows and Sargo development tooling

Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
This commit is contained in:
OverSu 2026-10-08 14:27:17 +02:00
commit 0041cd9660
101 changed files with 3883 additions and 335 deletions

View file

@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Reject stale Android payloads and inconsistent common release metadata."""
import argparse, hashlib, io, json, re, zipfile
import argparse, contextlib, hashlib, io, json, re, zipfile
from project import ROOT, release_info
p = argparse.ArgumentParser()
p.add_argument("--apk", type=str)
group = p.add_mutually_exclusive_group()
group.add_argument("--apk", type=str)
group.add_argument("--assets", action="store_true", help="Check staged Android resources, not a compiled APK")
args = p.parse_args()
r = release_info()
apk = ROOT / (args.apk or f'android/dist/vulpes-os-preview-{r["version"]}.apk')
@ -18,8 +20,15 @@ def translate(s):
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:18765", s)
class StagedAssets:
def read(self, name):
if not name.startswith('assets/') or '..' in name.split('/'):
raise ValueError('Invalid packaged path')
return (ROOT / 'android/app/src/main' / name).read_bytes()
results = {}
with zipfile.ZipFile(apk) as archive:
source = contextlib.nullcontext(StagedAssets()) if args.assets else zipfile.ZipFile(apk)
with source 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
@ -30,6 +39,7 @@ with zipfile.ZipFile(apk) as archive:
"platform-ui.js",
"gaia-camera.js",
"gaia-compat.js",
"settings-codec.js",
"gaia-services.js",
"gaia-styles.js",
"gaia-restoration.js",
@ -65,6 +75,9 @@ with zipfile.ZipFile(apk) as archive:
("calendar", "js/bundle.js", "overrides"),
("gallery", "js/metadata_scripts.js", "assets/webapps"),
("gallery", "js/ImageEditor.js", "assets/webapps"),
("communications", "contacts/js/views/form.js", "assets/webapps"),
("communications", "contacts/js/contacts.js", "assets/webapps"),
("communications", "contacts/js/activities.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:"),
@ -76,13 +89,17 @@ with zipfile.ZipFile(apk) as archive:
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
for name in ("marketplace.js", "marketplace.css", "icon.png"):
expected = (ROOT / "assets/webapps/marketplace.gaiamobile.org" / name).read_bytes()
if name.endswith(".js"): expected = translate(expected.decode()).encode()
assert gaia.read("marketplace/" + name) == expected, "Stale Marketplace: " + name
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"]:
for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs", "activities.mjs", "packages.mjs"]:
assert (
archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes()
@ -99,8 +116,11 @@ report = {
"androidEngine": r["androidEngine"],
"exactEngineMatch": r["desktopEngine"] == r["androidEngine"],
"sharedFiles": results,
"apk": str(apk),
"apk": None if args.assets else str(apk),
"stagedAssetsOnly": args.assets,
"scope": "Payload and metadata parity; hardware behavior requires separate integration tests.",
}
(ROOT / "logs/parity-payload.json").write_text(json.dumps(report, indent=2) + "\n")
report_path = ROOT / ("logs/parity-staged.json" if args.assets else "logs/parity-payload.json")
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))

72
tools/installed_apps.py Normal file
View file

@ -0,0 +1,72 @@
"""Private ordinary-web-app store. No Gaia permissions are read from packages."""
import base64
import json
import os
from pathlib import Path
import re
import secrets
import threading
class InstalledApps:
def __init__(self, directory, port):
self.root = Path(directory)
self.root.mkdir(parents=True, exist_ok=True)
self.port = port
self.lock = threading.RLock()
self.token = secrets.token_hex(32)
token = self.root / '.token'
token.write_text(self.token)
token.chmod(0o600)
self.items = {}
for path in self.root.glob('user-*.json'):
item = json.loads(path.read_text())
if self.valid_id(item.get('id')):
self.items[item['id']] = item
@staticmethod
def valid_id(value):
return isinstance(value, str) and re.fullmatch(r'user-[a-f0-9]{24}', value)
def records(self):
with self.lock:
return [self.record(item) for item in self.items.values()]
def record(self, item):
origin = f'http://{item["id"]}.localhost:{self.port}'
manifest = {**item['manifest'], 'permissions': {}, 'type': 'web'}
return dict(id=item['id'], origin=origin, manifestURL=origin+'/manifest.webapp',
manifest=manifest, removable=True, installed=True)
def save(self, item):
if not self.valid_id(item.get('id')) or not isinstance(item.get('files'), dict):
raise ValueError('INVALID_PACKAGE')
# A second boundary check keeps the storage API independent of ZIP parsing.
for name, content in item['files'].items():
if not isinstance(name, str) or not isinstance(content, str):
raise ValueError('INVALID_PACKAGE')
with self.lock:
target = self.root / (item['id']+'.json')
stage = target.with_suffix('.tmp')
with stage.open('w') as stream:
json.dump(item, stream)
stream.flush()
os.fsync(stream.fileno())
stage.replace(target)
self.items[item['id']] = item
def remove(self, app_id):
if not self.valid_id(app_id):
raise ValueError('NOT_REMOVABLE')
with self.lock:
(self.root/(app_id+'.json')).unlink(missing_ok=True)
self.items.pop(app_id, None)
def read(self, app_id, path):
with self.lock:
item = self.items.get(app_id)
if not item:
return None
if path == 'manifest.webapp':
return json.dumps(self.record(item)['manifest']).encode()
content = item['files'].get(path)
return base64.b64decode(content, validate=True) if content is not None else None

View file

@ -65,3 +65,12 @@ def release_info():
if not release["androidVersionCode"] > 4:
raise ValueError("Android versionCode must preserve upgrades from Preview 4")
return release
def application_manifest(app_id, manifest, include_development=False):
"""Keep development providers installed but hide their launcher entries."""
# Runtime exports omit dev_apps sources; keep the same policy in every package.
development = app_id in ('contacts-ds-provider1', 'contacts-ds-provider2', 'contacts-manager', 'ds-test', 'geoloc', 'in_app_pay_test', 'l20n-app', 'marketplace-dev.allizom.org', 'marketplace.allizom.org', 'membuster', 'mobile-wallet', 'mochitest', 'music-oga', 'nfc-api-test', 'share-receiver', 'template', 'test-agent', 'test-container', 'test-iac-publisher', 'test-iac-subscriber', 'test-ime', 'test-keyboard-app', 'test-otasp', 'test-receiver-1', 'test-receiver-2', 'test-receiver-inline', 'test-sensors', 'test-wappush', 'testpermission', 'theme-test-1', 'theme-test-2', 'theme-test-3', 'uitest', 'uitest-privileged', 'upnp-test', 'test-startup-limit')
if development and not include_development:
manifest = {**manifest, "role": "system"}
return manifest

View file

@ -3,15 +3,23 @@
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'
modules = [
('panels/display/slider_handler', 'js/panels/display/panel.js'),
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
('modules/page_transitions', 'js/main.js'),
]
for name, relative in modules:
original=ROOT/'assets/webapps/settings.gaiamobile.org'/relative
source=ROOT/'overrides/settings.gaiamobile.org'/f'js/{name}.js'
if not source.exists():
source=ROOT/'gaia/apps/settings'/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)
if 'define(function(require)' in text:
text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1)
else:
text=text.replace('define(function()', 'define('+repr(name)+',[],function()',1)
bundle=original.read_text()
start=bundle.index("define('"+name+"'")
end=bundle.find("define('",start+8)

View file

@ -15,7 +15,9 @@ 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
from project import build_info, validate_assets, release_info, application_manifest
from installed_apps import InstalledApps
INSTALLED = InstalledApps(ROOT / "profiles/installed-apps", args.port)
validate_assets()
info = build_info()
@ -44,7 +46,7 @@ for manifest_path in manifests:
if not re.fullmatch("[a-z0-9_-]+", name):
continue
origin = f"http://{name}.localhost:{args.port}"
manifest = json.loads(translate(manifest_path.read_text()))
manifest = application_manifest(name, json.loads(translate(manifest_path.read_text())), os.environ.get("VULPES_SHOW_DEV_APPS") == "1")
ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve()
REGISTRY.append(
{
@ -56,7 +58,7 @@ for manifest_path in manifests:
)
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"
json.dumps(REGISTRY + INSTALLED.records(), ensure_ascii=False, indent=2) + "\n"
)
(ROOT / "assets/settings-current-defaults.json").write_text(
translate((ROOT / "assets/settings-defaults.json").read_text())
@ -64,7 +66,45 @@ ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{arg
class Handler(BaseHTTPRequestHandler):
def json_reply(self, value):
data = json.dumps(value).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self):
import secrets
if self.headers.get('Host') != f'system.localhost:{args.port}' or not secrets.compare_digest(self.headers.get('X-Vulpes-Install', ''), INSTALLED.token):
self.send_error(403); return
try:
length = int(self.headers.get('Content-Length', '0'))
if not 0 < length <= 70*1024*1024: raise ValueError('INVALID_SIZE')
data = json.loads(self.rfile.read(length))
if self.path == '/_vulpes/packages/save': INSTALLED.save(data)
elif self.path == '/_vulpes/packages/remove': INSTALLED.remove(data['id'])
else: self.send_error(404); return
self.json_reply(INSTALLED.records())
except (ValueError, KeyError, OSError): self.send_error(400)
def do_GET(self):
host = self.headers.get('Host', '').lower()
if host == f'system.localhost:{args.port}' and self.path == '/_vulpes/packages/list':
self.json_reply(INSTALLED.records()); return
app_id = host.removesuffix(f'.localhost:{args.port}')
if INSTALLED.valid_id(app_id) and host == f'{app_id}.localhost:{args.port}':
path = unquote(urlsplit(self.path).path).lstrip('/') or 'index.html'
data = INSTALLED.read(app_id, path)
if data is None: self.send_error(404); return
self.send_response(200)
self.send_header('Content-Type', mimetypes.guess_type(path)[0] or 'application/octet-stream')
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Content-Security-Policy', "object-src 'none'; frame-ancestors http://*.localhost:"+str(args.port))
self.end_headers(); self.wfile.write(data); return
root = ORIGINS.get(self.headers.get("Host", "").lower())
if root is None:
self.send_error(421, "Unregistered application host")
@ -93,7 +133,7 @@ class Handler(BaseHTTPRequestHandler):
# 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"]
for name in ["settings-codec.js", "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:
@ -157,13 +197,13 @@ class Handler(BaseHTTPRequestHandler):
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>',
b'<head><script src="/_vulpes/settings-codec.js"></script><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>',
b'<head><script src="/_vulpes/platform-config.js"></script><link rel="stylesheet" href="/_vulpes/desktop.css"><script src="/_vulpes/settings-codec.js"></script><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 (

View file

@ -76,12 +76,13 @@ with Client(2857) as c:
"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};')
# Drive the desktop mouse through the normal touch simulator. Wait for
# layout after reload: a decoded video can precede a visible app frame.
point = wait("camera", 'const b=document.querySelector(".js-capture"),r=b.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;return x>0 && y>0 && x<innerWidth && y<innerHeight && b.contains(document.elementFromPoint(x,y)) && {x,y};')
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))
find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('MouseClick',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(

183
tools/test-contacts.py Normal file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Exercise Gaia Contacts and its SMS/dialer activities in the desktop test profile.
Creates one temporary contact; never sends a message or starts a call.
"""
import importlib.util
import json
import time
import sys
from pathlib import Path
from marionette_client import Client
spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
results = {}
contact_id = None
photo_name = None
name = 'Vulpes QA ' + str(time.time_ns())
with Client(2857) as client:
client.socket.settimeout(90)
probe.register(client)
def ev(app, code):
result = probe.evaluate(client, 'communications' if app == 'dialer' else app, code, '/dialer/' if app == 'dialer' else '/contacts/' if app == 'communications' else '/')
if 'error' in result:
raise AssertionError(result)
return result.get('value')
def wait(app, code):
for _ in range(100):
try:
value = ev(app, code)
except AssertionError as error:
if 'Actor' not in str(error) or 'destroyed' not in str(error):
raise
value = None
if value:
return value
time.sleep(.15)
raise TimeoutError(app + ': ' + code)
def click(app, selector):
print("click", app, selector, file=sys.stderr, flush=True)
point = wait(app, 'for(const e of document.querySelectorAll(' + json.dumps(selector) + ')) {'
'const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;'
'if(x>0&&y>0&&x<innerWidth&&y<innerHeight&&e.contains(document.elementFromPoint(x,y)))return {x,y};}return false;')
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(PREFIX))return b;
for(const c of b.children){const found=find(c);if(found)return found;}}
find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('MouseClick',POINT)
.then(()=>done(true),e=>done({error:String(e)}));'''.replace('PREFIX', json.dumps('http://' + app + '.localhost:8765/')).replace('POINT', json.dumps(point))
clicked = client.call('WebDriver:ExecuteAsyncScript', {'script': script, 'args': [],
'newSandbox': True, 'sandbox': 'system', 'scriptTimeout': 10000})['value']
assert clicked is True, clicked
def field(selector, value):
wait('communications', 'return !!document.querySelector(' + json.dumps(selector) + ');')
ev('communications', 'const e=document.querySelector(' + json.dumps(selector) + ');'
'e.value=' + json.dumps(value) + ';e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
def contact():
return ev('communications', '''const r=navigator.mozContacts.find({filterBy:['givenName'],
filterOp:'equals',filterValue:NAME});return await new Promise((ok,no)=>{
r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});'''.replace('NAME', json.dumps(name)))
try:
wait('system', 'return document.body?.getAttribute("ready-state")==="fullyLoaded";')
ev('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
wait('communications', 'return document.readyState==="complete"&&!document.hidden;')
ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;")
wait('gallery', 'return document.readyState==="complete"&&!document.hidden;')
fixture='vulpes-contacts-qa-'+str(time.time_ns())+'.jpg'
photo_name=ev('gallery', 'const c=document.createElement("canvas");c.width=c.height=400;'
'const ctx=c.getContext("2d");ctx.fillStyle="#2372ae";ctx.fillRect(0,0,400,400);'
'const blob=await new Promise(r=>c.toBlob(r,"image/jpeg"));'
'return await VulpesCompat.call("media.request",{operation:"add",type:"pictures",name:'+json.dumps(fixture)+',blob});')
ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
token = str(time.time_ns())
ev('communications', 'setTimeout(()=>location.href=location.origin+"/contacts/index.html?qa='+token+'",0);return true;')
wait('communications', 'return location.search==="?qa='+token+'" && document.readyState==="complete";')
wait('communications', 'return typeof Loader!=="undefined" && !!window.Contacts && MainNavigation.currentView()==="view-contacts-list";')
click('communications', '#add-contact-button')
wait('communications', 'return window.MainNavigation?.currentView()==="view-contact-form" && !!document.querySelector("#number_0");')
field('#givenName', name)
field('#familyName', 'Élodie Test')
field('#number_0', '5550199')
field('#email_0', 'qa@example.invalid')
click('communications', '#save-button')
for _ in range(80):
row = contact()
if row:
contact_id = row['id']
break
time.sleep(.15)
assert contact_id
results['create'] = row['tel'][0]['value'] == '5550199'
item = '[data-uuid=' + json.dumps(contact_id) + ']'
click('communications', '#search-start')
wait('communications', "return MainNavigation.currentView()==='search-view' && !!document.querySelector('#search-contact');")
field('#search-contact', 'elodie')
results['search_without_accent'] = bool(wait('communications', 'return !!document.querySelector(' + json.dumps('#search-list [data-uuid="' + contact_id + '"]') + ');'))
field('#search-contact', name + ' absent')
results['search_no_results'] = bool(wait('communications', "return !document.querySelector('#no-result').classList.contains('hide');"))
click('communications', '#cancel-search')
wait('communications', "return MainNavigation.currentView()==='view-contacts-list';")
click('communications', item)
click('communications', '#edit-contact-button')
wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
click('communications', '#photo-button')
wait('gallery', 'return !document.hidden && typeof picking!=="undefined" && picking;')
thumb='.thumbnailImage[data-filename='+json.dumps(photo_name)+']'
click('gallery',thumb)
wait('gallery','return !document.querySelector("#crop-done-button").disabled;')
click('gallery','#crop-done-button')
wait('communications', 'return !document.hidden && getComputedStyle(document.querySelector("#thumbnail-photo")).backgroundImage!=="none";')
field('#familyName', 'Élodie Modifiée')
click('communications', '#save-button')
results['edit_detail_refresh'] = bool(wait('communications', 'return document.querySelector("#contact-name-title")?.textContent.includes("Élodie Modifiée");'))
assert contact()['familyName'] == ['Élodie Modifiée']
results['contact_photo']=bool(ev('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.photo?.[0] instanceof Blob && c.photo[0].size>0;'))
assert results['contact_photo']
ev('communications', 'document.querySelector("#toggle-favorite").scrollIntoView({block:"center"});return true;')
click('communications', '#toggle-favorite')
results['favorite'] = bool(wait('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.category?.includes("favorite");'))
ev('communications', 'document.querySelector("#send-sms-button-0").scrollIntoView({block:"center"});return true;')
click('communications', '#send-sms-button-0')
results['sms_recipient'] = bool(wait('sms', 'return !document.hidden&&window.ConversationView?.recipients?.numbers.includes("5550199");'))
click('sms', '#messages-contact-pick-button')
wait('communications', 'return !document.hidden&&ActivityHandler.currentlyHandling;')
click('communications', item)
wait('communications', 'return !!document.querySelector("[data-l10n-id=pick_destination]");')
click('communications', '[data-l10n-id=pick_destination]')
wait('communications', 'return !ActivityHandler.currentlyHandling;')
results['picker_return'] = bool(wait('sms', 'return !document.hidden&&ConversationView.recipients.numbers.includes("5550199");'))
# This activity only prefills the original dialer; do not press Call.
ev('sms', 'window.__dialQA=new MozActivity({name:"dial",data:{type:"webtelephony/number",number:"5550199"}});return true;')
results['dialer_prefill'] = bool(wait('dialer', 'return !document.hidden&&document.querySelector("#phone-number-view")?.value==="5550199";'))
ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
wait('communications', 'return !document.hidden;')
click('communications', item)
click('communications', '#edit-contact-button')
wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
ev('communications', 'document.querySelector("#delete-contact").scrollIntoView({block:"center"});return true;')
click('communications', '#delete-contact')
click('communications', '#confirmation-message button[data-l10n-id=cancel]')
results['delete_cancel'] = contact() is not None
wait('communications', 'return document.querySelector("#confirmation-message").classList.contains("hide");')
click('communications', '#delete-contact')
click('communications', '#confirmation-message button[data-l10n-id=delete]')
for _ in range(100):
if contact() is None:
results['delete_confirm'] = True
results['temporary_contact_removed'] = True
contact_id = None
break
time.sleep(.15)
assert results.get('delete_confirm')
results['passed'] = all(results.values())
finally:
try:
if contact_id:
# Return to the address book before removing only our temporary entry.
ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
ev('communications', 'const r=navigator.mozContacts.remove(' + json.dumps(contact_id) + ');await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;')
results['temporary_contact_removed'] = contact() is None
except Exception as error:
results['cleanup_error'] = str(error)
results['temporary_contact_id'] = contact_id
finally:
if photo_name:
try:
ev('gallery','await VulpesCompat.call("media.request",{operation:"delete",type:"pictures",name:'+json.dumps(photo_name)+'});return true;')
results['temporary_photo_removed']=True
except Exception as error:
results['photo_cleanup_error']=str(error)
results['temporary_photo_name']=photo_name
print(json.dumps(results, indent=2, ensure_ascii=False))
if not results.get('passed') or results.get('cleanup_error') or results.get('photo_cleanup_error'):
raise SystemExit('Contacts qualification failed; see the report above.')

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Exercise Gaia's transient banner in the isolated desktop host."""
import importlib.util
import json
from pathlib import Path
from marionette_client import Client
spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
with Client(2857) as client:
probe.register(client)
result = probe.evaluate(client, 'system', '''
await LazyLoader.load('js/system_banner.js');
const banner=new SystemBanner();
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
try {
await banner.show({id:'app-install-success',args:{appName:'Vulpes test'}});
const shown=banner._banner.isConnected;
await sleep(4900);
const dismissed=!banner._banner.isConnected;
await banner.show('app-install-success');
await sleep(1000);
await banner.show('app-install-success');
await sleep(3300);
const replacementRetained=banner._banner.isConnected;
await sleep(1600);
const replacementDismissed=!banner._banner.isConnected;
await banner.show('app-install-success');
banner._banner.click();
await sleep(800);
const clickDismissed=!banner._banner.isConnected;
return {shown,dismissed,replacementRetained,replacementDismissed,clickDismissed};
} finally {await banner.hide();banner._banner?.remove();}
''')
assert 'error' not in result, result
checks=result['value']
assert all(checks.values()), checks
print(json.dumps(checks, indent=2))