- 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 大包传输必断时补历史
191 lines
6.2 KiB
Python
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
|