feat(rtos): 首次发布手机真机调试桥与S2探针

这个提交包含在:
sydxky 于 2026-11-14 13:25:37 +08:00 提交
当前提交 2f0f4b9702
59 个文件已更改
+4303

没有与搜索条件相匹配的文件

+98
查看文件
@@ -0,0 +1,98 @@
"""Verify ui.py against both native Glyphix emulator profiles (not a physical S2).
Build deviceprobe QuickApps first. Supply a compatible vendor global/launcher
image using --seed (or initialize the probe image). Never attaches to an
existing Inspector or terminates another user's emulator.
"""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
PROJECT = Path(__file__).resolve().parents[1]
ROOT = PROJECT.parent
sys.path.insert(0, str(PROJECT))
from ui import Inspector
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', required=True, type=Path)
parser.add_argument('--seed', type=Path, default=ROOT / 'com.sydxky.rtos.deviceprobe/.glyphix-work/image/generic-watch-466x466',
help='Initialized vendor image containing global/, global.pkg and apps/emulator.launcher.pkg')
args = parser.parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
results = []
for shape, device in [('round', '466x466'), ('rect', '390x450')]:
with socket.socket() as sock:
if sock.connect_ex(('127.0.0.1', 14201)) == 0:
raise RuntimeError('Inspector port in use; will not attach to another runtime')
folder = output / shape
image = folder / 'image'
image.mkdir(parents=True, exist_ok=True)
seed = args.seed.resolve()
shutil.copytree(seed / 'global', image / 'global', dirs_exist_ok=True)
shutil.copy2(seed / 'global.pkg', image / 'global.pkg')
(image / 'apps').mkdir(exist_ok=True)
shutil.copy2(seed / 'apps/emulator.launcher.pkg', image / 'apps/emulator.launcher.pkg')
name = 'com.sydxky.rtos.deviceprobe'
package = ROOT / name / '.glyphix-work/dist' / ('generic-watch-' + device + '-quickapp') / name / 'bundle.pkg'
shutil.copy2(package, image / 'apps' / (name + '.pkg'))
(image / 'pkgs.db').write_text(json.dumps({'apps': {p: {'uri': 'pkg://' + p} for p in (name, 'emulator.launcher')}, 'dials': {}}), encoding='utf-8')
command = [str(ROOT / 'temp/glyphix-v0.7.2-windows-x64/bin/glyphix-emu.exe'), '--config', 'pkgs.db', '--target', name, '--inspector']
command += ['--width', '624', '--height', '624', '--dpi', '326', '--font-size', '32', '--round', '--surface', 'surface/surface-466x466.json'] if shape == 'round' else ['--width', '390', '--height', '450', '--dpi', '321', '--font-size', '29']
command += ['--memory-profile', '8192', '--font-map', 'font-faces', '--font-family', 'sans-serif']
client = Inspector('http://127.0.0.1:14201')
item = {'profile': shape, 'evidence_scope': 'native_emulator_not_S2', 'package_sha256': hashlib.sha256(package.read_bytes()).hexdigest(), 'passed': False}
results.append(item)
with open(folder / 'stdout.log', 'w', encoding='utf-8') as out, open(folder / 'stderr.log', 'w', encoding='utf-8') as err:
proc = subprocess.Popen(command, cwd=image, stdin=subprocess.PIPE, stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW)
try:
for _ in range(70):
if proc.poll() is not None:
raise RuntimeError('Emulator exited before Inspector startup')
try:
client.request('/logs')
break
except OSError:
time.sleep(.3)
else:
raise TimeoutError('Inspector did not start')
time.sleep(1)
item['before'] = client.screenshot(folder / 'before.png')
item['initial_visible'] = client.exists(('text', '点击次数:0'))
client.click(('text', '点击验证'))
item['after_visible'] = client.exists(('text', '点击次数:1'))
item['after'] = client.screenshot(folder / 'after.png')
plan = json.loads((PROJECT / 'examples/probe-inspector.json').read_text(encoding='utf-8'))
plan['steps'][0]['value'] = '点击次数:1'
plan['steps'][2]['value'] = '点击次数:2'
planfile = folder / 'plan.json'
planfile.write_text(json.dumps(plan, ensure_ascii=False), encoding='utf-8')
run = subprocess.run([sys.executable, str(PROJECT / 'ui.py'), '--target', 'inspector', '--inspector', client.base, 'run', str(planfile), '--output', str(folder / 'plan-results')], capture_output=True, timeout=90)
item['plan_exit'] = run.returncode
item['passed'] = item['initial_visible'] and item['after_visible'] and run.returncode == 0
if run.returncode:
raise RuntimeError(run.stderr.decode('utf-8', 'replace') or 'UI plan failed')
except Exception as error:
item['error'] = type(error).__name__
raise
finally:
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=10)
proc.stdin.close()
(output / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(item, ensure_ascii=True), flush=True)
time.sleep(2)
return 0 if all(item['passed'] for item in results) else 1
if __name__ == '__main__':
sys.exit(main())
+117
查看文件
@@ -0,0 +1,117 @@
"""Exercise the PC client against a real local HTTP socket, without Android."""
import importlib.util
import json
from pathlib import Path
import tempfile
import threading
import unittest
from unittest.mock import patch
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
spec = importlib.util.spec_from_file_location('bridge', Path(__file__).resolve().parents[1] / 'bridge.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_): pass
def handle_request(self):
length = int(self.headers.get('Content-Length', 0))
data = self.rfile.read(length)
self.server.requests.append((self.command, self.path, dict(self.headers), data))
if self.path == '/reject':
status, result = 401, {'error': 'Unauthorized'}
elif self.command == 'PUT':
status, result = 200, {'file': self.path.split('/')[-1], 'sha256': self.headers['X-SHA256']}
elif self.path == '/command':
status, result = 202, {'id': 'test-job'}
elif self.path == '/jobs/test-job':
status, result = 200, self.server.job_result
else:
status, result = 200, {'state': 'bound'}
body = json.dumps(result).encode()
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = do_POST = do_PUT = handle_request
class ClientTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
cls.server.requests = []
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
self.client = module.Bridge(port=self.server.server_port)
self.client.token = 'test-token'
self.server.requests.clear()
def test_unicode_message_survives_http(self):
self.server.job_result = {'state': 'success', 'result': {'code': 200}}
self.client.command({'command': 'message', 'message': 'S2 真机 🙂'})
method, path, headers, body = self.server.requests[0]
self.assertEqual(json.loads(body)['message'], 'S2 真机 🙂')
self.assertEqual(headers['Authorization'], 'Bearer test-token')
self.assertEqual((method, path), ('POST', '/command'))
def test_watch_failure_is_preserved(self):
self.server.job_result = {'state': 'failed', 'result': {'code': 5005, 'msg': 'invalid package'}}
result = self.client.command({'command': 'install', 'file': 'test.pkg'})
self.assertEqual(result['state'], 'failed')
self.assertEqual(result['result']['code'], 5005)
def test_timeout_keeps_job_for_inspection(self):
result = self.client.command({'command': 'info'}, timeout=0)
self.assertEqual(result['state'], 'timeout')
self.assertEqual(result['id'], 'test-job')
def test_binary_upload_hash_and_safe_name(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / '中文 package.pkg'
data = bytes(range(256)) * 32
file.write_bytes(data)
name = self.client.upload(file)
request = self.server.requests[0]
self.assertEqual(request[3], data)
self.assertRegex(name, r'^[a-f0-9]{24}\.pkg$')
self.assertEqual(request[2]['X-Sha256'], module.hashlib.sha256(data).hexdigest())
def test_rejected_request_does_not_leak_token(self):
with self.assertRaises(RuntimeError) as ctx:
self.client.request('GET', '/reject')
self.assertIn('401', str(ctx.exception))
self.assertNotIn('test-token', str(ctx.exception))
def test_empty_upload_rejected_before_network(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / 'empty.pkg'
file.touch()
with self.assertRaises(ValueError): self.client.upload(file)
self.assertEqual(self.server.requests, [])
def test_wait_link_allows_health_to_reconnect(self):
offline = {'state': 'success', 'result': {'connected': False}}
online = {'state': 'success', 'result': {'connected': True}}
with patch.object(self.client, 'command', side_effect=[offline, online]) as command, patch.object(module.time, 'sleep'):
self.assertEqual(self.client.wait_link(5), online)
self.assertEqual(command.call_count, 2)
self.assertTrue(all(call.args[0] == {'command': 'link'} for call in command.call_args_list))
def test_wait_link_does_not_repeat_failed_calls(self):
failed = {'state': 'failed', 'result': 'disconnected'}
with patch.object(self.client, 'command', return_value=failed) as command:
self.assertEqual(self.client.wait_link(5), failed)
command.assert_called_once()
if __name__ == '__main__': unittest.main()
+118
查看文件
@@ -0,0 +1,118 @@
import json
import base64
import struct
from pathlib import Path
import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ui import Inspector, Phone, save_png, imported_watch_capture, jpeg_info, health_screenshot
class UIHandler(BaseHTTPRequestHandler):
def log_message(self, *_): pass
def do_GET(self):
self.server.paths.append(self.path)
if self.path == '/snapshot.json':
value = {'frames': [{'snapshot': 'frame.png'}]}
body = json.dumps(value).encode()
else:
body = b'broken PNG'
self.send_response(200)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
self.server.paths.append(self.path)
body = b'{}'
self.send_response(200)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
class UiTests(unittest.TestCase):
@staticmethod
def jpeg_header(width=480, height=464):
# Minimal marker stream exercises metadata parsing, not a JPEG decoder.
return b'\xff\xd8\xff\xc0' + struct.pack('>HBHHB', 8, 8, height, width, 0) + b'\xff\xd9'
def test_watch_jpeg_dimensions_and_truncation(self):
data = self.jpeg_header()
self.assertEqual((jpeg_info(data)['width'], jpeg_info(data)['height']), (480, 464))
with self.assertRaises(ValueError): jpeg_info(data[:-1])
def test_official_numbered_jpeg_is_exported_with_real_extension(self):
data = self.jpeg_header()
class HealthPhone:
queries = 0
find = staticmethod(Phone.find)
def unlocked(self): pass
def click(self, selector): pass
def tree(self):
return '<hierarchy><node resource-id="com.heytap.health:id/device_settings_tv_screen_shot" enabled="true" bounds="[0,0][10,10]"/></hierarchy>'
def run(self, *args, binary=False):
if binary:
self.path = args[-1]
return data
if 'content query' in args[-1]:
self.queries += 1
return '' if self.queries == 1 else 'Row: 0 _id=9, _data=/storage/emulated/0/Pictures/com.heytap.health/screenshots/watch_screen_shot (2).jpeg, width=480, height=464, date_modified=100'
return ''
phone = HealthPhone()
with tempfile.TemporaryDirectory() as tmp:
result = health_screenshot(phone, Path(tmp) / 'watch.png')
self.assertEqual(Path(result['file']).suffix, '.jpg')
self.assertEqual(Path(result['file']).read_bytes(), data)
self.assertTrue(phone.path.endswith('watch_screen_shot (2).jpeg'))
def test_failed_inspector_image_always_resumes_runtime(self):
server = ThreadingHTTPServer(('127.0.0.1', 0), UIHandler)
server.paths = []
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
client = Inspector('http://127.0.0.1:' + str(server.server_port))
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / 'capture.png'
with self.assertRaises(ValueError): client.screenshot(dest)
self.assertFalse(dest.exists())
self.assertEqual(server.paths[-1], '/control?pause=false')
finally:
server.shutdown(); server.server_close(); thread.join()
def test_phone_ambiguous_selector_is_rejected(self):
xml = '<hierarchy><node text="安装" bounds="[0,0][20,20]"/><node text="安装" bounds="[30,0][50,20]"/></hierarchy>'
with self.assertRaises(ValueError): Phone.find(xml, ('text', '安装'))
def test_phone_hidden_node_is_not_selected(self):
self.assertIsNone(Phone.find('<hierarchy><node text="隐藏" bounds="[0,0][0,0]"/></hierarchy>', ('text', '隐藏')))
self.assertIsNone(Phone.find('<hierarchy><node text="隐藏" visible-to-user="false" bounds="[0,0][20,20]"/></hierarchy>', ('text', '隐藏')))
def test_import_integrity_failure_does_not_write_capture(self):
class ImportedPhone:
def run(self, *args, binary=False):
return base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=') if binary else '{"sha256":"wrong"}'
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / 'watch.png'
with self.assertRaises(ValueError): imported_watch_capture(ImportedPhone(), dest)
self.assertFalse(dest.exists())
def test_inspector_is_explicit_loopback(self):
for url in ('https://example.com', 'http://10.0.0.1', 'http://user:secret@localhost'):
with self.assertRaises(ValueError): Inspector(url)
def test_no_phone_image_fallback_on_capture_failure(self):
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / 'watch.png'
with self.assertRaises(ValueError): save_png(dest, b'<html>failure</html>')
self.assertFalse(dest.exists())
if __name__ == '__main__': unittest.main()
+38
查看文件
@@ -0,0 +1,38 @@
"""Verification reports must preserve failure and omit device identifiers."""
import json
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from verify import safe_watch_info, watch_success
class VerificationTests(unittest.TestCase):
def test_nested_info_keeps_versions_and_omits_identifiers(self):
result = {'code': 200, 'values': json.dumps({
'model': 'S2', 'runtimeVersion': '0.7', 'deviceCode': 'private-device-id',
'mac': 'private-mac', 'license': 'private-license',
'firmwareVersion': {'serial': 'private-serial'}})}
self.assertEqual(safe_watch_info(result), {'model': 'S2', 'runtimeVersion': '0.7'})
def test_empty_callback_does_not_invent_a_model(self):
self.assertEqual(safe_watch_info({'code': 200, 'values': ''}), {})
def test_oww265_msg_envelope_is_redacted(self):
result = {'code': 200, 'values': '', 'msg': json.dumps({
'brand': 'OPPO', 'model': 'OWW265', 'masVersion': 2006005,
'runtimeVersion': '0.8.0-5', 'nucleusVersion': '0.8.0-229',
'mac': 'private-mac', 'uuid': 'private-uuid', 'vendorId': 1})}
self.assertEqual(safe_watch_info(result), {
'brand': 'OPPO', 'model': 'OWW265', 'masVersion': 2006005,
'runtimeVersion': '0.8.0-5', 'nucleusVersion': '0.8.0-229'})
def test_watch_error_is_not_success_even_with_success_state(self):
self.assertFalse(watch_success({'state': 'success', 'result': {'code': 4008}}))
self.assertFalse(watch_success({'state': 'timeout', 'result': {'code': 200}}))
self.assertTrue(watch_success({'state': 'success', 'result': {'code': 200}}))
if __name__ == '__main__':
unittest.main()