Files
MediaCrawler/tests/test_qrlogin.py
T
butubb 37ca1b1cd6
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat: CDP 接管开关 + 扫码登录面板 + Docker 部署
- runner: enable_cdp_mode 从硬编码 False 改为系统设置 cdp_enabled。服务器部署下爬虫接管已开启远程调试的 Chrome(默认 9222),复用其 profile 登录态;本机桌面默认仍为关,行为不变
- qrlogin: 新增 CDP 扫码登录。Chrome 在服务器上跑于 Xvfb,show_qrcode 依赖的 PIL 桌面看图程序不存在,二维码无处可显示;改为经 CDP 从页面取出二维码交给 WebUI 渲染。刻意复用 browser.contexts[0](新建 context 是无痕 profile,扫了也白扫),且绝不调用 browser.close()(会连带关掉操作者自己的 Chrome)
- webui: 设置页新增扫码面板,替换原本跳到采集页看终端二维码的入口
- Dockerfile / .dockerignore / docker-compose.yml: 服务器部署。host 网络是必需而非图省事——容器里 127.0.0.1:9222 必须落到宿主机回环
- UPSTREAM.md: 补充 gitcode 镜像,用于 GitHub 大包传输必断时补历史
2026-10-07 10:41:11 +08:00

191 lines
6.2 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for CDP-driven QR login.
The behaviour worth pinning down is not "does it call Playwright" but the two
decisions that silently produce a logged-out crawler if they regress:
* the QR must be read from the browser's **default** context, because a new
context is an incognito-like profile whose cookies the crawler never sees;
* success must be decided by ``web_session`` *changing*, not merely existing --
an account already signed in has a value before the operator ever scans.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from api.monitor import qrlogin
@pytest.fixture(autouse=True)
def _reset_module_state():
"""Each test starts from "nothing on screen" and leaves it that way."""
qrlogin._current = None
yield
qrlogin._current = None
def _fake_stack(qr_image: str = "data:image/png;base64,AAAA", cookies=None):
"""A Playwright/Chrome stand-in wired the way the real one behaves."""
context = MagicMock()
context.cookies = AsyncMock(return_value=list(cookies or []))
page = MagicMock()
page.goto = AsyncMock()
page.close = AsyncMock()
context.new_page = AsyncMock(return_value=page)
browser = MagicMock()
browser.contexts = [context]
# Reaching for a fresh context is the bug this guards against, so make it
# blow up loudly rather than quietly returning a throwaway profile.
browser.new_context = AsyncMock(
side_effect=AssertionError("must reuse browser.contexts[0], not a new context")
)
playwright = MagicMock()
playwright.chromium.connect_over_cdp = AsyncMock(return_value=browser)
playwright.stop = AsyncMock()
manager = MagicMock()
manager.start = AsyncMock(return_value=playwright)
return manager, playwright, browser, context, page
def _patch(monkeypatch, manager, qr_image="data:image/png;base64,AAAA"):
monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager)
monkeypatch.setattr(
qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr_image)
)
@pytest.mark.asyncio
async def test_idle_before_any_session():
snapshot = await qrlogin.status()
assert snapshot["status"] == qrlogin.STATUS_IDLE
assert snapshot["image"] == ""
@pytest.mark.asyncio
async def test_unwired_platform_is_rejected():
"""Only xhs is wired; anything else must fail loudly, not show a dead button."""
with pytest.raises(ValueError):
await qrlogin.start("dy")
@pytest.mark.asyncio
async def test_start_returns_the_qr_and_reuses_the_default_context(monkeypatch):
manager, playwright, browser, context, _page = _fake_stack()
_patch(monkeypatch, manager)
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_WAITING
assert snapshot["image"] == "data:image/png;base64,AAAA"
context.new_page.assert_awaited_once()
browser.new_context.assert_not_called()
playwright.chromium.connect_over_cdp.assert_awaited_once()
@pytest.mark.asyncio
async def test_success_requires_the_cookie_to_change():
session = qrlogin.QrLoginSession(
qrlogin.PLATFORM_XHS,
MagicMock(),
_page_with_cookies([{"name": "web_session", "value": "before"}]),
baseline="before",
)
await session.refresh()
assert session.status == qrlogin.STATUS_WAITING
session._page = _page_with_cookies([{"name": "web_session", "value": "after"}])
await session.refresh()
assert session.status == qrlogin.STATUS_SUCCESS
@pytest.mark.asyncio
async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch):
"""No QR on the page plus a session cookie means someone is already logged in."""
manager, _playwright, _browser, _context, _page = _fake_stack(
qr_image="", cookies=[{"name": "web_session", "value": "existing"}]
)
_patch(monkeypatch, manager, qr_image="")
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_SUCCESS
assert "登录" in snapshot["message"]
@pytest.mark.asyncio
async def test_no_qr_and_no_session_is_an_error(monkeypatch):
manager, _playwright, _browser, _context, _page = _fake_stack(qr_image="", cookies=[])
_patch(monkeypatch, manager, qr_image="")
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_ERROR
@pytest.mark.asyncio
async def test_session_expires(monkeypatch):
session = qrlogin.QrLoginSession(
qrlogin.PLATFORM_XHS, MagicMock(), _page_with_cookies([]), baseline=""
)
session.started_at -= qrlogin.QR_TTL_SECONDS + 1
await session.refresh()
assert session.status == qrlogin.STATUS_EXPIRED
@pytest.mark.asyncio
async def test_closed_tab_is_reported_rather_than_crashing():
page = MagicMock()
page.context.cookies = AsyncMock(side_effect=RuntimeError("Target closed"))
session = qrlogin.QrLoginSession(qrlogin.PLATFORM_XHS, MagicMock(), page, baseline="")
await session.refresh()
assert session.status == qrlogin.STATUS_ERROR
@pytest.mark.asyncio
async def test_unreachable_browser_is_reported(monkeypatch):
"""A server whose Chrome is not listening must say so, not 500 anonymously."""
playwright = MagicMock()
playwright.chromium.connect_over_cdp = AsyncMock(
side_effect=OSError("Connection refused")
)
playwright.stop = AsyncMock()
manager = MagicMock()
manager.start = AsyncMock(return_value=playwright)
monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager)
with pytest.raises(RuntimeError) as excinfo:
await qrlogin.start(qrlogin.PLATFORM_XHS)
assert "连接浏览器失败" in str(excinfo.value)
playwright.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_cancel_closes_the_tab_and_resets(monkeypatch):
manager, _playwright, _browser, _context, page = _fake_stack()
_patch(monkeypatch, manager)
await qrlogin.start(qrlogin.PLATFORM_XHS)
snapshot = await qrlogin.cancel()
assert snapshot["status"] == qrlogin.STATUS_IDLE
page.close.assert_awaited_once()
assert (await qrlogin.status())["status"] == qrlogin.STATUS_IDLE
def _page_with_cookies(cookies):
page = MagicMock()
page.context.cookies = AsyncMock(return_value=list(cookies))
return page