fix: show sync failures on hover and preserve browser error details
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<Flex vertical>
|
||||
<div><Tag color={meta.color}>{meta.label}</Tag></div>
|
||||
<div>
|
||||
<Tooltip title={account.sync_status === 'failed' || account.sync_status === 'blocked' ? account.sync_error : undefined}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
上次同步 {account.last_sync_at ? dateTime(account.last_sync_at) : '—'}
|
||||
</Typography.Text>
|
||||
{account.sync_error ? <Typography.Text type="danger" style={{ fontSize: 12 }}>{account.sync_error}</Typography.Text> : null}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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, /<Table\b/);
|
||||
assert.match(code, /rowKey="id"/);
|
||||
|
||||
Reference in New Issue
Block a user