Files
saphid--frame-control/tests/test_ios_contracts.py
T
saphidandClaude Opus 5.5 fed2249776 Apps / Games / Tools / Settings, one Add / Install flow, and the whole-app review fixes
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed /
Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install
chooser for catalogs, Steam, Linux apps, files and install links. Installed
rows show identity, one state and Open/Stop; the rest moves to app details.

Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper
delivery and catalogue jobs stay bound to the headset they were meant for;
report preview follows the latest consent; failed Store reads are not an empty
library; keyboard access to device details and the Store sheet; the iOS
bundle ships the artwork renderer and OpenXR resources; typed addresses and
custom SSH ports survive setup; WebKit crashes recover; native preferences
survive relaunch; APK update rollback and repair stay retryable.

Refs #101, #104, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:08:09 +11:00

176 lines
9.3 KiB
Python

"""iPhone packaging and Foundation contracts; no headset, downloads or Xcode build."""
import sandbox # noqa: F401
import fnmatch
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class BundleContracts(unittest.TestCase):
@classmethod
def setUpClass(cls):
spec = importlib.util.spec_from_file_location("ios_bundle_contract", ROOT / "ios/scripts/make_frame_bundle.py")
cls.bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.bundle)
cls.tmp = tempfile.TemporaryDirectory()
cls.root = Path(cls.tmp.name)
with tarfile.open(fileobj=io.BytesIO(cls.bundle.build()), mode="r:gz") as archive:
archive.extractall(cls.root)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def test_required_resources_ship_to_both_clients(self):
resources = ["frame/android/library_artwork.js", "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"]
config = json.loads((ROOT / "app/package.json").read_text())
for resource in resources:
with self.subTest(resource=resource):
self.assertTrue((self.root / resource).is_file(), resource)
# Match the desktop's real resource filters as well as the phone archive.
packaged = False
for entry in config["build"]["extraResources"]:
source = entry["from"].removeprefix("../")
if Path(resource).is_relative_to(source):
relative = str(Path(resource).relative_to(source))
packaged |= any(fnmatch.fnmatch(relative, pattern) for pattern in entry.get("filter", ["**/*"]))
self.assertTrue(packaged, "desktop resource contract: " + resource)
def test_extracted_runtime_can_render_and_load_xr_layer(self):
result = subprocess.run([sys.executable, "-I", "-B", "-c", """
import sys, zipfile
from unittest.mock import patch
sys.path.insert(0, 'ui')
import frame_android as android
with patch.object(android, 'ssh', return_value='rendered') as ssh:
assert android.shortcut_tool('render', '/synthetic/app') == 'rendered'
assert 'ART_RENDERER = ' in ssh.call_args.kwargs['input']
assert len(ssh.call_args.kwargs['input']) > 1000
with zipfile.ZipFile('synthetic.apk', 'w') as apk:
apk.writestr('lib/arm64-v8a/libopenxr_loader.so', b'synthetic loader')
files = android.xr_compat_files('synthetic.apk')
assert set(files) == set(android.XR_COMPAT_FILES)
assert files['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\\x7fELF')
"""], cwd=self.root, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class NativeContracts(unittest.TestCase):
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS Swift compiles actual Foundation production sources")
def test_foundation_contracts(self):
with tempfile.TemporaryDirectory() as tmp:
main = Path(tmp) / "main.swift"
main.write_text("import XCTest\nlet suite = XCTestSuite(forTestCaseClass: NativeSessionTests.self)\nsuite.run()\nexit(suite.testRun!.hasSucceeded ? 0 : 1)\n")
executable = Path(tmp) / "native-contracts"
developer = subprocess.check_output(["xcode-select", "-p"], text=True).strip()
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
shared = str(Path(developer).parent / "SharedFrameworks")
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
"-Xlinker", "-rpath", "-Xlinker", libraries,
"-Xlinker", "-rpath", "-Xlinker", shared,
"-D", "NATIVE_CONTRACT_STANDALONE",
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
str(ROOT / "ios/FrameControlTests/NativeSessionTests.swift"),
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Executed 5 tests", result.stdout + result.stderr)
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS exercises the production finder with controlled probes")
def test_finder_ignores_late_probes_after_edit_and_stop(self):
harness = r'''import Foundation
actor Probes {
var pending: [String: CheckedContinuation<Bool, Never>] = [:]
func probe(_ host: String) async -> Bool {
await withCheckedContinuation { pending[host] = $0 }
}
func has(_ host: String) -> Bool { pending[host] != nil }
func answer(_ host: String) { pending.removeValue(forKey: host)!.resume(returning: true) }
}
@main struct FinderContracts {
@MainActor static func wait(_ condition: () async -> Bool) async {
for _ in 0..<100 {
if await condition() { return }
try! await Task.sleep(nanoseconds: 5_000_000)
}
fatalError("controlled callback did not run")
}
@MainActor static func main() async {
let probes = Probes()
let finder = FrameFinder(probe: { await probes.probe($0) }, discoveryEnabled: false)
finder.start(fallback: "old.local")
await wait { await probes.has("old.local") }
finder.start(fallback: "100.100.100.2:2222", manual: true)
await wait { await probes.has("100.100.100.2:2222") }
await probes.answer("old.local")
// Let the cancelled task return its deliberately late successful answer.
try! await Task.sleep(nanoseconds: 20_000_000)
precondition(finder.found == nil, "A stale probe replaced the edited target")
await probes.answer("100.100.100.2:2222")
await wait { finder.found != nil }
precondition(finder.found?.host == "100.100.100.2:2222")
finder.start(fallback: "stopped.local", manual: true)
await wait { await probes.has("stopped.local") }
finder.stop()
await probes.answer("stopped.local")
try! await Task.sleep(nanoseconds: 20_000_000)
precondition(finder.found == nil, "A stopped search published a result")
print("Production FrameFinder: stale and cancelled probes ignored; manual target retained")
}
}
'''
with tempfile.TemporaryDirectory() as tmp:
main = Path(tmp) / "FinderContracts.swift"
main.write_text(harness)
executable = Path(tmp) / "finder-contracts"
result = subprocess.run(["swiftc", "-parse-as-library",
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
str(ROOT / "ios/FrameControl/SSH/FrameFinder.swift"),
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@unittest.skipUnless(shutil.which("node"), "Node executes the unmodified production bridge script")
def test_bridge_preferences_and_document_acknowledgement(self):
source = (ROOT / "ios/FrameControl/Web/WebShell.swift").read_text()
bridge = source.split('static let bridge = """', 1)[1].split('"""', 1)[0]
script = r'''const vm = require('node:vm');
const assert = require('node:assert/strict');
const calls = [];
const window = {webkit: {messageHandlers: {frameApp: {postMessage: body => {calls.push(body); return Promise.resolve(true)}}}}};
vm.runInNewContext(process.argv[1], {window, crypto: {randomUUID: () => 'document-42'}});
assert.equal(window.__frameInstallLink({}), false);
window.frameApp.preferences.read();
window.frameApp.preferences.write('mvSeparate', '0');
assert.equal(JSON.stringify(calls), JSON.stringify([
{name: 'preferencesRead', arg: null}, {name: 'preferencesWrite', arg: {key: 'mvSeparate', value: '0'}}
]));
const received = [];
window.frameApp.onInstallLink(req => received.push(req));
assert.equal(calls.at(-1).name, 'installLinkReady');
assert.equal(calls.at(-1).arg, 'document-42');
assert.equal(window.__frameInstallLink({target: 'a'}), true);
assert.equal(received[0].target, 'a');
window.frameApp.onInstallLink(() => {throw new Error('callback failed')});
assert.throws(() => window.__frameInstallLink({}), /callback failed/);
'''
result = subprocess.run(["node", "-e", script, bridge], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()