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:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
14
tools/amd-module-range.cjs
Normal file
14
tools/amd-module-range.cjs
Normal 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
43
tools/audit-settings.py
Normal 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;')
|
||||
|
|
@ -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
249
tools/export-changelog.py
Normal 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()
|
||||
|
|
@ -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__'}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
16
tools/refresh-camera-modules.py
Normal file
16
tools/refresh-camera-modules.py
Normal 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)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
114
tools/sync-forgejo-changelog.py
Normal file
114
tools/sync-forgejo-changelog.py
Normal 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
59
tools/test-appearance.py
Normal 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))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
56
tools/test-composer-actions.py
Normal file
56
tools/test-composer-actions.py
Normal 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;")
|
||||
78
tools/test-forgejo-feedback.py
Normal file
78
tools/test-forgejo-feedback.py
Normal 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))
|
||||
50
tools/test-gaia-dialogs.py
Normal file
50
tools/test-gaia-dialogs.py
Normal 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;')
|
||||
|
|
@ -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
71
tools/test-hotspot-ui.py
Normal 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;")
|
||||
32
tools/test-notification-swipe.py
Normal file
32
tools/test-notification-swipe.py
Normal 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;')
|
||||
25
tools/test-settings-followup.py
Normal file
25
tools/test-settings-followup.py
Normal 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;')
|
||||
46
tools/test-settings-selector.py
Normal file
46
tools/test-settings-selector.py
Normal 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;')
|
||||
Loading…
Reference in a new issue