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:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
|
|
@ -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
72
tools/installed_apps.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
183
tools/test-contacts.py
Normal 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.')
|
||||
38
tools/test-system-banner.py
Normal file
38
tools/test-system-banner.py
Normal 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))
|
||||
Loading…
Reference in a new issue