mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-02 05:27:21 +00:00
* feat: start fresh Tauri installs with a local Gateway * test: align Tauri startup proof with automatic local setup * test: rely on native proof for automatic Tauri startup * feat: connect Tauri first run to shared native setup card * test: align native setup card fixture with lint rules
576 lines
30 KiB
Python
576 lines
30 KiB
Python
"""Synthetic Gateway and real WebKit child-view proof for first_run.py.
|
|
|
|
The existing driver owns the private HOME, environment, app, deadline and AT-SPI
|
|
session. This fixture owns only its loopback HTTP server and synthetic config.
|
|
"""
|
|
|
|
import base64
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
from pathlib import Path
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
DASHBOARD = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<title>OpenClaw inline browser fixture</title>
|
|
<style>body{margin:24px;background:#f3f5fa;color:#18253d;font:18px sans-serif}
|
|
h1{font-size:26px}p{max-width:850px}</style></head><body>
|
|
<h1 id="status">Running native inline browser regression</h1>
|
|
<p>Synthetic loopback Gateway. Real native WebKit child views.</p>
|
|
<button id="dashboard-pointer" style="position:absolute;right:24px;top:85px"
|
|
aria-label="Dashboard pointer control">Dashboard pointer control</button>
|
|
<script>
|
|
(async () => {
|
|
const phase = __PHASE__;
|
|
const sessionKey = __SESSION__;
|
|
const refusedUrl = __REFUSED_URL__;
|
|
const tabId = sessionKey + '-tab';
|
|
const scope = sessionKey + '-scope';
|
|
const url = page => location.origin + '/browser/' + page;
|
|
const state = () => window.__OPENCLAW_NATIVE_BROWSER__;
|
|
const assert = (condition, message) => { if (!condition) throw Error(message); };
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
async function until(read, label) {
|
|
const deadline = Date.now() + 15000;
|
|
while (Date.now() < deadline) {
|
|
if (await read()) return;
|
|
await sleep(100);
|
|
}
|
|
throw Error('Timed out: ' + label);
|
|
}
|
|
async function report(step, extra = {}) {
|
|
const response = await fetch('/fixture/report', {method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({phase, step, ...extra})});
|
|
assert(response.ok, 'Fixture rejected report: ' + step);
|
|
}
|
|
async function post(message) {
|
|
const reply = await window.webkit.messageHandlers.openclawBrowser.postMessage(message);
|
|
assert(reply && reply.ok === true, message.type + ': ' + (reply?.error ?? 'invalid reply'));
|
|
return reply;
|
|
}
|
|
async function loaded(page) {
|
|
await until(() => state().tabs.some(tab => tab.id === tabId && tab.url === url(page)
|
|
&& tab.title === 'Synthetic browser ' + page && !tab.loading), 'native page ' + page);
|
|
}
|
|
async function present() {
|
|
await post({type:'present', scope, tabId, visible:true,
|
|
rect:{x:20,y:150,width:Math.min(620,innerWidth-40),height:360}});
|
|
}
|
|
async function inspect() {
|
|
const result = await post({type:'inspect', tabId, x:60, y:55});
|
|
assert(result.node?.id === 'inspect-target' && result.node.tag.toLowerCase() === 'button'
|
|
&& result.node.name === 'Synthetic inspect target' && result.node.focusable, 'Native inspect metadata');
|
|
}
|
|
async function snapshot(name) {
|
|
const result = await post({type:'snapshot', tabId});
|
|
assert(result.cssWidth > 0 && result.cssHeight > 0, 'Snapshot CSS size');
|
|
await report(name, {dataUrl:result.dataUrl});
|
|
}
|
|
document.getElementById('dashboard-pointer').addEventListener('click', event => {
|
|
if (event.isTrusted) report('dashboard-pointer-click');
|
|
});
|
|
try {
|
|
await until(() => typeof window.webkit?.messageHandlers?.openclawBrowser?.postMessage === 'function'
|
|
&& state(), 'native dashboard bridge ready');
|
|
if (phase === 1) {
|
|
assert(state().tabs.length === 0, 'Isolated app starts with no browser tabs');
|
|
const opened = await post({type:'open',tabId,url:url('one'),sessionKey});
|
|
assert(opened.tabId === tabId, 'Open returned native tab');
|
|
await loaded('one');
|
|
const duplicate = await post({type:'open',tabId:tabId+'-duplicate',url:url('one'),sessionKey});
|
|
assert(duplicate.tabId === tabId && state().tabs.length === 1, 'Same-session URL dedupe');
|
|
await present();
|
|
await until(async () => {
|
|
const telemetry = await (await fetch('/fixture/telemetry')).json();
|
|
return telemetry.one?.width === Math.min(620,innerWidth-40) && telemetry.one?.height === 360;
|
|
}, 'actual native child viewport');
|
|
await report('click-ready');
|
|
await until(async () => (await (await fetch('/fixture/telemetry')).json()).one?.clicked === true,
|
|
'native child received pointer click');
|
|
await report('native-pointer-click');
|
|
await inspect();
|
|
await snapshot('initial-snapshot');
|
|
const original = url('failed-initial');
|
|
const failed = await post({type:'open',tabId:tabId+'-failed',url:original,sessionKey});
|
|
await until(() => state().tabs.some(tab => tab.id === failed.tabId
|
|
&& tab.url === refusedUrl && !tab.loading), 'failed initial redirect completed');
|
|
const failedRequests = (await (await fetch('/fixture/telemetry')).json()).loads['failed-initial'];
|
|
const recovery = await fetch('/fixture/recover-initial', {method:'POST',
|
|
headers:{'Content-Type':'application/json'},body:'{}'});
|
|
assert(recovery.ok, 'Fixture initial route recovered');
|
|
const recovered = await post({type:'open',tabId:tabId+'-recovered',url:original,sessionKey});
|
|
assert(recovered.tabId !== failed.tabId, 'Failed initial alias must not reuse its stale tab');
|
|
await until(async () => (await (await fetch('/fixture/telemetry')).json()).loads['failed-initial'] > failedRequests,
|
|
'reopening failed original starts a new request');
|
|
await until(() => state().tabs.some(tab => tab.id === recovered.tabId
|
|
&& tab.url === url('recovered-initial') && tab.title === 'Synthetic browser recovered-initial'
|
|
&& !tab.loading), 'recovered initial redirect rendered');
|
|
const alias = await post({type:'open',tabId:tabId+'-alias',url:original,sessionKey});
|
|
assert(alias.tabId === recovered.tabId, 'Only successful redirect retains initial alias');
|
|
await post({type:'close',tabId:failed.tabId});
|
|
await post({type:'close',tabId:recovered.tabId});
|
|
await report('failed-initial-redirect-recovered');
|
|
const credentialUrl = new URL(url('credential-url'));
|
|
credentialUrl.username = 'inline-fixture'; credentialUrl.password = 'synthetic-password';
|
|
const credentialTab = await post({type:'open',tabId:tabId+'-credential',url:credentialUrl.href,sessionKey});
|
|
await until(() => state().tabs.some(tab => tab.id === credentialTab.tabId
|
|
&& tab.title === 'Synthetic browser credential-url' && !tab.loading), 'credential URL rendered');
|
|
await post({type:'close',tabId:credentialTab.tabId});
|
|
await until(() => state().tabs.length === 1, 'extra regression tabs cleaned up');
|
|
await report('credential-url-accepted');
|
|
await post({type:'navigate',tabId,url:url('two')});
|
|
await loaded('two');
|
|
await until(() => state().tabs.find(tab => tab.id === tabId).canGoBack, 'native back availability');
|
|
await post({type:'back',tabId}); await loaded('one');
|
|
await until(() => state().tabs.find(tab => tab.id === tabId).canGoForward, 'native forward availability');
|
|
await post({type:'forward',tabId}); await loaded('two');
|
|
const before = (await (await fetch('/fixture/telemetry')).json()).loads.two;
|
|
await post({type:'reload',tabId});
|
|
await until(async () => (await (await fetch('/fixture/telemetry')).json()).loads.two > before,
|
|
'native reload requested child document');
|
|
await loaded('two');
|
|
await report('navigation-history-reload');
|
|
location.reload();
|
|
} else if (phase === 2) {
|
|
assert(state().tabs.length === 1 && state().tabs[0].id === tabId
|
|
&& state().tabs[0].sessionKey === sessionKey && state().tabs[0].url === url('two'),
|
|
'Dashboard reload retained browser tab and conversation');
|
|
await present();
|
|
await snapshot('dashboard-reload-snapshot');
|
|
document.getElementById('status').textContent = 'Ready for Open Dashboard';
|
|
await report('dashboard-ready');
|
|
} else if (phase === 3) {
|
|
assert(state().tabs.length === 1 && state().tabs[0].id === tabId
|
|
&& state().tabs[0].sessionKey === sessionKey && state().tabs[0].url === url('two'),
|
|
'Passive Open Dashboard retained browser tab and conversation');
|
|
document.getElementById('status').textContent = 'Ready for explicit Gateway reconnect';
|
|
await report('explicit-reconnect-ready');
|
|
} else if (phase === 4) {
|
|
assert(state().tabs.length === 0, 'Explicit Gateway reconnect cleared previous Gateway tabs');
|
|
await report('replacement-cleared-tabs');
|
|
const opened = await post({type:'open',tabId,url:url('reopened'),sessionKey});
|
|
assert(opened.tabId === tabId, 'Reopen after native replacement');
|
|
await loaded('reopened');
|
|
await present();
|
|
await inspect();
|
|
await snapshot('replacement-reopened-snapshot');
|
|
await report('download-save-ready');
|
|
const saved = await post({type:'download',tabId});
|
|
assert(saved.cancelled === false, 'Native save completed');
|
|
await report('download-saved');
|
|
await report('download-cancel-ready');
|
|
const cancelled = await post({type:'download',tabId});
|
|
assert(cancelled.cancelled === true, 'Native save cancellation');
|
|
await report('download-cancelled');
|
|
await post({type:'release-scope',scope});
|
|
assert(state().tabs.some(tab => tab.id === tabId), 'Release scope retained tab');
|
|
await post({type:'close',tabId});
|
|
await until(() => state().tabs.length === 0, 'Final native tab closed');
|
|
document.getElementById('status').textContent = 'PASS: native inline browser and dashboard replacement';
|
|
await report('complete');
|
|
} else {
|
|
throw Error('Unexpected dashboard load count');
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('status').textContent = 'FAIL: native inline browser regression';
|
|
await report('failure', {error:String(error.message ?? error), readiness:{
|
|
handler:typeof window.webkit?.messageHandlers?.openclawBrowser?.postMessage === 'function',
|
|
state:!!state(), tauri:typeof window.__TAURI_INTERNALS__?.invoke === 'function',
|
|
token:!!window.__OPENCLAW_NATIVE_BROWSER_TOKEN__}});
|
|
}
|
|
})();
|
|
</script></body></html>"""
|
|
|
|
|
|
CHILD = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<title>Synthetic browser __PAGE__</title>
|
|
<style>body{margin:0;background:#e8eefc;color:#12223a;font:18px sans-serif}
|
|
button{position:absolute;left:32px;top:32px;width:210px;height:64px;background:#2663eb;
|
|
color:white;border:0;border-radius:8px;font-size:18px}h1{position:absolute;left:32px;right:32px;top:125px}</style>
|
|
</head><body><button id="inspect-target" aria-label="Synthetic inspect target">Click fixture</button>
|
|
<h1>Synthetic browser __PAGE__</h1><script>
|
|
let clicked = false;
|
|
function report() {
|
|
fetch('/fixture/child', {method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({page:__PAGE_JSON__,width:innerWidth,height:innerHeight,clicked})});
|
|
}
|
|
document.getElementById('inspect-target').addEventListener('click', event => {
|
|
clicked = event.isTrusted;
|
|
event.currentTarget.textContent = clicked ? 'Native click received' : 'Untrusted click';
|
|
report();
|
|
});
|
|
window.addEventListener('resize', report);
|
|
report();
|
|
</script></body></html>"""
|
|
|
|
|
|
class GatewayFixture(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self, artifacts_dir):
|
|
super().__init__(("127.0.0.1", 0), FixtureHandler)
|
|
self.session = "linux-inline-" + uuid.uuid4().hex
|
|
self.artifacts_dir = artifacts_dir
|
|
self.phase = 0
|
|
# Reserve and close an unused loopback port for a real connection refusal.
|
|
with socket.socket() as listener:
|
|
listener.bind(("127.0.0.1", 0))
|
|
self.refused_url = f"http://127.0.0.1:{listener.getsockname()[1]}/failed-target"
|
|
self.recover_initial = False
|
|
self.steps = []
|
|
self.telemetry = {"loads": {}}
|
|
self.failure = None
|
|
self.passed = False
|
|
self.download_bytes_match = False
|
|
self.signals = {name: threading.Event() for name in (
|
|
"click-ready", "dashboard-pointer-click", "dashboard-ready", "explicit-reconnect-ready",
|
|
"complete", "failure",
|
|
"download-save-ready", "download-saved", "download-cancel-ready", "download-cancelled",
|
|
)}
|
|
self.server_thread = threading.Thread(target=self.serve_forever, daemon=True)
|
|
|
|
def start(self):
|
|
config = Path.home() / ".openclaw/openclaw.json"
|
|
config.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
if config.exists():
|
|
raise RuntimeError("Inline fixture requires the driver's empty private HOME")
|
|
config.write_text(json.dumps({"gateway": {"mode": "remote", "remote": {
|
|
"transport": "direct", "url": f"ws://127.0.0.1:{self.server_port}/fixture/",
|
|
}}}))
|
|
config.chmod(0o600)
|
|
# The read-only fixture CLI describes the same isolated Gateway.
|
|
# Unexpected service or installation commands remain failures.
|
|
cli = config.parent / "bin/openclaw"
|
|
cli.parent.mkdir(mode=0o700)
|
|
dashboard_url = f"http://127.0.0.1:{self.server_port}/fixture/"
|
|
cli.write_text(
|
|
"#!/usr/bin/python3\n"
|
|
"import json, sys\n"
|
|
"command = ' '.join(sys.argv[1:])\n"
|
|
"if command == '--version':\n"
|
|
" print('OpenClaw inline fixture')\n"
|
|
"elif command == 'gateway status --json':\n"
|
|
" print(json.dumps({'service': {'loaded': True, 'runtime': {'status': 'running'}}, 'rpc': {'ok': True}}))\n"
|
|
"elif command == 'dashboard --json --no-open':\n"
|
|
f" print(json.dumps({{'ok': True, 'url': {dashboard_url!r}, 'browserUrl': {dashboard_url!r}, 'wsUrl': {'ws' + dashboard_url[4:]!r}}}))\n"
|
|
"else:\n"
|
|
" raise RuntimeError('Unexpected fixture CLI command: ' + command)\n"
|
|
)
|
|
cli.chmod(0o700)
|
|
self.server_thread.start()
|
|
|
|
def wait_for(self, name, app):
|
|
deadline = time.monotonic() + 45
|
|
while not self.signals[name].wait(0.1):
|
|
if self.failure:
|
|
raise RuntimeError(f"Native inline fixture: {self.failure}")
|
|
if app.poll() is not None:
|
|
raise RuntimeError("Native app exited during inline browser proof")
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError(f"Native inline fixture timed out waiting for {name}")
|
|
|
|
def open_native_menu(self, app, label):
|
|
from gi.repository import Gio, GLib
|
|
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
deadline = time.monotonic() + 5
|
|
|
|
def call(service, path, interface, method, arguments=None):
|
|
remaining = int((deadline - time.monotonic()) * 1000)
|
|
if remaining <= 0 or app.poll() is not None:
|
|
raise RuntimeError("Native menu lookup expired or its app exited")
|
|
try:
|
|
return bus.call_sync(
|
|
service, path, interface, method, arguments, None,
|
|
Gio.DBusCallFlags.NONE, min(1000, remaining), None,
|
|
).unpack()
|
|
except GLib.Error as error:
|
|
try:
|
|
remote_error = Gio.DBusError.get_remote_error(error)
|
|
context = {
|
|
"stage": "connection-settings-menu",
|
|
"service": service[:128],
|
|
"path": path[:128],
|
|
"interface": interface[:128],
|
|
"method": method[:128],
|
|
"errorType": "GLib.Error",
|
|
"remoteError": remote_error[:128] if isinstance(remote_error, str) else None,
|
|
}
|
|
message = "NATIVE_MENU_DBUS_ERROR " + json.dumps(context, ensure_ascii=True)
|
|
if len(message.encode("ascii")) < 4096:
|
|
print(message, flush=True)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
def owner(service):
|
|
return call(
|
|
"org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
|
"GetConnectionUnixProcessID", GLib.Variant("(s)", (service,)),
|
|
)[0]
|
|
|
|
def click_menu(service, path):
|
|
interface = "com.canonical.dbusmenu"
|
|
for attempt in range(2):
|
|
try:
|
|
pending_menus, count = [0], 0
|
|
while pending_menus:
|
|
parent = pending_menus.pop()
|
|
call(service, path, interface, "AboutToShow", GLib.Variant("(i)", (parent,)))
|
|
if parent:
|
|
call(service, path, interface, "Event", GLib.Variant(
|
|
"(isvu)", (parent, "opened", GLib.Variant("i", 0), 0),
|
|
))
|
|
_, layout = call(service, path, interface, "GetLayout", GLib.Variant(
|
|
"(iias)", (parent, 1, ["label", "enabled", "visible", "children-display"]),
|
|
))
|
|
for item_id, properties, children in layout[2]:
|
|
count += 1
|
|
if count > 200:
|
|
raise RuntimeError("Native menu exceeded 200 entries")
|
|
if properties.get("label", "").replace("_", "") == label:
|
|
if not properties.get("enabled", True) or not properties.get("visible", True):
|
|
raise RuntimeError(f"{label} menu item is unavailable")
|
|
if owner(service) != app.pid:
|
|
raise RuntimeError(f"{label} menu owner changed")
|
|
call(service, path, interface, "Event", GLib.Variant(
|
|
"(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0),
|
|
))
|
|
return True
|
|
if children or properties.get("children-display") == "submenu":
|
|
pending_menus.append(item_id)
|
|
return False
|
|
except GLib.Error as error:
|
|
# A replaced submenu can reject an ID before dispatching its
|
|
# event. Re-read once; never repeat an accepted click.
|
|
if attempt or Gio.DBusError.get_remote_error(error) != (
|
|
"org.gtk.GDBus.UnmappedGError.Quark._LIBDBUSMENU_2dGLIB.Code0"
|
|
):
|
|
raise
|
|
|
|
names = call(
|
|
"org.freedesktop.DBus", "/org/freedesktop/DBus",
|
|
"org.freedesktop.DBus", "ListNames",
|
|
)[0]
|
|
if len(names) > 128:
|
|
raise RuntimeError("Private session bus exceeded the native menu lookup bound")
|
|
pending = [(name, "/") for name in names if name.startswith(":") and owner(name) == app.pid]
|
|
visited = set()
|
|
while pending:
|
|
service, path = pending.pop()
|
|
if (service, path) in visited:
|
|
continue
|
|
visited.add((service, path))
|
|
if len(visited) > 64:
|
|
raise RuntimeError("Native menu object tree exceeded 64 paths")
|
|
try:
|
|
xml = call(service, path, "org.freedesktop.DBus.Introspectable", "Introspect")[0]
|
|
except GLib.Error as error:
|
|
# Client-only connections, including Secret Service clients, may
|
|
# not export an object tree. They are not native menu owners.
|
|
if Gio.DBusError.get_remote_error(error) in (
|
|
"org.freedesktop.DBus.Error.UnknownMethod",
|
|
"org.freedesktop.DBus.Error.UnknownObject",
|
|
) or (path == "/" and error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.TIMED_OUT)):
|
|
continue
|
|
raise
|
|
if len(xml.encode()) > 65536:
|
|
raise RuntimeError("Native menu introspection exceeded its byte bound")
|
|
node = ElementTree.fromstring(xml)
|
|
if any(item.get("name") == "com.canonical.dbusmenu" for item in node.findall("interface")):
|
|
if click_menu(service, path):
|
|
return
|
|
pending.extend((service, path.rstrip("/") + "/" + child.attrib["name"])
|
|
for child in node.findall("node"))
|
|
raise RuntimeError(f"Task app did not export the {label} menu")
|
|
|
|
def exercise(self, app, binary, wait, Atspi):
|
|
self.wait_for("click-ready", app)
|
|
|
|
def pointer_click(label):
|
|
# WebKit 2.52 exposes "button"; older supported WebKit uses "push button".
|
|
button = wait(label, ("button", "push button"))
|
|
component = button.get_component_iface()
|
|
component.scroll_to(Atspi.ScrollType.ANYWHERE)
|
|
rect = component.get_extents(Atspi.CoordType.SCREEN)
|
|
if rect.width <= 0 or rect.height <= 0:
|
|
raise RuntimeError("Native fixture button has no screen bounds")
|
|
print(f"Pointer click {label}: screen {rect.x},{rect.y} {rect.width}x{rect.height}", flush=True)
|
|
# Use XTest through xdotool: AT-SPI on Ubuntu 26 can report successful
|
|
# pointer synthesis without moving Xvfb's pointer. AT-SPI still owns
|
|
# target discovery; the fixture requires a trusted native click.
|
|
x, y = rect.x + rect.width // 2, rect.y + rect.height // 2
|
|
subprocess.run(
|
|
["/usr/bin/xdotool", "mousemove", "--sync", str(x), str(y), "click", "1"],
|
|
check=True, timeout=5,
|
|
)
|
|
|
|
pointer_click("Dashboard pointer control")
|
|
self.wait_for("dashboard-pointer-click", app)
|
|
pointer_click("Synthetic inspect target")
|
|
self.wait_for("dashboard-ready", app)
|
|
wait("Ready for Open Dashboard", "heading")
|
|
# Passive presentation keeps the current Gateway and its browser tabs.
|
|
subprocess.run(
|
|
[str(binary), "openclaw://dashboard"], check=True, timeout=15,
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
self.wait_for("explicit-reconnect-ready", app)
|
|
wait("Ready for explicit Gateway reconnect", "heading")
|
|
self.open_native_menu(app, "Connection Settings")
|
|
wait("Connection Settings", "heading")
|
|
entry = wait("Gateway URL", ("entry", "text"))
|
|
text = entry.get_text_iface()
|
|
if text is None or Atspi.Text.get_text(text, 0, -1) != f"ws://127.0.0.1:{self.server_port}/fixture/":
|
|
raise RuntimeError("Connection Settings did not retain the synthetic saved Gateway URL")
|
|
# Saving even the same URL is explicit replacement, unlike Open Dashboard.
|
|
pointer_click("Connect to Gateway")
|
|
self.wait_for("download-save-ready", app)
|
|
|
|
def filename_entry(node):
|
|
if not node.get_state_set().contains(Atspi.StateType.SHOWING):
|
|
return False
|
|
return any(
|
|
relation.get_relation_type() == Atspi.RelationType.LABELLED_BY
|
|
and any(relation.get_target(index).get_name().rstrip(":") == "Name"
|
|
for index in range(relation.get_n_targets()))
|
|
for relation in node.get_relation_set()
|
|
)
|
|
|
|
# GTK exposes its filename text field through LABELLED_BY rather than an
|
|
# accessible name. Select that relation, not an arbitrary empty text box.
|
|
name_entry = wait("", ("entry", "text"), prefix=True, predicate=filename_entry)
|
|
if self.artifacts_dir:
|
|
subprocess.run(
|
|
["/usr/bin/import", "-window", "root", str(self.artifacts_dir / "inline-browser-save-chooser.png")],
|
|
check=True, timeout=10,
|
|
)
|
|
destination = Path.home() / "inline-browser-download.html"
|
|
editable = name_entry.get_editable_text_iface()
|
|
if editable is None or not editable.set_text_contents(str(destination)):
|
|
raise RuntimeError("Could not fill the native Save filename")
|
|
pointer_click("Save")
|
|
self.wait_for("download-saved", app)
|
|
expected = CHILD.replace("__PAGE__", "reopened").replace("__PAGE_JSON__", json.dumps("reopened")).encode()
|
|
if destination.read_bytes() != expected:
|
|
raise RuntimeError("Native download bytes differ from the synthetic fixture")
|
|
self.download_bytes_match = True
|
|
self.wait_for("download-cancel-ready", app)
|
|
pointer_click("Cancel")
|
|
self.wait_for("download-cancelled", app)
|
|
if destination.read_bytes() != expected or list(Path.home().glob(".openclaw-download-*")):
|
|
raise RuntimeError("Cancelled native download changed the saved fixture or left staging files")
|
|
self.wait_for("complete", app)
|
|
wait("PASS: native inline browser and dashboard replacement", "heading")
|
|
if self.phase != 4:
|
|
raise RuntimeError("Expected initial dashboard, reload, passive presentation, and explicit replacement")
|
|
self.passed = True
|
|
print("PASS: native inline browser pointer input, history, snapshots, reload, passive presentation, explicit replacement and save/cancel", flush=True)
|
|
|
|
def close(self):
|
|
self.shutdown()
|
|
self.server_close()
|
|
self.server_thread.join(timeout=5)
|
|
if self.artifacts_dir:
|
|
(self.artifacts_dir / "inline-browser-results.json").write_text(json.dumps({
|
|
"passed": self.passed,
|
|
"download_bytes_match": self.download_bytes_match,
|
|
"dashboard_loads": self.phase, "steps": self.steps, "failure": self.failure,
|
|
}, indent=2) + "\n")
|
|
|
|
|
|
class FixtureHandler(BaseHTTPRequestHandler):
|
|
def setup(self):
|
|
super().setup()
|
|
self.connection.settimeout(5)
|
|
|
|
def log_message(self, _format, *_args):
|
|
pass
|
|
|
|
def reply(self, status, body=b"", content_type="text/plain"):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.headers.get("Upgrade"):
|
|
self.reply(501)
|
|
elif self.path == "/fixture/":
|
|
self.server.phase += 1
|
|
page = DASHBOARD.replace("__PHASE__", str(self.server.phase)).replace(
|
|
"__SESSION__", json.dumps(self.server.session),
|
|
).replace("__REFUSED_URL__", json.dumps(self.server.refused_url))
|
|
self.reply(200, page.encode(), "text/html; charset=utf-8")
|
|
elif self.path == "/browser/failed-initial":
|
|
loads = self.server.telemetry["loads"]
|
|
loads["failed-initial"] = loads.get("failed-initial", 0) + 1
|
|
self.send_response(302)
|
|
self.send_header("Location", "/browser/recovered-initial" if self.server.recover_initial else self.server.refused_url)
|
|
self.send_header("Content-Length", "0")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
elif self.path in ("/browser/one", "/browser/two", "/browser/reopened", "/browser/recovered-initial", "/browser/credential-url"):
|
|
name = self.path.rsplit("/", 1)[1]
|
|
loads = self.server.telemetry["loads"]
|
|
loads[name] = loads.get(name, 0) + 1
|
|
page = CHILD.replace("__PAGE__", name).replace("__PAGE_JSON__", json.dumps(name))
|
|
self.reply(200, page.encode(), "text/html; charset=utf-8")
|
|
elif self.path == "/fixture/telemetry":
|
|
self.reply(200, json.dumps(self.server.telemetry).encode(), "application/json")
|
|
elif self.path == "/favicon.ico":
|
|
self.reply(204)
|
|
else:
|
|
self.reply(404)
|
|
|
|
def do_POST(self):
|
|
if self.path not in ("/fixture/child", "/fixture/report", "/fixture/recover-initial"):
|
|
self.reply(404)
|
|
return
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if not 0 < length <= 8 * 1024 * 1024:
|
|
self.reply(400)
|
|
return
|
|
payload = json.loads(self.rfile.read(length))
|
|
if self.path == "/fixture/recover-initial":
|
|
self.server.recover_initial = True
|
|
elif self.path == "/fixture/child":
|
|
name = payload.get("page")
|
|
if name not in ("one", "two", "reopened", "recovered-initial", "credential-url"):
|
|
self.reply(400)
|
|
return
|
|
self.server.telemetry[name] = payload
|
|
else:
|
|
step = payload.get("step")
|
|
if "dataUrl" in payload:
|
|
try:
|
|
prefix = "data:image/png;base64,"
|
|
assert payload["dataUrl"].startswith(prefix)
|
|
png = base64.b64decode(payload["dataUrl"][len(prefix):], validate=True)
|
|
assert png[:8] == b"\x89PNG\r\n\x1a\n" and png[12:16] == b"IHDR"
|
|
width, height = struct.unpack(">II", png[16:24])
|
|
assert width > 0 and height > 0 and b"IEND" in png[-12:]
|
|
assert step in ("initial-snapshot", "dashboard-reload-snapshot", "replacement-reopened-snapshot")
|
|
if self.server.artifacts_dir:
|
|
(self.server.artifacts_dir / f"inline-browser-{step}.png").write_bytes(png)
|
|
except (AssertionError, ValueError, struct.error):
|
|
self.server.failure = "Native snapshot was not a valid PNG"
|
|
self.server.signals["failure"].set()
|
|
self.reply(400)
|
|
return
|
|
self.server.steps.append({"phase": payload.get("phase"), "step": step})
|
|
if step == "failure":
|
|
self.server.failure = str(payload.get("error", "Unknown fixture failure")) + " " + json.dumps(payload.get("readiness", {}))
|
|
if step in self.server.signals:
|
|
self.server.signals[step].set()
|
|
print(f"Inline browser fixture: phase {payload.get('phase')} {step}", flush=True)
|
|
self.reply(200, b"{}", "application/json")
|