mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Merge origin/main into live-touch
Kept both route tables: main's media, agent, assistant and Mac view routes plus /api/touch. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
60ade8c2e8
132 files changed
+53309
-213
No files matched your search
@@ -0,0 +1,43 @@
|
||||
// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency.
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const events = new Map();
|
||||
const requests = [];
|
||||
const element = id => {
|
||||
if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'',
|
||||
addEventListener(){}, reset(){}});
|
||||
return elements.get(id);
|
||||
};
|
||||
const context = {
|
||||
document:{getElementById:element}, location:{hash:''}, URLSearchParams,
|
||||
window:{addEventListener:(name, fn) => events.set(name, fn)},
|
||||
fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})),
|
||||
};
|
||||
const html = fs.readFileSync(process.argv[2], 'utf8');
|
||||
vm.runInNewContext(html.match(/<script>([\s\S]*?)<\/script>/)[1].replace('__FRAME_KEY__', '"test"'), context);
|
||||
const answer = (index, data) => requests[index].resolve({ok:true,json:async () => data});
|
||||
(async () => {
|
||||
context.location.hash = '#confirm=first';
|
||||
const first = events.get('hashchange')();
|
||||
context.location.hash = '#confirm=second';
|
||||
const second = events.get('hashchange')();
|
||||
answer(1, {action:{name:'second'},approved:false});
|
||||
await second;
|
||||
answer(0, {action:{name:'first'},approved:false});
|
||||
await first;
|
||||
assert.match(element('action').textContent, /second/);
|
||||
assert.doesNotMatch(element('action').textContent, /first/);
|
||||
const approved = element('approve').onclick();
|
||||
assert.equal(JSON.parse(requests[2].options.body).confirmation, 'second');
|
||||
context.location.hash = '#confirm=third';
|
||||
const third = events.get('hashchange')();
|
||||
answer(3, {action:{name:'third'},approved:false});
|
||||
await third;
|
||||
answer(2, {message:'Approved for one use'});
|
||||
await approved;
|
||||
assert.equal(element('approval-status').textContent, '');
|
||||
assert.match(element('action').textContent, /third/);
|
||||
console.log('Approval navigation races: pass');
|
||||
})().catch(error => { console.error(error); process.exitCode=1; });
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Real HTTP/MCP adapter against fake-Frame SSH; no model service needed."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
class Agents(harness.FrameTestCase):
|
||||
def client(self):
|
||||
return frame_mcp.Client('http://127.0.0.1:%d' % harness.Server.port)
|
||||
|
||||
def call(self, name, args):
|
||||
return json.loads(frame_mcp.call(self.client(), name, args)['content'][0]['text'])
|
||||
|
||||
def approve(self, proposal):
|
||||
ok('POST', '/api/agent/approval', {'confirmation': proposal['confirmation'], 'accept': True})
|
||||
return proposal['confirmation']
|
||||
|
||||
def test_status_and_approved_install_job(self):
|
||||
self.assertIn('battery', self.call('status', {}))
|
||||
proposal = self.call('install', {'id': 'org.example.AgentTest'})
|
||||
before = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': proposal['confirmation']})
|
||||
self.assertEqual(before[0], 400)
|
||||
token = self.approve(proposal)
|
||||
job = self.call('install', {'id': 'org.example.AgentTest', 'confirmation': token})
|
||||
self.assertFalse(finished(job).get('error'))
|
||||
self.assertIn('org.example.AgentTest', ssh('flatpak list --app --columns=application'))
|
||||
denied = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': token})
|
||||
self.assertEqual(denied[0], 400)
|
||||
|
||||
def test_approved_file_and_text(self):
|
||||
path = Path(self.path('agent-note.txt'))
|
||||
path.write_text('MCP file content\n')
|
||||
args = {'path': str(path)}
|
||||
token = self.approve(self.call('send_file', args))
|
||||
self.call('send_file', {**args, 'confirmation': token})
|
||||
self.assertEqual(ssh('cat ~/Downloads/agent-note.txt'), path.read_text())
|
||||
args = {'text': 'MCP clipboard text'}
|
||||
token = self.approve(self.call('send_text', args))
|
||||
self.call('send_text', {**args, 'confirmation': token})
|
||||
self.assertEqual(harness.state()['clipboard'], ['MCP clipboard text'])
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Media transfer through the real HTTP/SSH path; the fake has no VR renderer."""
|
||||
import harness
|
||||
from harness import ok, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Media(harness.FrameTestCase):
|
||||
def test_upload_and_list_without_launching_a_viewer(self):
|
||||
# The transfer endpoint doesn't decode. Rendering belongs to the real
|
||||
# headset smoke checks documented in docs/vr-video.md.
|
||||
self.assertEqual(ssh('stat -c "%U:%G %a" ~/.local/share').strip(), 'steamos:steamos 755')
|
||||
result = ok('POST', '/api/upload', raw=b'fake-media', headers={
|
||||
'X-Mode': 'media', 'X-Filename': 'test_SBS.png'})
|
||||
identity = result['id']
|
||||
try:
|
||||
files = ok('POST', '/api/media', {'action': 'list'})['files']
|
||||
self.assertIn(identity, [f['id'] for f in files])
|
||||
self.assertEqual(ssh('cat ~/Videos/FrameControl/'+identity), 'fake-media')
|
||||
finally:
|
||||
ssh('rm -rf ~/Videos/FrameControl/'+identity.split('/')[0])
|
||||
@@ -231,6 +231,13 @@ def sysfs(state):
|
||||
|
||||
def runtimes(state):
|
||||
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
|
||||
# Verified 2026-09-28, BUILD_ID 20260925.6191901: both parents are
|
||||
# steamos:steamos 0755. The root supervisor must not leave them root-owned
|
||||
# when creating Steam's fake manifests; user-account app installs need them.
|
||||
for directory in (HOME + '/.local', HOME + '/.local/share'):
|
||||
os.makedirs(directory, mode=0o755, exist_ok=True)
|
||||
chown(directory)
|
||||
os.chmod(directory, 0o755)
|
||||
apps = fs.STEAM_ROOT + '/steamapps'
|
||||
for alias, installed in state['runtimes'].items():
|
||||
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Imported first by every test module: nothing a test does reaches this person's
|
||||
app data, their telemetry, or the shared compatibility database.
|
||||
|
||||
Must run before any ui module is imported, since those read these at import time.
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
|
||||
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
|
||||
os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
@@ -0,0 +1,253 @@
|
||||
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_agent as agent
|
||||
import frame_assistant as assistant
|
||||
import frame_mcp as mcp
|
||||
import server
|
||||
|
||||
|
||||
class Approvals(unittest.TestCase):
|
||||
def test_requires_human_decision_exact_action_and_single_use(self):
|
||||
gate = agent.Approvals()
|
||||
action = {'name': 'power', 'arguments': {'action': 'reboot'}}
|
||||
token = gate.request(action)['confirmation']
|
||||
with self.assertRaises(ValueError):
|
||||
gate.consume(token, action)
|
||||
gate.decide(token, True)
|
||||
with self.assertRaises(ValueError):
|
||||
gate.consume(token, {'name': 'power', 'arguments': {'action': 'poweroff'}})
|
||||
gate.consume(token, action)
|
||||
with self.assertRaises(ValueError):
|
||||
gate.consume(token, action)
|
||||
|
||||
def test_expiry_rejection_and_non_boolean_approval(self):
|
||||
gate = agent.Approvals()
|
||||
token = gate.request({})['confirmation']
|
||||
gate.decide(token, 'true')
|
||||
with self.assertRaises(ValueError):
|
||||
gate.inspect(token)
|
||||
token = gate.request({})['confirmation']
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=float('inf')):
|
||||
with self.assertRaises(ValueError):
|
||||
gate.decide(token, True)
|
||||
|
||||
def test_concurrent_consumption_executes_once(self):
|
||||
gate = agent.Approvals()
|
||||
token = gate.request({})['confirmation']
|
||||
gate.decide(token, True)
|
||||
results = []
|
||||
def consume():
|
||||
try:
|
||||
gate.consume(token, {})
|
||||
results.append(True)
|
||||
except ValueError:
|
||||
results.append(False)
|
||||
threads = [threading.Thread(target=consume) for _ in range(8)]
|
||||
for thread in threads: thread.start()
|
||||
for thread in threads: thread.join()
|
||||
self.assertEqual(results.count(True), 1)
|
||||
|
||||
def test_action_never_runs_before_approval(self):
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'flatpak') as install:
|
||||
body = {'name': 'install', 'arguments': {'id': 'org.example.App'}}
|
||||
result = agent.call(server, body)
|
||||
install.assert_not_called()
|
||||
body['confirmation'] = result['confirmation']
|
||||
with self.assertRaises(ValueError): agent.call(server, body)
|
||||
agent.approvals.decide(body['confirmation'], True)
|
||||
agent.call(server, body)
|
||||
install.assert_called_once_with({'id': 'org.example.App', 'action': 'install'})
|
||||
with self.assertRaises(ValueError): agent.call(server, body)
|
||||
|
||||
def test_file_content_change_invalidates_approval(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'push_file') as push:
|
||||
path = Path(tmp) / 'note.txt'
|
||||
path.write_text('first')
|
||||
body = {'name': 'send_file', 'arguments': {'path': str(path)}}
|
||||
result = agent.call(server, body)
|
||||
agent.approvals.decide(result['confirmation'], True)
|
||||
body['confirmation'] = result['confirmation']
|
||||
path.write_text('second')
|
||||
with self.assertRaises(ValueError): agent.call(server, body)
|
||||
push.assert_not_called()
|
||||
|
||||
def test_no_arbitrary_commands_or_arguments(self):
|
||||
for name, args in [('shell', {'command': 'true'}), ('panel', {'id': 'org.example.App', 'args': '--evil'}),
|
||||
('power', {'action': 'factory-reset'}), ('send_text', {'text': ''})]:
|
||||
with self.assertRaises(ValueError): agent.call(server, {'name': name, 'arguments': args})
|
||||
|
||||
|
||||
class Assistant(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.received = []
|
||||
owner = self
|
||||
class Endpoint(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): pass
|
||||
def do_POST(self):
|
||||
owner.received.append((dict(self.headers), json.loads(self.rfile.read(int(self.headers['Content-Length'])))))
|
||||
if self.path == '/redirect':
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/other')
|
||||
self.end_headers()
|
||||
return
|
||||
data = json.dumps({'choices': [{'message': {'content': '<script>not executed</script>'}}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
self.httpd = ThreadingHTTPServer(('127.0.0.1', 0), Endpoint)
|
||||
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.body = {'endpoint': 'http://127.0.0.1:%d/chat' % self.httpd.server_port, 'model': 'local', 'prompt': 'Hello', 'consent': True}
|
||||
|
||||
def tearDown(self):
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
self.thread.join()
|
||||
|
||||
def test_no_opt_in_no_request_or_capture(self):
|
||||
capture = mock.Mock()
|
||||
for consent in (False, None, 'true', 1):
|
||||
with self.assertRaises(ValueError): assistant.chat({**self.body, 'consent': consent, 'screenshot': True}, capture)
|
||||
capture.assert_not_called()
|
||||
self.assertEqual(self.received, [])
|
||||
|
||||
def test_text_only_keyless_and_optional_screenshot(self):
|
||||
capture = mock.Mock(return_value=b'png')
|
||||
self.assertIn('script', assistant.chat(self.body, capture)['reply'])
|
||||
capture.assert_not_called()
|
||||
headers, body = self.received[-1]
|
||||
self.assertNotIn('Authorization', headers)
|
||||
self.assertEqual(body['messages'], [{'role': 'user', 'content': 'Hello'}])
|
||||
assistant.chat({**self.body, 'screenshot': True, 'key': 'test-key'}, capture)
|
||||
capture.assert_called_once()
|
||||
headers, body = self.received[-1]
|
||||
self.assertEqual(headers['Authorization'], 'Bearer test-key')
|
||||
self.assertEqual(body['messages'][0]['content'][1]['image_url']['url'], 'data:image/png;base64,cG5n')
|
||||
|
||||
def test_redirects_do_not_forward_context_or_credentials(self):
|
||||
with self.assertRaises(ValueError):
|
||||
assistant.chat({**self.body, 'endpoint': self.body['endpoint'].replace('/chat', '/redirect'), 'key': 'secret'}, mock.Mock())
|
||||
self.assertEqual(len(self.received), 1)
|
||||
|
||||
def test_bad_urls_fail_before_capture(self):
|
||||
for url in ('file:///etc/passwd', 'http://example.com/chat', 'https://user:pass@example.com', 'https://example.com?key=secret'):
|
||||
capture = mock.Mock()
|
||||
with self.assertRaises(ValueError): assistant.chat({**self.body, 'endpoint': url, 'screenshot': True}, capture)
|
||||
capture.assert_not_called()
|
||||
|
||||
|
||||
class AssistantPage(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is required for the page script regression')
|
||||
def test_approval_navigation_races(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', str(root / 'tests/assistant_ui.cjs'), str(root / 'ui/assistant.html')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class Protocol(unittest.TestCase):
|
||||
def test_stdio_initialize_list_call_errors_and_eof(self):
|
||||
messages = [
|
||||
{'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {'protocolVersion': '2025-06-18'}},
|
||||
{'jsonrpc': '2.0', 'method': 'notifications/initialized'},
|
||||
{'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'},
|
||||
{'jsonrpc': '2.0', 'id': 3, 'method': 'tools/call', 'params': {'name': 'shell'}},
|
||||
{'jsonrpc': '2.0', 'id': 4, 'method': 'ping'},
|
||||
]
|
||||
result = subprocess.run([sys.executable, str(Path(mcp.__file__))], input='\n'.join(map(json.dumps, messages)) + '\n', text=True, capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
replies = list(map(json.loads, result.stdout.splitlines()))
|
||||
self.assertEqual([r['id'] for r in replies], [1, 2, 3, 4])
|
||||
self.assertEqual(replies[0]['result']['protocolVersion'], '2025-06-18')
|
||||
self.assertIn('screenshot', [t['name'] for t in replies[1]['result']['tools']])
|
||||
self.assertTrue(replies[2]['result']['isError'])
|
||||
|
||||
def test_mcp_cannot_approve_and_returns_review_url(self):
|
||||
client = mock.Mock(url='http://127.0.0.1:47810')
|
||||
client.request.return_value = {'approvalPath': '/assistant#confirm=token'}
|
||||
result = mcp.call(client, 'power', {'action': 'reboot'})
|
||||
self.assertIn('http://127.0.0.1:47810/assistant', result['content'][0]['text'])
|
||||
with self.assertRaises(ValueError): mcp.call(client, 'approve', {'confirmation': 'token'})
|
||||
with self.assertRaises(ValueError): mcp.call(client, 'status', {'path': '/api/open'})
|
||||
|
||||
def test_loopback_only_backend(self):
|
||||
for url in ('https://example.com', 'http://127.0.0.1/api', 'http://secret@localhost:1234', 'file:///tmp/x'):
|
||||
with self.assertRaises(ValueError): mcp.Client(url)
|
||||
|
||||
|
||||
class ManagedBackend(unittest.TestCase):
|
||||
def test_private_backend_auth_and_cleanup(self):
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import urlopen
|
||||
with mock.patch.dict(os.environ, {'FRAME_ALIAS': 'frame-control-test.invalid'}):
|
||||
with mcp.backend() as client:
|
||||
url = client.url
|
||||
self.assertIn('os', client.request('/api/host'))
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(url + '/api/host', timeout=2)
|
||||
self.assertEqual(error.exception.code, 403)
|
||||
error.exception.close()
|
||||
# A second client has its own backend and key.
|
||||
with mcp.backend() as other:
|
||||
self.assertNotEqual(client.url, other.url)
|
||||
self.assertNotEqual(client.key, other.key)
|
||||
self.assertIn('os', client.request('/api/host'))
|
||||
with self.assertRaises(URLError):
|
||||
urlopen(url + '/', timeout=2)
|
||||
|
||||
def test_private_ssh_socket_is_not_the_desktop_socket(self):
|
||||
with mock.patch.object(server.frame_host, 'MUX', True), \
|
||||
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
|
||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
||||
|
||||
|
||||
class ComputerState(unittest.TestCase):
|
||||
def test_gamescope_triplets_and_empty_focus(self):
|
||||
import frame_computer
|
||||
parsed = frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 16, 42, 123, 32, 55, 999\nGAMESCOPE_FOCUSED_APP(CARDINAL) = \n')
|
||||
self.assertEqual(parsed['windows'], [{'windowId': '0x10', 'appid': 42, 'pid': 123}, {'windowId': '0x20', 'appid': 55, 'pid': 999}])
|
||||
self.assertIsNone(parsed['focusedApp'])
|
||||
with self.assertRaises(ValueError):
|
||||
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 2')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = untrusted')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS: no such atom on any window.')
|
||||
|
||||
def test_partial_snapshot_reports_failure_not_empty_success(self):
|
||||
import frame_computer
|
||||
with mock.patch.object(frame_computer.subprocess, 'run', side_effect=OSError('no display')), \
|
||||
mock.patch.object(frame_computer, 'accessibility', side_effect=OSError('no AT-SPI')):
|
||||
result = frame_computer.snapshot()
|
||||
self.assertIn('windowError', result)
|
||||
self.assertIn('accessibilityError', result)
|
||||
self.assertFalse(result['inputEnabled'])
|
||||
self.assertNotIn('windows', result)
|
||||
|
||||
def test_mcp_computer_state_is_read_only(self):
|
||||
client = mock.Mock()
|
||||
client.request.return_value = {'windows': []}
|
||||
mcp.call(client, 'computer_state', {})
|
||||
client.request.assert_called_once_with('/api/computer/state')
|
||||
spec = next(t for t in mcp.TOOLS if t['name'] == 'computer_state')
|
||||
self.assertTrue(spec['annotations']['readOnlyHint'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
@@ -3,6 +3,7 @@ steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""frame_apk against a small APK built here: binary manifest plus resource table."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline version lookup with small index-v2 fixtures."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -242,6 +243,7 @@ class UploadVersionsTest(unittest.TestCase):
|
||||
handler.rfile = io.BytesIO(b'x')
|
||||
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
|
||||
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
|
||||
patch.object(frame_android, 'install_hooks', []), \
|
||||
patch.object(server, 'ensure_master') as ssh:
|
||||
if mode == 'apkinfo':
|
||||
reply = handler.upload()
|
||||
@@ -256,5 +258,28 @@ class UploadVersionsTest(unittest.TestCase):
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
def test_blocked_uploads_are_reported_like_failed_installs(self):
|
||||
import server
|
||||
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
|
||||
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
|
||||
for apk_info, expected_info in ((dict(info), 'org.example.app'),
|
||||
(frame_android.FrameError('not an APK'), None)):
|
||||
handler = object.__new__(server.Handler)
|
||||
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': 'apk', 'Content-Length': '1'}
|
||||
handler.rfile = io.BytesIO(b'x')
|
||||
calls = []
|
||||
patch_info = (patch.object(frame_android, 'apk_info', side_effect=apk_info)
|
||||
if isinstance(apk_info, Exception) else
|
||||
patch.object(frame_android, 'apk_info', return_value=apk_info))
|
||||
with patch_info, patch.object(frame_android, 'install_hooks', [lambda *a: calls.append(a)]), \
|
||||
patch.object(server, 'ensure_master'):
|
||||
with self.assertRaises(server.Failure):
|
||||
handler.upload()
|
||||
self.assertEqual(len(calls), 1)
|
||||
got_info, meta, error, _ = calls[0]
|
||||
self.assertEqual((got_info or {}).get('package'), expected_info)
|
||||
self.assertIsNone(meta)
|
||||
self.assertIsInstance(error, frame_android.FrameError)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_apk
|
||||
import frame_apk_vr as vr
|
||||
import frame_apk_sign as signing
|
||||
import frame_android
|
||||
from test_frame_apk import pool
|
||||
|
||||
|
||||
DEFAULT = 'android.intent.category.DEFAULT'
|
||||
|
||||
|
||||
def manifest(utf8=False, launcher=False, category=None, samsung=False, split=False, alias=False, splash=False):
|
||||
strings = ['manifest', 'package', 'org.test.vr', 'application', 'activity', 'intent-filter',
|
||||
'action', 'category', 'name', vr.MAIN, category or next(iter(sorted(vr.VR))),
|
||||
vr.LAUNCHER, 'http://schemas.android.com/apk/res/android', 'meta-data', 'value',
|
||||
'com.samsung.android.vr.application.mode', 'vr_only', 'activity-alias', DEFAULT, next(iter(sorted(vr.VR))), 'targetActivity', '.Splash', '.Game']
|
||||
def start(tag, attrs=()):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, strings.index(tag), 20, 20, len(attrs), 0, 0, 0)
|
||||
for name, value in attrs:
|
||||
ns = 0xffffffff if name == 'package' else 12
|
||||
body += struct.pack('<IIIHBBI', ns, strings.index(name), strings.index(value), 8, 0, 3, strings.index(value))
|
||||
return struct.pack('<HHIII', 0x102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
def end(tag):
|
||||
return struct.pack('<HHIIIII', 0x103, 16, 24, 1, 0xffffffff, 0xffffffff, strings.index(tag))
|
||||
def leaf(tag, attrs):
|
||||
return start(tag, attrs) + end(tag)
|
||||
b = pool(strings, utf8) + start('manifest', [('package', 'org.test.vr')]) + start('application')
|
||||
if samsung:
|
||||
b += leaf('meta-data', [('name', strings[15]), ('value', 'vr_only')])
|
||||
if splash: # a helper activity with its own MAIN filter, ahead of the game's
|
||||
b += start('activity', [('name', '.Splash')]) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||
b += leaf('category', [('name', DEFAULT)]) + end('intent-filter') + end('activity')
|
||||
b += start('activity', [('name', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||
b += leaf('category', [('name', strings[10])])
|
||||
if split:
|
||||
b += end('intent-filter') + start('intent-filter')
|
||||
if launcher:
|
||||
b += leaf('category', [('name', vr.LAUNCHER)])
|
||||
b += end('intent-filter') + end('activity')
|
||||
if alias: # Godot 4: LAUNCHER only on an alias of the activity ('vr' or 'flat')
|
||||
b += start('activity-alias', [('targetActivity', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||
if alias == 'vr':
|
||||
b += leaf('category', [('name', next(iter(sorted(vr.VR))))])
|
||||
b += leaf('category', [('name', vr.LAUNCHER)])
|
||||
b += end('intent-filter') + end('activity-alias')
|
||||
b += end('application') + end('manifest')
|
||||
return struct.pack('<HHI', 3, 8, len(b) + 8) + b
|
||||
|
||||
|
||||
class VRTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.keypath = Path(cls.tmp.name) / 'key.json'
|
||||
cls.key = signing.signing_key(cls.keypath)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def test_manifest_patch(self):
|
||||
for utf8 in (False, True):
|
||||
for category in vr.VR:
|
||||
original = manifest(utf8, category=category)
|
||||
result = vr.add_launcher_category(original)
|
||||
info, filters = vr.inspect(result)
|
||||
self.assertTrue(info['launchable'])
|
||||
self.assertTrue(info['vr'])
|
||||
self.assertIn(vr.LAUNCHER, filters[0]['categories'])
|
||||
self.assertEqual(struct.unpack_from('<I', result, 4)[0], len(result))
|
||||
self.assertEqual(vr.add_launcher_category(result), result)
|
||||
self.assertEqual(vr.add_launcher_category(manifest(utf8, True)), manifest(utf8, True))
|
||||
|
||||
def test_filter_boundaries(self):
|
||||
self.assertFalse(vr.inspect(manifest(launcher=True, split=True))[0]['launchable'])
|
||||
self.assertTrue(vr.inspect(vr.add_launcher_category(manifest(launcher=True, split=True)))[0]['launchable'])
|
||||
|
||||
def test_alias_launcher_is_not_enough(self):
|
||||
# (manifest, still VR after patching)
|
||||
cases = [(manifest(alias='vr'), True), # Godot 4 VR export
|
||||
(manifest(alias='vr', category=DEFAULT), True), # VR category only on the alias
|
||||
(manifest(alias='flat', category=DEFAULT), False)] # Godot 4 flat export
|
||||
for original, is_vr in cases:
|
||||
info, filters = vr.inspect(original)
|
||||
self.assertFalse(info['launchable'])
|
||||
self.assertTrue(info['repairable'])
|
||||
self.assertEqual(len(filters), 1)
|
||||
self.assertFalse(filters[0]['alias'])
|
||||
result = vr.add_launcher_category(original)
|
||||
info, _ = vr.inspect(result)
|
||||
self.assertTrue(info['launchable'])
|
||||
self.assertFalse(info['repairable'])
|
||||
self.assertEqual(info['vr'], is_vr)
|
||||
|
||||
def test_alias_target_activity_is_patched(self):
|
||||
original = manifest(alias='flat', category=DEFAULT, splash=True)
|
||||
info, filters = vr.inspect(original)
|
||||
self.assertTrue(info['repairable'])
|
||||
self.assertEqual(filters[0]['activity'], 'org.test.vr.Game')
|
||||
owner, launchers = None, []
|
||||
for tag, attrs in frame_apk.manifest_elements(vr.add_launcher_category(original)):
|
||||
if tag in ('activity', 'activity-alias'):
|
||||
owner = (tag, attrs.get('name', (0, 0, None))[2])
|
||||
if tag == 'category' and attrs['name'][2] == vr.LAUNCHER:
|
||||
launchers.append(owner)
|
||||
self.assertEqual(launchers, [('activity', '.Game'), ('activity-alias', None)])
|
||||
|
||||
def test_alias_with_launchable_activity_is_left_alone(self):
|
||||
original = manifest(launcher=True, alias='vr')
|
||||
info, _ = vr.inspect(original)
|
||||
self.assertTrue(info['launchable'])
|
||||
self.assertFalse(info['repairable'])
|
||||
self.assertEqual(vr.add_launcher_category(original), original)
|
||||
|
||||
def test_patch_alias_apk(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
||||
with zipfile.ZipFile(src, 'w') as z:
|
||||
z.writestr('AndroidManifest.xml', manifest(alias='flat', category=DEFAULT))
|
||||
with patch.object(signing, 'signing_key', return_value=self.key):
|
||||
result = frame_android.patch(src, dst)
|
||||
self.assertEqual(result['patched'], ['launcher'])
|
||||
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
||||
self.assertTrue(signing.verify(dst))
|
||||
|
||||
def test_styled_pool(self):
|
||||
for utf8 in (False, True):
|
||||
p = bytearray(pool(['styled'], utf8))
|
||||
old_start = struct.unpack_from('<I', p, 20)[0]
|
||||
p[old_start:old_start] = struct.pack('<I', 0)
|
||||
style_start = len(p)
|
||||
p += struct.pack('<III', 0, 0, 2) + b'\xff' * 12
|
||||
struct.pack_into('<I', p, 4, len(p))
|
||||
struct.pack_into('<I', p, 16, (0x100 if utf8 else 0) | 1)
|
||||
struct.pack_into('<I', p, 12, 1)
|
||||
struct.pack_into('<II', p, 20, old_start + 4, style_start)
|
||||
result, idx = vr._append_string(bytes(p), vr.LAUNCHER)
|
||||
self.assertEqual(frame_apk._string_pool(result, 0), ['styled', vr.LAUNCHER])
|
||||
new_style = struct.unpack_from('<I', result, 24)[0]
|
||||
self.assertEqual(result[new_style:], p[style_start:])
|
||||
self.assertEqual(len(result) % 4, 0)
|
||||
|
||||
def test_detection(self):
|
||||
names = {'lib/arm64-v8a/' + n for n in ('libvrapi.so', 'libopenxr_loader.so', 'libovrplatformloader.so')}
|
||||
info = vr.detection(manifest(category='android.intent.category.LAUNCHER'), names)
|
||||
self.assertTrue(info['vr'])
|
||||
self.assertTrue(info['launchable'])
|
||||
self.assertEqual(len(info['vr_issues']), 3)
|
||||
self.assertFalse(vr.detection(manifest(category=vr.LAUNCHER), set())['vr'])
|
||||
self.assertTrue(vr.detection(manifest(category=vr.LAUNCHER, samsung=True), set())['vr'])
|
||||
|
||||
def test_key_cache(self):
|
||||
with patch.object(signing, '_prime', side_effect=AssertionError('regenerated')):
|
||||
self.assertEqual(signing.signing_key(self.keypath), self.key)
|
||||
if os.name != 'nt':
|
||||
self.assertEqual(self.keypath.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_repack_sign_tamper(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
||||
with zipfile.ZipFile(src, 'w') as z:
|
||||
z.writestr('AndroidManifest.xml', manifest(), compress_type=8)
|
||||
z.writestr('classes.dex', b'compress me' * 10000, compress_type=8)
|
||||
z.writestr('resources.arsc', b'1234')
|
||||
z.writestr('lib/arm64-v8a/libx.so', b'ELF' * 500000)
|
||||
z.writestr('META-INF/OLD.RSA', b'old')
|
||||
z.writestr('META-INF/MANIFEST.MF', b'old')
|
||||
with patch.object(signing, 'signing_key', return_value=self.key):
|
||||
result = frame_android.patch(src, dst, {'assets/layer.json': b'{}', 'lib/arm64-v8a/liblayer.so': b'layer'})
|
||||
self.assertEqual(result['patched'], ['launcher'])
|
||||
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
||||
self.assertTrue(signing.verify(dst))
|
||||
def compressed(path, info):
|
||||
data = path.read_bytes()
|
||||
nl, el = struct.unpack_from('<HH', data, info.header_offset + 26)
|
||||
start = info.header_offset + 30 + nl + el
|
||||
return data[start:start + info.compress_size], start
|
||||
with zipfile.ZipFile(src) as a, zipfile.ZipFile(dst) as b:
|
||||
self.assertNotIn('META-INF/OLD.RSA', b.namelist())
|
||||
self.assertNotIn('META-INF/MANIFEST.MF', b.namelist())
|
||||
for i in b.infolist():
|
||||
raw, start = compressed(dst, i)
|
||||
if i.compress_type == 0:
|
||||
self.assertEqual(start % (16384 if i.filename.endswith('.so') else 4), 0)
|
||||
if i.filename in ('classes.dex', 'resources.arsc', 'lib/arm64-v8a/libx.so'):
|
||||
self.assertEqual(raw, compressed(src, a.getinfo(i.filename))[0])
|
||||
_, off = compressed(dst, b.getinfo('resources.arsc'))
|
||||
original = dst.read_bytes()
|
||||
data = bytearray(original)
|
||||
eo, cd = signing._eocd(data)
|
||||
size = struct.unpack_from('<Q', data, cd - 24)[0]
|
||||
block_start = cd - size - 8
|
||||
value = data[block_start + 20:cd - 24]
|
||||
signer = signing._parts(signing._parts(value)[0])[0]
|
||||
signed, signatures, pub = signing._parts(signer)
|
||||
signature = signing._parts(signing._parts(signatures)[0][4:])[0]
|
||||
sig_offset = data.index(signature, block_start)
|
||||
data[sig_offset] ^= 1
|
||||
dst.write_bytes(data)
|
||||
with self.assertRaisesRegex(ValueError, 'RSA signature'):
|
||||
signing.verify(dst)
|
||||
data = bytearray(original)
|
||||
data[off] ^= 1
|
||||
dst.write_bytes(data)
|
||||
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||
signing.verify(dst)
|
||||
|
||||
@unittest.skipUnless(Path('/usr/bin/openssl').exists(), 'openssl absent')
|
||||
def test_openssl(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
(d / 'cert.der').write_bytes(signing.certificate(self.key))
|
||||
(d / 'message').write_bytes(b'independent signature check')
|
||||
(d / 'signature').write_bytes(signing.rsa_sign(b'independent signature check', self.key))
|
||||
def run(*args):
|
||||
return subprocess.run(['/usr/bin/openssl', *args], check=True, capture_output=True).stdout
|
||||
run('x509', '-inform', 'DER', '-in', str(d / 'cert.der'), '-out', str(d / 'cert.pem'))
|
||||
run('verify', '-check_ss_sig', '-CAfile', str(d / 'cert.pem'), str(d / 'cert.pem'))
|
||||
(d / 'pub.pem').write_bytes(run('x509', '-in', str(d / 'cert.pem'), '-pubkey', '-noout'))
|
||||
output = run('dgst', '-sha256', '-verify', str(d / 'pub.pem'), '-signature', str(d / 'signature'), str(d / 'message'))
|
||||
self.assertIn(b'Verified OK', output)
|
||||
|
||||
def test_install_auto_and_override(self):
|
||||
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
||||
'vr': True, 'vr_activity': True, 'launchable': False, 'repairable': True}
|
||||
with patch.object(frame_android, 'apk_info', return_value=base), \
|
||||
patch.object(frame_android, 'xr_compat_files', return_value={}), \
|
||||
patch.object(frame_android, 'patch', return_value={'patched': ['launcher']}) as repair, \
|
||||
patch.object(frame_android, '_install', return_value={}) as install:
|
||||
frame_android.install('original.apk')
|
||||
repair.assert_called_once()
|
||||
self.assertFalse(install.call_args.args[3])
|
||||
self.assertEqual(install.call_args.args[1]['patched'], ['launcher'])
|
||||
frame_android.install('original.apk', flatscreen=True)
|
||||
self.assertTrue(install.call_args.args[3])
|
||||
|
||||
def test_xr_compat_layer(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
apk = Path(d) / 'a.apk'
|
||||
with zipfile.ZipFile(apk, 'w') as z:
|
||||
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
|
||||
add = frame_android.xr_compat_files(str(apk))
|
||||
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
|
||||
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
|
||||
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
|
||||
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
|
||||
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
|
||||
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
|
||||
flat = Path(d) / 'flat.apk'
|
||||
with zipfile.ZipFile(flat, 'w') as z:
|
||||
z.writestr('classes.dex', b'')
|
||||
self.assertEqual(frame_android.xr_compat_files(str(flat)), {})
|
||||
|
||||
def test_xr_compat_layer_missing(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
apk = Path(d) / 'a.apk'
|
||||
with zipfile.ZipFile(apk, 'w') as z:
|
||||
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
|
||||
with patch.object(frame_android, 'XR_COMPAT', d):
|
||||
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
|
||||
frame_android.xr_compat_files(str(apk))
|
||||
|
||||
def test_patch_rejects_corrupt_manifest_cleanly(self):
|
||||
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
|
||||
with self.assertRaises(frame_android.FrameError):
|
||||
frame_android.patch('x.apk', 'y.apk')
|
||||
|
||||
def test_install_adds_layer_to_vr_apps(self):
|
||||
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
|
||||
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
|
||||
layer = {'x': b''}
|
||||
with patch.object(frame_android, 'apk_info', return_value=dict(base)), \
|
||||
patch.object(frame_android, 'xr_compat_files', return_value=layer), \
|
||||
patch.object(frame_android, 'patch', return_value={'patched': ['openxr-compat']}) as repair, \
|
||||
patch.object(frame_android, '_install', return_value={}) as install:
|
||||
frame_android.install('game.apk')
|
||||
self.assertIs(repair.call_args.args[2], layer)
|
||||
self.assertEqual(install.call_args.args[1]['patched'], ['openxr-compat'])
|
||||
repair.reset_mock()
|
||||
frame_android.install('game.apk', xr_compat=False) # launchable, no layer: install as is
|
||||
repair.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Mac in the headset (ui/frame_macview.py and the frame-mac-view agent).
|
||||
|
||||
The Python checks run anywhere. On macOS the agent is built and driven over
|
||||
HTTP and WebSocket with its test pattern, which needs no Screen Recording
|
||||
permission: status, the token, the viewer page, H.264 keyframes, pointer
|
||||
input reaching the source, and closing.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_macview # noqa: E402
|
||||
|
||||
|
||||
class Helpers(unittest.TestCase):
|
||||
def test_panel_id_is_stable_and_in_range(self):
|
||||
a = frame_macview.panel_id("window:123")
|
||||
self.assertEqual(a, frame_macview.panel_id("window:123"))
|
||||
self.assertNotEqual(a, frame_macview.panel_id("window:124"))
|
||||
self.assertTrue(2_001_000_000 <= a < 2_002_000_000)
|
||||
|
||||
def test_fit_keeps_aspect_inside_the_panel(self):
|
||||
self.assertEqual(frame_macview.fit(2560, 1440), (1920, 1080))
|
||||
w, h = frame_macview.fit(800, 1600)
|
||||
self.assertEqual(h, 1080)
|
||||
self.assertAlmostEqual(w / h, 0.5, places=2)
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_show_checks_the_source_before_anything_else(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
mv.show("rm -rf /")
|
||||
|
||||
def test_missing_browser_is_explained(self):
|
||||
calls = []
|
||||
|
||||
def run(remote, stdin=None, timeout=30):
|
||||
calls.append(remote)
|
||||
e = RuntimeError("exit 3")
|
||||
e.stdout = "NO_BROWSER\n"
|
||||
raise e
|
||||
|
||||
mv = frame_macview.MacView(["ssh"], run, "frame")
|
||||
mv.call = lambda path, **kw: {"screen": True, "ticket": "tk"}
|
||||
mv.ensure_tunnel = lambda **kw: None
|
||||
mv.remote_port = 47900
|
||||
with self.assertRaises(frame_macview.MacViewError) as cm:
|
||||
mv.show("window:5")
|
||||
self.assertIn("Chromium", str(cm.exception))
|
||||
self.assertTrue(calls[0].startswith("bash -s -- "))
|
||||
|
||||
def test_separate_windows_need_accessibility(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
|
||||
mv.call = lambda path, **kw: {"screen": True, "accessibility": False}
|
||||
with self.assertRaises(frame_macview.MacViewError) as cm:
|
||||
mv.show("separate:42")
|
||||
self.assertIn("Accessibility", str(cm.exception))
|
||||
|
||||
def test_screen_permission_is_checked_before_the_headset(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
|
||||
mv.call = lambda path, **kw: {"screen": False}
|
||||
with self.assertRaises(frame_macview.MacViewError) as cm:
|
||||
mv.show("display:1")
|
||||
self.assertIn("Screen Recording", str(cm.exception))
|
||||
|
||||
def test_stop_all_ends_the_viewer_browser_unless_shown_again(self):
|
||||
calls = []
|
||||
mv = frame_macview.MacView(["ssh"], lambda remote, **kw: calls.append(remote) or "", "frame")
|
||||
mv.agent = mock.Mock(poll=lambda: None)
|
||||
mv.call = lambda path, **kw: {"closed": 1}
|
||||
mv.shown = {"window:5"}
|
||||
with mock.patch.object(frame_macview.time, "sleep"):
|
||||
gen = mv.shows
|
||||
mv.shown.clear()
|
||||
mv._end_viewer_browser(gen)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("pkill -f '[f]rame-control/mac-view", calls[0])
|
||||
mv.shows += 1 # Show pressed during the wait: leave the new viewer alone
|
||||
mv._end_viewer_browser(gen)
|
||||
self.assertEqual(len(calls), 1)
|
||||
mv.launching = 1 # a Show replacing its own stream is still launching
|
||||
mv._end_viewer_browser(mv.shows)
|
||||
self.assertEqual(len(calls), 1)
|
||||
# A Show waits while the cleanup checks and runs pkill.
|
||||
mv.launching = 0
|
||||
in_pkill, release, entered = threading.Event(), threading.Event(), threading.Event()
|
||||
|
||||
def blocking_pkill(remote, **kw):
|
||||
in_pkill.set()
|
||||
release.wait(5)
|
||||
mv.run = blocking_pkill
|
||||
mv._show = lambda *a: entered.set() or "shown"
|
||||
with mock.patch.object(frame_macview.time, "sleep"):
|
||||
cleanup = threading.Thread(target=mv._end_viewer_browser, args=(mv.shows,))
|
||||
cleanup.start()
|
||||
self.assertTrue(in_pkill.wait(2))
|
||||
shower = threading.Thread(target=mv.show, args=("window:5",))
|
||||
shower.start()
|
||||
self.assertFalse(entered.wait(0.3), "Show started while the cleanup held the lock")
|
||||
release.set()
|
||||
self.assertTrue(entered.wait(2))
|
||||
cleanup.join()
|
||||
shower.join()
|
||||
|
||||
def test_tunnel_prefers_the_usb_c_network_when_plugged_in(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda remote, **kw: "13: usb0 inet 10.86.200.233/29 scope global", "frame")
|
||||
ssh_g = mock.Mock(stdout="user steamos\nhostname frame.example.ts.net\n")
|
||||
with mock.patch.object(frame_macview.socket, "create_connection") as conn, \
|
||||
mock.patch.object(frame_macview.subprocess, "run", return_value=ssh_g):
|
||||
self.assertEqual(mv._usb_route(), ["-o", "HostName=10.86.200.233", "-o", "HostKeyAlias=frame.example.ts.net"])
|
||||
conn.assert_called_once_with(("10.86.200.233", 22), timeout=1)
|
||||
ssh_g.stdout = "hostname frame.example.ts.net\nhostkeyalias paired-frame\n" # a configured alias wins
|
||||
conn.side_effect = None
|
||||
self.assertIn("HostKeyAlias=paired-frame", mv._usb_route())
|
||||
conn.side_effect = OSError("unplugged")
|
||||
self.assertEqual(mv._usb_route(), [])
|
||||
mv.run = lambda remote, **kw: "" # no usb0
|
||||
self.assertEqual(mv._usb_route(), [])
|
||||
mv.prefer_usb = False
|
||||
mv.run = lambda remote, **kw: self.fail("no ssh when USB is off")
|
||||
self.assertEqual(mv._usb_route(), [])
|
||||
|
||||
def test_a_failed_usb_tunnel_falls_back_to_the_network(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||
mv._usb_route = lambda: ["-o", "HostName=10.86.200.233"]
|
||||
tried = []
|
||||
|
||||
def attempt(via, ports):
|
||||
tried.append(list(via))
|
||||
mv._last_tunnel_error = "Connection refused"
|
||||
return not via # USB fails, the normal path works
|
||||
mv._open_tunnel = attempt
|
||||
mv.tunnel_up = lambda: False
|
||||
mv.ensure_tunnel()
|
||||
self.assertEqual(tried, [["-o", "HostName=10.86.200.233"], []])
|
||||
self.assertEqual(mv.route, "network")
|
||||
|
||||
|
||||
class WS:
|
||||
"""A minimal WebSocket client (masked frames out, plain frames in)."""
|
||||
|
||||
def __init__(self, port, path):
|
||||
self.s = socket.create_connection(("127.0.0.1", port), timeout=10)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.s.sendall(f"GET {path} HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n".encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
head += self.s.recv(1)
|
||||
self.status = int(head.split()[1])
|
||||
self.buf = b""
|
||||
|
||||
def _read(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.s.recv(65536)
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def recv(self):
|
||||
b0, b1 = self._read(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._read(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._read(8))[0]
|
||||
return b0 & 0x0F, self._read(n)
|
||||
|
||||
def send_text(self, text):
|
||||
data, mask = text.encode(), os.urandom(4)
|
||||
head = bytes([0x81, 0x80 | len(data)]) if len(data) < 126 else bytes([0x81, 0xFE]) + struct.pack(">H", len(data))
|
||||
self.s.sendall(head + mask + bytes(c ^ mask[i % 4] for i, c in enumerate(data)))
|
||||
|
||||
def close(self):
|
||||
self.s.close()
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("xcrun"), "the agent is macOS-only")
|
||||
class Agent(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.mkdtemp()
|
||||
cls.bin = Path(cls.tmp) / "frame-mac-view"
|
||||
r = subprocess.run(["/bin/sh", str(ROOT / "mac" / "frame-mac-view" / "build.sh"), str(cls.bin)],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
if r.returncode: # a real failure on a Mac with Xcode: don't hide it as a skip
|
||||
raise AssertionError("agent didn't build:\n" + (r.stderr or r.stdout)[-2000:])
|
||||
cls.token = "t0ken-" + os.urandom(6).hex()
|
||||
cls.proc = subprocess.Popen([str(cls.bin), "serve", "--port", "0", "--page", str(ROOT / "ui" / "mac-view.html"),
|
||||
"--exit-on-eof"], env={**os.environ, "FRAME_MAC_VIEW_TOKEN": cls.token},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
line = cls.proc.stdout.readline()
|
||||
cls.port = int(line.rsplit(":", 1)[1])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.stdin.close() # --exit-on-eof
|
||||
cls.proc.stdout.close()
|
||||
try:
|
||||
cls.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def get(self, path, method="GET"):
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
c.request(method, path)
|
||||
r = c.getresponse()
|
||||
return r.status, r.read()
|
||||
|
||||
def test_token_is_required(self):
|
||||
self.assertEqual(self.get("/status")[0], 403)
|
||||
self.assertEqual(self.get("/status?k=wrong")[0], 403)
|
||||
status, body = self.get(f"/status?k={self.token}")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("screen", json.loads(body))
|
||||
|
||||
def test_serves_the_viewer_page(self):
|
||||
status, body = self.get(f"/view?k={self.token}&src=test")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b"VideoDecoder", body)
|
||||
|
||||
def test_snapshot_needs_the_key(self):
|
||||
self.assertEqual(self.get("/snapshot?display=1")[0], 403)
|
||||
|
||||
def test_separate_without_accessibility_explains(self):
|
||||
if json.loads(self.get(f"/status?k={self.token}")[1])["accessibility"]:
|
||||
self.skipTest("this Mac allows Accessibility here")
|
||||
ws = WS(self.port, f"/stream?k={self.token}&src=separate:1")
|
||||
self.assertEqual(ws.status, 101)
|
||||
for _ in range(5):
|
||||
op, data = ws.recv()
|
||||
msg = json.loads(data) if op == 1 else {}
|
||||
if msg.get("t") in ("error", "closed"):
|
||||
break
|
||||
self.assertIn("Accessibility", msg.get("message", msg.get("reason", "")))
|
||||
ws.close()
|
||||
|
||||
def test_lists_displays(self):
|
||||
status, body = self.get(f"/displays?k={self.token}")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsInstance(json.loads(body)["displays"], list)
|
||||
|
||||
def ticket(self, src):
|
||||
status, body = self.get(f"/ticket?k={self.token}&src={src}", method="POST")
|
||||
self.assertEqual(status, 200)
|
||||
return json.loads(body)["ticket"]
|
||||
|
||||
def test_ping_and_page_are_open_but_streams_are_not(self):
|
||||
self.assertEqual(self.get("/ping"), (200, b"frame-mac-view"))
|
||||
self.assertEqual(WS(self.port, "/stream?src=test").status, 403)
|
||||
self.assertEqual(self.get("/ticket?src=test", method="POST")[0], 403)
|
||||
|
||||
def test_tickets_are_single_use_and_tied_to_one_source(self):
|
||||
t = self.ticket("test")
|
||||
self.assertEqual(WS(self.port, f"/stream?src=display:1&t={t}").status, 403) # wrong source
|
||||
ws = WS(self.port, f"/stream?src=test&t={t}")
|
||||
self.assertEqual(ws.status, 101)
|
||||
hello = json.loads(ws.recv()[1])
|
||||
self.assertEqual(hello["t"], "hello")
|
||||
# Until the viewer acknowledges, a retry (the hello got lost) gets the
|
||||
# same key, and replaces the first viewer rather than adding one.
|
||||
retry = WS(self.port, f"/stream?src=test&t={t}")
|
||||
self.assertEqual(retry.status, 101)
|
||||
self.assertEqual(json.loads(retry.recv()[1])["r"], hello["r"])
|
||||
time.sleep(0.3)
|
||||
_, body = self.get(f"/status?k={self.token}")
|
||||
self.assertEqual(len(json.loads(body)["streams"]), 1)
|
||||
ws.close()
|
||||
ws = retry
|
||||
ws.send_text(json.dumps({"t": "ack"}))
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&t={t}").status, 403) # spent
|
||||
again = WS(self.port, f"/stream?src=test&r={hello['r']}") # the viewer reconnecting
|
||||
self.assertEqual(again.status, 101)
|
||||
again.close()
|
||||
ws.close()
|
||||
# Stop revokes the reconnect key.
|
||||
self.get(f"/close?k={self.token}&src=test", method="POST")
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&r={hello['r']}").status, 403)
|
||||
|
||||
def test_stop_revokes_tickets_not_yet_used(self):
|
||||
t = self.ticket("test")
|
||||
self.get(f"/close?k={self.token}&src=test", method="POST")
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&t={t}").status, 403)
|
||||
|
||||
def test_bad_frame_lengths_close_the_socket_not_the_agent(self):
|
||||
ws = WS(self.port, f"/stream?src=test&t={self.ticket('test')}")
|
||||
self.assertEqual(ws.status, 101)
|
||||
# A masked frame claiming 2^63 bytes.
|
||||
ws.s.sendall(bytes([0x81, 0xFF]) + struct.pack(">Q", 1 << 63) + os.urandom(4))
|
||||
with self.assertRaises((EOFError, OSError)):
|
||||
for _ in range(1000):
|
||||
ws.recv()
|
||||
ws.close()
|
||||
self.assertEqual(self.get(f"/status?k={self.token}")[0], 200)
|
||||
|
||||
def test_stream_input_and_close(self):
|
||||
ws = WS(self.port, f"/stream?k={self.token}&src=test&codec=h264&fps=30&max=640")
|
||||
self.assertEqual(ws.status, 101)
|
||||
info = None
|
||||
key = None
|
||||
for _ in range(200):
|
||||
op, data = ws.recv()
|
||||
if op == 1:
|
||||
msg = json.loads(data)
|
||||
if msg["t"] == "hello":
|
||||
continue
|
||||
if msg["t"] == "error":
|
||||
self.skipTest("no H.264 encoder here: " + msg["message"])
|
||||
if msg["t"] == "info":
|
||||
info = msg
|
||||
elif op == 2 and data[0] == 1:
|
||||
key = data
|
||||
if info and key:
|
||||
break
|
||||
self.assertEqual(info["src"], "test")
|
||||
self.assertTrue(info["input"])
|
||||
# A keyframe: flags, 8-byte timestamp, sequence number, input echoed,
|
||||
# then Annex B with the SPS first.
|
||||
self.assertEqual(key[17:21], b"\x00\x00\x00\x01")
|
||||
self.assertEqual(key[21] & 0x1F, 7) # NAL type 7, SPS
|
||||
ws.send_text(json.dumps({"t": "m", "e": "down", "b": 0, "x": 0.5, "y": 0.5}))
|
||||
ws.send_text(json.dumps({"t": "key-frame"}))
|
||||
got_key = False
|
||||
for _ in range(200):
|
||||
op, data = ws.recv()
|
||||
if op == 2 and data[0] == 1:
|
||||
got_key = True
|
||||
break
|
||||
self.assertTrue(got_key, "no keyframe after asking for one")
|
||||
_, body = self.get(f"/status?k={self.token}")
|
||||
self.assertEqual([s["src"] for s in json.loads(body)["streams"]], ["test"])
|
||||
status, body = self.get(f"/close?k={self.token}", method="POST")
|
||||
self.assertEqual(json.loads(body)["closed"], 1)
|
||||
for _ in range(400):
|
||||
op, data = ws.recv()
|
||||
if op == 1 and json.loads(data)["t"] == "close":
|
||||
break
|
||||
self.assertEqual(json.loads(data)["t"], "close")
|
||||
# Without the viewer doing anything, the agent ends the stream itself.
|
||||
time.sleep(1)
|
||||
_, body = self.get(f"/status?k={self.token}")
|
||||
self.assertEqual(json.loads(body)["streams"], [])
|
||||
ws.close()
|
||||
|
||||
def test_timing_reports_and_input_echo(self):
|
||||
# What a viewer does: sync clocks, report each frame, stamp input.
|
||||
ws = WS(self.port, f"/stream?k={self.token}&src=test&codec=h264&fps=30&max=640")
|
||||
self.assertEqual(ws.status, 101)
|
||||
ws.send_text(json.dumps({"t": "w"})) # keep-warm filler: ignored
|
||||
ws.send_text(json.dumps({"t": "ping", "c": 1.5}))
|
||||
pong, echoed, seqs, info = None, None, [], None
|
||||
clicked = False
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline and not (pong and echoed):
|
||||
op, data = ws.recv()
|
||||
if op == 1:
|
||||
msg = json.loads(data)
|
||||
if msg["t"] == "error":
|
||||
self.skipTest("no H.264 encoder here: " + msg["message"])
|
||||
if msg["t"] == "pong":
|
||||
pong = msg
|
||||
if msg["t"] == "info":
|
||||
info = msg
|
||||
elif op == 2:
|
||||
seq, echo = struct.unpack(">II", data[9:17])
|
||||
seqs.append(seq)
|
||||
now = pong["a"] if pong else 0
|
||||
ws.send_text(json.dumps({"t": "rx", "s": seq, "r": now}))
|
||||
ws.send_text(json.dumps({"t": "fd", "f": [[seq, now + 1, now + 2, now + 3]], "drop": 0}))
|
||||
if echo == 7:
|
||||
echoed = seq
|
||||
if len(seqs) == 3 and not clicked:
|
||||
ws.send_text(json.dumps({"t": "m", "e": "down", "b": 0, "x": 0.5, "y": 0.5, "i": 7, "tv": now}))
|
||||
clicked = True
|
||||
self.assertEqual(pong["c"], 1.5)
|
||||
self.assertGreater(pong["a"], 0)
|
||||
self.assertEqual(seqs[:3], sorted(seqs[:3]))
|
||||
self.assertIsNotNone(echoed, "no frame was tagged as the first reply to the click")
|
||||
time.sleep(1.7) # frames are reported once the viewer has had time
|
||||
stream = json.loads(self.get(f"/stats?k={self.token}")[1])["streams"][0]
|
||||
first = stream["frames"][0]
|
||||
self.assertGreater(first["e1"], first["e0"])
|
||||
self.assertGreaterEqual(first["e0"], first["cap"])
|
||||
self.assertEqual(first["vs"] - first["rx"], 3)
|
||||
self.assertEqual([i["frame"] for i in stream["inputs"] if i["id"] == 7], [echoed])
|
||||
self.assertIn("total", stream["summary"])
|
||||
self.assertEqual(info["warm"], 0) # off unless FRAME_MAC_VIEW_WARM is set
|
||||
live = json.loads(self.get(f"/status?k={self.token}")[1])["streams"][0]
|
||||
self.assertEqual(live["controller"]["tier"], 0)
|
||||
self.assertIn("fps", live["stats"])
|
||||
ws.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_media as media
|
||||
import frame_media_player as player
|
||||
import frame_media_remote as remote
|
||||
import frame_splat as splat
|
||||
import server
|
||||
|
||||
|
||||
class Media(unittest.TestCase):
|
||||
def test_layout_evidence_and_override(self):
|
||||
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
||||
('film_FSBS.mp4', 'full-sbs'), ('photo_TB.png', 'ou')]:
|
||||
self.assertEqual(media.plan(name)['layout'], layout)
|
||||
self.assertEqual(media.plan('film_SBS_OU.mp4', 'mono')['source'], 'explicit')
|
||||
self.assertEqual(media.plan('film.mkv', metadata={'stereo_mode': 'top_bottom'})['layout'], 'full-ou')
|
||||
for name in ('film.mp4', 'film_SBS_OU.mp4', 'businessbs.mp4'):
|
||||
with self.assertRaises(ValueError):
|
||||
media.plan(name)
|
||||
with self.assertRaises(ValueError):
|
||||
media.plan('film.mkv', metadata={'stereo_mode': 'right_left'})
|
||||
|
||||
def test_unsupported_containers_do_not_flatten_spatial_photos(self):
|
||||
for name in ('spatial.HEIC', 'stereo.mpo', 'cloud.ply', 'cloud.spz', 'app.exe'):
|
||||
with self.assertRaises(ValueError):
|
||||
media.plan(name, 'sbs')
|
||||
|
||||
def test_ou_pixels_keep_each_eye_and_row(self):
|
||||
a, b, c, d = [bytes([n])*8 for n in (1, 2, 3, 4)]
|
||||
data, width, height = media.stereo_pixels(a+b+c+d, 2, 4, 'ou')
|
||||
self.assertEqual((data, width, height), (a+c+b+d, 4, 2))
|
||||
with self.assertRaises(ValueError):
|
||||
media.stereo_pixels(b'bad', 2, 4, 'sbs')
|
||||
self.assertEqual(media.geometry(3840, 2160, 'sbs'), (1920, 1080, 2))
|
||||
self.assertEqual(media.geometry(1920, 1080, 'ou'), (1920, 1080, .5))
|
||||
|
||||
def test_decode_is_hardware_and_one_clock_for_audio(self):
|
||||
for codec, decoder in [('h264', 'h264_v4l2m2m'), ('hevc', 'hevc_v4l2m2m')]:
|
||||
cmd = player.decoder_command(Path('/tmp/a file.mp4'), {'codec_name': codec}, 1280, 720, True)
|
||||
self.assertIn(decoder, cmd)
|
||||
self.assertIn('-re', cmd)
|
||||
self.assertIn('pulse', cmd)
|
||||
self.assertIn(str(Path('/tmp/a file.mp4')), cmd)
|
||||
with self.assertRaises(ValueError):
|
||||
player.decoder_command(Path('x.webm'), {'codec_name': 'vp9'}, 640, 480, False)
|
||||
cmd = player.decoder_command(Path('x.png'), {'codec_name': 'png'}, 640, 480, False, True)
|
||||
self.assertNotIn('-re', cmd)
|
||||
self.assertIn('-frames:v', cmd)
|
||||
|
||||
def test_fake_frame_library_and_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||
identity = 'a'*32+'/space and quote\'.png'
|
||||
path = Path(d)/identity
|
||||
path.parent.mkdir()
|
||||
path.write_bytes(b'test')
|
||||
self.assertEqual(remote.media_path(identity), path.resolve())
|
||||
for bad in ('../../etc/passwd', '/etc/passwd', 'a'*32+'/..', 'a'*32+'/x/y', None):
|
||||
with self.assertRaises((ValueError, FileNotFoundError)):
|
||||
remote.media_path(bad)
|
||||
link = path.parent/'link.png'
|
||||
link.symlink_to(path)
|
||||
with self.assertRaises(ValueError):
|
||||
remote.media_path('a'*32+'/link.png')
|
||||
with patch.object(remote, 'status', return_value={'state': 'idle'}):
|
||||
files = remote.run({'action': 'list'})['files']
|
||||
self.assertEqual([f['id'] for f in files], [identity])
|
||||
|
||||
def test_server_rejects_bad_actions_before_ssh(self):
|
||||
with patch.object(server, 'ssh') as ssh:
|
||||
for body in ({'action': 'delete'}, {'action': 'play', 'id': '../x'},
|
||||
{'action': 'play', 'id': 'a'*32+'/x', 'layout': 'invalid'},
|
||||
{'action': 'play', 'id': 'a'*32+'/x', 'theatre': 'false'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.media(body)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_fake_frame_stop_only_owns_our_unit(self):
|
||||
for rc in (0, 5): # 5: already collected ("not loaded"), a no-op
|
||||
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={'state': 'ended'}):
|
||||
run.return_value.returncode = rc
|
||||
self.assertEqual(remote.run({'action': 'stop'})['state'], 'ended')
|
||||
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
|
||||
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}):
|
||||
run.return_value.returncode, run.return_value.stderr = 1, 'Access denied'
|
||||
with self.assertRaisesRegex(RuntimeError, 'Access denied'):
|
||||
remote.run({'action': 'stop'})
|
||||
|
||||
def test_start_failure_reports_systemd_error(self):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)), \
|
||||
patch.object(remote, 'STATUS', Path(d)/'status.json'), \
|
||||
patch.object(remote, 'active', return_value=False), \
|
||||
patch.object(remote.subprocess, 'run') as run:
|
||||
identity = 'b'*32+'/still_SBS.png'
|
||||
(Path(d)/identity).parent.mkdir()
|
||||
(Path(d)/identity).write_bytes(b'x')
|
||||
run.return_value.returncode, run.return_value.stderr = 1, 'Unit already exists'
|
||||
with patch.object(remote, 'probe', return_value=({}, False)), \
|
||||
self.assertRaisesRegex(RuntimeError, 'Unit already exists'):
|
||||
remote.run({'action': 'play', 'id': identity})
|
||||
self.assertEqual(run.call_args.args[0][0], 'systemd-run') # reset-failed's result is ignored
|
||||
|
||||
def test_upload_rejects_unplayable_names_and_keeps_copy_error(self):
|
||||
with patch.object(server, 'ssh') as ssh, patch.object(server, 'push_file') as push:
|
||||
# On Windows a backslash is a separator, so such a name can't reach here.
|
||||
for name in ('.hidden.mp4',) + (('a\\b_SBS.mp4',) if os.sep == '/' else ()):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.push_media(Path('/tmp')/name)
|
||||
ssh.assert_not_called()
|
||||
push.side_effect = server.Failure('copy failed')
|
||||
ssh.side_effect = [None, server.Failure('link down')]
|
||||
with self.assertRaisesRegex(server.Failure, 'copy failed'):
|
||||
server.push_media(Path('/tmp/film_SBS.mp4'))
|
||||
|
||||
def test_splat_invalid_records_and_stereo_parallax(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d)/'small.splat'
|
||||
path.write_bytes(struct.pack('<6f8B', 0, 0, 0, .001, .001, .001,
|
||||
255, 0, 0, 255, 255, 128, 128, 128))
|
||||
data, w, h = splat.render(path, 64, 48)
|
||||
self.assertEqual((len(data), w, h), (128*48*4, 128, 48))
|
||||
def centroid(eye):
|
||||
weights = [(x, data[(y*w+x+eye*64)*4]) for y in range(h) for x in range(64)]
|
||||
return sum(x*v for x,v in weights)/sum(v for x,v in weights)
|
||||
self.assertGreater(centroid(0), centroid(1))
|
||||
for bad in (b'', b'bad', struct.pack('<6f8B', float('nan'), 0, 0, 1, 1, 1, *([128]*8))):
|
||||
path.write_bytes(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
splat.read(path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -5,6 +5,7 @@ request guards and input validation, which all run before any SSH call.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -13,6 +14,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_store # noqa: E402
|
||||
import frame_steam # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
|
||||
@@ -49,6 +51,39 @@ class FrameSteamHelper(unittest.TestCase):
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
def test_installed_game_is_not_reinstalled(self):
|
||||
with mock.patch.object(frame_steam, "Page") as page, \
|
||||
mock.patch.object(frame_steam, "steam_url") as launch:
|
||||
page.return_value.eval.return_value = {"name": "Gravitas", "installed": True}
|
||||
self.assertEqual(frame_steam.install(1067310)["state"], "installed")
|
||||
launch.assert_not_called()
|
||||
|
||||
def test_license_and_eula_wait_for_headset(self):
|
||||
# Seen during #26's free Gravitas install: state 3 is not permission
|
||||
# to click through the license. The same guard applies to an EULA.
|
||||
for state in (3, 8):
|
||||
with self.subTest(state=state), \
|
||||
mock.patch.object(frame_steam, "Page") as page, \
|
||||
mock.patch.object(frame_steam, "steam_url") as launch, \
|
||||
mock.patch.object(frame_steam.time, "sleep"):
|
||||
page.return_value.eval.side_effect = [
|
||||
None, {"app": 1067310, "state": state, "need": 1, "free": 100}]
|
||||
self.assertEqual(frame_steam.install(1067310)["state"], "headset")
|
||||
launch.assert_called_once_with("steam://install/1067310")
|
||||
self.assertNotIn(mock.call("SteamClient.Installs.ContinueInstall()"),
|
||||
page.return_value.eval.call_args_list)
|
||||
|
||||
def test_insufficient_space_leaves_options_open(self):
|
||||
with mock.patch.object(frame_steam, "Page") as page, \
|
||||
mock.patch.object(frame_steam, "steam_url"), \
|
||||
mock.patch.object(frame_steam.time, "sleep"):
|
||||
page.return_value.eval.side_effect = [
|
||||
{"name": "Gravitas", "installed": False},
|
||||
{"app": 1067310, "state": 7, "need": 100, "free": 10}]
|
||||
self.assertEqual(frame_steam.install(1067310)["state"], "headset")
|
||||
self.assertNotIn(mock.call("SteamClient.Installs.ContinueInstall()"),
|
||||
page.return_value.eval.call_args_list)
|
||||
|
||||
|
||||
class HelperErrors(unittest.TestCase):
|
||||
def test_json_error_found_despite_ssh_stderr(self):
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Anonymous analytics (ui/frame_telemetry.py): what's collected at each level,
|
||||
what's scrubbed, and that nothing is sent without a key, the notice, or consent.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_compat_db as db # noqa: E402
|
||||
import frame_report as fr # noqa: E402
|
||||
import frame_telemetry as tm # noqa: E402
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
"""A packaged build with a key, its state in a temp folder."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
state = Path(tmp.name)
|
||||
for name, value in (("STATE", state), ("SETTINGS", state / "settings.json"),
|
||||
("OUTBOX", state / "outbox.jsonl"), ("SENT", state / "sent.jsonl")):
|
||||
p = mock.patch.object(tm, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
env = mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": "phc_test", "FRAME_CONTROL_PACKAGED": "1",
|
||||
"FRAME_CONTROL_POSTHOG_HOST": "http://127.0.0.1:9",
|
||||
"FRAME_CONTROL_VERSION": "9.9.9"})
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
for k in ("DO_NOT_TRACK", "FRAME_CONTROL_TELEMETRY"):
|
||||
os.environ.pop(k, None)
|
||||
tm._seen_errors.clear()
|
||||
|
||||
def queued(self):
|
||||
return tm._read_lines(tm.OUTBOX)
|
||||
|
||||
|
||||
class Gates(Base):
|
||||
def test_blocked_without_key_or_in_a_checkout_or_by_do_not_track(self):
|
||||
self.assertIsNone(tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": ""}), \
|
||||
mock.patch.object(tm, "HERE", Path(tempfile.gettempdir()) / "no-config-here"):
|
||||
self.assertIn("key", tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_PACKAGED": ""}):
|
||||
self.assertIn("source checkout", tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}):
|
||||
self.assertIn("DO_NOT_TRACK", tm.blocked())
|
||||
self.assertFalse(tm.capture("app_opened"))
|
||||
self.assertFalse(tm.OUTBOX.exists())
|
||||
|
||||
def test_usage_is_on_by_default_the_others_are_opt_in(self):
|
||||
self.assertTrue(tm.capture("app_opened"))
|
||||
self.assertFalse(tm.capture("compat_report", {}, level="compat"))
|
||||
self.assertFalse(tm.capture("$exception", {}, level="diagnostics"))
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
|
||||
|
||||
def test_events_are_anonymous(self):
|
||||
tm.capture("app_opened")
|
||||
e = self.queued()[0]
|
||||
self.assertEqual(e["distinct_id"], tm.settings()["id"])
|
||||
self.assertIs(e["properties"]["$process_person_profile"], False)
|
||||
self.assertIs(e["properties"]["$geoip_disable"], True)
|
||||
self.assertEqual(e["properties"]["app_version"], "9.9.9")
|
||||
|
||||
def test_turning_a_level_off_drops_its_unsent_events(self):
|
||||
tm.update_settings({"diagnostics": True})
|
||||
tm.capture("app_opened")
|
||||
tm.diagnostic("somewhere", RuntimeError("boom"))
|
||||
self.assertEqual(len(self.queued()), 2)
|
||||
tm.update_settings({"diagnostics": False})
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
|
||||
tm.update_settings({"usage": False})
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertFalse(tm.capture("app_opened"))
|
||||
|
||||
def test_the_same_error_is_sent_once_in_a_while(self):
|
||||
tm.update_settings({"diagnostics": True})
|
||||
for _ in range(3):
|
||||
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
def test_page_events_are_checked(self):
|
||||
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
|
||||
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
|
||||
with self.assertRaises(ValueError):
|
||||
tm.page_event({"event": "anything_else"})
|
||||
with self.assertRaises(ValueError):
|
||||
tm.page_event({"event": "tab_viewed", "properties": {"tab": "/Users/me/secret"}})
|
||||
|
||||
|
||||
class Lifecycle(Base):
|
||||
def test_install_update_and_one_open_a_day(self):
|
||||
tm.app_started()
|
||||
tm.app_started()
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_installed", "app_opened"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
|
||||
class Sending(Base):
|
||||
def serve(self, status=200):
|
||||
got = []
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
got.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"status": 1}')
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
os.environ["FRAME_CONTROL_POSTHOG_HOST"] = f"http://127.0.0.1:{httpd.server_port}"
|
||||
return got
|
||||
|
||||
def test_nothing_is_sent_before_the_notice_was_shown(self):
|
||||
got = self.serve()
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 0)
|
||||
self.assertEqual(got, [])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
path, body = got[0]
|
||||
self.assertEqual((path, body["api_key"], body["batch"][0]["event"]), ("/batch/", "phc_test", "app_opened"))
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 0)
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
|
||||
class Scrub(unittest.TestCase):
|
||||
def test_personal_details_are_removed(self):
|
||||
home = str(Path.home())
|
||||
text = (f"open {home}/Downloads/My Game.apk failed; ssh alex@192.168.1.20 (frame.local) "
|
||||
"key ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM steam 76561198000000000 mac 3c:22:fb:12:34:56 "
|
||||
"url https://example.com/private/path?token=abc phc_abcdefghijklmnopqrstu C:\\Users\\Bob\\x "
|
||||
"/home/carol/y")
|
||||
out = tm.scrub(text)
|
||||
for leaked in (home, "192.168.1.20", "frame.local", "AAAAC3Nza", "76561198000000000", "3c:22:fb",
|
||||
"private/path", "phc_abcdefghijklmnopqrstu", "Bob", "carol", "alex@"):
|
||||
self.assertNotIn(leaked, out)
|
||||
self.assertIn("https://example.com/…", out)
|
||||
self.assertIn("~/Downloads", out)
|
||||
|
||||
def test_categories(self):
|
||||
self.assertEqual(tm.categorize("adb: failed to install: INSTALL_FAILED_NO_MATCHING_ABIS: x"),
|
||||
("android_installer", "INSTALL_FAILED_NO_MATCHING_ABIS"))
|
||||
self.assertEqual(tm.categorize("X has no arm64-v8a build (armeabi-v7a)")[0], "apk_wrong_abi")
|
||||
self.assertEqual(tm.categorize("ssh: connect to host 10.0.0.2 port 22: Connection refused")[0],
|
||||
"frame_unreachable")
|
||||
self.assertEqual(tm.categorize("something new")[0], "other")
|
||||
|
||||
|
||||
class Compat(Base):
|
||||
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
|
||||
r = {"id": "r1", "package": "org.example", "version": "1.0", "rating": "works", "via": "user",
|
||||
"notes": f"from {Path.home()}/x", "source": "MyPrivateBuild.apk", "date": "2026-09-28T10:00:00"}
|
||||
self.assertFalse(tm.compat_report(r))
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertTrue(tm.compat_report(r))
|
||||
p = self.queued()[-1]["properties"]
|
||||
self.assertEqual((p["package"], p["rating"], p["id"]), ("org.example", "works", "r1"))
|
||||
self.assertNotIn("source", p)
|
||||
self.assertNotIn(str(Path.home()), p["notes"])
|
||||
r2 = dict(r, id="r2", source="https://f-droid.org/repo/org.example_1.apk")
|
||||
tm.compat_report(r2)
|
||||
self.assertEqual(self.queued()[-1]["properties"]["source"], "https://f-droid.org/…")
|
||||
|
||||
def test_opting_in_shares_earlier_local_reports(self):
|
||||
with mock.patch.object(db, "shared", return_value=False), \
|
||||
mock.patch.object(db, "_outbox", return_value=[{"id": "old1", "package": "org.a", "rating": "works",
|
||||
"date": "2026-09-01T00:00:00"}]):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.update_settings({"compat": True}) # already sent: not again
|
||||
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
|
||||
|
||||
|
||||
class ApkInstallReports(unittest.TestCase):
|
||||
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
|
||||
|
||||
def setUp(self):
|
||||
import server
|
||||
self.server = server
|
||||
for target, name in ((server.frame_catalog, "add_report"), (server.frame_telemetry, "install_finished")):
|
||||
p = mock.patch.object(target, name)
|
||||
setattr(self, name, p.start())
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_wrong_abi_is_an_install_failed_report(self):
|
||||
info = {"package": "org.x", "version": "2.0", "label": "X"}
|
||||
self.server.apk_installed(info, None, self.server.frame_android.FrameError(
|
||||
"X has no arm64-v8a build (armeabi-v7a); Lepton is 64-bit ARM only"), 3.0)
|
||||
args, kw = self.add_report.call_args
|
||||
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
|
||||
self.assertIs(self.install_finished.call_args[0][1], False)
|
||||
|
||||
def test_connection_trouble_is_not_reported(self):
|
||||
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
|
||||
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
|
||||
self.add_report.assert_not_called()
|
||||
|
||||
def test_private_package_names_stay_here(self):
|
||||
with mock.patch.dict(self.server.frame_catalog._cache, {"by_pkg": {"org.public": {}}}):
|
||||
self.server.apk_installed({"package": "com.private.thing", "version": "1"}, {"package": "com.private.thing"},
|
||||
None, 2.0)
|
||||
self.assertIsNone(self.install_finished.call_args[1]["package"])
|
||||
self.server.apk_installed({"package": "org.public", "version": "1"}, {"package": "org.public"}, None, 2.0)
|
||||
self.assertEqual(self.install_finished.call_args[1]["package"], "org.public")
|
||||
|
||||
|
||||
class CommunitySync(unittest.TestCase):
|
||||
def ev(self, i, who="a", day="2026-09-28", **kw):
|
||||
return ({"id": f"id{i}", "package": "org.x", "rating": "works", "date": f"{day}T00:00:00",
|
||||
"via": "probe", **kw}, who, f"{day} 10:00:00")
|
||||
|
||||
def test_rows_are_validated_marked_and_capped_per_reporter(self):
|
||||
events = [self.ev(i) for i in range(5)] + [self.ev(9, who="b", rating="nonsense"), self.ev(10, who="b")]
|
||||
rows, skipped = db.community_rows(events, {}, cap=3)
|
||||
self.assertEqual([r["id"] for r in rows], ["id0", "id1", "id2", "id10"])
|
||||
self.assertTrue(all(r["via"] == "community-probe" for r in rows))
|
||||
self.assertEqual(len(skipped), 3)
|
||||
|
||||
def test_the_cap_and_duplicates_hold_across_syncs(self):
|
||||
state = {}
|
||||
rows, _ = db.community_rows([self.ev(i) for i in range(3)], state, cap=3)
|
||||
self.assertEqual(len(rows), 3)
|
||||
rows, skipped = db.community_rows([self.ev(i) for i in range(6)], state, cap=3) # overlapping re-read
|
||||
self.assertEqual(rows, [])
|
||||
self.assertEqual([why for _, why in skipped], ["over the daily limit for one reporter"] * 3)
|
||||
|
||||
def test_a_malformed_event_is_skipped_not_fatal(self):
|
||||
rows, skipped = db.community_rows([self.ev(1, via=["probe"]), ("not json", "a", "2026-09-28"), self.ev(2)], {})
|
||||
self.assertEqual([r["id"] for r in rows], ["id2"])
|
||||
self.assertEqual(len(skipped), 2)
|
||||
|
||||
|
||||
class Regressions(Base):
|
||||
"""Findings from the cross-provider review."""
|
||||
|
||||
def test_urls_lose_credentials_paths_and_private_hosts(self):
|
||||
for text, leaked in (("https://alice:secret@example.com/private.apk?token=credential", ("alice", "secret", "private", "credential")),
|
||||
("https://alice:secret@192.168.1.4/private.apk", ("alice", "192.168", "private")),
|
||||
("fe80::1234 and 2001:db8::5", ("fe80", "2001:db8")),
|
||||
("sk-proj-abcdefghijklmnopqrstuv", ("abcdefghijk",)),
|
||||
("http://frame.local:8080/x", ("frame.local", "8080"))):
|
||||
out = tm.scrub(text)
|
||||
for s in leaked:
|
||||
self.assertNotIn(s, out, (text, out))
|
||||
|
||||
def test_compat_labels_versions_and_sources_are_scrubbed(self):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.compat_report({"id": "r9", "package": "org.x", "rating": "works", "date": "2026-09-28T00:00:00",
|
||||
"label": "alice@example.com build", "version": "1.0-alice@example.com",
|
||||
"source": "https://alice:secret@192.168.1.4/private.apk"})
|
||||
p = self.queued()[-1]["properties"]
|
||||
self.assertNotIn("alice", json.dumps(p))
|
||||
self.assertNotIn("source", p)
|
||||
|
||||
def test_an_unsent_report_is_shared_again_after_opting_out_and_in(self):
|
||||
with mock.patch.object(db, "shared", return_value=False), \
|
||||
mock.patch.object(db, "_outbox", return_value=[{"id": "q1", "package": "org.a", "rating": "works",
|
||||
"date": "2026-09-01T00:00:00"}]):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.update_settings({"compat": False})
|
||||
self.assertEqual(self.queued(), [])
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["q1"])
|
||||
|
||||
def test_opting_out_waits_for_a_send_in_progress(self):
|
||||
tm.update_settings({"noticeShown": True})
|
||||
tm.capture("app_opened")
|
||||
order = []
|
||||
started = threading.Event()
|
||||
|
||||
def slow_open(req, timeout):
|
||||
started.set()
|
||||
time.sleep(0.3)
|
||||
order.append("sent")
|
||||
return mock.MagicMock(__enter__=lambda s: s, __exit__=lambda *a: False, read=lambda: b"{}")
|
||||
|
||||
with mock.patch.object(tm.urllib.request, "urlopen", side_effect=slow_open):
|
||||
th = threading.Thread(target=tm.flush)
|
||||
th.start()
|
||||
started.wait(2)
|
||||
tm.update_settings({"usage": False})
|
||||
order.append("opted out")
|
||||
th.join()
|
||||
self.assertEqual(order, ["sent", "opted out"])
|
||||
|
||||
def test_project_id_comes_from_the_config(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_PROJECT": "12345"}):
|
||||
self.assertEqual(tm.config()["project"], "12345")
|
||||
|
||||
|
||||
class ReportProblem(Base):
|
||||
"""Report a problem: diagnostics are scrubbed and bounded; the report goes privately to PostHog."""
|
||||
|
||||
def serve(self, status=200):
|
||||
got = []
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
got.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"status":"Ok"}')
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
p = mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_HOST": f"http://127.0.0.1:{httpd.server_port}"})
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return got
|
||||
|
||||
def test_diagnostics_are_scrubbed_and_include_the_log(self):
|
||||
log = tm.STATE / "server.log"
|
||||
log.write_text("GET /api/status 200\nTraceback: ssh alice@192.168.1.9 failed in %s/x\n" % Path.home())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
|
||||
text = fr.diagnostics(["16:00 Install failed: https://bob:pw@example.com/a.apk"], include_logs=True, limit=5000)
|
||||
self.assertIn("Frame Control 9.9.9", text)
|
||||
self.assertIn("Traceback", text)
|
||||
self.assertNotIn("GET /api/status", text)
|
||||
for leaked in ("alice", "192.168.1.9", str(Path.home()), "bob", "pw@"):
|
||||
self.assertNotIn(leaked, text)
|
||||
|
||||
def test_a_report_is_bounded_in_utf16_units(self):
|
||||
body = {"title": "Live view stops", "message": "It stops 😀 " * 800, "diagnostics": "log 😀 line\n" * 2000}
|
||||
title, text, diag = fr.compose(body)
|
||||
self.assertLessEqual(fr.u16(text), fr.TEXT_MAX)
|
||||
self.assertLessEqual(fr.u16(diag), fr.DIAG_MAX)
|
||||
self.assertTrue(text.startswith("It stops"))
|
||||
with self.assertRaises(ValueError):
|
||||
fr.compose({"title": "hi", "message": "It stops after a minute."})
|
||||
|
||||
def test_logs_only_when_asked_and_environment_is_kept_first(self):
|
||||
log = tm.STATE / "server.log"
|
||||
log.write_text("".join(f"old line {i}\n" for i in range(200)) + "newest line\n")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
|
||||
plain = fr.diagnostics(["Copy Jane Doe tax return.pdf to ~/Downloads"])
|
||||
full = fr.diagnostics(["Install failed"], include_logs=True, limit=400)
|
||||
self.assertNotIn("Jane Doe", plain)
|
||||
self.assertNotIn("line", plain)
|
||||
self.assertTrue(full.startswith("Frame Control 9.9.9"))
|
||||
self.assertIn("Install failed", full)
|
||||
self.assertIn("newest line", full)
|
||||
self.assertLessEqual(fr.u16(full), 400)
|
||||
|
||||
def test_the_previewed_diagnostics_are_what_is_sent(self):
|
||||
got = self.serve()
|
||||
fr.send({"title": "Live view stops", "message": "It stops after a minute.",
|
||||
"diagnostics": "Frame Control 9.9.9\nssh janes-mac.tail12345.ts.net failed"})
|
||||
diag = got[0][1]["batch"][0]["properties"]["diagnostics"]
|
||||
self.assertIn("Frame Control 9.9.9", diag)
|
||||
self.assertNotIn("janes-mac", diag)
|
||||
|
||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||
got = self.serve()
|
||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com"})
|
||||
path, body = got[0]
|
||||
event = body["batch"][0]
|
||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||
props = event["properties"]
|
||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
||||
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
|
||||
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||
self.assertIn(res["id"], res["message"])
|
||||
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["problem_report"])
|
||||
|
||||
def test_a_sent_report_is_not_an_error_if_the_local_log_fails(self):
|
||||
self.serve()
|
||||
with mock.patch.object(tm, "record_sent", side_effect=OSError("disk full")):
|
||||
res = fr.send({"title": "Live view stops", "message": "It stops after a minute."})
|
||||
self.assertTrue(res["id"])
|
||||
|
||||
def test_events_queued_by_older_versions_get_the_placeholder_address(self):
|
||||
got = self.serve()
|
||||
tm.update_settings({"noticeShown": True})
|
||||
tm._write_lines(tm.OUTBOX, [{"event": "app_opened", "distinct_id": "x", "uuid": "u1",
|
||||
"properties": {"level": "usage"}}])
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(got[0][1]["batch"][0]["properties"]["$ip"], "0.0.0.0")
|
||||
self.assertEqual(tm._read_lines(tm.SENT)[0]["properties"]["$ip"], "0.0.0.0")
|
||||
|
||||
def test_the_inbox_skips_malformed_reports(self):
|
||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||
"0.4.0", "macOS", "", ""]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("AB12CD34", printed)
|
||||
self.assertIn("Hand-made", printed)
|
||||
|
||||
def test_a_refused_report_is_an_error(self):
|
||||
self.serve(status=401)
|
||||
with self.assertRaisesRegex(fr.ReportError, "HTTP 401"):
|
||||
fr.send({"title": "Live view stops", "message": "It stops after a minute."})
|
||||
self.assertEqual(tm._read_lines(tm.SENT), [])
|
||||
|
||||
def test_no_key_means_no_report(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": ""}), \
|
||||
mock.patch.object(tm, "HERE", tm.STATE):
|
||||
with self.assertRaisesRegex(fr.ReportError, "no PostHog project key"):
|
||||
fr.send({"title": "Live view stops", "message": "It stops after a minute."})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ the localhost-testing rule allows.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
Reference in new issue
Block a user