Release Preview 20 shared desktop, Android and Sargo updates

Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

View file

@ -0,0 +1,14 @@
// Locate an AMD definition without swallowing surrounding require() callbacks.
const fs=require('node:fs'), esprima=require('esprima');
const [path,name]=process.argv.slice(2), source=fs.readFileSync(path,'utf8');
let result;
function visit(node) {
if(!node || typeof node!=='object')return;
if(node.type==='CallExpression' && node.callee.type==='Identifier' && node.callee.name==='define' && node.arguments[0]?.value===name)result=node.range;
for(const [key,value] of Object.entries(node))if(key!=='range') {
if(Array.isArray(value))value.forEach(visit);else visit(value);
}
}
visit(esprima.parseScript(source,{range:true}));
if(result)while(result[1]<source.length && /[;\s]/.test(source[result[1]]))result[1]++;
process.stdout.write(JSON.stringify(result||null));

43
tools/audit-settings.py Normal file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Read-only Settings panel audit in an isolated desktop test profile."""
import argparse,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)
a=argparse.ArgumentParser();a.add_argument('--output',type=Path,required=True);args=a.parse_args()
contextual={'wifi-joinHidden','wifi-wps','call-cf-not-reachable-settings','call-cf-no-reply-settings','call-cf-mobile-busy-settings','call-cf-unconditional-settings','call-voiceMailSettings','hotspot-wifiSettings','screenLock-passcode','wifi-status','wifi-auth','wifi-enterCertificateNickname','apn-editor','apn-list','appPermissions-details','addon-details','homescreen-details','frame','simpin-dialog','call-fdnList-add','call-barring-passcode-change','settings-alert-dialog','settings-confirm-dialog','settings-prompt-dialog','ums-partial-warning','template-messaging','full-developer-mode-warning','full-developer-mode-final-warning'}
with Client(2857) as c:
p.register(c)
def ev(code):
v=p.evaluate(c,'settings',code)
if 'error' in v:raise RuntimeError(v['error'])
return v.get('value')
def ready():
for _ in range(100):
if ev('return !!document.querySelector("#root.current") && typeof require==="function";'):return
time.sleep(.15)
raise RuntimeError('Settings root did not load')
p.evaluate(c,'system','if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
ev('location.reload();return true;');time.sleep(.7);ready()
ids=ev('return Array.from(document.querySelectorAll("section[id]"),e=>e.id);')
report={'scope':'panel navigation only; no hardware writes or destructive actions','panels':{}}
for id in ids:
if id in contextual:
report['panels'][id]={'contextRequired':True};continue
ev('window.__vulpesDiagnostics=[];require(["modules/settings_service"],s=>s.navigate('+json.dumps(id)+','+json.dumps({'cardIndex':0} if id=='messaging-details' else {})+'));return true;')
opened=False
for _ in range(25):
time.sleep(.15)
if ev('return document.querySelector("section.current")?.id;')==id:opened=True;break
errors=ev('return window.__vulpesDiagnostics;')
if any(e['kind']=='rejection' for e in errors or []):break
time.sleep(.35)
result=ev('const p=document.getElementById('+json.dumps(id)+');return {title:p.querySelector("h1")?.textContent,unavailable:!!p.querySelector("[data-vulpes-unavailable]"),links:Array.from(p.querySelectorAll("a[href^=\\"#\\"]"),e=>e.getAttribute("href")),controls:Array.from(p.querySelectorAll("input,select,gaia-switch"),e=>({name:e.name||e.id||e.getAttribute("name"),disabled:e.disabled,hidden:!e.checkVisibility()})),errors:window.__vulpesDiagnostics};')
result['opened']=opened;report['panels'][id]=result
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False)+'\n')
print(id,'OPEN' if opened else 'FAILED',json.dumps(result['errors'],ensure_ascii=False),flush=True)
if not opened:
ev('location.reload();return true;');time.sleep(.7);ready()
print('Recorded',len(report['panels']),'panels',flush=True)
ev('require(["modules/settings_service"],s=>s.navigate("root"));return true;')

View file

@ -41,9 +41,9 @@ with source as archive:
"gaia-compat.js",
"settings-codec.js",
"gaia-services.js",
"gaia-styles.js",
"gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-input.js",
"gaia-restoration.js",
"gaia-wifi.js",
"gaia-wifi.js", "gaia-bluetooth.js",
"desktop.css",
]:
expected = translate((ROOT / "host" / name).read_text()).encode()
@ -60,7 +60,8 @@ with source as archive:
("system", "js/notification_screen.js"),
("system", "style/system/system.css"),
("communications", "dialer/style/call_log.css"),
]:
] + [("settings", path.relative_to(ROOT / "overrides/settings.gaiamobile.org").as_posix())
for path in sorted((ROOT / "overrides/settings.gaiamobile.org").rglob("*.js"))]:
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("),
@ -73,7 +74,11 @@ with source as archive:
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/metadata_scripts.js", "overrides"),
("gallery", "js/MetadataParser.js", "overrides"),
("video", "gaia_build_defer_index.js", "overrides"),
("camera", "js/main.js", "overrides"),
("camera", "js/lib/dcf.js", "overrides"),
("gallery", "js/ImageEditor.js", "assets/webapps"),
("communications", "contacts/js/views/form.js", "assets/webapps"),
("communications", "contacts/js/contacts.js", "assets/webapps"),
@ -89,6 +94,10 @@ with source as archive:
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
helper = "dialer/js/telephony_helper.js"
expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"
results["communications/" + helper] = hashlib.sha256(expected).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()
@ -99,7 +108,8 @@ with source as archive:
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", "activities.mjs", "packages.mjs"]:
assert archive.read("assets/bridge/input-editor.js") == (ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor').encode(), "Stale Android input editor"
for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs", "activities.mjs", "packages.mjs", "input.mjs"]:
assert (
archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes()

249
tools/export-changelog.py Normal file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Generate Markdown, website data and Forgejo notes from changelog.json."""
import argparse
import hashlib
import html
import json
from pathlib import Path
import re
import runpy
ROOT = Path(__file__).resolve().parents[1]
def inline(value):
tokens = r'(`[^`]+`|\*\*.+?\*\*|!?\[[^\]]+\]\([^)]+\))'
def render(match):
text = match.group()
if text.startswith('`'):
return '<code>' + html.escape(text[1:-1]) + '</code>'
if text.startswith('**'):
return '<strong>' + html.escape(text[2:-2]) + '</strong>'
if text.startswith('!['):
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
if url.startswith('https://'):
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
return html.escape(label)
label, url = re.fullmatch(r'\[([^\]]+)\]\(([^)]+)\)', text).groups()
# Local documentation paths are not website routes.
# Only HTTPS links are exported.
if url.startswith('https://'):
return '<a href="' + html.escape(url, quote=True) + '">' + html.escape(label) + '</a>'
return html.escape(label)
return ''.join(render(re.match(tokens, part)) if re.fullmatch(tokens, part) else html.escape(part)
for part in re.split(tokens, value, flags=re.DOTALL) if part)
def blocks(lines):
result, paragraph, items = [], [], []
def flush():
if paragraph:
text = ' '.join(paragraph)
if not text.startswith(('Détails :', 'Details:')):
result.append('<p>' + inline(text) + '</p>')
paragraph.clear()
if items:
result.append('<ul>' + ''.join('<li>' + inline(s) + '</li>' for s in items) + '</ul>')
items.clear()
for line in lines + ['']:
if not line.strip():
flush()
elif line.startswith('- '):
if paragraph: flush()
items.append(line[2:])
elif items:
items[-1] += ' ' + line.strip()
else:
paragraph.append(line.strip())
return ''.join(result)
def parse(path):
return parse_text(path.read_text())
def parse_text(markdown):
entries = []
months = {name: index for names in (
('janvier février mars avril mai juin juillet août septembre octobre novembre décembre'),
('january february march april may june july august september october november december'),
) for index, name in enumerate(names.split(), 1)}
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
heading, _, body = chunk.partition('\n')
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
version = re.match(r'\d+\.\d+[.\w-]*', heading)
key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history')
kind = 'version' if version else key
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
date = None
if stamp and stamp[2].lower() in months:
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
names = re.findall(r'^### (.+)', body, re.MULTILINE)
platforms = []
if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc')
if any('Android' in name for name in names): platforms.append('apk')
if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
if not platforms:
platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
groups = []
intro = blocks(parts[0].splitlines())
if len(parts) == 1:
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
intro = ''
for name, text in zip(parts[1::2], parts[2::2]):
common = name.startswith(('Commun', 'Shared'))
scope = platforms if common else []
if not common:
if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
if 'Android' in name: scope.append('apk')
if 'Sargo' in name or 'Tundra' in name: scope.append('models')
if not scope: scope = ['pc']
# Closing limits belong to the whole entry, not to the last group.
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())})
if len(split)>1: intro += blocks(split[1].splitlines())
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
'groups': groups, 'release': release.group(1) if release else None})
# Number same-day development entries from oldest to newest. Adding a new
# entry at the top preserves older anchors and avoids duplicate HTML IDs.
counts = {}
for entry in reversed(entries):
if entry['kind'] == 'development':
base = 'development-' + (entry['date'] or 'undated')
counts[base] = counts.get(base, 0) + 1
entry['id'] = base + '-' + str(counts[base])
return entries
LANGUAGES = ('fr', 'en')
NOTICE = '<!-- Generated from changelog.json. Edit that source, then run tools/export-changelog.py. -->'
def require(condition, message):
if not condition:
raise ValueError(message)
def load_source(path):
source = json.loads(path.read_text())
require(source.get('schema') == 1, 'Unsupported changelog schema')
require(set(source['preamble']) == set(LANGUAGES), 'Missing preamble translation')
require(bool(source['entries']), 'Empty changelog')
ids = [entry['id'] for entry in source['entries']]
require(len(set(ids)) == len(ids), 'Duplicate entry IDs')
languages = {lang: [] for lang in LANGUAGES}
for entry in source['entries']:
key = entry['id']
require(bool(re.fullmatch(r'[a-zA-Z0-9_.-]+', key)), 'Invalid entry ID: ' + key)
require(set(entry['text']) == set(LANGUAGES), 'Missing translation: ' + key)
metadata = entry.get('metadata')
if metadata is not None:
require(set(metadata) == {'kind', 'status', 'platforms', 'models'}, 'Invalid metadata fields: ' + key)
require(metadata['kind'] in ('version', 'development', 'history'), 'Invalid entry kind: ' + key)
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
len(set(metadata['platforms'])) == len(metadata['platforms']) and
set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key)
require(isinstance(metadata['models'], list) and
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
for lang in LANGUAGES:
parsed = parse_text(entry['text'][lang])
require(len(parsed) == 1, 'Expected one ## entry: ' + key)
item = parsed[0]
if metadata is not None:
require((item['kind'] == 'version') == (metadata['kind'] == 'version'), 'Version metadata mismatch: ' + key)
item.update(metadata)
# Flat changelogs keep scope independently of translated prose.
if len(item['groups']) == 1 and not item['groups'][0]['name']:
item['groups'][0]['platforms'] = metadata['platforms']
if item['kind'] == 'version':
require(item['id'] == key, 'Version/ID mismatch: ' + key)
# Development IDs are fixed in the source, independent of order/date.
item['id'] = key
languages[lang].append(item)
a, b = (languages[lang][-1] for lang in LANGUAGES)
fields = ('kind', 'date', 'status', 'platforms', 'models', 'release')
require(all(a[f] == b[f] for f in fields), 'Translation metadata differs: ' + key)
require([(g['platforms'], g['common'], g['html'].count('<li>')) for g in a['groups']] ==
[(g['platforms'], g['common'], g['html'].count('<li>')) for g in b['groups']],
'Translation structure differs: ' + key)
for tag, release in source['releases'].items():
require(bool(re.fullmatch(r'[a-zA-Z0-9_.-]+', tag)), 'Invalid release tag')
require(release['version'] in ids and release['after'] in ids, 'Unknown release boundary: ' + tag)
require(ids.index(release['version']) < ids.index(release['after']), 'Reversed release range: ' + tag)
require(bool(release['summary'].strip()), 'Missing release summary: ' + tag)
return source, languages
def release_documents(source, tag):
release = source['releases'][tag]
ids = [e['id'] for e in source['entries']]
selected = source['entries'][ids.index(release['version']):ids.index(release['after'])]
histories = {}
for lang in LANGUAGES:
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; '
'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent '
'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else
'Dated sections describe each development stage. Limitations and fixes evolved; the release '
'summary describes the final state. Documentation paths refer to local sources; source '
'publication is separate from this APK.')
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
body = release['summary'].strip() + '\n\n'
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
('en', '🇬🇧 Full history, stage by stage')]:
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'
return {'release-body.md': body.rstrip() + '\n',
**{f'CHANGELOG.{lang}.md': text for lang, text in histories.items()}}
def generate(source_path, root, website, release_dir, release_tag=None):
source, languages = load_source(source_path)
outputs = {}
render = runpy.run_path(str(ROOT/'tools/render-changelog.py'))['render']
outputs.update({root/name: content for name, content in render(source).items()})
if website:
data = {'schema': 1, 'source': {'file': 'changelog.json',
'sha256': hashlib.sha256(source_path.read_bytes()).hexdigest()}, 'languages': languages}
outputs[website] = json.dumps(data, ensure_ascii=False, indent=2) + '\n'
tags = [release_tag] if release_tag else source['releases']
for tag in tags:
require(tag in source['releases'], 'Unknown release: ' + tag)
for name, content in release_documents(source, tag).items():
outputs[release_dir/tag/name] = content
return outputs
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=ROOT/'changelog.json')
parser.add_argument('--output', type=Path, help='Website changelog-data.json destination')
parser.add_argument('--release', help='Generate only this Forgejo tag (default: all configured releases)')
parser.add_argument('--release-dir', type=Path, default=ROOT/'logs/changelog-export')
parser.add_argument('--check', action='store_true', help='Fail on stale/missing exports without writing')
args = parser.parse_args()
try:
outputs = generate(args.source, ROOT, args.output, args.release_dir, args.release)
stale = [str(p) for p, content in outputs.items() if not p.exists() or p.read_text() != content]
if args.check:
if stale:
parser.exit(1, 'Stale changelog exports:\n' + '\n'.join(stale) + '\n')
print(f'{len(outputs)} changelog exports are current.')
else:
for path, content in outputs.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
print(f'{len(outputs)} exports generated from {args.source}')
except (ValueError, KeyError) as error:
parser.exit(1, f'Changelog validation failed: {error}\n')
if __name__ == '__main__':
main()

