feat(rtos): 首次发布手机真机调试桥与S2探针
这个提交包含在:
当前提交
2f0f4b9702
59 个文件已更改
+4303
没有与搜索条件相匹配的文件
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
在新工单中引用
屏蔽一个用户