Ctrl K
ComunidadGuías…

tests/test_downloads_pikpak_browser.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""PikPak 用浏览器登录(ADR-0093):假浏览器回 CDP `Runtime.evaluate` 的结果,验证读取、存储、清理与收尾。

真实浏览器与真实 PikPak 账号都不碰:浏览器换成记录调用的假对象,凭据存进测试自己的字典。
"""
from __future__ import annotations

import json
import threading
import time
import unittest
from unittest.mock import patch
from pathlib import Path

from peach import downloads_pikpak_browser as pb
from peach.browser_transport import BrowserUnavailable

KEY = "credentials_YUMx5nI8ZU8Ap8pm"
CAPTURE = {"key": KEY, "access_token": "page-access", "refresh_token": "page-refresh",
           "expires_at": "2026-10-02T10:00:00.000Z", "sub": "user-9", "device": "0123456789abcdef0123456789abcdef"}


class FakeSocket:
    def __init__(self, owner):
        self.owner = owner

    def call(self, method, timeout=30.0, **params):
        self.owner.calls.append((method, params))
        return {}


class FakeBrowser:
    """`_Browser` 的替身:`replies` 依次是每次读凭据时的回包,可以是字符串、None 或要抛出的异常。"""

    def __init__(self, replies, *, alive=True, fail_start=None):
        self.replies = list(replies)
        self.calls: list[tuple[str, dict]] = []
        self.evaluated: list[str] = []
        self.closed = False
        self._alive = alive
        self.fail_start = fail_start
        self.socket = FakeSocket(self)
        self.read = threading.Event()

    def start(self):
        if self.fail_start:
            raise self.fail_start
        self.calls.append(("start", {}))

    def navigate(self, url, *, referrer=None):
        self.calls.append(("navigate", {"url": url}))

    def place(self, *, visible):
        self.calls.append(("place", {"visible": visible}))

    def alive(self):
        return self._alive and not self.closed

    def evaluate(self, expression, *, timeout=30.0):
        self.evaluated.append(expression)
        if expression is pb._FORGET_SCRIPT:
            return True
        self.read.set()
        reply = self.replies.pop(0) if self.replies else None
        if isinstance(reply, BaseException):
            raise reply
        return reply

    def close(self):
        self.closed = True


class Clock:
    def __init__(self):
        self.now = 0.0

    def __call__(self):
        return self.now

    def sleep(self, seconds):
        self.now += seconds


class BrowserLoginTests(unittest.TestCase):
    def setUp(self):
        self.saved: list[dict] = []
        self.clock = Clock()
        self.woken = 0

    def make(self, browser: FakeBrowser, *, executable="C:/fake/chrome.exe", sleep=None, wait=600.0):
        made: list[tuple[str, Path]] = []

        def factory(path, profile):
            made.append((path, profile))
            return browser

        def wake():
            self.woken += 1
        login = pb.BrowserLogin(Path("C:/fake/secrets/browser-pikpak"), self.saved.append,
                                find=lambda: executable, browser=factory, clock=self.clock,
                                sleep=sleep or self.clock.sleep, wait_seconds=wait, on_done=wake)
        self.addCleanup(login.close)
        return login, made

    def finish(self, login: pb.BrowserLogin) -> dict:
        thread = login._thread
        assert thread is not None
        thread.join(timeout=5)
        self.assertFalse(thread.is_alive())
        return login.status()

    def test_proxy_setup_failure_finishes_the_login_without_a_browser(self):
        login, _ = self.make(FakeBrowser([]))
        with patch.object(login, '_browser', side_effect=BrowserUnavailable('代理配置不可用')):
            login.start()
            status = self.finish(login)
        self.assertEqual(status['state'], pb.FAILED)
        self.assertIn('代理配置不可用', status['message'])

    def test_browser_login_uses_the_saved_proxy_from_its_secrets_root(self):
        from peach import peach_proxy
        with patch.object(peach_proxy, 'client_options', return_value={'trust_env': False, 'proxy': 'http://127.0.0.1:17890'}) as options, \
                patch.object(pb, '_Browser') as factory:
            pb._attended_browser('chrome.exe', Path('C:/fake/secrets/browser-pikpak'))
        options.assert_called_once_with(Path('C:/fake/secrets'))
        self.assertEqual(factory.call_args.args[2], ('--proxy-server=http://127.0.0.1:17890',))

    def test_the_page_credentials_are_stored_with_the_client_from_the_key_and_then_forgotten(self):
        browser = FakeBrowser([None, RuntimeError("Execution context was destroyed"), json.dumps(CAPTURE)])
        login, made = self.make(browser)
        with self.assertNoLogs(pb.LOGGER, level="WARNING"):
            self.assertEqual(login.start()["state"], pb.WAITING)
            status = self.finish(login)
        self.assertEqual(status, {"state": pb.DONE, "message": "已用浏览器登录 PikPak"})
        self.assertEqual(made[0][1], Path("C:/fake/secrets/browser-pikpak"))
        self.assertEqual(self.saved, [{
            "refresh_token": "page-refresh", "access_token": "page-access", "expires_at": "1790935200",
            "user_id": "user-9", "device_id": "0123456789abcdef0123456789abcdef",
            "client_id": "YUMx5nI8ZU8Ap8pm"}])
        methods = [method for method, _params in browser.calls]
        self.assertLess(methods.index("Page.addScriptToEvaluateOnNewDocument"), methods.index("navigate"))
        self.assertEqual(browser.calls[methods.index("navigate")][1]["url"], pb.LOGIN_URL)
        self.assertIn(("place", {"visible": True}), browser.calls)
        self.assertIs(browser.evaluated[-1], pb._FORGET_SCRIPT)
        self.assertTrue(browser.closed)
        self.assertEqual(self.woken, 1)
        self.assertNotIn("page-refresh", json.dumps(login.status()))

    def test_the_first_document_forgets_old_credentials_before_the_page_runs(self):
        """下次拉起时网页从未登录开始:不会拿旧 token 去刷新,Peach 也不会把旧 token 当新登录读回来。"""
        browser = FakeBrowser([json.dumps(CAPTURE)])
        login, _made = self.make(browser)
        login.start()
        self.finish(login)
        script = next(params["source"] for method, params in browser.calls
                      if method == "Page.addScriptToEvaluateOnNewDocument")
        self.assertIs(script, pb._FORGET_ON_FIRST_DOCUMENT)

    def test_closing_the_window_cancels_the_login(self):
        browser = FakeBrowser([None, ConnectionError("浏览器关闭了调试连接")])
        login, _made = self.make(browser)
        login.start()
        self.assertEqual(self.finish(login)["state"], pb.CANCELLED)
        self.assertEqual(self.saved, [])
        self.assertTrue(browser.closed)
        self.assertEqual(self.woken, 0)

    def test_an_exited_browser_process_cancels_the_login(self):
        browser = FakeBrowser([], alive=False)
        login, _made = self.make(browser)
        login.start()
        self.assertEqual(self.finish(login)["state"], pb.CANCELLED)

    def test_waiting_too_long_times_out_and_closes_the_window(self):
        browser = FakeBrowser([])
        login, _made = self.make(browser, wait=5.0)
        login.start()
        status = self.finish(login)
        self.assertEqual(status["state"], pb.TIMEOUT)
        self.assertIn("用浏览器登录", status["message"])
        self.assertTrue(browser.closed)

    def test_cancel_returns_at_once_and_the_thread_closes_the_window(self):
        browser = FakeBrowser([])
        login, _made = self.make(browser, sleep=lambda _seconds: time.sleep(0.01))
        login.start()
        self.assertTrue(browser.read.wait(5))
        self.assertEqual(login.cancel()["state"], pb.CANCELLED)
        self.assertEqual(self.finish(login)["state"], pb.CANCELLED)
        self.assertTrue(browser.closed)
        self.assertEqual(self.saved, [])

    def test_a_second_start_while_waiting_does_not_open_another_window(self):
        browser = FakeBrowser([])
        login, made = self.make(browser, sleep=lambda _seconds: time.sleep(0.01))
        login.start()
        self.assertTrue(browser.read.wait(5))
        self.assertEqual(login.start()["state"], pb.WAITING)
        self.assertEqual(len(made), 1)
        login.cancel()
        self.finish(login)

    def test_without_a_browser_the_start_is_refused(self):
        login, made = self.make(FakeBrowser([]), executable=None)
        self.assertFalse(login.available())
        with self.assertRaises(ValueError) as caught:
            login.start()
        self.assertIn("账号密码", str(caught.exception))
        self.assertEqual(made, [])

    def test_a_browser_that_does_not_start_is_a_failure_to_poll(self):
        browser = FakeBrowser([], fail_start=BrowserUnavailable("浏览器没有写出调试口"))
        login, _made = self.make(browser)
        login.start()
        status = self.finish(login)
        self.assertEqual(status["state"], pb.FAILED)
        self.assertIn("调试口", status["message"])


class ParseTests(unittest.TestCase):
    def test_a_multiple_account_key_still_yields_the_client(self):
        values = pb.parse_capture({**CAPTURE, "key": "credentials_YUMx5nI8ZU8Ap8pm@user-9"})
        self.assertEqual(values["client_id"], "YUMx5nI8ZU8Ap8pm")

    def test_missing_tokens_or_client_are_not_a_login(self):
        self.assertIsNone(pb.parse_capture({**CAPTURE, "refresh_token": ""}))
        self.assertIsNone(pb.parse_capture({**CAPTURE, "key": "credentials_"}))
        self.assertIsNone(pb.parse_capture({**CAPTURE, "key": "captcha_x"}))

    def test_a_missing_device_gets_a_fresh_one_and_an_unreadable_expiry_forces_a_refresh(self):
        with self.assertLogs(pb.LOGGER, level="WARNING") as logged:
            values = pb.parse_capture({**CAPTURE, "device": "", "expires_at": "soon"})
        self.assertEqual(len(values["device_id"]), 32)
        self.assertEqual(values["expires_at"], "0")
        self.assertNotIn("page-refresh", "\n".join(logged.output))

    def test_the_profile_is_its_own_directory_under_the_secrets_root(self):
        self.assertEqual(pb.profile_dir(Path("S:/secrets")), Path("S:/secrets/browser-pikpak"))


if __name__ == "__main__":
    unittest.main()