View file

@ -15,7 +15,6 @@ ROOT_FILES = {'.gitignore','.nvmrc','.prettierignore','.prettierrc.json','LICENS
'README.md','README.fr.md','CHANGELOG.md','CHANGELOG.en.md','engine-lock.json','package.json','package-lock.json',
'pyproject.toml','requirements-dev.txt','release.json','changelog.json','run.sh','setup.sh'}
INTERNAL = {'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__'}

View file

@ -6,6 +6,13 @@ import subprocess
ROOT = Path(__file__).resolve().parents[1]
# Editable Gaia files shared by desktop serving and Android packaging.
SOURCE_OVERRIDES = {
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
"js/core.js", "js/rocketbar.js"],
"communications": ["dialer/js/telephony_helper.js"],
}
def validate_assets():
"""Require the checked-in Gaia payload instead of a private profile copy."""

View file

@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Update changed source modules in the existing Gaia Camera bundle."""
import json,re,subprocess
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
TARGET=ROOT/'overrides/camera.gaiamobile.org/js/main.js'
dcf=ROOT/'overrides/camera.gaiamobile.org/js/lib/dcf.js'
dcf.parent.mkdir(parents=True,exist_ok=True)
dcf.write_text((ROOT/'gaia/apps/camera/js/lib/dcf.js').read_text())
for name in ['lib/storage','controllers/storage','controllers/activity']:
source=(ROOT/'gaia/apps/camera/js'/f'{name}.js').read_text()
deps=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",source)))
source=source.replace('define(function(require, exports, module)', 'define('+json.dumps(name)+','+json.dumps(['require','exports','module',*deps])+',function(require, exports, module)',1)
start,end=json.loads(subprocess.check_output(['/usr/bin/node',str(ROOT/'tools/amd-module-range.cjs'),str(TARGET),name],text=True))
bundle=TARGET.read_text();TARGET.write_text(bundle[:start]+source[source.index('define('):].rstrip()+'\n'+bundle[end:])
subprocess.run(['/usr/bin/node','--check',str(TARGET)],check=True)

View file

@ -2,17 +2,38 @@
"""Refresh selected AMD modules inside Gaia's existing Settings panel bundles."""
from pathlib import Path
import re
import json
import subprocess
import shutil
ROOT=Path(__file__).resolve().parents[1]
modules = [
('panels/wifi_status/panel', 'js/panels/wifi_status/panel.js'),
('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'),
('modules/settings_service', 'js/main.js'),
('modules/dialog_service', 'js/modules/dialog_service.js'),
('modules/dialog_manager', 'js/modules/dialog_service.js'),
('modules/date_time', 'js/modules/date_time.js'),
('panels/downloads/download_api_manager', 'js/panels/downloads/panel.js'),
('panels/help/support', 'js/panels/help/panel.js'),
]
names=list(dict.fromkeys(name for name,relative in modules))
base=ROOT/'assets/webapps/settings.gaiamobile.org'
for script in (base/'js').rglob('*.js'):
if script.is_symlink(): continue
bundle=script.read_text()
for name in names:
pair=(name,str(script.relative_to(base)))
if pair not in modules and re.search(r"define\(\s*['\"]"+re.escape(name)+r"['\"]",bundle):
modules.append(pair)
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'
if name.startswith('modules/') or source == ROOT/'overrides/settings.gaiamobile.org'/relative:
source=ROOT/'gaia/apps/settings'/f'js/{name}.js'
text=source.read_text()
dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text)))
import json
@ -20,10 +41,15 @@ for name, relative in modules:
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)
if end<0:end=len(bundle)
target=ROOT/'overrides/settings.gaiamobile.org'/relative
target.write_text(bundle[:start]+text+'\n'+bundle[end:])
bundle=(target if target.exists() else original).read_text()
if not re.search(r"define\(\s*['\"]"+re.escape(name)+r"['\"]",bundle):
raise ValueError('AMD module missing from target: '+name+' / '+relative)
node='/usr/bin/node' if Path('/usr/bin/node').exists() else shutil.which('node')
selected=target if target.exists() else original
start,end=json.loads(subprocess.check_output([node,str(ROOT/'tools/amd-module-range.cjs'),str(selected),name],text=True))
target=ROOT/'overrides/settings.gaiamobile.org'/relative
target.parent.mkdir(parents=True,exist_ok=True)
definition=text[text.index('define('):].rstrip()
target.write_text(bundle[:start]+definition+'\n'+bundle[end:])
print(target.relative_to(ROOT))

