一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
.../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
/api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
响应下发(/api/login 与 /api/me 都带),客户端不用手填。
二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。
三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。
四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
静默吞掉):带身份快照,不匹配即丢弃。
五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
(手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。
测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
(装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
1884 lines
88 KiB
Python
1884 lines
88 KiB
Python
#!/usr/bin/env python3
|
||
"""svfs_client Qt 层的界面测试(offscreen 渲染)。
|
||
|
||
约定与 test_studio_ui.py 相同:``QT_QPA_PLATFORM=offscreen`` 必须在任何
|
||
PySide6 导入之前设置;没装含 WebEngine 的 PySide6 时整个模块干净跳过。
|
||
|
||
WebEngine 冒烟测试会真正创建 QWebEngineView 并加载示例插件——部分环境
|
||
(无显卡/容器)可能起不来,那种情况跳过而不是误报失败。
|
||
"""
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
# WebEngine 在无显示环境下的兜底(可用环境变量覆盖)
|
||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu")
|
||
|
||
import json
|
||
import gc
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
from helpers import VFSTestCase, make_tree, quiet
|
||
|
||
from sqlite_vfs.core import SQLiteVFS
|
||
|
||
try:
|
||
from PySide6.QtCore import QCoreApplication, QEvent, QSettings, Qt, QTimer, QUrl
|
||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - WebEngine 探测
|
||
|
||
HAVE_QT = True
|
||
except ImportError:
|
||
HAVE_QT = False
|
||
|
||
if HAVE_QT:
|
||
from svfs_client import PLUGIN_API_VERSION, paths as client_paths, settings as client_settings
|
||
from svfs_client.app import create_app
|
||
from svfs_client.bridge import SvfsBridge
|
||
from svfs_client.scheme import (
|
||
SvfsSchemeHandler,
|
||
looks_like_navigation,
|
||
make_lookup,
|
||
plugin_url,
|
||
)
|
||
from svfs_client.worker import Client
|
||
|
||
from svfs_client import sample as client_sample
|
||
from svfs_client.installer import install_from_file
|
||
from svfs_client.shell import MainWindow
|
||
from svfs_studio import theme as client_theme
|
||
|
||
|
||
class _ApiHandler(BaseHTTPRequestHandler):
|
||
"""net 测试用的最小 API 服务:/api/ping → 200,/<id>/manifest.json → 清单,
|
||
/api/private → 恒 401,其它 → 404。``manifest_version`` 非 None 时用来模拟
|
||
服务器上有新版本;``ping_status`` 用来模拟"服务器在线但拒绝凭据"。"""
|
||
|
||
manifest_version = None
|
||
ping_status = 200
|
||
client_version = ""
|
||
|
||
def _reply(self, code, body):
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_GET(self):
|
||
if self.path == "/api/ping":
|
||
if _ApiHandler.ping_status != 200:
|
||
self._reply(_ApiHandler.ping_status, b'{"detail": "unauthorized"}')
|
||
return
|
||
self._reply(200, b'{"pong": true}')
|
||
return
|
||
if self.path == "/api/private":
|
||
# 恒定 401:登录失效链路(桥的信号 + 状态灯)测试用
|
||
self._reply(401, b'{"detail": "token expired"}')
|
||
return
|
||
if self.path == "/client/manifest.json":
|
||
if not _ApiHandler.client_version:
|
||
self._reply(404, b'{"error": "no client manifest"}')
|
||
return
|
||
body = json.dumps({
|
||
"version": _ApiHandler.client_version,
|
||
"url": "https://example.invalid/SVFS-Client.zip",
|
||
"notes": "测试用新版本",
|
||
}).encode("utf-8")
|
||
self._reply(200, body)
|
||
return
|
||
if (_ApiHandler.manifest_version
|
||
and self.path.endswith("/manifest.json")):
|
||
plugin_id = self.path.strip("/").split("/")[0]
|
||
body = json.dumps({
|
||
"format": "svfs-objects", "version": 1, "name": plugin_id,
|
||
"snapshot": {"name": _ApiHandler.manifest_version,
|
||
"digest": "deadbeef"},
|
||
"entries": {},
|
||
}).encode("utf-8")
|
||
self._reply(200, body)
|
||
return
|
||
self._reply(404, b'{"error": "not found"}')
|
||
|
||
def log_message(self, *args): # 静音
|
||
pass
|
||
|
||
|
||
class _FakeLoginWindow:
|
||
"""登录窗替身:``exec()`` 立刻返回,``session`` 就是预设的会话(None = 用户关掉了窗)。
|
||
|
||
``visible_probe`` 在 exec() 期间被调用一次,用来断言"进登录门时主窗口已经
|
||
关掉了"这件事——真去驱动一个模态登录窗要绕一大圈(见
|
||
test_client_login_ui.py 里的 QTimer 轮询写法),这里只需要登录门的时序。
|
||
"""
|
||
|
||
def __init__(self, session, visible_probe=None):
|
||
self.session = session
|
||
self.shown = False
|
||
self.raised = False
|
||
self._visible_probe = visible_probe
|
||
self.main_window_visible_during = None
|
||
|
||
def show(self):
|
||
self.shown = True
|
||
|
||
def raise_(self):
|
||
self.raised = True
|
||
|
||
def activateWindow(self): # noqa: N802 - 模拟 Qt 命名
|
||
pass
|
||
|
||
def exec(self):
|
||
if self._visible_probe is not None:
|
||
self.main_window_visible_during = self._visible_probe()
|
||
return 0
|
||
|
||
|
||
def start_api_server():
|
||
server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
return server, f"http://127.0.0.1:{server.server_address[1]}"
|
||
|
||
|
||
def pump(app, predicate=None, timeout=15.0, interval=0.02, settle=0.08):
|
||
"""跑事件循环:predicate 为 None 时跑 settle 秒;否则等到谓词成立或超时。"""
|
||
deadline = time.monotonic() + timeout
|
||
if predicate is None:
|
||
end = time.monotonic() + settle
|
||
while time.monotonic() < end:
|
||
app.processEvents()
|
||
time.sleep(interval)
|
||
return True
|
||
while time.monotonic() < deadline:
|
||
app.processEvents()
|
||
time.sleep(interval)
|
||
if predicate():
|
||
end = time.monotonic() + settle
|
||
while time.monotonic() < end:
|
||
app.processEvents()
|
||
time.sleep(interval)
|
||
return True
|
||
return False
|
||
|
||
|
||
if HAVE_QT:
|
||
|
||
_APP = None
|
||
|
||
def setUpModule():
|
||
global _APP
|
||
# QSettings 重定向到临时目录,测试不碰真实注册表/配置
|
||
QSettings.setDefaultFormat(QSettings.Format.IniFormat)
|
||
QSettings.setPath(
|
||
QSettings.Format.IniFormat, QSettings.Scope.UserScope,
|
||
tempfile.mkdtemp(prefix="svfs_client_settings_"),
|
||
)
|
||
# create_app 内部会先 register_scheme() 再建 QApplication(顺序是硬要求)
|
||
_APP = QApplication.instance() or create_app([sys.argv[0]])
|
||
_APP.setQuitOnLastWindowClosed(False)
|
||
|
||
def tearDownModule():
|
||
"""退出前把 WebEngine 对象主动拆掉(否则进程会以段错误收场)。
|
||
|
||
留着窗口不管,Chromium 的 profile / 渲染子进程就要等解释器退出时才拆,
|
||
而 offscreen 下那条路径会以 access violation 结束——测试全绿、退出码却是
|
||
139,CI 照样判失败(本机 3.13 + PySide6 6.11.2 实测)。close() 走的是
|
||
MainWindow.closeEvent 那条正常销毁顺序:收插件页面 → 停工作线程 → 删
|
||
profile;这里再跑一轮 deferred delete 让 deleteLater 真正落地。
|
||
"""
|
||
global _APP
|
||
# 收掉本模块残留的窗口,让各窗口自己的 WebEngine 资源在**这里**释放,
|
||
# 而不是全堆到解释器退出时一起析构。注意别在这里 gc.collect() 或
|
||
# sendPostedEvents:那会把几十个 profile 的析构挤到同一时刻,实测直接崩。
|
||
#
|
||
# 即便这样,解释器退出路径在 offscreen + WebEngine 下仍会段错误(与
|
||
# 被测代码无关),所以测试统一走 tests/run_tests.py —— 它按 unittest 的
|
||
# 结果 os._exit,绕开那条会崩的清理路径。
|
||
for widget in list(QApplication.topLevelWidgets()):
|
||
widget.close()
|
||
widget.deleteLater()
|
||
for _ in range(30):
|
||
_APP.processEvents()
|
||
time.sleep(0.01)
|
||
|
||
class BridgeTest(VFSTestCase):
|
||
"""桥的权限模型与存储隔离(不经过 WebView)。"""
|
||
|
||
def bridge(self, permissions):
|
||
return SvfsBridge(
|
||
"com.example.demo", permissions,
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
)
|
||
|
||
def test_storage_roundtrip(self):
|
||
bridge = self.bridge(["storage"])
|
||
self.assertTrue(bridge.storageSet("visits", "42"))
|
||
self.assertEqual(json.loads(bridge.storageGet("visits")), 42)
|
||
self.assertEqual(bridge.storageKeys(), ["visits"])
|
||
self.assertTrue(bridge.storageRemove("visits"))
|
||
self.assertIsNone(bridge.storageGet("visits"))
|
||
|
||
def test_storage_persists_to_isolated_file(self):
|
||
self.bridge(["storage"]).storageSet("k", '"v"')
|
||
self.assertTrue(
|
||
client_paths.storage_file(self.path("data"), "com.example.demo").endswith(
|
||
"com.example.demo.json"
|
||
)
|
||
)
|
||
with open(client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
encoding="utf-8") as f:
|
||
self.assertEqual(json.load(f), {"k": "v"})
|
||
|
||
def test_permission_denied(self):
|
||
bridge = self.bridge([]) # 什么都没声明
|
||
self.assertFalse(bridge.storageSet("k", "1"))
|
||
self.assertIsNone(bridge.storageGet("k"))
|
||
self.assertFalse(bridge.notify("hi"))
|
||
self.assertFalse(bridge.setTitle("t"))
|
||
self.assertEqual(bridge.storageKeys(), [])
|
||
|
||
def test_bad_values_rejected(self):
|
||
bridge = self.bridge(["storage"])
|
||
self.assertFalse(bridge.storageSet("k", "not-json"))
|
||
self.assertFalse(bridge.storageSet("", "1"))
|
||
self.assertFalse(bridge.storageSet("x" * 300, "1"))
|
||
|
||
def test_notify_and_title_signals(self):
|
||
bridge = self.bridge(["notify", "title"])
|
||
seen = []
|
||
bridge.notifyRequested.connect(seen.append)
|
||
bridge.titleRequested.connect(lambda t: seen.append("T:" + t))
|
||
self.assertTrue(bridge.notify("你好"))
|
||
self.assertTrue(bridge.setTitle("新标题"))
|
||
pump(_APP, settle=0.05)
|
||
self.assertIn("你好", seen)
|
||
self.assertIn("T:新标题", seen)
|
||
|
||
def test_host_info_needs_no_permission(self):
|
||
info = self.bridge([]).hostInfo()
|
||
self.assertEqual(info["pluginId"], "com.example.demo")
|
||
self.assertEqual(info["apiVersion"], PLUGIN_API_VERSION)
|
||
self.assertEqual(info["baseUrl"], "") # 没给 provider
|
||
|
||
def test_host_info_carries_base_url(self):
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", [],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: " http://srv:8000/ ", # 故意带斜杠和空格
|
||
)
|
||
info = bridge.hostInfo()
|
||
self.assertEqual(info["baseUrl"], "http://srv:8000")
|
||
|
||
def test_dialog_needs_permission(self):
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
result = json.loads(
|
||
bridge.uiDialog(json.dumps({"kind": "info", "text": "hi"}))
|
||
)
|
||
self.assertFalse(result["ok"])
|
||
self.assertIn("权限", result["error"])
|
||
|
||
def test_dialog_rejects_bad_payload(self):
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["dialog"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
result = json.loads(bridge.uiDialog("not json"))
|
||
self.assertFalse(result["ok"])
|
||
|
||
def test_dialog_shows_and_returns(self):
|
||
# 真弹原生 QMessageBox:定时器在嵌套事件循环里把它自动关掉
|
||
# (等价于用户点掉了对话框),slot 才会返回。
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["dialog"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
from PySide6.QtCore import QTimer
|
||
|
||
def close_modal():
|
||
modal = QApplication.activeModalWidget()
|
||
if modal is not None:
|
||
modal.close()
|
||
|
||
# confirm(Ok/Cancel 两个按钮):直接关闭 = 取消 → accepted False
|
||
QTimer.singleShot(150, close_modal)
|
||
result = json.loads(
|
||
bridge.uiDialog(json.dumps(
|
||
{"kind": "confirm", "title": "调试", "text": "自动关闭"}
|
||
))
|
||
)
|
||
self.assertTrue(result["ok"])
|
||
self.assertFalse(result["accepted"])
|
||
|
||
# message info(只有 Ok 一个按钮):直接关闭 = 默认按钮 Ok → True
|
||
QTimer.singleShot(150, close_modal)
|
||
result = json.loads(
|
||
bridge.uiDialog(json.dumps(
|
||
{"kind": "info", "title": "调试", "text": "自动关闭"}
|
||
))
|
||
)
|
||
self.assertTrue(result["ok"])
|
||
self.assertTrue(result["accepted"])
|
||
|
||
def test_net_request_roundtrip(self):
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: base,
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen = []
|
||
bridge.netResult.connect(seen.append)
|
||
req_id = bridge.netRequest(json.dumps({"path": "/api/ping"}))
|
||
self.assertTrue(req_id)
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15))
|
||
msg = json.loads(seen[-1])
|
||
self.assertEqual(msg["reqId"], req_id)
|
||
self.assertTrue(msg["ok"])
|
||
self.assertEqual(msg["status"], 200)
|
||
self.assertIn("pong", msg["bodyText"])
|
||
|
||
def test_net_http_error_and_missing_permission(self):
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: base,
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen = []
|
||
bridge.netResult.connect(seen.append)
|
||
# 404 走 HTTPError 分支:请求送达,状态码交给插件处理
|
||
bridge.netRequest(json.dumps({"path": "/api/nope"}))
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15))
|
||
msg = json.loads(seen[-1])
|
||
self.assertFalse(msg["ok"])
|
||
self.assertEqual(msg["status"], 404)
|
||
# 无效方法被拒
|
||
before = len(seen)
|
||
bridge.netRequest(json.dumps({"path": "/api/ping", "method": "TRACE"}))
|
||
self.assertTrue(pump(_APP, predicate=lambda: len(seen) > before, timeout=15))
|
||
self.assertIn("error", json.loads(seen[-1]))
|
||
|
||
def test_net_denied_without_permission(self):
|
||
bridge = self.bridge([]) # 未声明 net
|
||
seen = []
|
||
bridge.netResult.connect(seen.append)
|
||
self.assertEqual(bridge.netRequest(json.dumps({"path": "/api/ping"})), "")
|
||
pump(_APP, settle=0.2)
|
||
self.assertEqual(seen, [])
|
||
|
||
def test_net_host_allowlist_blocks_other_hosts(self):
|
||
"""清单声明了 net_hosts:白名单外的主机被拒(请求根本发不出去)。
|
||
|
||
拒绝也要回一个明确错误给插件(不是静默超时),否则插件作者
|
||
只会看到"请求中…"。
|
||
"""
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: "http://127.0.0.1:1",
|
||
allowed_hosts=["api.example.com"],
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen = []
|
||
bridge.netResult.connect(seen.append)
|
||
bridge.netRequest(json.dumps(
|
||
{"url": "http://evil.example/collect", "method": "GET"}))
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=10))
|
||
msg = json.loads(seen[-1])
|
||
self.assertFalse(msg["ok"])
|
||
self.assertIn("net_hosts", msg["error"])
|
||
self.assertIn("evil.example", msg["error"])
|
||
|
||
def test_net_host_allowlist_allows_listed_host(self):
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: base,
|
||
allowed_hosts=["127.0.0.1"], # 不带端口:按主机名匹配
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen = []
|
||
bridge.netResult.connect(seen.append)
|
||
self.assertTrue(bridge.netRequest(json.dumps({"path": "/api/ping"})))
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15))
|
||
self.assertEqual(json.loads(seen[-1])["status"], 200)
|
||
|
||
def test_net_401_reports_session_rejection(self):
|
||
"""宿主注入的 token 被 401 拒绝 → 通知壳层引导重新登录。
|
||
|
||
插件拿到的应答不变(401 仍照常交付),只是宿主多知道一件事。
|
||
"""
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"), "com.example.demo"),
|
||
base_url_provider=lambda: base,
|
||
session_provider=lambda: {"token": "tok", "username": "admin"},
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen, rejected = [], []
|
||
bridge.netResult.connect(seen.append)
|
||
bridge.sessionRejected.connect(rejected.append)
|
||
self.assertTrue(bridge.netRequest(json.dumps({"path": "/api/private"})))
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15))
|
||
msg = json.loads(seen[-1])
|
||
self.assertFalse(msg["ok"])
|
||
self.assertEqual(msg["status"], 401)
|
||
# 信号从工作线程发出,排队到主线程 → 再跑一次事件循环
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(rejected), timeout=5),
|
||
"401 没有触发 sessionRejected")
|
||
self.assertIn("401", rejected[0])
|
||
|
||
def test_net_401_without_host_token_is_silent(self):
|
||
"""没有宿主注入的 token(未登录 / 插件自带 Authorization)时,
|
||
401 与宿主会话无关,不该惊动用户。"""
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
|
||
for provider, extra in (
|
||
(None, {}), # 未登录:没有可注入的凭据
|
||
(lambda: {"token": "tok", "username": "admin"},
|
||
{"headers": {"Authorization": "Bearer plugin-own"}}), # 插件自带
|
||
):
|
||
bridge = SvfsBridge(
|
||
"com.example.demo", ["net"],
|
||
client_paths.storage_file(self.path("data"),
|
||
"com.example.demo"),
|
||
base_url_provider=lambda: base,
|
||
session_provider=provider,
|
||
)
|
||
self.addCleanup(bridge.shutdown)
|
||
seen, rejected = [], []
|
||
bridge.netResult.connect(seen.append)
|
||
bridge.sessionRejected.connect(rejected.append)
|
||
payload = {"path": "/api/private", **extra}
|
||
self.assertTrue(bridge.netRequest(json.dumps(payload)))
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15))
|
||
self.assertEqual(json.loads(seen[-1])["status"], 401)
|
||
pump(_APP, settle=0.2)
|
||
self.assertEqual(rejected, [])
|
||
|
||
class SchemeHandlerTest(VFSTestCase):
|
||
"""协议层的路径解析、查找闭包与句柄缓存。"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.base = client_paths.ensure_layout(self.path("data"))
|
||
package = self.path("plugin.svfs")
|
||
with quiet():
|
||
client_sample.build(package)
|
||
install_from_file(package, base=self.base)
|
||
self.pid = client_sample.PLUGIN_ID
|
||
|
||
def test_make_lookup_respects_registry(self):
|
||
lookup = make_lookup(self.base)
|
||
entry = lookup(self.pid)
|
||
self.assertIsNotNone(entry)
|
||
self.assertEqual(entry["entry"], "index.html")
|
||
self.assertTrue(entry["package_path"].endswith("plugin.svfs"))
|
||
# 未安装的插件拿不到
|
||
self.assertIsNone(lookup("com.nope.app"))
|
||
# 禁用后拿不到(隔离由 lookup 把守)
|
||
from svfs_client import installer
|
||
|
||
installer.set_enabled(self.base, self.pid, False)
|
||
self.assertIsNone(lookup(self.pid))
|
||
|
||
def test_package_handle_reads_bytes(self):
|
||
lookup = make_lookup(self.base)
|
||
handle = SvfsSchemeHandler(lookup)._handle_for(lookup(self.pid))
|
||
data, ok = handle.read("index.html")
|
||
self.assertTrue(ok)
|
||
self.assertIn(b"<!doctype html>", data)
|
||
# 桥的用法在 app.js 里(页面里引用了它)
|
||
data, ok = handle.read("app.js")
|
||
self.assertTrue(ok)
|
||
self.assertIn(b"svfsReady", data)
|
||
data, ok = handle.read("no/such/file.js")
|
||
self.assertFalse(ok)
|
||
self.assertEqual(data, b"")
|
||
handle.close()
|
||
|
||
def test_normalize_rejects_climbing(self):
|
||
normalize = SvfsSchemeHandler._normalize
|
||
self.assertEqual(normalize("/"), "")
|
||
self.assertEqual(normalize("/app.js"), "app.js")
|
||
# 相对段被收敛后落回包内:/a/../b → b(仍在包根层级,安全)
|
||
self.assertEqual(normalize("/a/../b"), "b")
|
||
# 绝对路径穿出顶层会被收敛回根下,同样安全
|
||
self.assertEqual(normalize("/../x"), "x")
|
||
self.assertEqual(normalize("/a/../../etc/passwd"), "etc/passwd")
|
||
# 收敛不掉的相对段(非 QUrl 常态,纯防御)直接拒绝
|
||
self.assertIsNone(normalize("../../etc/passwd"))
|
||
|
||
def test_spa_heuristic(self):
|
||
self.assertTrue(looks_like_navigation("settings"))
|
||
self.assertTrue(looks_like_navigation("user/42"))
|
||
self.assertTrue(looks_like_navigation("assets/logo")) # 无扩展名按路由对待(与 fastapi_static 同一启发式)
|
||
self.assertTrue(looks_like_navigation("images/")) # 目录路径同理(先试目录索引,再回退 SPA)
|
||
self.assertFalse(looks_like_navigation("app.js"))
|
||
|
||
def test_invalidate_closes_handles(self):
|
||
lookup = make_lookup(self.base)
|
||
handler = SvfsSchemeHandler(lookup)
|
||
entry = lookup(self.pid)
|
||
handle = handler._handle_for(entry)
|
||
self.assertIsNotNone(handle.read("index.html")[0])
|
||
handler.invalidate(self.pid)
|
||
# 句柄被关闭:缓存为空,老句柄再也读不到内容
|
||
self.assertEqual(len(handler._cache), 0)
|
||
data, ok = handle.read("index.html")
|
||
self.assertFalse(ok)
|
||
self.assertEqual(data, b"")
|
||
|
||
class MainWindowTest(VFSTestCase):
|
||
"""主窗口渲染与工作线程流程(不创建 WebEngine 视图)。"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.base = client_paths.ensure_layout(self.path("data"))
|
||
# 侧栏收缩/最近打开经 QSettings 跨用例持久化(真实行为就是记住),
|
||
# 测试之间必须复位,否则前一个用例的状态会带进下一个窗口
|
||
from svfs_client.shell import _ui_settings
|
||
|
||
_ui_settings().remove("sidebar/collapsed")
|
||
_ui_settings().remove("ui/last_plugin")
|
||
self.window = MainWindow(self.base)
|
||
self.window.show()
|
||
pump(_APP)
|
||
# 等第一次列表到达(启动直显只消费第一次列表),测试才有确定状态
|
||
self.assertTrue(pump(_APP, predicate=lambda: self.window._startup_resolved))
|
||
|
||
def tearDown(self):
|
||
# 必须走 close() 而不是直接 deleteLater():只有 closeEvent 会拆掉
|
||
# WebEngine profile、协议层句柄与工作线程。不拆的话它们全留到
|
||
# 解释器退出时一起析构,而 offscreen 下那条路径会崩(实测)。
|
||
# 每个用例各拆各的,也避免了"几十个 profile 同时析构"。
|
||
self.window.close()
|
||
self.window.deleteLater()
|
||
pump(_APP)
|
||
super().tearDown()
|
||
|
||
def _submit_and_wait(self, op, payload, timeout=20.0):
|
||
done, failed = [], []
|
||
self.window.client.submit(
|
||
op, payload, on_done=lambda p: done.append(p),
|
||
on_fail=lambda t, d: failed.append((t, d)),
|
||
)
|
||
self.assertTrue(pump(_APP, predicate=lambda: done or failed,
|
||
timeout=timeout),
|
||
f"{op} 没有在 {timeout}s 内完成")
|
||
self.assertFalse(failed, f"{op} 失败: {failed}")
|
||
return done[0]
|
||
|
||
def test_empty_render(self):
|
||
self.assertIs(self.window.stack.currentWidget(), self.window.manager)
|
||
self.assertTrue(self.window.manager.empty.isVisibleTo(self.window.manager))
|
||
self.assertEqual(self.window.plugin_list.count(), 0)
|
||
|
||
def test_install_sample_flow(self):
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
pid = client_sample.PLUGIN_ID
|
||
self.assertIn(pid, self.window.manager.cards)
|
||
card = self.window.manager.cards[pid]
|
||
self.assertTrue(card.enabled_check.isChecked())
|
||
self.assertTrue(card.open_button.isEnabled())
|
||
self.assertFalse(self.window.manager.empty.isVisibleTo(self.window.manager))
|
||
# 注册表里也是启用状态
|
||
from svfs_client import registry
|
||
|
||
self.assertTrue(
|
||
registry.load(self.base)["plugins"][pid]["enabled"]
|
||
)
|
||
|
||
def test_install_rejects_non_plugin_package(self):
|
||
# 打一个没有 plugin.json 的普通包
|
||
src = make_tree(self.path("bare"), {"readme.txt": b"not a plugin"})
|
||
bare = self.path("bare.svfs")
|
||
packer = SQLiteVFS(bare, compress=True)
|
||
try:
|
||
from sqlite_vfs.folder_packer import FolderPacker
|
||
|
||
FolderPacker(packer).pack_folder(src, incremental=True)
|
||
finally:
|
||
packer.close()
|
||
failed = []
|
||
self.window.client.submit(
|
||
"install_file", {"path": bare},
|
||
on_done=lambda p: None,
|
||
on_fail=lambda t, d: failed.append((t, d)),
|
||
)
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(failed)))
|
||
title, detail = failed[0]
|
||
self.assertIn("plugin.json", detail)
|
||
|
||
def test_disable_removes_from_sidebar(self):
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
pid = client_sample.PLUGIN_ID
|
||
payload = self._submit_and_wait(
|
||
"set_enabled", {"id": pid, "enabled": False}
|
||
)
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
self.assertEqual(self.window.plugin_list.count(), 0)
|
||
card = self.window.manager.cards[pid]
|
||
self.assertEqual(card.state_badge.text(), "已禁用")
|
||
self.assertFalse(card.open_button.isEnabled())
|
||
|
||
def test_version_switch_updates_registry(self):
|
||
with quiet():
|
||
client_sample.build(self.path("sample.svfs"))
|
||
install_from_file(self.path("sample.svfs"), base=self.base)
|
||
client_sample.build(self.path("sample.svfs")) # 升到 2.0.0
|
||
install_from_file(self.path("sample.svfs"), base=self.base)
|
||
payload = self._submit_and_wait("list_plugins", {})
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
pid = client_sample.PLUGIN_ID
|
||
entry = self.window.entry_of(pid)
|
||
self.assertEqual(len(entry["snapshots"]), 2)
|
||
old_id = entry["snapshots"][0]["id"]
|
||
payload = self._submit_and_wait("use_version",
|
||
{"id": pid, "snapshot_id": old_id})
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
self.assertEqual(
|
||
self.window.entry_of(pid)["version"],
|
||
entry["snapshots"][0]["name"],
|
||
)
|
||
|
||
def test_verify_reports_ok(self):
|
||
with quiet():
|
||
client_sample.build(self.path("sample.svfs"))
|
||
install_from_file(self.path("sample.svfs"), base=self.base)
|
||
done = []
|
||
self.window.client.submit(
|
||
"verify", {"id": client_sample.PLUGIN_ID, "check_content": True},
|
||
on_done=done.append,
|
||
)
|
||
self.assertTrue(pump(_APP, predicate=lambda: bool(done)))
|
||
self.assertTrue(done[0]["verify"]["ok"], done[0]["verify"]["errors"])
|
||
|
||
def test_server_config_flow(self):
|
||
# 初始未配置
|
||
self.assertIn("未配置", self.window.manager.server_url_label.full_text())
|
||
# 设置(故意带尾部斜杠,应被规范化)
|
||
self.assertIsNone(self.window.set_base_url(" http://10.0.0.5:9000/ "))
|
||
self.assertEqual(
|
||
self.window.manager.server_url_label.full_text(), "http://10.0.0.5:9000"
|
||
)
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["base_url"], "http://10.0.0.5:9000"
|
||
)
|
||
self.assertEqual(self.window.status_label.text(), "服务器地址已更新")
|
||
# 非法地址被拒绝,且不落盘
|
||
error = self.window.set_base_url("ftp://nope")
|
||
self.assertIn("http", error)
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["base_url"], "http://10.0.0.5:9000"
|
||
)
|
||
# 清除
|
||
self.assertIsNone(self.window.set_base_url(" "))
|
||
self.assertEqual(client_settings.load(self.base)["base_url"], "")
|
||
self.assertIn("未配置", self.window.manager.server_url_label.full_text())
|
||
|
||
def _settle_sidebar(self, width, timeout=5.0):
|
||
"""等收缩动画结束(宽度是并行的 min/max 属性动画)。"""
|
||
self.assertTrue(
|
||
pump(_APP, predicate=lambda: self.window.sidebar.width() == width,
|
||
timeout=timeout),
|
||
f"侧栏宽度没有到达 {width}(当前 {self.window.sidebar.width()})",
|
||
)
|
||
|
||
def test_sidebar_collapse_toggle(self):
|
||
from svfs_client.shell import RAIL_WIDTH, SIDEBAR_WIDTH
|
||
from PySide6.QtWidgets import QBoxLayout
|
||
|
||
self.assertFalse(self.window._sidebar_collapsed)
|
||
self.window._set_sidebar_collapsed(True)
|
||
self.assertTrue(self.window._sidebar_collapsed)
|
||
self._settle_sidebar(RAIL_WIDTH)
|
||
self.assertEqual(self.window.collapse_button.text(), "»")
|
||
self.assertFalse(self.window.plugin_list.isVisibleTo(self.window.sidebar))
|
||
# 收起后不是"插件不可达":图标栏接过列表职责
|
||
self.assertTrue(self.window.rail_list.isVisibleTo(self.window.sidebar))
|
||
self.assertEqual(self.window.rail_list.width(), RAIL_WIDTH - 8)
|
||
# 搜索框只在展开态出现
|
||
self.assertFalse(self.window.search_edit.isVisibleTo(self.window.sidebar))
|
||
# 底栏竖排(头像在齿轮上方),头像收成图标
|
||
self.assertEqual(self.window._bottom_box.direction(),
|
||
QBoxLayout.Direction.TopToBottom)
|
||
self.assertEqual(self.window.user_button.text(), "")
|
||
self.assertEqual(self.window.settings_button.text(), "⚙")
|
||
|
||
self.window._set_sidebar_collapsed(False)
|
||
self.assertFalse(self.window._sidebar_collapsed)
|
||
self._settle_sidebar(SIDEBAR_WIDTH)
|
||
self.assertEqual(self.window.collapse_button.text(), "«")
|
||
self.assertTrue(self.window.plugin_list.isVisibleTo(self.window.sidebar))
|
||
self.assertFalse(self.window.rail_list.isVisibleTo(self.window.sidebar))
|
||
self.assertTrue(self.window.search_edit.isVisibleTo(self.window.sidebar))
|
||
self.assertEqual(self.window._bottom_box.direction(),
|
||
QBoxLayout.Direction.LeftToRight)
|
||
# 齿轮按钮两种状态下都是图标态(账号条右侧的固定方块)
|
||
self.assertEqual(self.window.settings_button.text(), "⚙")
|
||
self.assertEqual(self.window.settings_button.width(), 30)
|
||
|
||
def test_sidebar_search_filters_both_lists(self):
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pid = client_sample.PLUGIN_ID # com.svfs.demo
|
||
pump(_APP)
|
||
self.assertEqual(self.window.plugin_list.count(), 1)
|
||
self.assertEqual(self.window.rail_list.count(), 1)
|
||
|
||
# 按 id 片段(缩写匹配)命中
|
||
self.window.search_edit.setText("csd")
|
||
pump(_APP)
|
||
self.assertEqual(self.window.plugin_list.count(), 1)
|
||
self.assertEqual(self.window.rail_list.count(), 1)
|
||
self.assertFalse(self.window.search_hint.isVisibleTo(self.window.sidebar))
|
||
|
||
# 无匹配:两套列表都空 + 显示提示
|
||
self.window.search_edit.setText("zzz-nothing")
|
||
pump(_APP)
|
||
self.assertEqual(self.window.plugin_list.count(), 0)
|
||
self.assertEqual(self.window.rail_list.count(), 0)
|
||
self.assertTrue(self.window.search_hint.isVisibleTo(self.window.sidebar))
|
||
|
||
# 清空即恢复全部
|
||
self.window.search_edit.clear()
|
||
pump(_APP)
|
||
self.assertEqual(self.window.plugin_list.count(), 1)
|
||
self.assertEqual(self.window.rail_list.count(), 1)
|
||
self.assertFalse(self.window.search_hint.isVisibleTo(self.window.sidebar))
|
||
self.assertEqual(pid, self.window.plugin_list.item(0).data(
|
||
Qt.ItemDataRole.UserRole))
|
||
|
||
def test_rail_click_opens_plugin_and_syncs_selection(self):
|
||
from svfs_client.shell import RAIL_WIDTH
|
||
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pump(_APP)
|
||
self.window._set_sidebar_collapsed(True)
|
||
self._settle_sidebar(RAIL_WIDTH)
|
||
|
||
# 图标栏点击 → 打开对应插件,两套列表选中同步
|
||
self.window._on_rail_row(0)
|
||
pump(_APP)
|
||
pid = client_sample.PLUGIN_ID
|
||
self.assertEqual(self.window._current_view_id(), pid)
|
||
self.assertEqual(self.window.plugin_list.currentRow(), 0)
|
||
self.assertEqual(self.window.rail_list.currentRow(), 0)
|
||
|
||
def test_sidebar_has_no_manager_or_sample_button(self):
|
||
# 插件管理入口在设置里,示例插件入口已删除;侧栏只剩插件列表
|
||
self.assertFalse(hasattr(self.window, "manage_button"))
|
||
self.assertFalse(hasattr(self.window, "sample_button"))
|
||
self.assertFalse(hasattr(self.window.manager, "sample_button"))
|
||
|
||
def _install_extra_plugin(self, plugin_id):
|
||
"""装一个非示例插件(最小合法清单 + 入口),用于多插件场景。"""
|
||
src = make_tree(self.path(plugin_id), {
|
||
"plugin.json": json.dumps({
|
||
"id": plugin_id, "name": "Other", "version": "1.0.0",
|
||
"api_version": PLUGIN_API_VERSION, "entry": "index.html",
|
||
"permissions": [],
|
||
}).encode("utf-8"),
|
||
"index.html": b"<html><body>other</body></html>",
|
||
})
|
||
pkg = self.path(plugin_id + ".svfs")
|
||
packer = SQLiteVFS(pkg)
|
||
try:
|
||
from sqlite_vfs.folder_packer import FolderPacker
|
||
|
||
FolderPacker(packer).pack_folder(src)
|
||
finally:
|
||
packer.close()
|
||
with quiet():
|
||
install_from_file(pkg, base=self.base)
|
||
|
||
def test_startup_plugin_resolution(self):
|
||
from svfs_client.shell import _ui_settings
|
||
|
||
# last_plugin 经 QSettings 跨用例持久化(如调试台用例会写它),
|
||
# 先清掉才能假设"无配置、无最近打开"
|
||
_ui_settings().remove("ui/last_plugin")
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload) # _startup_resolved 已是 True,不会自动打开
|
||
pid = client_sample.PLUGIN_ID
|
||
other = "com.other.app"
|
||
self._install_extra_plugin(other)
|
||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||
|
||
# 默认规则:无配置、无 last_plugin → 显示顺序里第一个启用的插件
|
||
# (默认顺序 = 按名称:"Other" 在 "SVFS 桥调试台" 前面)
|
||
self.assertEqual(
|
||
self.window._resolve_startup_plugin(), min(pid, other)
|
||
)
|
||
# 配置优先于一切
|
||
self.assertIsNone(self.window.set_startup_plugin(other))
|
||
self.assertEqual(self.window._resolve_startup_plugin(), other)
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["startup_plugin"], other
|
||
)
|
||
# 未安装的插件被拒绝
|
||
self.assertIn("未安装", self.window.set_startup_plugin("com.nope.app"))
|
||
# 清配置 → 上次打开的插件
|
||
self.assertIsNone(self.window.set_startup_plugin(""))
|
||
_ui_settings().setValue("ui/last_plugin", other)
|
||
self.assertEqual(self.window._resolve_startup_plugin(), other)
|
||
# 禁用的插件跳过:禁用 other 后配置它也落空 → 回到第一个启用的
|
||
_ui_settings().remove("ui/last_plugin")
|
||
payload = self._submit_and_wait(
|
||
"set_enabled", {"id": other, "enabled": False}
|
||
)
|
||
self.window.apply_payload(payload)
|
||
self.window.set_startup_plugin(other)
|
||
self.assertEqual(self.window._resolve_startup_plugin(), pid)
|
||
# 收尾清理:不给后续用例留下配置与 last_plugin
|
||
self.assertIsNone(self.window.set_startup_plugin(""))
|
||
_ui_settings().remove("ui/last_plugin")
|
||
|
||
def test_plugin_order_arrangement(self):
|
||
"""编排插件顺序:↑/↓ 改显示顺序并落盘,侧栏/管理页/启动默认都跟随。"""
|
||
from svfs_client import settings as client_settings
|
||
from svfs_client.shell import _ui_settings
|
||
|
||
_ui_settings().remove("ui/last_plugin")
|
||
self.window.set_startup_plugin("")
|
||
self.window.apply_payload(self._submit_and_wait("install_sample", {}))
|
||
other = "com.other.app"
|
||
self._install_extra_plugin(other)
|
||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||
|
||
ids = [entry["id"] for entry in self.window._plugins]
|
||
self.assertEqual(len(ids), 2, "多插件场景没建起来,用例前提不成立")
|
||
self.assertEqual(client_settings.plugin_order(self.base), [])
|
||
self.assertFalse(self.window.manager.order_reset_button.isVisible())
|
||
|
||
top, bottom = ids[0], ids[-1]
|
||
# 边界:首位不能上移、末位不能下移
|
||
self.assertFalse(
|
||
self.window.manager.cards[top].up_button.isEnabled())
|
||
self.assertFalse(
|
||
self.window.manager.cards[bottom].down_button.isEnabled())
|
||
self.assertTrue(
|
||
self.window.manager.cards[bottom].up_button.isEnabled())
|
||
|
||
# 上移一位:列表顺序、侧栏顺序、落盘的顺序三者一致
|
||
self.window.manager.cards[bottom].up_button.click()
|
||
pump(_APP)
|
||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||
[bottom, top])
|
||
self.assertEqual(client_settings.plugin_order(self.base),
|
||
[bottom, top])
|
||
sidebar = [
|
||
self.window.plugin_list.item(row).data(Qt.ItemDataRole.UserRole)
|
||
for row in range(self.window.plugin_list.count())
|
||
]
|
||
self.assertEqual(sidebar, [bottom, top])
|
||
# 编排过 → "恢复默认顺序"出现
|
||
self.assertTrue(self.window.manager.order_reset_button.isVisible())
|
||
|
||
# 启动直显的默认规则跟着编排走(不再按 id/名称)
|
||
self.assertEqual(self.window._resolve_startup_plugin(), bottom)
|
||
|
||
# worker 重新拉列表时也按编排顺序(不是只有界面上排了一下)
|
||
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
|
||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||
[bottom, top])
|
||
|
||
# 恢复默认顺序:回到按名称("Other" 在前)、设置清空、按钮消失
|
||
self.window.manager.order_reset_button.click()
|
||
pump(_APP)
|
||
self.assertEqual(client_settings.plugin_order(self.base), [])
|
||
self.assertEqual([entry["id"] for entry in self.window._plugins],
|
||
[other, client_sample.PLUGIN_ID])
|
||
self.assertFalse(self.window.manager.order_reset_button.isVisible())
|
||
|
||
# 收尾:别把启动配置留给后续用例
|
||
self.window.set_startup_plugin("")
|
||
_ui_settings().remove("ui/last_plugin")
|
||
|
||
def test_settings_dialog_mqtt_config(self):
|
||
"""设置里配 broker:落盘 + 立即重连(不用等下次登录)。"""
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
applied = []
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url,
|
||
session_provider=lambda: self.window._session,
|
||
on_mqtt_settings_changed=lambda: (applied.append(1),
|
||
self.window._restart_messaging()),
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
# 没配 broker、服务端也没下发 → 显示"未启用"
|
||
self.assertIn("未启用", dialog.mqtt_status.text())
|
||
|
||
dialog.mqtt_host_edit.setText("mqtts://broker.acme:8883")
|
||
dialog.mqtt_prefix_edit.setText("acme")
|
||
dialog.org_edit.setText("acme")
|
||
dialog.mqtt_user_edit.setText("svc-client")
|
||
dialog._save_mqtt_settings()
|
||
pump(_APP)
|
||
|
||
# 落盘 + 回调(重连)
|
||
data = client_settings.load(self.base)
|
||
self.assertEqual(data["mqtt_host"], "mqtts://broker.acme:8883")
|
||
self.assertEqual(data["mqtt_prefix"], "acme")
|
||
self.assertEqual(data["org"], "acme")
|
||
self.assertEqual(data["mqtt_username"], "svc-client")
|
||
self.assertEqual(applied, [1])
|
||
# 壳层确实按新配置重连了(tls 与端口由 URL 推出来)
|
||
self.assertEqual(self.window.mqtt.config["host"], "broker.acme")
|
||
self.assertEqual(self.window.mqtt.config["port"], 8883)
|
||
self.assertTrue(self.window.mqtt.config["tls"])
|
||
# 状态行显示生效配置
|
||
self.assertIn("broker.acme", dialog.mqtt_status.text())
|
||
self.assertIn("已保存并重连", dialog.mqtt_status.text())
|
||
|
||
# 清空 broker 地址 = 回到"不启用推送"(同步仍然工作)
|
||
dialog.mqtt_host_edit.setText("")
|
||
dialog._save_mqtt_settings()
|
||
pump(_APP)
|
||
self.assertFalse(self.window.mqtt.config["enabled"])
|
||
|
||
def test_settings_dialog_shows_server_announced_broker(self):
|
||
"""服务端下发的 broker 也算生效配置(设置里留空时状态行要说明来源)。"""
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
session = {"username": "admin", "token": "tok", "org": "acme",
|
||
"mqtt": {"host": "broker.acme", "port": 8883, "tls": True}}
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url,
|
||
session_provider=lambda: session,
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.assertIn("broker.acme", dialog.mqtt_status.text())
|
||
self.assertIn("服务端下发", dialog.mqtt_status.text())
|
||
|
||
def test_settings_dialog_tabs_group_by_type(self):
|
||
"""设置项按类型分页签,而不是堆成一长条。"""
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
dialog = SettingsDialog(self.base, apply_base_url=self.window.set_base_url)
|
||
self.addCleanup(dialog.deleteLater)
|
||
self.assertEqual(
|
||
[dialog.tabs.tabText(i) for i in range(dialog.tabs.count())],
|
||
["插件", "服务器", "存储", "网络", "诊断"])
|
||
# 默认停在第一页:别的页上的控件此刻不可见
|
||
self.assertTrue(dialog.startup_combo.isVisibleTo(dialog))
|
||
self.assertFalse(dialog.server_edit.isVisibleTo(dialog))
|
||
|
||
# 各控件待在各自那一页
|
||
for title, attr in (("服务器", "server_edit"), ("服务器", "mqtt_host_edit"),
|
||
("服务器", "credentials_label"), ("存储", "cache_edit"),
|
||
("存储", "cache_label"), ("网络", "proxy_edit"),
|
||
("网络", "timeout_spin"), ("诊断", "log_check"),
|
||
("插件", "startup_combo")):
|
||
self.assertTrue(dialog.show_tab(title), title)
|
||
widget = getattr(dialog, attr)
|
||
self.assertTrue(widget.isVisibleTo(dialog),
|
||
f"{attr} 不在「{title}」页签上")
|
||
# 找不到的页签:不炸,返回 False
|
||
self.assertFalse(dialog.show_tab("不存在的页"))
|
||
|
||
def test_settings_dialog_startup_and_cache(self):
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pid = client_sample.PLUGIN_ID
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url,
|
||
plugins_provider=lambda: [
|
||
(e["id"], e.get("name") or e["id"]) for e in self.window._plugins
|
||
],
|
||
startup_provider=lambda: (
|
||
client_settings.load(self.base)["startup_plugin"]
|
||
),
|
||
apply_startup=self.window.set_startup_plugin,
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
# 启动插件下拉含已装插件;选它并保存
|
||
self.assertIsNotNone(dialog.startup_combo.findData(pid))
|
||
dialog.startup_combo.setCurrentIndex(dialog.startup_combo.findData(pid))
|
||
dialog._apply_startup_clicked()
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["startup_plugin"], pid
|
||
)
|
||
self.assertIn("已保存", dialog.startup_status.text())
|
||
# 缓存路径:自定义 → 落盘并显示生效路径;恢复默认 → 清空
|
||
custom = self.path("cache-custom")
|
||
dialog.cache_edit.setText(custom)
|
||
dialog._save_cache_dir()
|
||
self.assertEqual(client_settings.load(self.base)["cache_dir"], custom)
|
||
self.assertIn("cache-custom", dialog.cache_label.full_text())
|
||
dialog._reset_cache_dir()
|
||
self.assertEqual(client_settings.load(self.base)["cache_dir"], "")
|
||
|
||
def test_settings_dialog_opens_manager(self):
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url,
|
||
on_open_manager=self.window._show_manager,
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
dialog._open_manager()
|
||
# 入口先关对话框再切到插件管理页
|
||
self.assertIs(self.window.stack.currentWidget(), self.window.manager)
|
||
|
||
def test_settings_dialog_debug_callback(self):
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
calls = []
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url,
|
||
on_install_debug=lambda: calls.append(True),
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
dialog._install_debug()
|
||
self.assertEqual(len(calls), 1)
|
||
|
||
# ---- 更新检查 / 服务器状态 / 诊断 ----
|
||
|
||
def test_check_updates_marks_cards(self):
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pid = client_sample.PLUGIN_ID
|
||
card = self.window.manager.cards[pid]
|
||
|
||
# 未检查前没有可升级标记
|
||
self.assertFalse(card.update_badge.isVisibleTo(card))
|
||
self.assertFalse(card.upgrade_button.isVisibleTo(card))
|
||
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
_ApiHandler.manifest_version = "9.9.9" # 服务器上有"新版本"
|
||
self.addCleanup(setattr, _ApiHandler, "manifest_version", None)
|
||
self.window.set_base_url(base)
|
||
|
||
result = self._submit_and_wait("check_updates", {})
|
||
self.assertEqual(result["updates"], {pid: "9.9.9"})
|
||
self.window._on_updates(result)
|
||
pump(_APP)
|
||
self.assertTrue(card.update_badge.isVisibleTo(card))
|
||
self.assertIn("9.9.9", card.update_badge.text())
|
||
self.assertTrue(card.upgrade_button.isVisibleTo(card))
|
||
|
||
# 服务器上的版本不比本地新 → 不标记
|
||
_ApiHandler.manifest_version = "0.0.1"
|
||
result = self._submit_and_wait("check_updates", {})
|
||
self.assertEqual(result["updates"], {})
|
||
self.window._on_updates(result)
|
||
pump(_APP)
|
||
self.assertFalse(card.update_badge.isVisibleTo(card))
|
||
|
||
def test_upgrade_plugin_resolves_manifest_url(self):
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
pid = client_sample.PLUGIN_ID
|
||
self.window.set_base_url("http://10.0.0.7:9000")
|
||
|
||
captured = []
|
||
original = self.window._submit_install
|
||
self.window._submit_install = (
|
||
lambda op, payload, label: captured.append((op, payload, label)))
|
||
try:
|
||
self.window._upgrade_plugin(pid)
|
||
finally:
|
||
self.window._submit_install = original
|
||
self.assertEqual(len(captured), 1)
|
||
op, payload, _label = captured[0]
|
||
self.assertEqual(op, "install_url")
|
||
self.assertEqual(payload["url"],
|
||
f"http://10.0.0.7:9000/{pid}/manifest.json")
|
||
|
||
def test_ping_server_status_light(self):
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
|
||
# 未配置:灰灯、无文字,不发请求
|
||
self.window._ping_server()
|
||
pump(_APP, settle=0.2)
|
||
self.assertEqual(self.window.manager.server_status_label.text(), "")
|
||
self.assertIn(client_theme.TEXT_FAINT,
|
||
self.window.manager.server_dot.styleSheet())
|
||
|
||
# 可达:绿灯 + 延迟
|
||
self.window.set_base_url(base)
|
||
result = self._submit_and_wait("ping_server", {})
|
||
self.assertTrue(result["online"])
|
||
self.window._on_server_status(result)
|
||
self.assertIn("ms", self.window.manager.server_status_label.text())
|
||
|
||
# 不可达:红灯 + 离线
|
||
self.window.set_base_url("http://127.0.0.1:1")
|
||
result = self._submit_and_wait("ping_server", {})
|
||
self.assertFalse(result["online"])
|
||
self.window._on_server_status(result)
|
||
self.assertEqual(self.window.manager.server_status_label.text(), "离线")
|
||
|
||
def test_ping_reports_expired_session_as_warn(self):
|
||
"""服务器在线但拒绝凭据:状态灯是「登录已失效」(黄),不是「离线」。
|
||
|
||
报"离线"会把用户引去查网络,而真相是会话没了——这两件事要分开。
|
||
"""
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
self.window.set_base_url(base)
|
||
self.window.set_session({"username": "admin", "token": "tok",
|
||
"base_url": base})
|
||
_ApiHandler.ping_status = 401
|
||
self.addCleanup(setattr, _ApiHandler, "ping_status", 200)
|
||
|
||
result = self._submit_and_wait("ping_server", {})
|
||
self.assertTrue(result["online"])
|
||
self.assertTrue(result["auth_rejected"])
|
||
self.window._on_server_status(result)
|
||
pump(_APP)
|
||
self.assertEqual(self.window.manager.server_status_label.text(),
|
||
"登录已失效")
|
||
self.assertIn(client_theme.WARN,
|
||
self.window.manager.server_dot.styleSheet())
|
||
# 同一件事也把会话标成失效(用户中心/侧栏头像跟着变)
|
||
self.assertTrue(self.window._session.get("invalid"))
|
||
|
||
def test_session_rejected_prompts_relogin_once(self):
|
||
"""插件 net 撞上 401 → 提示一次 + 一键重新登录;重登后恢复。"""
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
self.window._on_session_rejected("服务器返回 401:登录状态已失效")
|
||
pump(_APP)
|
||
self.assertIn("登录已失效",
|
||
self.window.manager.server_login_badge.text())
|
||
self.assertTrue(self.window.manager.banner.isVisible())
|
||
self.assertEqual(self.window.manager.banner._button.text(), "重新登录…")
|
||
self.assertTrue(self.window.manager.banner._button.isVisible())
|
||
|
||
# 每个打开的插件都会撞上同一个 401:提示只发一次
|
||
self.window.manager.clear_banner()
|
||
self.window._on_session_rejected("服务器返回 401:登录状态已失效")
|
||
pump(_APP)
|
||
self.assertFalse(self.window.manager.banner.isVisible())
|
||
|
||
# 重新登录成功(新的干净会话)→ 徽章恢复,允许再次提示
|
||
self.window.set_session({"username": "admin", "token": "new"})
|
||
self.window._on_session_rejected("又一次 401")
|
||
pump(_APP)
|
||
self.assertIn("登录已失效",
|
||
self.window.manager.server_login_badge.text())
|
||
self.assertTrue(self.window.manager.banner.isVisible())
|
||
|
||
def test_check_client_update_notifies_and_opens_download(self):
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
self.window.set_base_url(base)
|
||
|
||
# 服务器没提供 client/manifest.json → 静默(不是错误)
|
||
result = self._submit_and_wait("check_client_update", {})
|
||
self.assertFalse(result["update"])
|
||
self.assertFalse(result["checked"])
|
||
self.window._on_client_update(result)
|
||
pump(_APP)
|
||
self.assertFalse(self.window.manager.banner.isVisible())
|
||
|
||
# 提供了更高版本 → 状态栏 + 横幅,动作按钮打开下载地址
|
||
_ApiHandler.client_version = "99.0.0"
|
||
self.addCleanup(setattr, _ApiHandler, "client_version", "")
|
||
result = self._submit_and_wait("check_client_update", {})
|
||
self.assertTrue(result["update"])
|
||
self.assertEqual(result["latest"], "99.0.0")
|
||
self.window._on_client_update(result)
|
||
pump(_APP)
|
||
self.assertIn("99.0.0", self.window.status_label.text())
|
||
self.assertTrue(self.window.manager.banner.isVisible())
|
||
self.assertEqual(self.window.manager.banner._button.text(),
|
||
"下载新版本…")
|
||
|
||
opened = []
|
||
self.window._open_update_url = opened.append
|
||
self.window.manager.banner._button.click()
|
||
pump(_APP)
|
||
self.assertEqual(opened, ["https://example.invalid/SVFS-Client.zip"])
|
||
|
||
def test_banner_action_follows_current_message(self):
|
||
"""横幅动作按钮只做"当前这条消息"的事(换消息不能沿用上一个动作)。"""
|
||
calls = []
|
||
self.window.manager.show_message("info", "标题", "详情",
|
||
action_text="点我",
|
||
action=lambda: calls.append("a"))
|
||
self.window.manager.banner._button.click()
|
||
pump(_APP)
|
||
self.assertEqual(calls, ["a"])
|
||
# 新的一条消息没带动作 → 按钮连显示都不该有
|
||
self.window.manager.show_message("ok", "只有文字")
|
||
pump(_APP)
|
||
self.assertFalse(self.window.manager.banner._button.isVisible())
|
||
|
||
def test_plugin_notify_reaches_tray_and_status_bar(self):
|
||
"""notify 权限名副其实:有托盘就发系统通知,同时界面内也留痕。"""
|
||
class _FakeTray:
|
||
def __init__(self):
|
||
self.calls = []
|
||
|
||
def showMessage(self, title, message, icon, msecs): # noqa: N802
|
||
self.calls.append((title, message, msecs))
|
||
|
||
fake = _FakeTray()
|
||
original, self.window._tray = self.window._tray, fake
|
||
self.addCleanup(setattr, self.window, "_tray", original)
|
||
self.window._on_plugin_notify("打包完成")
|
||
pump(_APP)
|
||
self.assertEqual(len(fake.calls), 1)
|
||
self.assertEqual(fake.calls[0][1], "打包完成")
|
||
self.assertEqual(self.window.status_label.text(), "打包完成")
|
||
|
||
def test_plugin_notify_without_tray_still_shows_in_window(self):
|
||
"""没有系统托盘(容器/被策略禁用/offscreen)时降级为界面内提示。"""
|
||
original, self.window._tray = self.window._tray, None
|
||
self.addCleanup(setattr, self.window, "_tray", original)
|
||
self.window._on_plugin_notify("服务端返回 500")
|
||
pump(_APP)
|
||
self.assertEqual(self.window.status_label.text(), "服务端返回 500")
|
||
self.assertTrue(self.window.manager.banner.isVisible())
|
||
|
||
@staticmethod
|
||
def _white_pixels(image, x0, y0, width, height):
|
||
"""数一块区域里的近白像素(扫原始字节,比逐点 pixelColor 快很多)。"""
|
||
from PySide6.QtGui import QImage
|
||
|
||
if image.format() != QImage.Format.Format_RGB32:
|
||
image = image.convertToFormat(QImage.Format.Format_RGB32)
|
||
raw = bytes(image.constBits())
|
||
stride = image.bytesPerLine()
|
||
count = 0
|
||
for y in range(y0, y0 + height):
|
||
start = y * stride + x0 * 4
|
||
row = raw[start:start + width * 4]
|
||
for x in range(0, len(row), 4):
|
||
blue, green, red = row[x], row[x + 1], row[x + 2]
|
||
if red > 230 and green > 230 and blue > 230:
|
||
count += 1
|
||
return count
|
||
|
||
def test_collapsed_sidebar_leaves_no_white_gap(self):
|
||
"""收缩侧栏后不该留下没重绘的白块。
|
||
|
||
曾复现:主题只给 QMainWindow/QDialog 设了背景,页面栈是透明的,
|
||
侧栏让出的那一条在重绘时序里露出白底——表现为页面左上角一块
|
||
44×30 的纯白方块(`#ffffff`)。修法是给 QStackedWidget 不透明底色
|
||
(见 app.create_app 里的样式)。这条用例在图上一把尺子,两个页面都量。
|
||
"""
|
||
self.window._set_sidebar_collapsed(True, animate=False)
|
||
for page in (self.window.manager, self.window.messages_page):
|
||
self.window.stack.setCurrentWidget(page)
|
||
pump(_APP)
|
||
image = self.window.grab().toImage()
|
||
# 量侧栏右侧那片(白块出现的地方)——竖着量满高,横向量 300px
|
||
whites = self._white_pixels(image, self.window.sidebar.width(), 0,
|
||
300, image.height())
|
||
self.assertEqual(
|
||
whites, 0,
|
||
f"{type(page).__name__} 在收缩侧栏后出现了 {whites} 个近白像素")
|
||
self.window._set_sidebar_collapsed(False, animate=False)
|
||
|
||
def test_push_routes_host_and_plugin_messages(self):
|
||
"""同一条总线:主机消息进消息中心,插件消息只进插件队列。"""
|
||
from svfs_client import topics
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
bus = self.window.bus
|
||
host_topic = topics.user_message_topic(bus.prefix, bus.org, "admin")
|
||
plugin_topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin",
|
||
"com.example.dash")
|
||
|
||
self.window._on_push(host_topic, '{"id": "m1", "title": "备份完成"}')
|
||
self.window._on_push(plugin_topic, '{"cpu": 0.5}')
|
||
pump(_APP)
|
||
|
||
self.assertEqual(len(bus.log), 1)
|
||
self.assertEqual(self.window.messages_page.cards_box.count(),
|
||
2) # 一张卡片 + 末尾 stretch
|
||
self.assertEqual(self.window.messages_button.text(), "✉1")
|
||
self.assertIn("1", self.window.messages_button.toolTip())
|
||
# 插件消息没进消息中心,而是留在队列里等页面打开
|
||
self.assertEqual(len(bus.log), 1)
|
||
self.assertEqual(bus.pending("com.example.dash"), 1)
|
||
self.assertEqual(self.window.messages_button.text(), "✉1")
|
||
|
||
def test_messages_center_mark_read_and_clear(self):
|
||
from svfs_client import topics
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||
self.window.bus.org, "admin")
|
||
for index in range(2):
|
||
self.window._on_push(topic, '{"id": "m%d", "body": "x"}' % index)
|
||
pump(_APP)
|
||
self.assertEqual(self.window.messages_button.text(), "✉2")
|
||
|
||
self.window._show_messages()
|
||
pump(_APP)
|
||
# 进消息中心即算读过(徽标不该一直挂着)
|
||
self.assertEqual(self.window.bus.log.unread(), 0)
|
||
self.assertEqual(self.window.messages_button.text(), "✉")
|
||
self.assertIs(self.window.stack.currentWidget(),
|
||
self.window.messages_page)
|
||
|
||
self.window.messages_page.clear_button.click()
|
||
pump(_APP)
|
||
self.assertEqual(len(self.window.bus.log), 0)
|
||
self.assertTrue(self.window.messages_page.empty.isVisible())
|
||
self.assertFalse(self.window.messages_page.read_button.isEnabled())
|
||
|
||
def test_plugin_push_is_buffered_until_page_opens(self):
|
||
"""插件页面没开时消息先攒着,页面建好后一次性补投。"""
|
||
from svfs_client import topics
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
bus = self.window.bus
|
||
topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin", "com.dash")
|
||
self.window._on_push(topic, '{"cpu": 1}')
|
||
self.assertEqual(bus.pending("com.dash"), 1)
|
||
|
||
pushed = []
|
||
|
||
class _FakeView(QWidget):
|
||
def push(self, payload):
|
||
pushed.append(payload)
|
||
|
||
def shutdown(self): # close_plugin 会调(见 tearDownModule)
|
||
pass
|
||
|
||
self.window._views["com.dash"] = _FakeView()
|
||
self.window._flush_plugin_pushes("com.dash")
|
||
self.assertEqual(pushed, ['{"cpu": 1}'])
|
||
self.assertEqual(bus.pending("com.dash"), 0)
|
||
# 页面已经开着时是直接投递,不进队列
|
||
self.window._on_push(topic, '{"cpu": 2}')
|
||
self.assertEqual(pushed, ['{"cpu": 1}', '{"cpu": 2}'])
|
||
self.assertEqual(bus.pending("com.dash"), 0)
|
||
|
||
def test_login_sync_feeds_bus_and_dedupes_with_push(self):
|
||
"""登录后先同步:补齐的消息与推送走同一条总线、按 id 去重。"""
|
||
from svfs_client import topics
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||
self.window.bus.org, "admin")
|
||
# 推送先到一条,补齐(HTTP)又把同一条送回来 → 只留一条
|
||
self.window._on_push(topic, '{"id": "m1", "title": "备份完成"}')
|
||
self.window._on_messages_synced(
|
||
{"messages": [{"id": "m1", "title": "备份完成"},
|
||
{"id": "m2", "title": "另一条"}],
|
||
"fetched": 2}, "admin")
|
||
pump(_APP)
|
||
self.assertEqual([m["id"] for m in self.window.bus.log.items()],
|
||
["m1", "m2"])
|
||
self.assertEqual(len(self.window.messages_page.cards_box.count() and
|
||
self.window.bus.log.items()), 2)
|
||
|
||
# 没配服务器地址时同步不报错,只是拿不到东西(登录后的自动同步
|
||
# 在测试环境里走的正是这条)
|
||
self.window._pending_sync = False
|
||
result = self.window.client.call_blocking("sync_messages", {})
|
||
self.assertEqual(result["messages"], [])
|
||
self.assertFalse(result["fetched"])
|
||
|
||
def test_stale_sync_result_is_discarded(self):
|
||
"""在途的补齐结果在退出登录/换账号后必须丢掉(不能喂给新会话)。"""
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
self.window._on_messages_synced(
|
||
{"messages": [{"id": "m1", "body": "上一个人的消息"}]}, "admin")
|
||
self.assertEqual(len(self.window.bus.log), 1)
|
||
|
||
# 退出登录:身份没了 → 在途结果丢弃(这里不抛异常也算断言的一部分)
|
||
self.window.logout()
|
||
pump(_APP)
|
||
self.window._on_messages_synced(
|
||
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
|
||
self.assertEqual(len(self.window.bus.log), 0)
|
||
|
||
# 换了账号:同一条在途结果属于旧身份 → 也丢弃
|
||
self.window.set_session({"username": "bob", "token": "tok2"})
|
||
self.window._on_messages_synced(
|
||
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
|
||
self.assertEqual(len(self.window.bus.log), 0)
|
||
|
||
def test_logout_stops_push_and_clears_messages(self):
|
||
from svfs_client import topics
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
topic = topics.user_message_topic(self.window.bus.prefix,
|
||
self.window.bus.org, "admin")
|
||
self.window._on_push(topic, '{"id": "m1", "body": "x"}')
|
||
self.assertEqual(len(self.window.bus.log), 1)
|
||
|
||
self.window.logout()
|
||
pump(_APP)
|
||
# 上一个身份的消息不能留给下一个人看
|
||
self.assertEqual(len(self.window.bus.log), 0)
|
||
self.assertEqual(self.window.messages_button.text(), "✉")
|
||
# 断开后推来的东西也进不来(总线身份已清空)
|
||
self.assertEqual(
|
||
self.window.bus.deliver(topic, '{"body": "x"}')["action"],
|
||
"forbidden")
|
||
|
||
def test_mqtt_state_is_shown_in_message_center(self):
|
||
from svfs_client import mqtt
|
||
|
||
# 走服务自己的状态机:它先更新状态、再发信号(生产路径就是这样)
|
||
self.window.mqtt._on_state(mqtt.STATE_ERROR, "认证失败")
|
||
pump(_APP)
|
||
self.assertIn("连接失败", self.window.messages_page.transport_label.text())
|
||
self.assertIn("消息推送", self.window.status_label.text())
|
||
|
||
def test_diagnostics_log_collects_errors(self):
|
||
from svfs_client import diagnostics
|
||
|
||
diagnostics.clear()
|
||
self.window._on_fail_silent("测试错误", "详情")
|
||
self.assertTrue(any("测试错误" in line
|
||
for line in diagnostics.recent()))
|
||
# 页面控制台消息也进日志(不要求 WebEngine:直接验证日志接口)
|
||
diagnostics.log("console 输出", "console:com.svfs.demo")
|
||
text = diagnostics.report({"数据目录": self.base})
|
||
self.assertIn("console:com.svfs.demo", text)
|
||
diagnostics.clear()
|
||
|
||
def test_install_debug_plugin_opens_new_page(self):
|
||
# 设置 → 桥调试台:装完自动打开(新装/升级都一样)
|
||
self.window._install_debug_plugin()
|
||
self.assertTrue(
|
||
pump(_APP, predicate=lambda: (
|
||
client_sample.PLUGIN_ID in self.window._views
|
||
), timeout=30),
|
||
"调试台安装后没有自动打开",
|
||
)
|
||
self.assertIs(
|
||
self.window.stack.currentWidget(),
|
||
self.window._views[client_sample.PLUGIN_ID],
|
||
)
|
||
# 再点一次走升级:视图因版本变化被收掉并重建,仍是当前页
|
||
self.window._install_debug_plugin()
|
||
self.assertTrue(pump(_APP, predicate=lambda: (
|
||
self.window.stack.currentWidget() is self.window._views.get(
|
||
client_sample.PLUGIN_ID)
|
||
), timeout=30))
|
||
|
||
def test_settings_dialog_applies_base_url(self):
|
||
from svfs_client.dialogs import SettingsDialog
|
||
|
||
dialog = SettingsDialog(
|
||
self.base, apply_base_url=self.window.set_base_url
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
# 合法地址:走 MainWindow.set_base_url(校验+落盘+同步界面)
|
||
dialog.server_edit.setText(" http://10.0.0.9:9000/ ")
|
||
dialog._apply_base_url_clicked()
|
||
self.assertFalse(dialog.server_error.isVisibleTo(dialog))
|
||
self.assertEqual(dialog.server_status.text(), "已保存")
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000"
|
||
)
|
||
self.assertEqual(
|
||
self.window.manager.server_url_label.full_text(),
|
||
"http://10.0.0.9:9000",
|
||
)
|
||
# 非法地址:报错、不落盘(服务器地址在"服务器"页签上)
|
||
dialog.show_tab("服务器")
|
||
dialog.server_edit.setText("ftp://nope")
|
||
dialog._apply_base_url_clicked()
|
||
self.assertTrue(dialog.server_error.isVisibleTo(dialog))
|
||
self.assertIn("http", dialog.server_error.text())
|
||
self.assertEqual(
|
||
client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000"
|
||
)
|
||
|
||
def test_user_center_refresh_and_actions(self):
|
||
from svfs_client.dialogs import UserCenterDialog
|
||
|
||
self.window.set_session({
|
||
"token": "tok", "username": "admin",
|
||
"base_url": "http://10.0.0.9:9000",
|
||
"login_time": "2026-09-15T10:00:00",
|
||
})
|
||
dialog = UserCenterDialog(
|
||
self.base,
|
||
session_provider=lambda: self.window._session,
|
||
on_relogin=self.window._relogin,
|
||
on_logout=self.window.logout,
|
||
)
|
||
self.addCleanup(dialog.deleteLater)
|
||
# 已登录态:头像/用户名/徽章/按钮
|
||
self.assertIn("admin", dialog.user_label.text())
|
||
self.assertEqual(dialog.state_badge.text(), "已登录")
|
||
self.assertTrue(dialog.logout_button.isEnabled())
|
||
self.assertTrue(dialog.relogin_button.objectName() != "Primary")
|
||
# 退出登录(真回调):凭据/会话清空,对话框关掉自己并把界面去向
|
||
# 交给主窗口(登录门在那个模态循环外面跑)
|
||
self.assertFalse(dialog.logout_requested)
|
||
dialog.logout_button.click()
|
||
self.assertIsNone(self.window._session)
|
||
self.assertTrue(dialog.logout_requested)
|
||
self.assertEqual(dialog.result(), QDialog.DialogCode.Accepted)
|
||
# 侧栏头像同步回到「未登录」
|
||
self.assertEqual(self.window.user_button.text(), "未登录")
|
||
|
||
def test_logout_closes_window_and_returns_to_login(self):
|
||
"""退出登录 = 关主窗口 + 回登录界面(强制登录下没有"未登录的主窗口")。"""
|
||
from svfs_client import credentials as client_credentials
|
||
|
||
payload = self._submit_and_wait("install_sample", {})
|
||
self.window.apply_payload(payload)
|
||
self.window.open_plugin(client_sample.PLUGIN_ID)
|
||
pump(_APP)
|
||
self.assertTrue(self.window._views, "插件页面没建起来,用例前提不成立")
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
client_credentials.save(self.base, "tok",
|
||
base_url="http://127.0.0.1:1",
|
||
username="admin")
|
||
|
||
fake = _FakeLoginWindow({"username": "bob", "token": "new"},
|
||
visible_probe=lambda: self.window.isVisible())
|
||
self._patch_login_window(fake)
|
||
self.window._logout_with_gate()
|
||
pump(_APP)
|
||
|
||
# 凭据清干净、插件页面收掉(不该留着上一个账号的界面状态)
|
||
self.assertEqual(client_credentials.load(self.base), "")
|
||
self.assertEqual(self.window._views, {})
|
||
# 登录期间主窗口是关掉的,登录成功后带着新会话回来
|
||
self.assertFalse(fake.main_window_visible_during)
|
||
self.assertEqual(self.window._session["username"], "bob")
|
||
self.assertTrue(self.window.isVisible())
|
||
self.assertEqual(self.window.user_button.text(), "bob")
|
||
|
||
def test_logout_cancelled_login_exits_client(self):
|
||
"""用户关掉登录窗:强制登录下没有可去的界面 → 关窗口并退出客户端。"""
|
||
fake = _FakeLoginWindow(None,
|
||
visible_probe=lambda: self.window.isVisible())
|
||
self._patch_login_window(fake)
|
||
app = QApplication.instance()
|
||
quits = []
|
||
original_quit = app.quit
|
||
app.quit = lambda: quits.append(1)
|
||
self.addCleanup(setattr, app, "quit", original_quit)
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
self.window._logout_with_gate()
|
||
pump(_APP)
|
||
|
||
self.assertFalse(fake.main_window_visible_during)
|
||
self.assertFalse(self.window.isVisible())
|
||
self.assertEqual(quits, [1], "没有请求退出客户端")
|
||
self.assertIsNone(self.window._session)
|
||
|
||
def test_logout_with_real_login_window_cancelled(self):
|
||
"""真登录窗(不填表,直接关掉):主窗口关掉、客户端请求退出。
|
||
|
||
上面两条用替身验证登录门的时序;这条用真窗口走一遍
|
||
show/exec/close,确认"隐藏主窗口 + 模态登录窗"在真 Qt 对象上不炸。
|
||
"""
|
||
from svfs_client import login as client_login
|
||
|
||
self.window.set_session({"username": "admin", "token": "tok"})
|
||
app = QApplication.instance()
|
||
quits = []
|
||
original_quit = app.quit
|
||
app.quit = lambda: quits.append(1)
|
||
self.addCleanup(setattr, app, "quit", original_quit)
|
||
|
||
# 登录门是模态的(exec 阻塞),只能在嵌套循环里用定时器关掉它
|
||
seen = {"login": False}
|
||
|
||
def close_login():
|
||
for widget in QApplication.topLevelWidgets():
|
||
if isinstance(widget, client_login.LoginWindow):
|
||
seen["login"] = True
|
||
widget.reject()
|
||
return
|
||
|
||
timer = QTimer()
|
||
timer.setInterval(50)
|
||
timer.timeout.connect(close_login)
|
||
timer.start()
|
||
self.addCleanup(timer.stop)
|
||
|
||
self.window._logout_with_gate()
|
||
pump(_APP)
|
||
|
||
self.assertTrue(seen["login"], "退出登录后没有出现登录窗")
|
||
self.assertFalse(self.window.isVisible(), "主窗口没关掉")
|
||
self.assertEqual(quits, [1], "没有请求退出客户端")
|
||
self.assertIsNone(self.window._session)
|
||
|
||
def _patch_login_window(self, fake):
|
||
"""把登录门里的登录窗换成替身(登录门内部才 import,patch 源头即可)。"""
|
||
from svfs_client import login as client_login
|
||
|
||
original = client_login.LoginWindow
|
||
client_login.LoginWindow = lambda *args, **kwargs: fake
|
||
self.addCleanup(setattr, client_login, "LoginWindow", original)
|
||
|
||
|
||
if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0":
|
||
|
||
class WebEngineSmokeTest(VFSTestCase):
|
||
"""端到端冒烟:WebView 经 svfs:// 加载示例插件 + 桥可用。
|
||
|
||
WebEngine 对环境挑剔(无显卡的容器等),起不来时跳过而不是误报。
|
||
"""
|
||
|
||
def test_sample_plugin_loads_and_bridge_works(self):
|
||
base = client_paths.ensure_layout(self.path("data"))
|
||
with quiet():
|
||
client_sample.build(self.path("sample.svfs"))
|
||
install_from_file(self.path("sample.svfs"), base=base)
|
||
window = MainWindow(base)
|
||
window.show()
|
||
try:
|
||
self.assertTrue(pump(_APP))
|
||
window.open_plugin(client_sample.PLUGIN_ID)
|
||
view = window._views.get(client_sample.PLUGIN_ID)
|
||
self.assertIsNotNone(view)
|
||
# 首开白屏修复:页面底色必须是主题底色,而不是 WebEngine
|
||
# 默认的白色
|
||
self.assertEqual(
|
||
view.page().backgroundColor().name(), client_theme.BG
|
||
)
|
||
except Exception:
|
||
# WebEngine 的 profile/子进程起不来:此环境无法冒烟,跳过
|
||
window._thread.quit()
|
||
window._thread.wait(5000)
|
||
window.deleteLater()
|
||
pump(_APP)
|
||
self.skipTest("此环境无法创建 WebEngine 视图(offscreen 冒烟被跳过)")
|
||
|
||
try:
|
||
loaded = {}
|
||
view.page().loadFinished.connect(
|
||
lambda ok: loaded.update(ok=ok)
|
||
)
|
||
if view.page().url().host():
|
||
# 可能已经加载完了
|
||
loaded.setdefault("ok", True)
|
||
self.assertTrue(
|
||
pump(_APP, predicate=lambda: loaded.get("ok"), timeout=40),
|
||
"页面在 40s 内没有加载完成——此环境可能无法运行 WebEngine",
|
||
)
|
||
|
||
# 桥已被注入且宿主信息渲染进 DOM
|
||
probe = {}
|
||
page = view.page()
|
||
|
||
def ask():
|
||
view.page().runJavaScript(
|
||
"typeof window.svfs === 'object' && typeof window.svfs.host.info === 'function'",
|
||
0,
|
||
lambda r: probe.update(bridge=r),
|
||
)
|
||
view.page().runJavaScript(
|
||
"document.querySelectorAll('#host-info dt').length > 0",
|
||
0,
|
||
lambda r: probe.update(dom=r),
|
||
)
|
||
return None
|
||
|
||
def probe_ready():
|
||
if probe.get("bridge") is None or probe.get("dom") is None:
|
||
ask() # 未就绪就继续探(runJavaScript 异步回调)
|
||
return (probe.get("bridge") is not None
|
||
and probe.get("dom") is not None)
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=probe_ready, timeout=15),
|
||
f"桥探针没有拿到结果: {probe}",
|
||
)
|
||
self.assertTrue(probe.get("bridge"), "window.svfs 桥没有注入成功")
|
||
self.assertTrue(probe.get("dom"), "host.info 的结果没有渲染进 DOM")
|
||
|
||
# 滚动条默认样式已注入页面(宿主给所有插件页统一暗色滚动条)
|
||
page.runJavaScript(
|
||
"!!document.querySelector('style[data-svfs-scrollbar]')",
|
||
0, lambda r: probe.update(scrollbar_css=r),
|
||
)
|
||
self.assertTrue(
|
||
pump(_APP, predicate=lambda: probe.get("scrollbar_css") is not None),
|
||
"滚动条样式探针没有拿到结果",
|
||
)
|
||
self.assertTrue(
|
||
probe.get("scrollbar_css"),
|
||
"宿主没有把滚动条默认样式注入插件页面",
|
||
)
|
||
|
||
# storage 桥端到端:页面里 set → Python 落盘 → get 回读
|
||
page.runJavaScript(
|
||
"(function(){ window.__probe='pending';"
|
||
" window.svfs.storage.set('manual', 42).then(function(ok){"
|
||
" window.svfs.storage.get('manual').then(function(v){"
|
||
" window.__probe = JSON.stringify({ok: ok, v: v,"
|
||
" count: document.getElementById('count').textContent});"
|
||
" }); }); })();",
|
||
0, lambda r: None,
|
||
)
|
||
storage = {}
|
||
|
||
def storage_ready():
|
||
page.runJavaScript("window.__probe", 0,
|
||
lambda r: storage.update(r=r))
|
||
r = storage.get("r")
|
||
return r is not None and r != "pending"
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=storage_ready, timeout=15),
|
||
f"storage 探针没有拿到结果: {storage}",
|
||
)
|
||
result = json.loads(storage["r"])
|
||
self.assertTrue(result["ok"])
|
||
self.assertEqual(result["v"], 42)
|
||
|
||
# 示例自己的访问计数是异步链(get → set → 渲染),
|
||
# 轮询到它变为 "1" 为止,避免和上面的探针抢时序
|
||
def count_ready():
|
||
page.runJavaScript("document.getElementById('count').textContent",
|
||
0, lambda r: storage.update(count=r))
|
||
return storage.get("count") == "1"
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=count_ready, timeout=15),
|
||
f"访问计数没有变成 1: {storage.get('count')}",
|
||
)
|
||
|
||
# net 桥端到端(页面级):本地假服务器 + 页面里发起请求,
|
||
# Promise 必须落定——这是"接口一直在请求中"的回归探针
|
||
server, base = start_api_server()
|
||
self.addCleanup(server.shutdown)
|
||
self.addCleanup(server.server_close)
|
||
window.set_base_url(base)
|
||
page.runJavaScript(
|
||
"(function(){ window.__net='pending';"
|
||
" if (!window.svfs.net) { window.__net='error:没有net'; return; }"
|
||
" window.svfs.net.request({method:'GET', path:'/api/ping'})"
|
||
" .then(function(r){ window.__net = JSON.stringify("
|
||
" {status: r.status, body: (r.bodyText || '').slice(0, 100)}); })"
|
||
" .catch(function(e){ window.__net = 'error:' + e.message; }); })();",
|
||
0, lambda r: None,
|
||
)
|
||
net_probe = {}
|
||
|
||
def net_ready():
|
||
page.runJavaScript("window.__net", 0,
|
||
lambda r: net_probe.update(r=r))
|
||
value = net_probe.get("r")
|
||
return value is not None and value != "pending"
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=net_ready, timeout=20),
|
||
"页面级 net 请求 20s 内没有落定(一直停在请求中)",
|
||
)
|
||
net_result = json.loads(net_probe["r"])
|
||
self.assertEqual(net_result["status"], 200)
|
||
self.assertIn("pong", net_result["body"])
|
||
self.assertIsNone(window.set_base_url(""))
|
||
|
||
# 消息通道端到端(页面级):插件订阅 → 宿主推送 → 回调收到。
|
||
# 这条覆盖 v5 的 messages 能力(页面里的 svfs.messages 与
|
||
# window.__svfsPush 的投递路径)
|
||
msgs = {}
|
||
page.runJavaScript(
|
||
"(function(){ window.__msg=null;"
|
||
" if (!window.svfs || !window.svfs.messages) { return 'no-api'; }"
|
||
" window.svfs.messages.subscribe(function (m) {"
|
||
" window.__msg = m.text; });"
|
||
" return 'ok'; })()",
|
||
0, lambda r: msgs.update(api=r),
|
||
)
|
||
self.assertTrue(
|
||
pump(_APP, predicate=lambda: msgs.get("api") is not None,
|
||
timeout=15),
|
||
"messages 桥探针没有拿到结果",
|
||
)
|
||
self.assertEqual(msgs.get("api"), "ok",
|
||
"示例插件声明了 messages 权限,却拿不到 svfs.messages")
|
||
|
||
view.push('{"kind": "dash", "value": 7}')
|
||
|
||
def pushed_ready():
|
||
page.runJavaScript("window.__msg", 0,
|
||
lambda r: msgs.update(received=r))
|
||
return msgs.get("received") is not None
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=pushed_ready, timeout=15),
|
||
f"推送没有到达插件页面: {msgs}",
|
||
)
|
||
self.assertIn("dash", msgs["received"])
|
||
|
||
# 渲染进程崩溃 → 崩溃占位页(而不是永久白屏)
|
||
from svfs_client import diagnostics
|
||
|
||
diagnostics.clear()
|
||
view._on_render_terminated(2, 139) # 2 = 进程崩溃
|
||
crash_probe = {}
|
||
|
||
def crash_ready():
|
||
page.runJavaScript(
|
||
"document.body ? document.body.innerText : ''", 0,
|
||
lambda r: crash_probe.update(t=r))
|
||
text = crash_probe.get("t") or ""
|
||
return "崩溃" in text
|
||
|
||
self.assertTrue(
|
||
pump(_APP, predicate=crash_ready, timeout=15),
|
||
f"崩溃占位页没有渲染: {crash_probe}",
|
||
)
|
||
self.assertTrue(
|
||
any("渲染进程退出" in line for line in diagnostics.recent()),
|
||
"崩溃事件没有进诊断日志",
|
||
)
|
||
diagnostics.clear()
|
||
finally:
|
||
window._thread.quit()
|
||
window._thread.wait(5000)
|
||
window.deleteLater()
|
||
pump(_APP)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|