from __future__ import annotations import base64 import io import json import socket import threading import unittest from collections import deque from contextlib import contextmanager, nullcontext from importlib import import_module from types import SimpleNamespace from typing import Any, cast from unittest.mock import Mock, patch import websocket from .platform.douyin import ( BrowserResponse, CDPConnection, DouyinBrowser, DouyinError, DouyinSubscription, SubscriptionManager, ack_expression, action_expression, detect_challenge, im_expression, install_expression, is_douyin_content_url, is_douyin_share_url, is_douyin_url, normalize_notice, notice_ids, wait_expression, ) from .proxy import ( MemoryProxy, ProxyExit, ProxyRegistry, _copy_until_close, _dial_http_proxy, _dial_socks4, _dial_socks5, _is_ipv4, _parse_request, _read_request, _read_status, _recv_exact, ) gateway_module = import_module(f"{__package__}.server.http") douyin_module = import_module(f"{__package__}.platform.douyin") proxy_module = import_module(f"{__package__}.proxy") Gateway = gateway_module.Gateway RequestError = gateway_module.RequestError decode_generation = gateway_module.decode_generation json_bytes = gateway_module.json_bytes load_config = gateway_module.load_config split_listen_address = gateway_module.split_listen_address valid_douyin_url = gateway_module.valid_douyin_url valid_douyin_source_url = gateway_module.valid_douyin_source_url validate_create = gateway_module.validate_create parse_proxy_exit = gateway_module.parse_proxy_exit validate_proxy_exit = gateway_module.validate_proxy_exit validate_proxy_restore = gateway_module.validate_proxy_restore valid_douyin_generation = gateway_module.valid_douyin_generation valid_account_key_query = gateway_module.valid_account_key_query numeric_cursor = gateway_module.numeric_cursor proxy_port = gateway_module.proxy_port has_control = gateway_module.has_control class FakeSocket: def __init__(self, messages: list[object]) -> None: self.messages = list(messages) self.sent: list[bytes] = [] self.timeout = 0.0 def send(self, data: bytes) -> None: self.sent.append(data) def recv(self) -> str: if not self.messages: raise TimeoutError("no more messages") return json.dumps(self.messages.pop(0)) def settimeout(self, value: float) -> None: self.timeout = value def close(self) -> None: return None class FakeConnection: def __init__(self, values: list[object]) -> None: self.values = list(values) def evaluate(self, expression: str) -> object: del expression if not self.values: raise DouyinError("no fake response") return self.values.pop(0) class GatewayValidationTests(unittest.TestCase): def test_create_and_generation_validation(self) -> None: value = { "alias": "safe-account", "name": "Safe account", "profile_id": "safe-account", "cmd": ["about:blank"], "binding_version": 1, "network_exit_id": "", "network_exit": {}, "stopped": True, } validate_create(value) self.assertEqual(value["network_exit"], ProxyExit("", "", 0)) self.assertEqual(value["network_exit_id"], "") self.assertEqual( decode_generation( { "binding_version": 1, "runtime_id": "runtime-not-found", "network_id": "native-" + "a" * 32, }, False, False, )["runtime_id"], "runtime-not-found", ) with self.assertRaises(RequestError): validate_create( {**value, "cmd": ["--proxy-server=http://x", "about:blank"]} ) with self.assertRaises(RequestError): decode_generation( {"binding_version": 1, "runtime_id": 4, "network_id": "n"}, True, True ) def test_config_and_urls(self) -> None: self.assertEqual(split_listen_address(":8081"), ("", 8081)) self.assertEqual(split_listen_address("[::1]:8081"), ("::1", 8081)) with self.assertRaises(ValueError): split_listen_address("missing-port") config = load_config( { "LISTEN_ADDR": ":8081", "BROWSER_PATH": "/bin/true", "GATEWAY_TOKEN": "0123456789abcdef", } ) self.assertEqual(config["listen"], ("", 8081)) self.assertEqual(config["browser_path"], "/bin/true") self.assertEqual(config["ready_timeout"], 60.0) self.assertIsNone(config["external_display"]) external = load_config( { "BROWSER_PATH": "/bin/true", "GATEWAY_TOKEN": "0123456789abcdef", "RUNTIME_EXTERNAL_DISPLAY": "99", } ) self.assertEqual(external["external_display"], 99) with self.assertRaises(ValueError): load_config( { "GATEWAY_TOKEN": "0123456789abcdef", "BROWSER_PATH": "/not/a/browser", } ) self.assertTrue( valid_douyin_url( "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp" ) ) self.assertTrue( valid_douyin_url( "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913" ) ) self.assertTrue( valid_douyin_url( "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK" ) ) self.assertFalse( valid_douyin_url( "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20" ) ) self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/")) self.assertTrue(is_douyin_url("https://www.douyin.com/video/123")) self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123")) self.assertTrue(is_douyin_content_url("https://www.douyin.com/video/123")) self.assertTrue(is_douyin_share_url("https://v.douyin.com/abc123/")) self.assertTrue(valid_douyin_source_url("https://v.douyin.com/abc123/")) self.assertFalse(is_douyin_share_url("https://v.douyin.com/abc123/#bad")) self.assertFalse(is_douyin_share_url("https://www.douyin.com/user/123")) self.assertTrue( valid_douyin_url( "https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&device_platform=webapp&aweme_id=123" ) ) def test_http_routes_and_body_validation(self) -> None: handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler) gateway = Mock() gateway.list_browsers.return_value = [] gateway.douyin_identity.return_value = {"uid": "123"} gateway.douyin_login_qr.return_value = { "content_type": "image/png", "body_base64": "cG5n", "qr_detected": True, } gateway.douyin_action.return_value = {"status": "succeeded"} gateway.douyin_message_history.return_value = {"status": "succeeded"} gateway.poll_douyin_events.return_value = [] server = Mock() server.gateway = gateway cast(Any, handler).server = server cast(Any, handler).server_as_gateway = lambda: server self.assertEqual(handler._route("GET", "/v1/browsers", {}, {}), []) self.assertEqual( handler._route("POST", "/v1/browsers", {}, {}), (201, gateway.create.return_value), ) handler._route( "DELETE", "/v1/browsers/safe", {}, {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""}, ) gateway.remove.assert_called_once_with( "safe", {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""} ) handler._route("POST", "/v1/browsers/safe/start", {}, {}) handler._route("POST", "/v1/browsers/safe/stop", {}, {}) handler._route("POST", "/v1/browsers/safe/proxy", {}, {}) with self.assertRaises(RequestError): handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {}) self.assertEqual( handler._route("POST", "/v1/browsers/safe/douyin/login-qr", {}, {}), gateway.douyin_login_qr.return_value, ) gateway.douyin_login_qr.assert_called_once_with("safe", {}) handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {}) self.assertEqual( handler._route("POST", "/v1/browsers/safe/douyin/messages", {}, {}), {"status": "succeeded"}, ) gateway.douyin_message_history.assert_called_once_with("safe", {}) self.assertEqual( handler._route("GET", "/v1/browsers/safe/douyin/events", {}, {}), [] ) handler._route("POST", "/v1/browsers/safe/douyin/events", {}, {}) handler._route("DELETE", "/v1/browsers/safe/douyin/events", {}, {}) with self.assertRaises(RequestError): handler._route("GET", "/v1/unknown", {}, {}) cast(Any, handler).headers = {"Content-Length": "7"} cast(Any, handler).rfile = io.BytesIO(b'{"x":1}') self.assertEqual(handler._body(), {"x": 1}) cast(Any, handler).headers = {} with self.assertRaises(RequestError): handler._body() test_token = "x" * 16 cast(Any, handler).headers = {"Authorization": f"Bearer {test_token}"} server.gateway.token = test_token self.assertTrue(handler._authorized()) handler._respond = Mock() handler._handle_exception("/v1", RequestError("bad", 400)) handler._handle_exception("/v1", ValueError("bad")) self.assertEqual(handler._respond.call_count, 2) def test_get_event_route_reads_generation_body(self) -> None: handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler) cast(Any, handler).path = "/v1/browsers/safe/douyin/events?wait=1" cast(Any, handler).headers = {"Content-Length": "67"} cast(Any, handler).rfile = io.BytesIO( b'{"binding_version":1,"runtime_id":"runtime","network_id":"network"}' ) cast(Any, handler)._authorized = lambda: True cast(Any, handler)._route = Mock(return_value=[]) cast(Any, handler)._respond = Mock() handler._dispatch("GET") cast(Any, handler)._route.assert_called_once_with( "GET", "/v1/browsers/safe/douyin/events", {"wait": ["1"]}, {"binding_version": 1, "runtime_id": "runtime", "network_id": "network"}, ) def test_validation_boundaries(self) -> None: self.assertEqual(proxy_port("http://127.0.0.1:1234"), 1234) self.assertTrue(has_control("bad\nvalue")) exit_value = parse_proxy_exit( {"protocol": "http", "host": "proxy", "port": 8080} ) validate_proxy_exit(exit_value) with self.assertRaises(RequestError): validate_proxy_exit( parse_proxy_exit({"protocol": "ftp", "host": "proxy", "port": 21}) ) with self.assertRaises(RequestError): validate_proxy_exit(ProxyExit("http", "proxy", 0)) generation = { "binding_version": 1, "runtime_id": "a" * 64, "network_id": "native-" + "b" * 32, "network_exit_id": "exit", } self.assertTrue(valid_douyin_generation(generation)) self.assertFalse( valid_douyin_generation({**generation, "binding_version": True}) ) self.assertTrue(valid_account_key_query({"account": ["account"]}, "account")) self.assertFalse(valid_account_key_query({"account": ["bad key"]}, "account")) self.assertTrue(numeric_cursor(["0"])) self.assertFalse(numeric_cursor(["-1"])) restore = { "binding_version": 1, "runtime_id": "a" * 64, "network_id": "native-" + "b" * 32, "network_exit_id": "exit", "network_exit": {"protocol": "http", "host": "proxy", "port": 8080}, } validate_proxy_restore(restore, "safe") with self.assertRaises(RequestError): validate_proxy_restore({**restore, "network_exit_id": ""}, "safe") with self.assertRaises(RequestError): validate_proxy_restore({"network_exit_id": ""}, "safe") with self.assertRaises(RequestError): validate_create({"alias": "safe", "unknown": True}) with self.assertRaises(ValueError): load_config({"GATEWAY_TOKEN": "short"}) with self.assertRaises(ValueError): load_config( { "BROWSER_PATH": "/bin/true", "GATEWAY_TOKEN": "0123456789abcdef", "RUNTIME_EXTERNAL_DISPLAY": "0", } ) self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1")) self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown")) with self.assertRaises(RequestError): decode_generation( {"binding_version": 1, "runtime_id": "a" * 64}, True, True ) class GatewayBusinessMethodTests(unittest.TestCase): def setUp(self) -> None: self.runtime_id = "a" * 64 self.network_id = "native-" + "b" * 32 self.generation = { "binding_version": 1, "runtime_id": self.runtime_id, "network_id": self.network_id, "network_exit_id": "exit-1", } self.runtimes = Mock() self.runtimes.proxies = Mock() self.runtimes.browser_path = "/bin/true" self.runtimes.alias_lock.return_value = nullcontext() self.runtimes.require_generation.return_value = SimpleNamespace( runtime_id=self.runtime_id, network_exit_id="exit-1" ) self.runtimes.endpoint.return_value = "http://127.0.0.1:19001" self.runtimes.list_public.return_value = [] self.browser = Mock() self.browser.get.return_value = Mock(status=200, body="body", challenge="") self.browser.resolve.return_value = "https://www.douyin.com/video/123" self.browser.get_media.return_value = Mock( status=200, content_type="video/mp4", body_base64="dm" ) self.browser.identity.return_value = { "uid": "12345678901234567890", "sec_uid": "sec", "unique_id": "name", } self.browser.login_qr.return_value = Mock( content_type="image/png", body_base64="cG5n", qr_detected=True ) self.browser.message_history.return_value = {"status": "succeeded"} self.browser.action.return_value = {"status": "succeeded"} self.browser.action_ownership.return_value = None self.xhs_browser = Mock() self.xhs_browser.get.return_value = Mock(status=200, body="body", challenge="") self.xhs_browser.post.return_value = Mock(status=200, body="body", challenge="") self.xhs_browser.resolve.return_value = "https://www.xiaohongshu.com/explore/abc" self.xhs_browser.get_media.return_value = Mock( status=200, content_type="video/mp4", body_base64="dm" ) self.xhs_browser.identity.return_value = {"uid": "xhs-user"} self.gateway = Gateway( self.runtimes, "gateway-token-123456", "node-a", browser=self.browser, xiaohongshu_browser=self.xhs_browser, ) self.gateway.subscriptions = Mock() self.gateway.subscriptions.start.return_value = {"status": "started"} self.gateway.subscriptions.poll.return_value = [] def test_list_browsers_excludes_released_journal_rows(self) -> None: self.runtimes.list_public.return_value = [ {"alias": "same", "state": "released"}, {"alias": "same", "state": "stopped"}, ] self.assertEqual(self.gateway.list_browsers(), [{"alias": "same", "state": "stopped"}]) def test_lifecycle_info_and_douyin_operations(self) -> None: self.runtimes.create.return_value = {"state": "running"} self.assertEqual(self.gateway.info()["node_id"], "node-a") self.assertEqual(self.gateway.list_browsers(), []) self.assertEqual(self.gateway.create({"alias": "safe"}), {"state": "running"}) runtime_generation = {key: self.generation[key] for key in ("binding_version", "runtime_id", "network_id")} self.gateway.change_state("safe", "stop", runtime_generation) self.gateway.remove("safe", {**runtime_generation, "purge_profile": True}) self.gateway.restore_proxy( "safe", {**self.generation, "network_exit": {"protocol": "http", "host": "proxy", "port": 8080}}, ) self.assertEqual( self.gateway.get_douyin( "safe", {**self.generation, "url": "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"}, )["status"], 200, ) self.assertEqual( self.gateway.resolve_douyin( "safe", {**self.generation, "url": "https://v.douyin.com/abc123/"} )["url"], "https://www.douyin.com/video/123", ) self.browser.resolve.reset_mock() self.assertEqual( self.gateway.resolve_douyin( "safe", {**self.generation, "url": "https://www.douyin.com/video/123"} )["url"], "https://www.douyin.com/video/123", ) self.browser.resolve.assert_called_once_with( "safe", "https://www.douyin.com/video/123" ) self.assertEqual( self.gateway.get_douyin_media( "safe", {**self.generation, "url": "https://cdn.example/video.mp4"} )["content_type"], "video/mp4", ) self.assertEqual( self.gateway.douyin_identity( "safe", {**self.generation, "expected_account_key": "12345678901234567890"} )["uid"], "12345678901234567890", ) self.assertTrue( self.gateway.douyin_login_qr("safe", self.generation)["qr_detected"] ) expected = "12345678901234567890" target = "22345678901234567890" self.assertEqual( self.gateway.douyin_message_history( "safe", {**self.generation, "expected_uid": expected, "target_uid": target} )["status"], "succeeded", ) self.assertEqual( self.gateway.douyin_action( "safe", { **self.generation, "expected_uid": expected, "target_uid": target, "action": "follow", "operation_id": "operation-1", "confirm": True, }, )["status"], "succeeded", ) def test_xhs_and_event_operations(self) -> None: page = "https://www.xiaohongshu.com/explore/abc" generation = {**self.generation, "network_exit_id": "exit-1"} self.assertEqual( self.gateway.get_xiaohongshu( "safe", {**generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v2/user/me"} )["status"], 200, ) self.assertEqual( self.gateway.post_xiaohongshu( "safe", {**generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v1/feed", "body": {"ok": True}}, )["status"], 200, ) self.assertEqual( self.gateway.resolve_xiaohongshu("safe", {**generation, "url": page})["url"], page, ) self.assertEqual( self.gateway.get_xiaohongshu_media("safe", {**generation, "url": page})["status"], 200, ) self.assertEqual( self.gateway.xiaohongshu_identity( "safe", {**generation, "expected_account_key": "xhs-user"} )["uid"], "xhs-user", ) self.assertEqual( self.gateway.start_douyin_events( "safe", {**self.generation, "expected_uid": "12345678901234567890"} ), {"status": "started"}, ) self.assertEqual( self.gateway.poll_douyin_events("safe", self.generation, {"limit": ["1"], "wait": ["0"]}), [], ) self.gateway.stop_douyin_events("safe", self.generation) def test_http_info_route_and_action_error_ownership(self) -> None: handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler) server = Mock() server.gateway = self.gateway cast(Any, handler).server = server cast(Any, handler).server_as_gateway = lambda: server self.assertEqual(handler._route("GET", "/v1/info", {}, {}), self.gateway.info()) cast(Any, handler).path = "/v1/info" cast(Any, handler).headers = {"Authorization": "Bearer gateway-token-123456"} cast(Any, handler)._respond = Mock() handler._dispatch("GET") self.assertEqual(handler._respond.call_args.args[0], 200) self.gateway._handle_douyin_action_error("safe", "follow", DouyinError("timed out"), "operation-1") self.assertEqual(self.gateway._uncertain_actions["safe"], float("inf")) self.gateway._release_action_ownership("safe", "operation-1") self.assertNotIn("safe", self.gateway._uncertain_actions) with self.assertRaises(RequestError): self.gateway.post_xiaohongshu( "safe", {**self.generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v1/feed", "body": []} ) class CDPTests(unittest.TestCase): def test_connect_response_headers_are_drained(self) -> None: connection = ChunkSocket( [b"HTTP/1.1 200 Connection Established\r\n", b"X-Proxy: value\r\n\r\nTLS"] ) self.assertEqual(_read_status(cast(socket.socket, connection)), 200) def test_command_queues_events_for_wait_event(self) -> None: socket_ = FakeSocket( [ { "method": "Page.frameNavigated", "params": {"frame": {"id": "frame-1"}}, }, {"id": 1, "result": {}}, ] ) connection = CDPConnection(cast(websocket.WebSocket, socket_)) self.assertEqual(connection.command("Page.enable"), {}) event = connection.wait_event( "Page.frameNavigated", lambda params: params["frame"]["id"] == "frame-1" ) self.assertEqual(event["method"], "Page.frameNavigated") self.assertTrue(socket_.sent) def test_eof_status_is_a_failure(self) -> None: class Closed: def recv(self, size: int) -> bytes: del size return b"" with self.assertRaises(OSError): _read_status(cast(socket.socket, Closed())) def test_expression_markers_and_challenge(self) -> None: expression = im_expression({"text": "hello EXPECTED_UID_VALUE"}, "123") self.assertIn("hello EXPECTED_UID_VALUE", expression) self.assertIn( "https://www.douyin.com", action_expression({"action": "like_work"}) ) self.assertEqual(detect_challenge(429, "captcha"), "") self.assertEqual(detect_challenge(412, ""), "captcha") def test_notification_details_normalize_safe_targets(self) -> None: notice = { "nid_str": "9007199254740993", "user_id": "99491952055", "create_time": 1700000000, "aweme_id": "123456", "comment": { "from_user": [{"uid": "7654321"}], "comment": {"cid_str": "987654", "user": {"uid": "7654321"}}, }, } self.assertEqual( normalize_notice(notice), { "event_key": "9007199254740993", "event_type": "comment", "interactor_uid": "7654321", "comment_id": "987654", "work_id": "123456", "platform_event_at": "2023-11-14T22:13:20+00:00", }, ) favorite = {"nid_str": "7", "favorite": {"from_user": [{"uid": "1"}]}} self.assertIsNone(normalize_notice(favorite)) def test_notification_detail_retries_partial_response(self) -> None: details = [ {"nid_str": "1", "user_id": "123"}, {"nid_str": "2", "user_id": "123"}, ] payload = json.dumps({"status_code": 0, "notice_list_v2": details}) subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.uid = "123" subscription.connection = cast( CDPConnection, FakeConnection( [ json.dumps( { "status": 200, "body": json.dumps( {"status_code": 0, "notice_list_v2": details[:1]} ), } ), json.dumps({"status": 200, "body": payload}), ] ), ) subscription._get_connection = lambda: subscription.connection with patch.object(douyin_module.time, "sleep"): self.assertEqual(len(subscription._details(["1", "2"])), 2) def test_notification_detail_rejects_unexpected_id(self) -> None: subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.uid = "123" subscription.connection = cast( CDPConnection, FakeConnection( [ json.dumps( { "status": 200, "body": json.dumps( { "status_code": 0, "notice_list_v2": [ {"nid_str": "9", "user_id": "123"} ], } ), } ) ] ), ) subscription._get_connection = lambda: subscription.connection with self.assertRaises(DouyinError): subscription._details(["1"]) class BrowserCDP: def __init__(self, values: list[object]) -> None: self.values = list(values) self.commands: list[tuple[str, dict | None]] = [] self.events: list[str] = [] self.expressions: list[str] = [] self.closed = False def command(self, method: str, params: dict | None = None) -> dict: self.commands.append((method, params)) if method == "Page.navigate": return {"frameId": "frame-1"} return {} def wait_event(self, method: str, predicate: object, timeout: float = 15.0) -> dict: del predicate, timeout self.events.append(method) return {"method": method} def evaluate(self, expression: str) -> object: self.expressions.append(expression) if not self.values: raise DouyinError("fake CDP value exhausted") return self.values.pop(0) def close(self) -> None: self.closed = True class FakeHTTPResponse: def __init__(self, status: int, body: bytes) -> None: self.status = status self.body = body def read(self, limit: int = -1) -> bytes: del limit return self.body class FakeHTTPConnection: def __init__(self, response: FakeHTTPResponse) -> None: self.response = response self.requested: list[tuple[str, str]] = [] self.closed = False def request( self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None, ) -> None: del body, headers self.requested.append((method, path)) def getresponse(self) -> FakeHTTPResponse: return self.response def close(self) -> None: self.closed = True class BrowserTests(unittest.TestCase): def _with_connection(self, browser: DouyinBrowser, connection: BrowserCDP) -> None: @contextmanager def bound(alias: str): del alias yield connection cast(Any, browser).connection = bound def test_resolve_initializes_anonymous_blank_runtime(self) -> None: cdp = Mock() cdp.evaluate.side_effect = ["null", True, "https://www.douyin.com/video/123"] cdp.command.return_value = {"frameId": "frame-1"} cdp.wait_event.return_value = { "params": {"frame": {"url": "https://www.douyin.com/video/123"}} } browser = DouyinBrowser() self._with_connection(browser, cast(BrowserCDP, cdp)) self.assertEqual( browser.resolve("safe", "https://www.douyin.com/video/123"), "https://www.douyin.com/video/123", ) self.assertEqual(cdp.evaluate.call_count, 1) self.assertEqual(cdp.evaluate.call_args_list[0].args, ("location.origin",)) self.assertEqual(cdp.command.call_args_list[0].args, ("Page.enable",)) self.assertEqual(cdp.notify.call_args_list[0].args, ("Page.navigate", {"url": "https://www.douyin.com/video/123"})) def test_browser_fetch_uses_manually_logged_session(self) -> None: cdp = BrowserCDP( [ "https://www.douyin.com", {"status": 200, "body": "{}", "too_large": False}, ] ) browser = DouyinBrowser() self._with_connection(browser, cdp) response = browser.get( "safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383" ) self.assertEqual(response.status, 200) self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands]) def test_browser_fetch_retries_transient_auth_response(self) -> None: cdp = BrowserCDP( [ "https://www.douyin.com", {"status": 403, "body": "challenge", "too_large": False}, {"status": 200, "body": "{}", "too_large": False}, ] ) browser = DouyinBrowser() self._with_connection(browser, cdp) with patch("browser_gateway.platform.douyin.time.sleep") as sleep: response = browser.get( "safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383", ) self.assertEqual(response.status, 200) sleep.assert_called_once_with(2) def test_login_qr_captures_a_browser_screen_without_credentials(self) -> None: screenshot = base64.b64encode(b"png-bytes" * 3000).decode("ascii") cdp = Mock() cdp.evaluate.side_effect = [ True, {"origin": "https://www.douyin.com", "qr_detected": True}, ] cdp.command.side_effect = [ {"frameId": "frame-1"}, {"data": screenshot}, {"data": screenshot}, ] browser = DouyinBrowser() self._with_connection(browser, cast(BrowserCDP, cdp)) with patch.object(douyin_module.time, "sleep"): response = browser.login_qr("safe") self.assertEqual(response.content_type, "image/png") self.assertEqual(response.body_base64, screenshot) self.assertTrue(response.qr_detected) self.assertEqual( [call.args[0] for call in cdp.command.call_args_list], [ "Page.navigate", "Page.captureScreenshot", "Page.captureScreenshot", ], ) self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args)) def test_connect_selects_configured_target_from_large_mixed_list(self) -> None: targets = [ {"type": "service", "url": "http://127.0.0.1:9222/json"} for _ in range(40) ] targets.extend( [ { "id": "other", "type": "page", "url": "https://www.douyin.com/video/1", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1", }, { "id": "selected", "type": "page", "url": "https://www.douyin.com/user/self", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/2", }, ] ) http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode())) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), patch.object( douyin_module.websocket, "create_connection", return_value=FakeSocket([]), ), ): connection = DouyinBrowser( lambda alias: "http://127.0.0.1:9222", target_id="selected" )._connect("safe") self.assertIsInstance(connection, CDPConnection) def test_media_download_is_browser_mediated_and_bounded(self) -> None: payload = base64.b64encode(b"video-bytes").decode("ascii") cdp = BrowserCDP( [ {"url": "https://www.douyin.com/video/123", "readyState": "complete"}, {"status": 200, "content_type": "video/mp4", "body": payload}, ] ) browser = DouyinBrowser() self._with_connection(browser, cdp) response = browser.get_media("safe", "https://www.douyin.com/video/123") self.assertEqual(response.status, 200) self.assertEqual(response.content_type, "video/mp4") self.assertEqual(base64.b64decode(response.body_base64), b"video-bytes") self.assertIn("Page.navigate", [method for method, _ in cdp.commands]) self.assertIn("querySelectorAll", cdp.expressions[-1]) self.assertIn("uuu_265.mp4", cdp.expressions[-1]) def test_connect_identity_and_actions(self) -> None: target = [ { "type": "page", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1", } ] http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode())) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), patch.object( douyin_module.websocket, "create_connection", return_value=FakeSocket([]), ), ): connection = DouyinBrowser(lambda alias: "http://127.0.0.1:9222")._connect( "safe" ) self.assertIsInstance(connection, CDPConnection) self.assertEqual(http.requested[0], ("GET", "/json/list")) expression = install_expression("safe", "123") self.assertIn("safe", expression) self.assertIn("old?.dispose?.()", expression) self.assertEqual( notice_ids( { "service": 20313, "payload": json.dumps( {"notices": [{"notice_id_str": "1", "effect_groups": [960]}]} ), } ), ["1"], ) self.assertEqual( notice_ids( { "service": 20003, "payload": json.dumps({"notice_type": 45, "notice_id_str": "2"}), } ), ["2"], ) browser = DouyinBrowser() cast(Any, browser).identity = lambda alias, expected: {"uid": expected} cast(Any, browser)._evaluate = lambda *args: { "status": "succeeded", "action": "followed", } preview = browser.action( "safe", "123", "dm", "456", text="hello", confirm=False ) self.assertEqual(preview["action"], "preview") self.assertEqual( browser.action("safe", "123", "like_work", work_id="789", confirm=False)[ "action" ], "preview", ) with self.assertRaises(DouyinError): browser.action("safe", "123", "dm", "456", text=" ", confirm=False) with self.assertRaises(DouyinError): browser.action("safe", "123", "follow", "bad", confirm=False) def test_browser_queue_is_retained_until_ack(self) -> None: wait = wait_expression("alpha") self.assertIn("delivered", wait) self.assertNotIn("splice(0)", wait) ack = ack_expression("__creatorhub_notice_sub_alpha", ["browser-1"]) self.assertIn("browser-1", ack) self.assertIn("__creatorhub_notice_sub_alpha", ack) self.assertIn( "crypto.randomUUID", douyin_module.install_expression("alpha", "123") ) def test_listener_start_retries_until_runtime_ready(self) -> None: subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.alias = "safe" subscription.uid = "123" subscription.stopped = threading.Event() subscription.stopped.wait = Mock(return_value=False) subscription._open_listener = Mock( side_effect=[ DouyinError("SDK_NOT_READY"), (Mock(), "2026-09-14T18:00:00+00:00"), ] ) with patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0]): connection, boundary = subscription._open_listener_until_ready() self.assertEqual(boundary, "2026-09-14T18:00:00+00:00") self.assertIsNotNone(connection) self.assertEqual(subscription._open_listener.call_count, 2) subscription.stopped.wait.assert_called_once_with(0.5) def test_subscription_receipts_are_replayed_until_ack(self) -> None: subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.uid = "123" subscription.queue = deque() subscription.condition = threading.Condition() subscription.stopped = threading.Event() subscription._put({"kind": "notice", "notice": {"event_key": "1"}}) first = subscription.poll(10, 0) second = subscription.poll(10, 0) self.assertEqual(first, second) self.assertTrue(first[0]["notice"]["gateway_received_at"].endswith("+00:00")) subscription.ack([first[0]["delivery_id"]]) self.assertEqual(subscription.poll(10, 0), []) def test_subscription_connection_failure_requests_recovery(self) -> None: subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.alias = "safe" subscription.browser = Mock() subscription.browser._connect.side_effect = DouyinError("browser unavailable") subscription._put = Mock() subscription._request_recovery = Mock() subscription._browser_inflight_lock = threading.Lock() subscription._browser_inflight = set() subscription._process_push_async({"delivery_id": "delivery-1"}, 0, False) subscription._request_recovery.assert_called_once_with() def test_subscription_detail_failure_does_not_discard_siblings(self) -> None: bad = json.dumps( { "status": 200, "body": json.dumps( { "status_code": 0, "notice_list_v2": [{"nid_str": "9", "user_id": "123"}], } ), } ) good = json.dumps( { "status": 200, "body": json.dumps( { "status_code": 0, "notice_list_v2": [ { "nid_str": "2", "user_id": "123", "follow": {"from_user": [{"uid": "7"}]}, } ], } ), } ) subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.uid = "123" subscription.queue = deque() subscription.condition = threading.Condition() subscription.stopped = threading.Event() subscription.connection = cast(CDPConnection, FakeConnection([bad, bad, good])) subscription._get_connection = lambda: subscription.connection subscription._handle( { "kind": "push", "service": 20313, "payload": json.dumps( { "notices": [ {"notice_id_str": "1", "effect_groups": [960]}, {"notice_id_str": "2", "effect_groups": [960]}, ] } ), } ) events = subscription.poll(10, 0) self.assertEqual(events[0]["kind"], "error") self.assertEqual(events[0]["event_key"], "1") self.assertEqual(events[1]["notice"]["event_key"], "2") def test_subscription_manager_and_queue(self) -> None: event = {"kind": "open"} subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.uid = "123" subscription.queue = deque() subscription.condition = __import__("threading").Condition() subscription.stopped = __import__("threading").Event() subscription._handle(event) first = subscription.poll(10, 0) self.assertEqual(first[0]["kind"], "open") self.assertTrue(first[0]["delivery_id"]) subscription.queue = deque([{} for _ in range(1000)]) subscription._put({"kind": "new"}) self.assertEqual(subscription.queue[0]["kind"], "error") subscription.stopped.set() overflow = subscription.poll(10, 0) self.assertEqual(overflow[0]["kind"], "error") self.assertEqual(overflow[0]["reason"], "notification queue overflow") self.assertTrue(overflow[0]["delivery_id"]) self.assertEqual(len(overflow), 10) self.assertEqual(len(subscription.queue), 1000) subscription.ack([overflow[0]["delivery_id"]]) self.assertEqual(len(subscription.queue), 999) self.assertEqual(subscription.queue[0], {}) browser = DouyinBrowser() fake = Mock() fake.poll.return_value = [{"kind": "notice"}] with patch.object(douyin_module, "DouyinSubscription", return_value=fake): manager = SubscriptionManager(browser) self.assertTrue(manager.start("safe", "123")["connected"]) self.assertEqual(manager.poll("safe", 1, 0), [{"kind": "notice"}]) manager.stop("safe") manager.close() with self.assertRaises(DouyinError): manager.poll("safe", 1, 0) class ProxyTests(unittest.TestCase): def test_chunked_request_body_is_decoded_and_forwarded_with_length(self) -> None: request = ChunkSocket( [ ( b"POST http://example.test/a HTTP/1.1\r\nHost: example.test\r\n" b"Transfer-Encoding: chunked\r\n\r\n2\r\nab\r\n3;part=x\r\ncde\r\n0\r\nX-Trailer: yes\r\n\r\n" ) ] ) head, body = _read_request(cast(socket.socket, request)) self.assertEqual(body, b"abcde") proxy = MemoryProxy.__new__(MemoryProxy) proxy.exit = ProxyExit("socks5", "proxy", 1080) upstream = ChunkSocket([b""]) proxy.dial = lambda target, timeout=20.0: cast(socket.socket, upstream) with patch.object(proxy_module, "_copy_until_close"): proxy.forward_http( cast(socket.socket, ChunkSocket([])), "POST", "http://example.test/a", _parse_request(head)[2], body, ) sent = upstream.sent[0].decode("iso-8859-1") self.assertIn("Content-Length: 5", sent) self.assertNotIn("Transfer-Encoding:", sent) def test_registry_generation_and_shutdown(self) -> None: registry = ProxyRegistry() url, undo = registry.configure( "safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network-1" ) port = int(url.rsplit(":", 1)[1]) self.assertFalse(registry.ready("safe", port, "runtime-1", "network-1")) self.assertTrue(registry.bind("safe", 1, url, "runtime-1", "network-1")) self.assertTrue(registry.ready("safe", port, "runtime-1", "network-1")) self.assertFalse(registry.remove("safe", 1, "runtime-2", "network-1")) undo() registry.close() def test_memory_proxy_can_close(self) -> None: proxy = MemoryProxy( "safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network" ) self.assertGreater(proxy.listener.getsockname()[1], 0) proxy.close() def test_proxy_request_parser_and_copy_helpers(self) -> None: request = ChunkSocket( [ b"POST http://example.test/a HTTP/1.1\r\nContent-Length: 3\r\nHost: example.test\r\n\r\nabc" ] ) head, body = _read_request(cast(socket.socket, request)) self.assertEqual(body, b"abc") method, target, headers = _parse_request(head) self.assertEqual((method, target), ("POST", "http://example.test/a")) self.assertEqual(headers[0], ("Content-Length", "3")) source = ChunkSocket([b"one", b"", b"ignored"]) destination = ChunkSocket([]) _copy_until_close(cast(socket.socket, source), cast(socket.socket, destination)) self.assertEqual(b"".join(destination.sent), b"one") self.assertEqual( _recv_exact(cast(socket.socket, ChunkSocket([b"ab", b"cd"])), 4), b"abcd" ) self.assertTrue(_is_ipv4("127.0.0.1")) self.assertFalse(_is_ipv4("host.example")) with self.assertRaises(ValueError): _parse_request(b"BROKEN\r\n\r\n") def test_connect_payload_in_same_read_is_preserved(self) -> None: class GreedySocket(ChunkSocket): def recv(self, size: int) -> bytes: del size return self.chunks.pop(0) if self.chunks else b"" upstream = GreedySocket([b"HTTP/1.1 200 OK\r\nX-Test: yes\r\n\r\nTLS"]) with patch.object(proxy_module, "_open_host", return_value=upstream): result = _dial_http_proxy( ProxyExit("http", "proxy", 8080), "target:443", 1.0 ) self.assertEqual(_recv_exact(cast(socket.socket, result), 3), b"TLS") result.close() def test_http_and_socks_handshakes(self) -> None: http_socket = ChunkSocket([b"HTTP/1.1 200 Connection Established\r\n\r\n"]) with patch.object(proxy_module, "_open_host", return_value=http_socket): result = _dial_http_proxy( ProxyExit("http", "proxy", 8080, "u", "c"), "target:443", 1.0 ) self.assertIs(result, http_socket) self.assertIn(b"Proxy-Authorization: Basic dTpj", http_socket.sent[0]) socks4_socket = ChunkSocket([b"\x00\x5a\x00\x00\x00\x00\x00\x00"]) with patch.object(proxy_module, "_open_host", return_value=socks4_socket): self.assertIs( _dial_socks4(ProxyExit("socks4", "proxy", 1080), "127.0.0.1:80", 1.0), socks4_socket, ) socks5_socket = ChunkSocket( [b"\x05\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"] ) with patch.object(proxy_module, "_open_host", return_value=socks5_socket): self.assertIs( _dial_socks5(ProxyExit("socks5", "proxy", 1080), "127.0.0.1:80", 1.0), socks5_socket, ) with self.assertRaises(OSError): _dial_socks5(ProxyExit("socks5", "proxy", 1080), "host:0", 1.0) def test_proxy_forward_and_tunnel_paths(self) -> None: proxy = MemoryProxy.__new__(MemoryProxy) proxy.exit = ProxyExit("socks5", "proxy", 1080) upstream = ChunkSocket([b"reply", b""]) client = ChunkSocket([]) cast(Any, proxy).dial = lambda target, timeout=20.0: cast( socket.socket, upstream ) with patch.object(proxy_module, "_copy_until_close") as copy: proxy.forward_http( cast(socket.socket, client), "GET", "http://example.test/path?q=1", [("Host", "example.test")], b"", ) copy.assert_called_once() self.assertIn(b"GET /path?q=1 HTTP/1.1", upstream.sent[0]) proxy.exit = ProxyExit("http", "proxy", 8080) upstream = ChunkSocket([b""]) cast(Any, proxy).dial = lambda target, timeout=20.0: cast( socket.socket, upstream ) with ( patch.object(proxy_module, "_open_host", return_value=upstream), patch.object(proxy_module, "_copy_until_close"), ): proxy.forward_http( cast(socket.socket, client), "GET", "http://example.test/", [], b"" ) self.assertIn(b"GET http://example.test/ HTTP/1.1", upstream.sent[0]) class ChunkSocket: def __init__(self, chunks: list[bytes]) -> None: self.chunks = list(chunks) self.sent: list[bytes] = [] def recv(self, size: int) -> bytes: if not self.chunks: return b"" chunk = self.chunks.pop(0) if len(chunk) <= size: return chunk self.chunks.insert(0, chunk[size:]) return chunk[:size] def sendall(self, data: bytes) -> None: self.sent.append(data) def send(self, data: bytes) -> None: self.sent.append(data) def settimeout(self, value: float | None) -> None: del value def close(self) -> None: return None class GatewayLifecycleTests(unittest.TestCase): def test_server_tracks_daemon_request_threads_and_timeout(self) -> None: server = gateway_module.GatewayHTTPServer(("127.0.0.1", 0), Mock()) try: self.assertTrue(server.daemon_threads) self.assertEqual(gateway_module.GatewayHandler.protocol_version, "HTTP/1.1") finally: server.server_close() def _input(self, stopped: bool = True) -> dict: return { "alias": "safe", "name": "Safe", "profile_id": "safe", "cmd": ["about:blank"], "binding_version": 1, "network_exit_id": "", "network_exit": {}, "stopped": stopped, } class AdditionalGatewayCoverageTests(unittest.TestCase): def test_douyin_connect_rejects_bad_discovery(self) -> None: browser = DouyinBrowser(lambda _: "https://browser:9222") with self.assertRaises(DouyinError): browser._connect("safe") cases = [ (500, b"{}"), (200, b"{}"), (200, json.dumps([{"type": "service"}]).encode()), ( 200, json.dumps( [{"type": "page", "url": "https://www.douyin.com/1"}] ).encode(), ), ( 200, json.dumps( [ { "type": "page", "url": "https://www.douyin.com/1", "webSocketDebuggerUrl": "http://browser/devtools/page/1", } ] ).encode(), ), ] for status, body in cases: with self.subTest(status=status, body=body): http = FakeHTTPConnection(FakeHTTPResponse(status, body)) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), self.assertRaises(DouyinError), ): browser._connect("safe") targets = [ { "type": "page", "url": "https://www.douyin.com/1", "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1", }, { "type": "page", "url": "https://www.douyin.com/2", "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/2", }, ] http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode())) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), self.assertRaises(DouyinError), ): browser._connect("safe") def test_douyin_fetch_identity_and_confirmed_actions(self) -> None: browser = DouyinBrowser() def bind(connection: BrowserCDP) -> None: BrowserTests()._with_connection(browser, connection) for result in ( {"too_large": True}, {"status": 302, "body": ""}, {"status": 200, "body": 1}, ): bind(BrowserCDP(["https://www.douyin.com", result])) with self.assertRaises(DouyinError): browser.get("safe", "https://www.douyin.com/a") cast(Any, browser).get = lambda alias, target: BrowserResponse(200, "not-json") with self.assertRaises(DouyinError): browser.identity("safe") cast(Any, browser).get = lambda alias, target: BrowserResponse( 200, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}}) ) self.assertEqual(browser.identity("safe")["uid"], "1") cast(Any, browser).get = lambda alias, target: BrowserResponse( 200, json.dumps( { "status_code": 0, "extra": {"now": 1789401629000}, "user": {"uid": "1", "sec_uid": "sec"}, } ), ) self.assertTrue(browser.identity("safe")["platform_now"].startswith("2026-")) cast(Any, browser).get = lambda alias, target: BrowserResponse( 403, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}}) ) with self.assertRaises(DouyinError): browser.identity("safe") cast(Any, browser).identity = lambda alias, expected_uid=None: { "uid": expected_uid or "1" } cast(Any, browser)._evaluate = lambda alias, expression: { "status": 200, "action": "sent", } bind(BrowserCDP(["https://www.douyin.com", {}])) self.assertEqual( browser.action("safe", "1", "follow", "2", confirm=True)["action"], "sent" ) bind(BrowserCDP(["https://www.douyin.com", {}])) self.assertEqual( browser.action("safe", "1", "dm", "2", text="hello", confirm=True)[ "status" ], 200, ) cast(Any, browser)._evaluate = lambda alias, expression: "bad" bind(BrowserCDP(["https://www.douyin.com", "bad"])) with self.assertRaises(DouyinError): browser.action("safe", "1", "follow", "2", confirm=True) def test_gateway_and_proxy_validation_edges(self) -> None: self.assertEqual( parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, "" ) with self.assertRaises(RequestError): validate_proxy_exit( parse_proxy_exit({"protocol": "http", "host": "", "port": 80}) ) with self.assertRaises(RequestError): validate_proxy_exit( parse_proxy_exit({"protocol": "http", "host": "proxy", "port": 0}) ) with self.assertRaises(RequestError): validate_proxy_restore( { "binding_version": 1, "runtime_id": "a" * 64, "network_id": "n", "network_exit_id": "x", }, "bad alias", ) self.assertFalse(valid_account_key_query({"key": ["bad key"]}, "key")) def test_cdp_error_and_proxy_auth_paths(self) -> None: socket_ = FakeSocket([{"id": 1, "result": {"result": {"value": {"ok": True}}}}]) connection = CDPConnection(cast(websocket.WebSocket, socket_)) self.assertEqual(connection.evaluate("1"), {"ok": True}) socket_ = FakeSocket([{"id": 1, "result": {"result": {}}}]) with self.assertRaises(DouyinError): CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1") with self.assertRaises(DouyinError): CDPConnection( cast(websocket.WebSocket, FakeSocket([{"id": 1, "error": {}}])) ).command("Page.enable") with self.assertRaises(DouyinError): CDPConnection( cast(websocket.WebSocket, FakeSocket([{"id": 1, "result": []}])) ).command("Page.enable") with self.assertRaises(DouyinError): CDPConnection(cast(websocket.WebSocket, FakeSocket([]))).wait_event( "Page.loadEventFired", lambda _: True, timeout=0.01 ) socket_ = FakeSocket([{"id": 1, "result": {"exceptionDetails": {}}}]) with self.assertRaises(DouyinError): CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1") target = [ { "type": "page", "url": "https://www.douyin.com/1", "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1", } ] http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode())) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), patch.object( douyin_module.websocket, "create_connection", side_effect=OSError("down"), ), self.assertRaises(DouyinError), ): DouyinBrowser(lambda _: "http://browser:9222")._connect("safe") socks5 = ChunkSocket( [b"\x05\x02", b"\x01\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"] ) with patch.object(proxy_module, "_open_host", return_value=socks5): self.assertIs( _dial_socks5( ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0 ), socks5, ) def test_direct_message_notice_and_uncertain_post_contract(self) -> None: notice = normalize_notice( { "dm": { "message_id": "123456", "from_user": {"uid": "456789"}, "text": "hello", }, "create_time": 1700000000, } ) self.assertIsNotNone(notice) assert notice is not None self.assertEqual(notice["event_type"], "dm") self.assertEqual(notice["interactor_uid"], "456789") self.assertEqual(notice["message_type"], "text") self.assertEqual(notice["message_text"], "hello") non_text = normalize_notice( { "dm": { "message_id": "123457", "from_user": {"uid": "456789"}, "message_type": "image", "image_url": "https://example.invalid/image", } } ) assert non_text is not None self.assertEqual(non_text["message_type"], "image") self.assertEqual(non_text["message_text"], "") script = action_expression({"alias": "safe", "action": "follow", "target": "2"}) self.assertIn("POST_UNCERTAIN", script) self.assertIn("BUSINESS_REJECTED", script) def test_cdp_timeout_terminates_page_evaluation(self) -> None: class TimeoutSocket(FakeSocket): def __init__(self) -> None: super().__init__([]) self.receives = 0 def recv(self) -> str: self.receives += 1 if self.receives == 1: raise TimeoutError("deadline") return json.dumps({"id": 2, "result": {}}) socket_ = TimeoutSocket() connection = CDPConnection(cast(websocket.WebSocket, socket_)) with ( patch.object(douyin_module, "CONTROL_TIMEOUT", 0.001), self.assertRaises(DouyinError), ): connection.command("Runtime.evaluate") sent = [json.loads(item) for item in socket_.sent] self.assertEqual(sent[1]["method"], "Runtime.terminateExecution") def test_configured_target_and_media_wait_edge_cases(self) -> None: targets = [ { "type": "page", "id": "other", "url": "https://www.douyin.com/1", "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1", } ] http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode())) with ( patch.object( douyin_module.http.client, "HTTPConnection", return_value=http ), self.assertRaisesRegex(DouyinError, "configured browser page target"), ): DouyinBrowser( lambda _: "http://browser:9222", target_id="missing" )._connect("safe") cdp = Mock() cdp.command.return_value = {"frameId": "frame-1"} cdp.evaluate.side_effect = [ DouyinError("page is still loading"), {"url": "https://www.douyin.com/video/123", "readyState": "complete"}, {"error": "media_source_unavailable"}, ] browser = DouyinBrowser() BrowserTests()._with_connection(browser, cast(BrowserCDP, cdp)) with ( patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0, 2.0]), patch.object(douyin_module.time, "sleep"), self.assertRaisesRegex(DouyinError, "media_source_unavailable"), ): browser.get_media("safe", "https://www.douyin.com/video/123") def test_identity_and_message_history_reject_invalid_values(self) -> None: browser = DouyinBrowser() for server_now in (0, float("nan")): cast(Any, browser).get = lambda alias, target, now=server_now: ( BrowserResponse( 200, json.dumps( { "status_code": 0, "extra": {"now": now}, "user": {"uid": "123", "sec_uid": "sec"}, } ), ) ) with ( self.subTest(server_now=server_now), self.assertRaisesRegex(DouyinError, "platform clock"), ): browser.identity("safe") identity = Mock(return_value={"uid": "123"}) evaluate = Mock(return_value={"status": "succeeded", "messages": []}) cast(Any, browser).identity = identity cast(Any, browser)._evaluate = evaluate result = browser.message_history("safe", "123", "456", 20) self.assertEqual(result["status"], "succeeded") identity.assert_called_once_with("safe", "123") self.assertIn("456", evaluate.call_args.args[1]) for expected_uid, target_uid, limit in ( ("bad", "456", 20), ("123", "bad", 20), ("123", "456", 0), ("123", "456", 201), ): with ( self.subTest( expected_uid=expected_uid, target_uid=target_uid, limit=limit ), self.assertRaises(DouyinError), ): browser.message_history("safe", expected_uid, target_uid, limit) evaluate.return_value = "bad" with self.assertRaisesRegex(DouyinError, "message history response"): browser.message_history("safe", "123", "456") def test_listener_open_and_recovery_boundaries(self) -> None: browser = Mock() browser.identity.return_value = { "uid": "123", "platform_now": "2026-09-14T00:00:00+00:00", } connection = BrowserCDP([json.dumps({"connected": True})]) browser._connect.return_value = connection subscription = DouyinSubscription.__new__(DouyinSubscription) subscription.browser = browser subscription.alias = "safe" subscription.uid = "123" subscription.key = "__creatorhub_notice_sub_safe" opened, boundary_at = subscription._open_listener() self.assertIs(opened, connection) self.assertEqual(boundary_at, "2026-09-14T00:00:00+00:00") constructor_connection = BrowserCDP([json.dumps({"connected": True})]) browser._connect.return_value = constructor_connection with patch.object(threading.Thread, "start"): constructed = DouyinSubscription(browser, "safe", "123") self.assertEqual(constructed.queue[0]["kind"], "baseline") constructed.stopped.set() constructed._detail_pool.shutdown(wait=True, cancel_futures=True) broken = BrowserCDP([json.dumps({"connected": True}), "{}"]) browser._connect.return_value = broken browser.identity.return_value = {"uid": "123"} with self.assertRaisesRegex(DouyinError, "event boundary"): subscription._open_listener() self.assertTrue(broken.closed) old = Mock() old.evaluate.side_effect = DouyinError("stale listener") new = Mock() subscription.connection = old subscription._connection_lock = threading.RLock() subscription._epoch = 0 subscription._initial_boundary_pending = False subscription.stopped = threading.Event() subscription.stopped.wait = Mock(return_value=False) subscription._open_listener = Mock( return_value=(new, "2026-09-14T00:00:01+00:00") ) subscription._put = Mock() subscription._recover() old.close.assert_called_once_with() self.assertIs(subscription.connection, new) self.assertEqual(subscription._epoch, 1) self.assertEqual( [call.args[0]["kind"] for call in subscription._put.call_args_list], ["reconnected", "baseline"], ) class DouyinReleaseRemediationTests(unittest.TestCase): def test_browser_action_marker_is_persisted_in_profile_storage(self) -> None: browser = DouyinBrowser() browser._evaluate = Mock(side_effect=[{"runtime_id": "old"}, True, True]) self.assertEqual(browser.action_ownership("safe"), {"runtime_id": "old"}) browser.set_action_ownership( "safe", {"runtime_id": "new", "operation_id": "op-1"} ) browser.clear_action_ownership("safe", "op-1") self.assertEqual(browser._evaluate.call_count, 3) self.assertIn( "__creatorhub_action_ownership_v1", browser._evaluate.call_args_list[1].args[1], ) def test_gateway_action_claim_rejects_marker_from_any_runtime(self) -> None: gateway = Gateway.__new__(Gateway) gateway._action_ownership_lock = threading.Lock() gateway._uncertain_actions = {} gateway.browser = Mock() gateway.browser.action_ownership.return_value = {"runtime_id": "old-runtime"} with self.assertRaises(RequestError): gateway._claim_action("safe", "new-runtime", "op-1") gateway.browser.clear_action_ownership.assert_not_called() gateway.browser.set_action_ownership.assert_not_called() def test_notice_keeps_raw_gateway_receipt_time(self) -> None: notice = normalize_notice( {"dm": {"message_id": "1", "from_user": {"uid": "2"}, "text": "hi"}}, "2026-09-15T00:00:00+00:00", ) assert notice is not None self.assertEqual(notice["gateway_received_at"], "2026-09-15T00:00:00+00:00") if __name__ == "__main__": unittest.main()