View file

@ -15,7 +15,7 @@ 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, application_manifest
from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
from installed_apps import InstalledApps
INSTALLED = InstalledApps(ROOT / "profiles/installed-apps", args.port)
@ -133,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 ["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"]
for name in ["settings-codec.js", "gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-restoration.js", "gaia-wifi.js", "gaia-bluetooth.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
]:
file = ROOT / "host" / path.rsplit("/", 1)[1]
else:
@ -143,16 +143,7 @@ class Handler(BaseHTTPRequestHandler):
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, []):
if path.lstrip("/") in SOURCE_OVERRIDES.get(root.name.removesuffix(".gaiamobile.org"), []):
file = (
ROOT
/ "gaia/apps"
@ -197,13 +188,13 @@ class Handler(BaseHTTPRequestHandler):
if file.suffix == ".html" and root in LEGACY_ROOTS:
data = data.replace(
b"<head>",
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>',
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-mobile-settings.js"></script><script src="/_vulpes/gaia-bluetooth.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/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>',
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-mobile-settings.js"></script><script src="/_vulpes/gaia-bluetooth.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><link rel="stylesheet" href="/_vulpes/gaia-theme.css"><script src="/_vulpes/gaia-theme.js"></script><script src="/_vulpes/gaia-select.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

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Compare or publish an existing Forgejo release's notes from changelog.json."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import secrets
import urllib.error
import urllib.parse
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('changelog_export', ROOT/'tools/export-changelog.py')
export = importlib.util.module_from_spec(spec)
spec.loader.exec_module(export)
def request(url, token=None, method='GET', payload=None, content_type=None):
headers = {'Accept': 'application/json'}
if token:
headers['Authorization'] = 'token ' + token
if isinstance(payload, dict):
payload = json.dumps(payload).encode()
content_type = 'application/json'
if content_type:
headers['Content-Type'] = content_type
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as response:
raw = response.read()
return raw
def api_url(release_url):
url = urllib.parse.urlsplit(release_url)
if url.scheme != 'https':
raise ValueError('Forgejo requires HTTPS')
repository, _, tag = url.path.partition('/releases/tag/')
prefix, owner, repo = repository.rsplit('/', 2)
if not tag or url.query or url.fragment:
raise ValueError('Expected a Forgejo release URL')
return f'https://{url.netloc}{prefix}/api/v1/repos/{owner}/{repo}'
def sync(source_path, tag, publish=False):
source, _ = export.load_source(source_path)
release = source['releases'][tag]
documents = export.release_documents(source, tag)
api = api_url(release['url'])
token = os.environ.get('FORGEJO_TOKEN')
if publish and not token:
raise ValueError('Set FORGEJO_TOKEN to synchronize Forgejo; no remote changes made')
data = json.loads(request(api+'/releases/tags/'+urllib.parse.quote(tag, safe=''), token))
if data['tag_name'] != tag:
raise ValueError('Remote release tag mismatch')
endpoint = api+'/releases/'+str(data['id'])
changes = []
attachments = {}
for name in ('CHANGELOG.fr.md', 'CHANGELOG.en.md'):
matches = [a for a in data['assets'] if a['name'] == name]
if len(matches) > 1:
raise ValueError('Duplicate remote attachment: ' + name)
old = matches[0] if matches else None
expected = documents[name].encode()
if old is None or request(old['browser_download_url']) != expected:
changes.append(name)
attachments[name] = (old, expected)
if data['body'].replace('\r\n', '\n').rstrip() != documents['release-body.md'].rstrip():
changes.append('release-body.md')
if not publish:
print('Forgejo: ' + (', '.join(changes) + ' need synchronization' if changes else 'up to date'))
return not changes
for name, (old, content) in attachments.items():
# Upload before removing the old text attachment. APK/checksums are untouched.
temporary = name + '.' + hashlib.sha256(content).hexdigest()[:12]
boundary = secrets.token_hex(24)
payload = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; '
f'filename="{temporary}"\r\nContent-Type: text/markdown\r\n\r\n').encode()
payload += content + f'\r\n--{boundary}--\r\n'.encode()
staged = [a for a in data['assets'] if a['name'] == temporary]
if len(staged) > 1:
raise ValueError('Duplicate staged attachment: ' + temporary)
added = staged[0] if staged else json.loads(request(
endpoint+'/assets?name='+urllib.parse.quote(temporary), token,
'POST', payload, 'multipart/form-data; boundary='+boundary))
if request(added['browser_download_url']) != content:
raise ValueError('Uploaded text failed verification: ' + name)
if old:
request(endpoint+'/assets/'+str(old['id']), token, 'DELETE')
request(endpoint+'/assets/'+str(added['id']), token, 'PATCH', {'name': name})
if 'release-body.md' in changes:
request(endpoint, token, 'PATCH', {'body': documents['release-body.md']})
# Check actual remote contents, not just API status codes.
return sync(source_path, tag, publish=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=ROOT/'changelog.json')
parser.add_argument('--release', required=True, help='Existing Forgejo release tag')
parser.add_argument('--publish', action='store_true', help='Update notes and FR/EN text attachments only')
args = parser.parse_args()
try:
ok = sync(args.source, args.release, args.publish)
except urllib.error.HTTPError as error:
parser.exit(1, f'Forgejo HTTP {error.code}. If publishing, a partial update may have occurred; rerun the check.\n')
except (ValueError, KeyError, OSError) as error:
parser.exit(1, f'Changelog sync failed: {error}\n')
parser.exit(0 if ok else 1)
if __name__ == '__main__':
main()

59
tools/test-appearance.py Normal file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Check shared appearance changes in an isolated desktop Gaia profile."""
import importlib.util
import json
from pathlib import Path
import time
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)
checks = []
with Client(2857) as client:
probe.register(client)
def ev(app, code):
result = probe.evaluate(client, app, code)
assert 'error' not in result, result
return result.get('value')
def wait(app, code):
for _ in range(80):
result = ev(app, code)
if result:
return result
time.sleep(.15)
raise AssertionError((app, code))
def check(name, result):
assert result, name
checks.append(name)
print(name + ': OK', flush=True)
def theme(mode):
ev('settings', f'await navigator.mozSettings.createLock().set({{"vulpes.theme":{json.dumps(mode)}}});return true;')
ev('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;')
ev('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
wait('settings', 'return document.readyState==="complete";')
previous = ev('settings', 'return await VulpesCompat.call("appearance.current",{});')
try:
ev('settings', 'const s=await new Promise(r=>require(["modules/settings_service"],r));s.navigate("display");return true;')
wait('settings', 'return !!document.querySelector("#vulpes-theme");')
theme('light')
wait('homescreen', 'return document.documentElement.dataset.vulpesTheme==="light";')
home = ev('homescreen', 'return [getComputedStyle(document.documentElement).backgroundColor,getComputedStyle(document.body).backgroundColor,getComputedStyle(document.body).backgroundImage];')
# Exercise the actual Settings selector rather than only the broker.
ev('settings', 'const s=document.getElementById("vulpes-theme");s.value="dark";s.dispatchEvent(new Event("change",{bubbles:true}));return true;')
for app in ['settings', 'homescreen', 'system']:
check('dark-live-' + app, wait(app, 'return document.documentElement.dataset.vulpesTheme==="dark";'))
check('home-background-preserved', ev('homescreen', 'return [getComputedStyle(document.documentElement).backgroundColor,getComputedStyle(document.body).backgroundColor,getComputedStyle(document.body).backgroundImage];') == home)
check('readable-display-header', ev('settings', 'const h=document.querySelector("#display gaia-header");return getComputedStyle(h.shadowRoot.querySelector(".inner")).backgroundColor==="rgb(37, 52, 66)" && getComputedStyle(h.querySelector("h1")).color==="rgb(241, 245, 249)";'))
check('selector-label-does-not-overlap', ev('settings', 'const label=document.querySelector("#vulpes-theme-label"),l=label.getBoundingClientRect(),s=document.querySelector("#vulpes-theme").getBoundingClientRect();return parseFloat(getComputedStyle(label).height)>0 && l.bottom<=s.top && s.bottom<=document.querySelector("#vulpes-theme-row").getBoundingClientRect().bottom;'))
ev('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
wait('sms', 'return document.readyState==="complete";')
old_time = ev('sms', 'setTimeout(()=>location.reload(),50);return performance.timeOrigin;')
check('reloaded-app-reads-saved-theme', wait('sms', f'return performance.timeOrigin!=={old_time} && document.documentElement.dataset.vulpesTheme==="dark";'))
theme('system')
check('system-preference', wait('sms', 'return document.documentElement.dataset.vulpesTheme===(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'))
theme('light')
check('return-to-light', wait('sms', 'return document.documentElement.dataset.vulpesTheme==="light";'))
finally:
theme(previous or 'light')
print(json.dumps({'checks':checks}, ensure_ascii=False, indent=2))

View file

@ -31,6 +31,7 @@ with Client(2857) as c:
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==='dial'){Runtime.testCommands.push({op,params});Runtime.testCalls=[{path:'/ril_0/voicecall02',number:params.number,state:'dialing'}];return {path:'/ril_0/voicecall02'};}
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;}
@ -53,7 +54,10 @@ with Client(2857) as c:
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'
for _ in range(20):
if c.execute("const w=Services.wm.getMostRecentWindow('vulpes:host');return w.document.getElementById('screen').getBoundingClientRect().bottom<=w.document.getElementById('home').getBoundingClientRect().top+1;"):break
time.sleep(.1)
else:raise AssertionError('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)
@ -72,6 +76,16 @@ with Client(2857) as c:
time.sleep(.2)
else:raise AssertionError('Call history was not acknowledged')
print('PASS: original call log imports the completed call.',flush=True)
ev('communications','document.querySelector(".log-item").click();return true;')
wait('callscreen','return navigator.mozTelephony?.calls[0]?.state==="dialing";')
commands=c.execute("return ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs').Runtime.testCommands.filter(c=>c.op==='dial');")
assert len(commands)==1 and commands[0]['params']['number']=='0123456789',commands
assert ev('communications','return navigator.mozTelephony.calls[0] instanceof TelephonyCall;')
calls('active')
wait('communications','return navigator.mozTelephony.calls[0]?.state==="connected";')
calls(None)
wait('communications','return navigator.mozTelephony.calls.length===0;')
print('PASS: call-log redial reaches the modem once and follows the call lifecycle.',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')
@ -82,7 +96,7 @@ with Client(2857) as c:
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");')
assert ev('system','const v=document.getElementById("volume"),r=v.getBoundingClientRect();return v.classList.contains("visible") && r.height>=30 && r.width>100 && v.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));'), 'Gaia volume HUD must be painted, not only marked 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)

View file

@ -65,12 +65,12 @@ with Client(2857) as c:
)
results["unavailable_controls"] = ev(
"camera",
'return [".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true");',
'return [".js-flash"].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;',
'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();return true;',
)
assert ev(
"camera",

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Check touch send and attachment selection on the isolated desktop, with a fake modem."""
import importlib.util
import json
from pathlib import Path
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)
with Client(2857) as c:
probe.register(c)
c.execute("Services.prefs.setCharPref('layout.css.devPixelsPerPx','1');const w=Services.wm.getMostRecentWindow('vulpes:host');w.resizeTo(360,720);return true;")
time.sleep(.5)
def ev(app,code):
value=probe.evaluate(c,app,code)
if 'value' not in value:raise RuntimeError(value)
return value['value']
def wait(app,code):
for _ in range(80):
if probe.evaluate(c,app,code).get('value'):return
time.sleep(.1)
raise AssertionError(code)
def tap(app,selector):
rect=ev(app,'return document.querySelector('+json.dumps(selector)+').getBoundingClientRect().toJSON();')
point={'x':rect['x']+rect['width']/2,'y':rect['y']+rect['height']/2}
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 child of b.children){const f=find(child);if(f)return f;}}
find(w.document.getElementById('system').browsingContext).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',POINT).then(done,e=>done({error:String(e)}));'''
result=c.call('WebDriver:ExecuteAsyncScript',{'script':script.replace('PREFIX',json.dumps('http://'+app+'.localhost:8765/')).replace('POINT',json.dumps(point)),'args':[],'newSandbox':True,'sandbox':'system'})['value']
assert result is True,result
time.sleep(.35)
ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("activities.cancel",{});dispatchEvent(new CustomEvent("home"));await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
wait('sms',"return typeof Navigation?.toPanel==='function';")
c.execute("const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');Runtime.messages.__saved=[Runtime.messages.radio,Runtime.messages.radioStatus];Runtime.messages.__count=0;Runtime.messages.radio=async()=>{Runtime.messages.__count++;return {state:'sent'};};Runtime.messages.radioStatus=null;return true;")
try:
ev('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");Compose.clear();ConversationView.recipients.length=0;ConversationView.recipients.add({name:"5550199",number:"5550199",source:"manual"});Compose.append("One touch send regression");return true;')
tap('sms','#messages-input')
wait('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>100;')
tap('sms','#messages-send-button')
wait('sms','return [...document.querySelectorAll(".message-content-body")].some(e=>e.textContent==="One touch send regression");')
count=c.execute("return ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs').Runtime.messages.__count;")
assert count==1,count
assert ev('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>100;'),'Keyboard closed after send'
tap('sms','#messages-attach-button')
wait('system','return document.querySelector("#action-menu")?.checkVisibility() && document.querySelectorAll("#action-menu-list button").length>=4;')
labels=ev('system','return [...document.querySelectorAll("#action-menu-list button")].map(e=>e.textContent);')
tap('system','#action-menu-list button')
wait('gallery','return location.hash==="#pick";')
report={'singleTapSend':True,'modemSubmissions':count,'keyboardRetained':True,'attachmentChoices':labels,'originalGalleryPicker':True}
print(json.dumps(report,ensure_ascii=False))
finally:
ev('system','await VulpesCompat.call("activities.cancel",{});dispatchEvent(new CustomEvent("home"));return true;')
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;delete Runtime.messages.__count;return true;")

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Exercise the October Settings regressions in the isolated Gaia test profile."""
import importlib.util
import json
import 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'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
checks={}
with Client(2857) as client:
probe.register(client)
def ev(app,code):
r=probe.evaluate(client,app,code)
assert 'error' not in r,r
return r.get('value')
def wait(app,code):
for _ in range(80):
v=ev(app,code)
if v:return v
time.sleep(.15)
raise AssertionError((app,code))
def check(name,value):
checks[name]=value
assert value,(name,value)
print(name+': OK',flush=True)
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';")
def panel(name,extra='{}'):
ev('settings',f"const s=await new Promise(r=>require(['modules/settings_service'],r));s.navigate('{name}',{extra});return true;")
wait('settings',f"return document.getElementById('{name}')?.classList.contains('current');")
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;")
launch('settings')
# An IME commits text through input, without a synthetic legacy keypress.
ev('settings', "const s=await new Promise(r=>require(['modules/dialog_service'],r));if(!s._navigating)s.show('screenLock-passcode',{mode:'create'});return true;")
wait('settings', "return document.querySelector('#screenLock-passcode input')?.type==='password';")
check('pin-ime-input',ev('settings',"""
const p=document.querySelector('#screenLock-passcode'),i=p.querySelector('input');
i.value='12341234';i.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'12341234'}));
return p.dataset.passcodeStatus==='success' && i.type==='password' && i.inputMode==='numeric' && p.querySelectorAll('[data-dot]').length===8;
"""))
check('pin-backspace',ev('settings',"""
const p=document.querySelector('#screenLock-passcode'),i=p.querySelector('input');
i.value='1234123';i.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'deleteContentBackward'}));
return p.dataset.passcodeStatus!=='success' && p.querySelectorAll('[data-dot]').length===7;
"""))
ev('settings', "document.querySelector('#screenLock-passcode button[type=reset]').click();return true;")
panel('appPermissions')
check('permissions-list-without-retired-api',ev('settings',"""
const List=await new Promise(r=>require(['panels/app_permissions_list/app_permissions_list'],r));
const table=await (await fetch('/resources/permissions_table.json')).json();
const list=List(),ul=document.createElement('ul');list.init(ul);list.setPermissionsTable(table);
list._initExplicitPermissionsTable(await navigator.mozApps.getSelf());list._apps=[];
list._loadApps([{origin:'http://test.localhost:8765',manifestURL:'http://test.localhost:8765/manifest.webapp',manifest:{name:'Test installed app',type:'web'}}]);
return ul.querySelector('li span')?.textContent==='Test installed app';
"""))
panel('about-legal')
for lang,header in [('fr','Paramètres'),('en-US','Settings'),('el','Ρυθμίσεις')]:
ev('settings',f"await navigator.mozSettings.createLock().set({{'language.current':'{lang}'}});return true;")
wait('settings',f"return document.querySelector('#root h1')?.textContent==={json.dumps(header)};")
wait('homescreen',f"return document.documentElement.lang==='{lang}';")
check('language-'+lang,ev('settings',"return document.querySelector('#vulpes-credits').textContent.startsWith("+json.dumps('Vulpes OS repose' if lang=='fr' else 'Vulpes OS builds')+");"))
ev('settings',"await navigator.mozSettings.createLock().set({'lockscreen.enabled':false});return true;")
ev('system',"dispatchEvent(new CustomEvent('vulpes-service-event',{detail:{type:'screen-off',data:{}}}));return true;")
time.sleep(.5)
check('disabled-lock-stays-unlocked',ev('system',"return !Service.query('locked') && !document.querySelector('#screen').classList.contains('locked');"))
panel('sound')
ev('settings',"document.querySelector('.manage-tones-button').click();return true;")
wait('ringtones',"return location.pathname==='/manage.html' && document.body?.dataset.ready==='true';")
check('manage-tones-opens',True)
ev('system',"dispatchEvent(new CustomEvent('home'));return true;")
ev('system',"await navigator.mozSettings.createLock().set({'homescreen.manifestURL':'http://verticalhome.localhost:8765/manifest.webapp'});return true;")
wait('verticalhome',"return window.app?.itemStore.ready && app.grid.getItems().length>10;")
check('alternate-home-first-start',True)
ev('system',"await navigator.mozSettings.createLock().set({'homescreen.manifestURL':'http://homescreen.localhost:8765/manifest.webapp','language.current':'fr','lockscreen.enabled':true});return true;")
print(json.dumps(checks,ensure_ascii=False,indent=2))

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Regression: visible Gaia errors and retained MMS composer, without real sends."""
import importlib.util,json,time
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):
result=p.evaluate(c,app,code,"/dialer/" if app=="communications" else "/")
assert 'value' in result,result
return result['value']
def wait(app,code):
for _ in range(100):
if p.evaluate(c,app,code,"/dialer/" if app=="communications" else "/").get('value'):return
time.sleep(.1)
raise AssertionError(code)
def visible(app,selector):
return ev(app,'''const f=document.querySelector(SELECTOR),s=f.querySelector('section'),m=f.querySelector('menu');
const r=s.getBoundingClientRect(),b=m.getBoundingClientRect(),v=f.getBoundingClientRect();
return {text:s.innerText,visible:r.top>=v.top && r.bottom<=b.top && r.right<=v.right && r.height>0,
section:r.toJSON(),menu:b.toJSON()};'''.replace('SELECTOR',json.dumps(selector)))
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:"dialer"});return true;')
wait('communications','return !!window.CallHandler;')
time.sleep(1.5)
ev('communications','await new Promise(r=>LazyLoader.load("/shared/js/dialer/telephony_messages.js",r));TelephonyMessages.displayMessage("NoNetwork");return true;')
time.sleep(.5)
result=visible('communications','#confirmation-message');assert result['visible'] and result['text'],result
print('PASS: original Gaia network-error text stays above its buttons.',flush=True)
ev('communications','ConfirmDialog.hide();return true;')
ev('system','await VulpesCompat.call("apps.launch",{manifestURL:"http://sms.localhost:8765/manifest.webapp"});return true;')
wait('sms','return !!window.ConversationView && !!window.Compose;')
time.sleep(1.5)
ev('sms','''await Navigation.toPanel('thread-list');await Navigation.toPanel('composer');Compose.clear();ConversationView.recipients.length=0;
ConversationView.recipients.add({name:'5550199',number:'5550199',source:'manual'});Compose.append('MMS draft regression');
const canvas=document.createElement('canvas');canvas.width=16;canvas.height=16;canvas.getContext('2d').fillRect(0,0,16,16);
const blob=await new Promise(r=>canvas.toBlob(r,'image/png'));
Compose.append(new Attachment(blob,{name:'regression.png',isDraft:true}));return true;''')
wait('sms','return Compose.type==="mms";')
for lang in ['fr','en-US']:
ev('sms',f'document.documentElement.lang={json.dumps(lang)};document.getElementById("messages-send-button").click();return true;')
time.sleep(.3)
result=visible('sms','#vulpes-gaia-notice');assert result['visible'] and 'MMS' in result['text'],result
assert ('pas encore disponible' if lang=='fr' else 'not yet available') in result['text'],result
ev('sms','document.querySelector("#vulpes-gaia-notice button").click();return true;')
assert ev('sms','return Compose.type==="mms" && Compose.getText()==="MMS draft regression" && Compose.getContent().some(i=>i instanceof Attachment) && Navigation.isCurrentPanel("composer");')
print('PASS: French/English MMS notice visible; text, attachment and composer retained.',flush=True)
ev('sms','Compose.clear();ConversationView.recipients.length=0;return true;')
ev('system','dispatchEvent(new Event("home"));return true;')

