diff --git a/docs/agents.md b/docs/agents.md index f01d315..1d5e6c0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -150,4 +150,10 @@ launches of user games, power operations or keep-awake changes were performed. binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked -because the Docker daemon was unavailable. CI runs those regressions. +because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed +on this branch (run 36421345682). + +**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated +browser profile and SSH tunnel and remove the profile and panel log. + +![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png) diff --git a/docs/img/assistant-panel.png b/docs/img/assistant-panel.png new file mode 100644 index 0000000..514a1d5 Binary files /dev/null and b/docs/img/assistant-panel.png differ diff --git a/scripts/assistant-on-frame.py b/scripts/assistant-on-frame.py index 7c8b59f..64e88ba 100644 --- a/scripts/assistant-on-frame.py +++ b/scripts/assistant-on-frame.py @@ -9,10 +9,10 @@ import os from pathlib import Path import re import shlex +import signal import shutil import subprocess import sys -import time import uuid ROOT = Path(__file__).resolve().parent.parent @@ -32,6 +32,8 @@ def main(): from frame_mcp import Client Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host') profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex + log_path = '' + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=2', '-R', @@ -44,10 +46,14 @@ def main(): stdout=subprocess.DEVNULL, timeout=30) if probe.returncode or tunnel.poll() is not None: raise RuntimeError('Could not forward Frame Control to the Frame') - subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant', + launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant', 'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run', '--disable-background-networking', '--disable-sync', - f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45) + f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True) + print(launched.stdout, end='', flush=True) + match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout) + if match: + log_path = match.group(1) print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True) tunnel.wait() raise RuntimeError('SSH tunnel ended') @@ -77,9 +83,11 @@ for sig in (signal.SIGTERM, signal.SIGKILL): except ProcessLookupError: pass time.sleep(.3) shutil.rmtree(profile, ignore_errors=True) +if sys.argv[2]: + pathlib.Path(sys.argv[2]).unlink(missing_ok=True) ''' result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias, - 'python3 - ' + shlex.quote(profile)], input=cleanup, text=True, timeout=20) + 'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20) if result.returncode: print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr) diff --git a/tests/assistant_ui.cjs b/tests/assistant_ui.cjs new file mode 100644 index 0000000..6848ba3 --- /dev/null +++ b/tests/assistant_ui.cjs @@ -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(/