Files
svfs/tests/test_client_ui.py
T
chakcy e0d5bad7b2 feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签
一、消息模块:服务端主动推送 + 插件定向分发
* 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。
2026-09-17 01:06:02 +08:00

1884 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()