from __future__ import annotations import json import unittest from contextlib import contextmanager from importlib import import_module from unittest.mock import Mock, patch from .platform.douyin import CHAT_PAGE_NAME, CHAT_PAGE_URL, DouyinBrowser, DouyinError, ORIGIN douyin_module = import_module(f"{__package__}.platform.douyin") class BrowserTargetSelectionTests(unittest.TestCase): @staticmethod def page(target_id: str, url: str = ORIGIN) -> dict: return { "id": target_id, "type": "page", "url": url, "webSocketDebuggerUrl": f"ws://127.0.0.1:9222/devtools/page/{target_id}", } @contextmanager def targets(self, pages: list, browser: DouyinBrowser | None = None): browser = browser or DouyinBrowser(lambda _: "http://browser:9222") response = Mock(status=200) response.read.return_value = json.dumps(pages).encode() http = Mock() http.getresponse.return_value = response cdp = Mock() with ( patch.object(douyin_module.http.client, "HTTPConnection", return_value=http), patch.object(douyin_module.websocket, "create_connection") as connect, patch.object(douyin_module, "CDPConnection", return_value=cdp), ): yield browser, connect, cdp def assert_selected(self, connect: Mock, target_id: str) -> None: connect.assert_called_once_with( f"ws://browser:9222/devtools/page/{target_id}", timeout=douyin_module.CONTROL_TIMEOUT, suppress_origin=True, enable_multithread=True, ) def test_multiple_douyin_pages_prefer_self_profile_in_any_order(self) -> None: pages = [ self.page("A", ORIGIN + "/video/123"), self.page("Z", ORIGIN + "/user/self?showTab=post"), self.page("B", ORIGIN + "/"), ] for ordered in (pages, list(reversed(pages))): with self.subTest(order=[page["id"] for page in ordered]): with self.targets(ordered) as (browser, connect, cdp): browser._connect("safe") self.assert_selected(connect, "Z") cdp.command.assert_not_called() cdp.notify.assert_not_called() def test_multiple_profile_pages_use_stable_target_id(self) -> None: pages = [ self.page("Z", ORIGIN + "/user/self"), self.page("A", ORIGIN + "/user/self?showTab=like"), ] with self.targets(pages) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "A") def test_multiple_douyin_pages_without_profile_use_stable_target_id(self) -> None: pages = [self.page("Z", ORIGIN + "/video/123"), self.page("A")] for ordered in (pages, list(reversed(pages))): with self.subTest(order=[page["id"] for page in ordered]): with self.targets(ordered) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "A") def test_unrelated_pages_and_non_page_targets_are_not_selected(self) -> None: pages = [ self.page("A", "https://example.com/"), {"id": "worker", "type": "service_worker", "url": ORIGIN}, None, self.page("Z", ORIGIN + "/video/123"), self.page("B", ORIGIN + "/user/self"), ] with self.targets(pages) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "B") def test_dedicated_chat_target_is_not_used_for_regular_operations(self) -> None: browser = DouyinBrowser(lambda _: "http://browser:9222") browser._chat_targets["safe"] = "A" pages = [self.page("A", CHAT_PAGE_URL), self.page("Z"), self.page("B")] with self.targets(pages, browser) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "B") self.assertEqual(browser._chat_targets["safe"], "A") def test_dedicated_chat_page_is_identified_after_gateway_restart(self) -> None: pages = [self.page("chat", CHAT_PAGE_URL + "#" + CHAT_PAGE_NAME), self.page("B"), self.page("A")] with self.targets(pages) as (browser, connect, cdp): cdp.evaluate.return_value = CHAT_PAGE_NAME browser._connect("safe") self.assertEqual(connect.call_count, 2) self.assertEqual(connect.call_args.args[0], "ws://browser:9222/devtools/page/A") cdp.close.assert_called_once() cdp.command.assert_not_called() cdp.notify.assert_not_called() def test_only_dedicated_chat_page_after_restart_is_not_reused(self) -> None: with self.targets([self.page("chat", CHAT_PAGE_URL + "#" + CHAT_PAGE_NAME)]) as (browser, connect, cdp): cdp.evaluate.return_value = CHAT_PAGE_NAME with self.assertRaisesRegex(DouyinError, "page target is unavailable"): browser._connect("safe") # Only the read-only page-role probe connects; no account request follows. connect.assert_called_once() cdp.close.assert_called_once() cdp.command.assert_not_called() cdp.notify.assert_not_called() def test_only_dedicated_chat_page_is_not_reused(self) -> None: browser = DouyinBrowser(lambda _: "http://browser:9222") browser._chat_targets["safe"] = "chat" with self.targets([self.page("chat", CHAT_PAGE_URL)], browser) as (browser, connect, _): with self.assertRaisesRegex(DouyinError, "page target is unavailable"): browser._connect("safe") connect.assert_not_called() def test_chat_page_with_blank_regular_page_preserves_initial_login(self) -> None: browser = DouyinBrowser(lambda _: "http://browser:9222") browser._chat_targets["safe"] = "chat" pages = [self.page("chat", CHAT_PAGE_URL), self.page("blank", "about:blank")] with self.targets(pages, browser) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "blank") def test_explicit_target_remains_pinned_even_for_private_chat(self) -> None: browser = DouyinBrowser(lambda _: "http://browser:9222", target_id="chat") browser._chat_targets["safe"] = "chat" pages = [self.page("main", ORIGIN + "/user/self"), self.page("chat", CHAT_PAGE_URL)] with self.targets(pages, browser) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "chat") def test_missing_explicit_target_does_not_choose_another_page(self) -> None: browser = DouyinBrowser(lambda _: "http://browser:9222", target_id="missing") with self.targets([self.page("A"), self.page("B")], browser) as (browser, connect, _): with self.assertRaisesRegex(DouyinError, "configured browser page target is unavailable"): browser._connect("safe") connect.assert_not_called() def test_multiple_unrelated_pages_are_not_navigated_arbitrarily(self) -> None: pages = [self.page("A", "https://example.com/"), self.page("B", "about:blank")] with self.targets(pages) as (browser, connect, _): with self.assertRaises(DouyinError): browser._connect("safe") connect.assert_not_called() def test_single_blank_page_still_supports_initial_login(self) -> None: with self.targets([self.page("blank", "about:blank")]) as (browser, connect, _): browser._connect("safe") self.assert_selected(connect, "blank") def test_multi_page_identity_verifies_bound_uid_and_closes_connection(self) -> None: payload = {"status_code": 0, "user": {"uid": "1004291301", "sec_uid": "safe_sec_uid"}} pages = [self.page("A", ORIGIN + "/video/123"), self.page("B", ORIGIN + "/user/self")] with self.targets(pages) as (browser, connect, cdp): cdp.evaluate.side_effect = [ORIGIN, {"status": 200, "body": json.dumps(payload)}] identity = browser.identity("safe", "1004291301") self.assertEqual(identity["uid"], "1004291301") self.assert_selected(connect, "B") cdp.close.assert_called_once() cdp.notify.assert_not_called() def test_multi_page_identity_still_rejects_wrong_uid(self) -> None: payload = {"status_code": 0, "user": {"uid": "987654321", "sec_uid": "safe_sec_uid"}} with self.targets([self.page("A"), self.page("B")]) as (browser, connect, cdp): cdp.evaluate.side_effect = [ORIGIN, {"status": 200, "body": json.dumps(payload)}] with self.assertRaisesRegex(DouyinError, "does not match the expected account"): browser.identity("safe", "1004291301") self.assert_selected(connect, "A") cdp.close.assert_called_once() if __name__ == "__main__": unittest.main()