View file

@ -78,7 +78,7 @@ find(w.document.getElementById('system').browsingContext).currentWindowGlobal
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;')
wait('sms', "return typeof window.Navigation?.toPanel === 'function';")
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\\"]");')
@ -93,7 +93,7 @@ find(w.document.getElementById('system').browsingContext).currentWindowGlobal
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'
assert evaluate('sms', 'return document.querySelector(`.recipient[data-number="23"]`)?.dataset.number || document.querySelector(".recipient[contenteditable=true]").textContent.trim();') == number.strip(), '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'

71
tools/test-hotspot-ui.py Normal file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Exercise real Gaia hotspot dialogs in the isolated PC profile with a fake radio.
This tests UI behavior only. Native hotspot qualification is a separate test.
"""
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'))
probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
with Client(2857) as client:
probe.register(client)
def ev(code):
result=probe.evaluate(client,'settings',code)
if 'error' in result:raise AssertionError(result['error'])
return result.get('value')
def wait(code):
for _ in range(100):
if ev(code):return
time.sleep(.1)
state=ev("const d=await new Promise(r=>require(['modules/dialog_service'],r));const p=document.getElementById('hotspot-wifiSettings');return {navigating:d._navigating,pending:d._pendingRequests.length,hidden:p.hidden,current:Array.from(document.querySelectorAll('section.current'),p=>p.id),transition:getComputedStyle(p).transition,visibility:getComputedStyle(p).visibility,notes:Array.from(p.querySelectorAll('[role=status]'),p=>p.textContent)};")
raise AssertionError((code,state))
for _ in range(100):
ready=probe.evaluate(client,'system','return document.body?.getAttribute("ready-state")==="fullyLoaded";')
if ready.get('value'):break
time.sleep(.2)
probe.evaluate(client,'system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
wait('return !!document.querySelector("#root.current") && typeof require==="function";')
ev('location.reload();return true;');time.sleep(.7)
wait('return !!document.querySelector("#root.current") && typeof require==="function";')
ev('''window.__hotspotOriginalCall=VulpesCompat.call;
window.__hotspotFake={enabled:false,configured:false,ssid:'Vulpes OS',security:'wpa2-psk',password:''};
window.__hotspotWrites=[];window.__hotspotFail=false;
VulpesCompat.call=async(method,p)=>{
if(method!=='platform.wifi')return __hotspotOriginalCall(method,p);
if(p.operation==='hotspotSave'){
if(__hotspotFail)throw Error('HOTSPOT_SAVE_FAILED');
__hotspotWrites.push('save');Object.assign(__hotspotFake,{configured:true,ssid:p.ssid,security:p.security,password:p.password});
} else if(p.operation==='hotspotPower'){__hotspotWrites.push(p.enabled?'on':'off');__hotspotFake.enabled=p.enabled;}
return {...__hotspotFake};
};
window.VulpesNativeWifi=true;
const script=document.createElement('script');script.src='/_vulpes/gaia-wifi.js';document.head.append(script);return true;''')
try:
wait('return !!window.VulpesWifiSettings;')
ev("require(['modules/settings_service'],s=>s.navigate('hotspot'));return true;")
wait("return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])');")
def open_dialog():
ev("document.querySelector('#hotspot-settings-section a').click();return true;")
wait("return document.querySelector('#hotspot-wifiSettings').classList.contains('current');")
def closed():
wait("const d=await new Promise(r=>require(['modules/dialog_service'],r));return !d._navigating && !document.querySelector('#hotspot-wifiSettings').classList.contains('current') && !document.querySelector('#tethering-wifi-enabled').disabled;")
open_dialog()
ev("document.querySelector('#hotspot-wifiSettings button[type=reset]').click();return true;");closed()
open_dialog()
ev("const p=document.querySelector('#hotspot-wifiSettings');p.querySelector('input[type=text]').value='UI test';p.querySelector('input[name=password]').value='short';p.querySelector('button[type=submit]').click();return true;")
time.sleep(.3)
assert ev("return document.querySelector('#hotspot-wifiSettings').classList.contains('current') && __hotspotWrites.length===0;")
ev("window.__hotspotFail=true;const p=document.querySelector('#hotspot-wifiSettings');p.querySelector('input[name=password]').value='testpassword';p.querySelector('button[type=submit]').click();return true;")
wait("return !!document.querySelector('#hotspot-wifiSettings [role=status]').textContent;")
state=ev("const p=document.getElementById('hotspot-wifiSettings');return {preserved:p.querySelector('input[name=password]').value==='testpassword',writes:__hotspotWrites,current:p.classList.contains('current'),message:p.querySelector('[role=status]').textContent,disabled:p.querySelector('button[type=submit]').disabled};")
assert state['preserved'] and not state['writes'],state
ev("window.__hotspotFail=false;document.querySelector('#hotspot-wifiSettings button[type=submit]').click();return true;");closed()
assert ev("return __hotspotFake.configured && __hotspotWrites.join(',')==='save';")
for enabled in (True,False):
ev("const c=document.querySelector('#tethering-wifi-enabled');c.checked="+json.dumps(enabled)+";c.dispatchEvent(new Event('change',{bubbles:true}));return true;")
wait("return !document.querySelector('#tethering-wifi-enabled').disabled && __hotspotFake.enabled==="+json.dumps(enabled)+';')
assert ev("return __hotspotWrites.join(',')==='save,on,off';")
print('PASS: Gaia hotspot cancel/reopen, invalid input, failed save retention, retry, start/stop. Fake radio only.')
finally:
ev("VulpesCompat.call=__hotspotOriginalCall;location.reload();return true;")

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Dismiss a synthetic Gaia notification with touch events in the test profile."""
import importlib.util
import json
from pathlib import Path
import time
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 c:
probe.register(c)
def ev(code):
r=probe.evaluate(c,'system',code)
assert 'value' in r,r
return r['value']
for _ in range(80):
if probe.evaluate(c,'system','return document.body?.getAttribute("ready-state")==="fullyLoaded";').get('value'):break
time.sleep(.2)
ev('ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});dispatchEvent(new Event("home"));return true;')
try:
ev('NotificationScreen.addNotification({id:"vulpes-swipe-test",title:"Gesture test",body:"Temporary test notification",timestamp:Date.now()});NotificationScreen.closeToast();UtilityTray.show(true);return true;')
time.sleep(.5)
point=ev('const e=document.querySelector("#notifications-container [data-notification-id=\\"vulpes-swipe-test\\"]"),r=e.getBoundingClientRect();return {x:r.x+r.width*.2,y:r.y+r.height*.5,toX:r.x+r.width*.92,toY:r.y+r.height*.5};')
code='''const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');w.document.getElementById('system').browsingContext.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Swipe',POINT).then(done,e=>done({error:String(e)}));'''.replace('POINT',json.dumps(point))
result=c.call('WebDriver:ExecuteAsyncScript',{'script':code,'args':[],'newSandbox':True,'sandbox':'system'})['value']
assert result is True,result
time.sleep(1)
result=ev('return {dismissed:!document.querySelector("#notifications-container [data-notification-id=\\"vulpes-swipe-test\\"]"),rootStable:scrollX===0&&scrollY===0&&visualViewport.offsetLeft===0&&visualViewport.offsetTop===0,lockGesture:getComputedStyle(document.getElementById("lockscreen")).touchAction};')
assert result['dismissed'] and result['rootStable'],result
print(json.dumps(result))
finally:
ev('NotificationScreen.removeNotification("vulpes-swipe-test");UtilityTray.hide(true);return true;')

