303 lines
15 KiB
Python
303 lines
15 KiB
Python
"""Offline owned-CDP startup regressions; never open a browser or serial port."""
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
HIL = Path(__file__).resolve().parents[1] / "tools/hil"
|
|
sys.path.insert(0, str(HIL))
|
|
spec = importlib.util.spec_from_file_location("official_acceptance", HIL / "official_console_acceptance.py")
|
|
acceptance = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(acceptance)
|
|
|
|
|
|
class OwnedStartupTests(unittest.TestCase):
|
|
def fixture(self, *, fail_first_page=False, wrong_profile=False, pages=1):
|
|
requests, sockets, connections = [], [], []
|
|
fail = [fail_first_page]
|
|
profile = Path("/private/owned-profile")
|
|
arguments = ["--user-data-dir=" + str(profile), "--remote-debugging-port=12345"]
|
|
if wrong_profile:
|
|
arguments[0] = "--user-data-dir=/private/another-browser"
|
|
|
|
class Socket:
|
|
def __init__(self, browser):
|
|
self.browser, self.closed, self.queue = browser, False, []
|
|
sockets.append(self)
|
|
|
|
def send(self, text):
|
|
request = json.loads(text)
|
|
if self.browser:
|
|
# Browser events must not be mistaken for the command reply.
|
|
self.queue.extend([{"method": "Target.targetCreated"},
|
|
{"id": request["id"], "result": {"arguments": arguments}}])
|
|
else:
|
|
if fail[0]:
|
|
fail[0] = False
|
|
raise OSError("renderer connection interrupted")
|
|
self.queue.append({"id": request["id"], "result": {}})
|
|
|
|
def recv(self):
|
|
return json.dumps(self.queue.pop(0))
|
|
|
|
def settimeout(self, timeout):
|
|
self.timeout = timeout
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
def connect(url, **kwargs):
|
|
connections.append((url, kwargs))
|
|
return Socket("/browser/" in url)
|
|
|
|
class Opener:
|
|
def open(self, request, timeout):
|
|
requests.append(request)
|
|
if request.endswith("/json/version"):
|
|
value = {"webSocketDebuggerUrl": "ws://127.0.0.1:12345/browser/owned", "Browser": "test Chromium"}
|
|
elif request.endswith("/json/list"):
|
|
value = [{"type": "page", "url": "about:blank",
|
|
"webSocketDebuggerUrl": f"ws://127.0.0.1:12345/page/{i}"} for i in range(pages)]
|
|
else:
|
|
raise AssertionError("Startup must reuse its initial page, never PUT /json/new")
|
|
return io.StringIO(json.dumps(value))
|
|
|
|
websocket = SimpleNamespace(create_connection=connect,
|
|
WebSocketTimeoutException=type("SocketTimeout", (Exception,), {}),
|
|
WebSocketConnectionClosedException=type("SocketClosed", (Exception,), {}))
|
|
return profile, websocket, Opener(), requests, sockets, connections
|
|
|
|
def test_reuses_initial_page_and_matches_owned_reply_after_event(self):
|
|
profile, websocket, opener, requests, sockets, connections = self.fixture()
|
|
with patch.object(acceptance.urllib.request, "build_opener", return_value=opener):
|
|
page = acceptance.CDP(websocket, 12345, profile)
|
|
self.assertEqual(requests, ["http://127.0.0.1:12345/json/version", "http://127.0.0.1:12345/json/list"])
|
|
self.assertEqual(page.browser_version, "test Chromium")
|
|
self.assertTrue(sockets[0].closed)
|
|
self.assertFalse(sockets[1].closed)
|
|
for _, kwargs in connections:
|
|
self.assertEqual(kwargs["http_no_proxy"], ["127.0.0.1", "localhost"])
|
|
page.socket.close()
|
|
|
|
def test_partial_page_socket_is_closed_and_retry_creates_no_new_target(self):
|
|
profile, websocket, opener, requests, sockets, _ = self.fixture(fail_first_page=True)
|
|
browser = SimpleNamespace(poll=lambda: None)
|
|
report = {}
|
|
with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \
|
|
patch.object(acceptance.time, "sleep"):
|
|
page = acceptance.wait_for_owned_browser(websocket, 12345, profile, browser, report)
|
|
self.assertEqual(report["browser_startup"]["attempts"], 2)
|
|
self.assertEqual(report["browser_startup"]["last_phase"], "initial_page_enable")
|
|
self.assertTrue(sockets[0].closed and sockets[1].closed and sockets[2].closed)
|
|
self.assertFalse(sockets[3].closed)
|
|
self.assertEqual(sum(request.endswith("/json/list") for request in requests), 2)
|
|
self.assertTrue(all(isinstance(request, str) for request in requests))
|
|
page.socket.close()
|
|
|
|
def test_another_owned_profile_is_rejected_before_page_attach(self):
|
|
profile, websocket, opener, requests, sockets, _ = self.fixture(wrong_profile=True)
|
|
with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \
|
|
self.assertRaisesRegex(RuntimeError, "does_not_match_owned_profile"):
|
|
acceptance.CDP(websocket, 12345, profile)
|
|
self.assertEqual(len(requests), 1)
|
|
self.assertTrue(sockets[0].closed)
|
|
|
|
def test_extra_pages_are_rejected_without_adding_another_page(self):
|
|
profile, websocket, opener, _, sockets, _ = self.fixture(pages=2)
|
|
with patch.object(acceptance.urllib.request, "build_opener", return_value=opener), \
|
|
self.assertRaisesRegex(RuntimeError, "one_initial_blank_page"):
|
|
acceptance.CDP(websocket, 12345, profile)
|
|
self.assertEqual(len(sockets), 1)
|
|
self.assertTrue(sockets[0].closed)
|
|
|
|
def test_startup_deadline_preserves_phase_without_arbitrary_exception_text(self):
|
|
_, websocket, _, _, _, _ = self.fixture()
|
|
error = OSError("private host details")
|
|
error.startup_phase = "version_endpoint"
|
|
report = {}
|
|
with patch.object(acceptance, "CDP", side_effect=error), \
|
|
patch.object(acceptance.time, "monotonic", side_effect=[0, 0, .001, .02]), \
|
|
patch.object(acceptance.time, "sleep"), \
|
|
self.assertRaisesRegex(RuntimeError, "owned_Chromium_startup_deadline"):
|
|
acceptance.wait_for_owned_browser(websocket, 12345, Path("/owned"),
|
|
SimpleNamespace(poll=lambda: None), report, timeout=.01)
|
|
self.assertEqual(report["browser_startup"]["attempts"], 2)
|
|
self.assertEqual(report["browser_startup"]["last_phase"], "version_endpoint")
|
|
self.assertNotIn("private host details", json.dumps(report))
|
|
|
|
|
|
class OwnedCleanupTests(unittest.TestCase):
|
|
def test_termination_unwinds_and_restores_original_handler(self):
|
|
previous = signal.getsignal(signal.SIGTERM)
|
|
unwound = []
|
|
with self.assertRaises(acceptance.NativeConsoleTerminated):
|
|
with acceptance.cleanup_on_sigterm():
|
|
try:
|
|
signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None)
|
|
finally:
|
|
unwound.append(True)
|
|
self.assertEqual(signal.getsignal(signal.SIGTERM), signal.SIG_IGN)
|
|
self.assertEqual(unwound, [True])
|
|
self.assertIs(signal.getsignal(signal.SIGTERM), previous)
|
|
|
|
def test_normal_exit_restores_original_handler(self):
|
|
previous = signal.getsignal(signal.SIGTERM)
|
|
with acceptance.cleanup_on_sigterm():
|
|
self.assertTrue(callable(signal.getsignal(signal.SIGTERM)))
|
|
self.assertIs(signal.getsignal(signal.SIGTERM), previous)
|
|
|
|
def test_socket_and_stream_failure_still_closes_only_owned_browser(self):
|
|
from unittest.mock import Mock
|
|
page = SimpleNamespace(evaluate=Mock(side_effect=RuntimeError("private stream details")),
|
|
socket=SimpleNamespace(close=Mock(side_effect=OSError("private socket details"))))
|
|
browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, 0]), wait=Mock())
|
|
report = {}
|
|
with patch.object(acceptance.os, "killpg") as kill:
|
|
acceptance.close_owned_browser(page, browser, report)
|
|
kill.assert_called_once_with(123456, signal.SIGTERM)
|
|
browser.wait.assert_called_once_with(timeout=10)
|
|
self.assertEqual(report, {"browser_stream_cleanup_error": True,
|
|
"browser_socket_cleanup_error": True, "owned_browser_closed": True})
|
|
self.assertNotIn("private", json.dumps(report))
|
|
|
|
def test_interrupted_stream_cleanup_still_closes_owned_browser(self):
|
|
from unittest.mock import Mock
|
|
page = SimpleNamespace(evaluate=Mock(side_effect=acceptance.NativeConsoleTerminated()),
|
|
socket=SimpleNamespace(close=Mock()))
|
|
browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, 0]), wait=Mock())
|
|
with patch.object(acceptance.os, "killpg") as kill, \
|
|
self.assertRaises(acceptance.NativeConsoleTerminated):
|
|
acceptance.close_owned_browser(page, browser, {})
|
|
page.socket.close.assert_called_once_with()
|
|
kill.assert_called_once_with(123456, signal.SIGTERM)
|
|
|
|
def test_unresponsive_owned_browser_uses_bounded_kill_fallback(self):
|
|
from unittest.mock import Mock, call
|
|
browser = SimpleNamespace(pid=123456, poll=Mock(side_effect=[None, -9]),
|
|
wait=Mock(side_effect=[subprocess.TimeoutExpired("owned", 10), None]))
|
|
report = {}
|
|
with patch.object(acceptance.os, "killpg") as kill:
|
|
acceptance.close_owned_browser(None, browser, report)
|
|
self.assertEqual(kill.call_args_list, [call(123456, signal.SIGTERM), call(123456, signal.SIGKILL)])
|
|
self.assertEqual(browser.wait.call_args_list, [call(timeout=10), call(timeout=5)])
|
|
self.assertTrue(report["owned_browser_closed"])
|
|
|
|
def test_termination_produces_private_failure_report_and_143(self):
|
|
def interrupted(_args, report):
|
|
try:
|
|
signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None)
|
|
finally:
|
|
report["mock_owned_resources_unwound"] = True
|
|
|
|
previous = signal.getsignal(signal.SIGTERM)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
work.chmod(0o700)
|
|
output = work / "result.json"
|
|
with patch.object(acceptance, "execute", side_effect=interrupted), \
|
|
patch("sys.stdout", new=io.StringIO()):
|
|
status = acceptance.main(["--serial", "exact", "--expected-board", "board",
|
|
"--expected-version", "version", "--logging", "off", "--console-source", "/unused",
|
|
"--artifact-manifest", "/unused", "--output", str(output),
|
|
"--work-dir", str(work), "--execute"])
|
|
report = json.loads(output.read_text())
|
|
self.assertEqual(status, 143)
|
|
self.assertFalse(report["ok"])
|
|
self.assertEqual(report["termination_signal"], "SIGTERM")
|
|
self.assertTrue(report["mock_owned_resources_unwound"])
|
|
self.assertEqual(output.stat().st_mode & 0o777, 0o600)
|
|
self.assertIs(signal.getsignal(signal.SIGTERM), previous)
|
|
|
|
|
|
class SafeJavaScriptFailureTests(unittest.TestCase):
|
|
def evaluate_error(self, details):
|
|
page = object.__new__(acceptance.CDP)
|
|
page.call = lambda *args, **kwargs: {"exceptionDetails": details}
|
|
with self.assertRaises(acceptance.NativeConsoleJavaScriptError) as caught:
|
|
page.evaluate("hil.query('board')")
|
|
return caught.exception
|
|
|
|
def test_page_literal_error_is_reported_without_stack_or_description(self):
|
|
error = self.evaluate_error({"text": "Uncaught (in promise)", "exception": {
|
|
"className": "Error", "description": "Error: framed_reply_deadline\n at private-site/secret"}})
|
|
self.assertEqual(error.safe_details, {"class": "Error", "code": "framed_reply_deadline"})
|
|
self.assertEqual(str(error), "native_console_javascript_failed:framed_reply_deadline")
|
|
self.assertNotIn("secret", json.dumps(error.safe_details))
|
|
|
|
def test_dom_exception_name_is_safe_but_native_message_is_hashed(self):
|
|
message = "NetworkError: private serial endpoint secret"
|
|
error = self.evaluate_error({"exception": {"className": "DOMException", "description": message}})
|
|
self.assertEqual(error.safe_details["class"], "DOMException")
|
|
self.assertEqual(error.safe_details["dom_exception_name"], "NetworkError")
|
|
self.assertEqual(error.safe_details["message_sha256"], acceptance.hashlib.sha256(message.encode()).hexdigest())
|
|
self.assertNotIn("secret", str(error) + json.dumps(error.safe_details))
|
|
|
|
def test_unknown_class_and_known_code_prefix_do_not_expose_arbitrary_text(self):
|
|
message = "Error: framed_reply_deadline secret-password"
|
|
error = self.evaluate_error({"exception": {"className": "private-class-secret", "description": message}})
|
|
self.assertEqual(error.safe_details["class"], "unrecognized")
|
|
self.assertEqual(error.safe_details["code"], "unclassified_exception")
|
|
self.assertEqual(error.safe_details["message_sha256"], acceptance.hashlib.sha256(message.encode()).hexdigest())
|
|
self.assertNotIn("secret", str(error) + json.dumps(error.safe_details))
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "Node required for offline PAGE protocol test")
|
|
def test_page_accepts_exact_prompt_echo_and_requires_complete_response(self):
|
|
# Execute the actual PAGE module body with an inert SDK-shaped class.
|
|
# navigator.serial returns a plain object; no browser/device is opened.
|
|
script = acceptance.PAGE.split('<script type="module">', 1)[1].split('</script>', 1)[0]
|
|
self.assertIn("import {SerialConsole} from './console.js';", script)
|
|
script = script.replace("import {SerialConsole} from './console.js';", "")
|
|
setup = r'''
|
|
import assert from 'node:assert/strict';
|
|
globalThis.window=globalThis;
|
|
Object.defineProperty(globalThis,'navigator',{value:{serial:{getPorts:async()=>[
|
|
{getInfo:()=>({usbVendorId:0x303a,usbProductId:0x1001})}
|
|
]}}});
|
|
const realTimeout=globalThis.setTimeout;
|
|
// Only test deadlines are shortened; idle remains a no-transmit timer.
|
|
globalThis.setTimeout=(fn,ms)=>realTimeout(fn,ms===10000?5:ms);
|
|
let echoPrefix='> ', complete=true;
|
|
class SerialConsole {
|
|
constructor(port){this.port=port;this.connected=false;}
|
|
async connect(){this.connected=true;}
|
|
async disconnect(){this.connected=false;}
|
|
async reset(){throw Error('reset_must_never_be_called');}
|
|
async sendCommand(command){
|
|
this.onOutput(echoPrefix+command+'\n');
|
|
this.onOutput(' -> bench reply'+(complete?'\n':''));
|
|
}
|
|
}
|
|
'''
|
|
checks = r'''
|
|
await hil.open(0x303a,0x1001);
|
|
for(const prefix of ['> ','>','']){
|
|
echoPrefix=prefix;
|
|
const result=await hil.query('board');
|
|
assert.equal(result.body,'bench reply');
|
|
assert.equal(result.complete_CRLF_reply,true);
|
|
}
|
|
const writes=hil.writes;
|
|
await hil.idle(0);
|
|
assert.equal(hil.writes,writes);
|
|
echoPrefix='> stale ';
|
|
await assert.rejects(hil.query('board'),/framed_reply_deadline/);
|
|
echoPrefix='> ';complete=false;
|
|
await assert.rejects(hil.query('board'),/framed_reply_deadline/);
|
|
await hil.close();
|
|
'''
|
|
ran = subprocess.run([shutil.which("node"), "--input-type=module", "-"],
|
|
input=setup + script + checks, capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(ran.returncode, 0, ran.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|