Files
creator-hub/browser_gateway/test_gateway.py
T

1754 lines
68 KiB
Python

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()