diff --git a/browser_gateway/server/http.py b/browser_gateway/server/http.py index 91a160f..f19750b 100644 --- a/browser_gateway/server/http.py +++ b/browser_gateway/server/http.py @@ -172,12 +172,14 @@ class Gateway: response = self.browser.get(alias, input["url"]) self._require_douyin_generation(alias, input) except DouyinError as exc: - LOG.warning( - "Douyin GET failed alias=%s reason=%s", + LOG.exception( + "Douyin GET failed alias=%s binding_version=%s url=%s reason=%s", alias, + input["binding_version"], + input["url"], str(exc), ) - raise RequestError("restricted browser operation failed") from exc + raise RequestError(f"restricted browser operation failed: {exc}") from exc return { "status": response.status, "body": response.body, diff --git a/browser_gateway/test_gateway.py b/browser_gateway/test_gateway.py index a8144ae..2dd3650 100644 --- a/browser_gateway/test_gateway.py +++ b/browser_gateway/test_gateway.py @@ -16,6 +16,7 @@ from unittest.mock import Mock, patch import websocket +from .browser.cdp import BrowserError from .platform.douyin import ( BrowserResponse, CDPConnection, @@ -443,6 +444,19 @@ class GatewayBusinessMethodTests(unittest.TestCase): ] self.assertEqual(self.gateway.list_browsers(), [{"alias": "same", "state": "stopped"}]) + def test_get_douyin_preserves_failure_reason_and_logs_traceback(self) -> None: + url = "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913" + reason = "CDP command timed out: Runtime.evaluate" + self.browser.get.side_effect = DouyinError(reason) + with self.assertLogs("creatorhub.gateway", level="ERROR") as captured: + with self.assertRaisesRegex(RequestError, reason): + self.gateway.get_douyin("safe", {**self.generation, "url": url}) + record = captured.records[0] + self.assertIn(url, record.getMessage()) + self.assertIn("safe", record.getMessage()) + self.assertIn(f"binding_version={self.generation['binding_version']}", record.getMessage()) + self.assertIsNotNone(record.exc_info) + 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") @@ -830,6 +844,7 @@ class BrowserTests(unittest.TestCase): {"origin": "https://www.douyin.com", "qr_detected": True}, ] cdp.command.side_effect = [ + {}, {"frameId": "frame-1"}, {"data": screenshot}, {"data": screenshot}, @@ -844,17 +859,32 @@ class BrowserTests(unittest.TestCase): self.assertEqual( [call.args[0] for call in cdp.command.call_args_list], [ + "Page.enable", "Page.navigate", "Page.captureScreenshot", "Page.captureScreenshot", ], ) self.assertEqual( - cdp.command.call_args_list[0].args, + cdp.command.call_args_list[1].args, ("Page.navigate", {"url": LOGIN_PAGE_URL}), ) + cdp.wait_event.assert_called_once() + self.assertEqual(cdp.wait_event.call_args.args[0], "Page.domContentEventFired") + methods = [call[0] for call in cdp.mock_calls] + self.assertLess(methods.index('wait_event'), methods.index('evaluate')) self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args)) + def test_login_qr_does_not_capture_an_unloaded_page(self) -> None: + cdp = Mock() + cdp.command.side_effect = [{}, {"frameId": "frame-1"}] + cdp.wait_event.side_effect = BrowserError("Douyin DOM did not load") + browser = DouyinBrowser() + self._with_connection(browser, cast(BrowserCDP, cdp)) + with self.assertRaisesRegex(BrowserError, 'Douyin DOM did not load'): + browser.login_qr('safe') + self.assertNotIn('Page.captureScreenshot', [call.args[0] for call in cdp.command.call_args_list]) + 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) diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index 2a26026..15e7dd6 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -1,7 +1,7 @@ // 监控账号列表:表格呈现账号画像、作品数据与同步状态。 import { useCallback, useEffect, useState } from 'react'; import { history } from '@umijs/max'; -import { Alert, App, Avatar, Button, Card, Flex, Form, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'; +import { Alert, App, Avatar, Button, Card, Flex, Form, Modal, Popconfirm, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons'; import { getList, remove, creatorAction, creatorUpdate } from '@/services/api'; @@ -165,11 +165,14 @@ export default function MonitoringAccountList() { const meta = syncStatusMeta[account.sync_status ?? ''] || { label: account.sync_status || '—' }; return ( -
{meta.label}
+
+ + {meta.label} + +
上次同步 {account.last_sync_at ? dateTime(account.last_sync_at) : '—'} - {account.sync_error ? {account.sync_error} : null}
); }, diff --git a/web/tests/monitoring-account-list.test.cjs b/web/tests/monitoring-account-list.test.cjs index ad1882c..b6cf9ef 100644 --- a/web/tests/monitoring-account-list.test.cjs +++ b/web/tests/monitoring-account-list.test.cjs @@ -12,6 +12,68 @@ const source = ts.createSourceFile( ); const code = source.getFullText(); +function syncRenderer() { + let meta; + let renderer; + function visit(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'syncStatusMeta') { + meta = node.initializer.getText(source); + } + if (ts.isObjectLiteralExpression(node) && node.properties.some( + (property) => ts.isPropertyAssignment(property) + && property.name.getText(source) === 'title' + && property.initializer.getText(source) === "'同步状态'", + )) { + renderer = node.properties.find((property) => property.name?.getText(source) === 'render').initializer.getText(source); + } + ts.forEachChild(node, visit); + } + visit(source); + assert.ok(meta && renderer, '同步列和状态定义必须存在'); + const { outputText } = ts.transpileModule(`const syncStatusMeta = ${meta}; return (${renderer});`, { + compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 }, + }); + return new Function('React', 'Flex', 'Tooltip', 'Tag', 'Typography', 'dateTime', outputText)( + require('react'), 'Flex', 'Tooltip', 'Tag', { Text: 'Text' }, (value) => value || '—', + ); +} + +function elements(node) { + return node && typeof node === 'object' + ? [node, ...[node.props?.children].flat(Infinity).flatMap(elements)] + : []; +} + +test('失败原因仅在悬停同步失败标签时显示,完整保留长错误', () => { + const render = syncRenderer(); + const reason = 'douyin browser request rejected with HTTP 502: ' + '执行页面请求超时'.repeat(80); + for (const status of ['failed', 'blocked']) { + const nodes = elements(render(null, { sync_status: status, sync_error: reason, last_sync_at: '2026-05-14' })); + const tooltip = nodes.find((node) => node.type === 'Tooltip'); + assert.ok(tooltip, '失败原因必须使用 Tooltip'); + assert.equal(tooltip.props.title, reason); + assert.equal(tooltip.props.children.type, 'Tag', '浮层仅绑定状态标签'); + assert.ok(nodes.every((node) => node.props?.children !== reason), '错误不占用表格行空间'); + } +}); + +test('其他同步状态及无失败原因时不显示错误浮层', () => { + const render = syncRenderer(); + for (const status of ['idle', 'running', 'paused', 'failed', 'blocked', 'unknown', undefined]) { + for (const sync_error of ['', null, undefined]) { + const nodes = elements(render(null, { sync_status: status, sync_error })); + const tag = nodes.find((node) => node.type === 'Tag'); + assert.ok(tag); + const tooltip = nodes.find((node) => node.type === 'Tooltip'); + assert.ok(!tooltip || !tooltip.props.title); + } + } + for (const status of ['idle', 'running', 'paused']) { + const nodes = elements(render(null, { sync_status: status, sync_error: '上次失败' })); + assert.ok(nodes.every((node) => node.type !== 'Tooltip' || !node.props.title)); + } +}); + test('监控账号列表使用 Table,一行一个账号,保留详情入口和操作', () => { assert.match(code, /