View file

@ -0,0 +1,25 @@
#!/usr/bin/env python3
import json,sys
from pathlib import Path
from marionette_client import Client
ROOT=Path(__file__).resolve().parents[1]
with Client(2857) as c:
c.socket.settimeout(180)
c.call("WebDriver:SetTimeouts",{"script":150000})
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"),{});for(const kind of ["camera","microphone"])Services.perms.addFromPrincipal(p,kind,Ci.nsIPermissionManager.ALLOW_ACTION);return true;')
# The actor evaluation is the same mechanism used by the phone's private probe.
source=(ROOT/'host/SettingsFollowupAudit.sys.mjs').read_text().replace('export async function','async function',1)
script='''const done=arguments[arguments.length-1];
try{ChromeUtils.registerWindowActor('VulpesProbe',{child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},allFrames:true,safeForUntrustedWebProcess:true});}catch(_){}
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function evaluate(app,code){const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return b;for(const c of b.children){const f=find(c);if(f)return f;}}const b=find(root);if(!b)throw Error('Missing '+app);return b.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',code);}
async function wait(app,code,timeout=15000){const end=Date.now()+timeout;let error;while(Date.now()<end){try{if(await evaluate(app,code))return true;}catch(e){error=e;}await sleep(150);}throw Error('Timeout '+app+' '+code+' '+(error||''));}
''' + '''
async function tap(app,selector){const point=await evaluate(app,`const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return b;for(const c of b.children){const f=find(c);if(f)return f;}}await find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('MouseClick',point);}
''' + source+'''\nauditFollowup({evaluate,wait,sleep,tap,save:async data=>{await IOUtils.writeJSON(PathUtils.join(PathUtils.profileDir,'followup.json'),data);}}).then(done,e=>done({error:String(e)}));'''
try:
result=c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':150000})['value']
print(json.dumps(result,indent=2))
(ROOT/'logs/settings-followup-20261012/desktop-followup.json').write_text(json.dumps(result,indent=2)+'\n')
if not result.get('passed'):sys.exit(1)
finally:c.execute('Services.prefs.clearUserPref("media.navigator.streams.fake");Services.prefs.clearUserPref("media.navigator.permission.fake");return true;')

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Use trusted input to open Gaia's selector, commit/cancel and reopen it."""
import importlib.util,json,time,os
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)
with Client(2857) as c:
p.register(c)
def ev(code):
r=p.evaluate(c,'settings',code);assert 'error' not in r,r
return r.get('value')
def wait(code):
for _ in range(80):
v=ev(code)
if v:return v
time.sleep(.1)
raise AssertionError(code)
def tap(selector, gesture=None):
point=ev('const e=document.querySelector('+json.dumps(selector)+');e.scrollIntoView({block:"center"});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};')
method=gesture or ('Tap' if os.environ.get('VULPES_DIRECT_TOUCH') and selector=='#vulpes-theme' else 'MouseClick')
if method=='Swipe':point.update(toX=point['x']+30,toY=point['y'])
result=c.call('WebDriver:ExecuteAsyncScript',{'script':'''const done=arguments[arguments.length-1];const w=Services.wm.getMostRecentWindow('vulpes:host');function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://settings.localhost:8765/'))return b;for(const child of b.children){const found=find(child);if(found)return found;}}const bc=find(w.document.getElementById('system').browsingContext);bc.currentWindowGlobal.getActor('VulpesProbe').sendQuery(METHOD,POINT).then(done,e=>done({error:String(e)}));'''.replace('POINT',json.dumps(point)).replace('METHOD',json.dumps(method)),'args':[],'newSandbox':True,'sandbox':'system','scriptTimeout':10000})
assert result['value'] is True,result
time.sleep(.3)
p.evaluate(c,'system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
wait('return document.readyState==="complete" && typeof require==="function";')
previous=ev('return await VulpesCompat.call("appearance.current",{});')
try:
ev('const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");await navigator.mozSettings.createLock().set({"vulpes.theme":"light"});return true;')
wait('return !!document.querySelector("#vulpes-theme");')
time.sleep(1)
tap('#vulpes-theme','Swipe')
assert ev('return !document.getElementById("vulpes-value-selector");'), 'A drag must not open the selector'
tap('#vulpes-theme')
wait('return !!document.querySelector("#vulpes-value-selector");')
wait('return getComputedStyle(document.querySelector("#vulpes-value-selector")).backgroundColor!=="rgba(0, 0, 0, 0)";')
tap('#vulpes-value-selector [data-value="dark"]')
tap('#vulpes-value-selector [data-action="confirm"]')
wait('return document.documentElement.dataset.vulpesTheme==="dark" && !document.getElementById("vulpes-value-selector");')
tap('#vulpes-theme');tap('#vulpes-value-selector [data-value="light"]');tap('#vulpes-value-selector [data-action="cancel"]')
assert ev('return document.documentElement.dataset.vulpesTheme==="dark";')
tap('#vulpes-theme');tap('#vulpes-value-selector [data-value="light"]');tap('#vulpes-value-selector [data-action="confirm"]')
wait('return document.documentElement.dataset.vulpesTheme==="light";')
print('PASS '+('direct touch' if os.environ.get('VULPES_DIRECT_TOUCH') else 'mouse via touch simulator')+': drag ignored, open, styled selector, dark, cancel, reopen, light')
finally:ev('await navigator.mozSettings.createLock().set({"vulpes.theme":'+json.dumps(previous)+'});return true;')