T-585 Chrome 路径自动检测
This commit is contained in:
+1
-1
@@ -71,7 +71,7 @@ LEGACY_USER_DATA_PATHS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
"chrome_path": "",
|
||||||
"user_data_root": "chrome_user_data_dir",
|
"user_data_root": "chrome_user_data_dir",
|
||||||
"image_dir": "images",
|
"image_dir": "images",
|
||||||
"db_path": "cmshopee.db",
|
"db_path": "cmshopee.db",
|
||||||
|
|||||||
+155
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -15,12 +16,166 @@ from . import config as account_config
|
|||||||
|
|
||||||
|
|
||||||
CDP_HOST = "127.0.0.1"
|
CDP_HOST = "127.0.0.1"
|
||||||
|
CHROME_APP_PATHS_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"
|
||||||
|
|
||||||
|
|
||||||
class ChromeLaunchError(RuntimeError):
|
class ChromeLaunchError(RuntimeError):
|
||||||
"""Raised when Chrome launch configuration is invalid."""
|
"""Raised when Chrome launch configuration is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chrome_path(value) -> str:
|
||||||
|
"""Return a filesystem-ready Chrome path, or an empty string."""
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"\"", "'"}:
|
||||||
|
text = text[1:-1].strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = os.path.expandvars(os.path.expanduser(text))
|
||||||
|
return os.path.abspath(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_winreg():
|
||||||
|
"""Return winreg only on Windows so non-Windows callers stay harmless."""
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return winreg
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_view_flags(winreg):
|
||||||
|
flags = [0]
|
||||||
|
for name in ("KEY_WOW64_64KEY", "KEY_WOW64_32KEY"):
|
||||||
|
value = getattr(winreg, name, 0)
|
||||||
|
if value and value not in flags:
|
||||||
|
flags.append(value)
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def _close_registry_key(key):
|
||||||
|
close = getattr(key, "Close", None) or getattr(key, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
try:
|
||||||
|
close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_chrome_candidates():
|
||||||
|
"""Yield Chrome App Paths values without exposing registry failures."""
|
||||||
|
|
||||||
|
winreg = _load_winreg()
|
||||||
|
if winreg is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
key_read = getattr(winreg, "KEY_READ", 0)
|
||||||
|
hives = (
|
||||||
|
getattr(winreg, "HKEY_CURRENT_USER", None),
|
||||||
|
getattr(winreg, "HKEY_LOCAL_MACHINE", None),
|
||||||
|
)
|
||||||
|
seen = set()
|
||||||
|
for hive in hives:
|
||||||
|
if hive is None:
|
||||||
|
continue
|
||||||
|
for view_flag in _registry_view_flags(winreg):
|
||||||
|
marker = (hive, view_flag)
|
||||||
|
if marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
key = None
|
||||||
|
try:
|
||||||
|
key = winreg.OpenKey(
|
||||||
|
hive,
|
||||||
|
CHROME_APP_PATHS_KEY,
|
||||||
|
0,
|
||||||
|
key_read | view_flag,
|
||||||
|
)
|
||||||
|
value, _value_type = winreg.QueryValueEx(key, "")
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
if key is not None:
|
||||||
|
_close_registry_key(key)
|
||||||
|
if value:
|
||||||
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_chrome_candidates():
|
||||||
|
"""Yield the supported Windows Chrome install locations."""
|
||||||
|
|
||||||
|
suffix = ("Google", "Chrome", "Application", "chrome.exe")
|
||||||
|
for variable in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
|
||||||
|
base = os.environ.get(variable, "").strip()
|
||||||
|
if base:
|
||||||
|
yield os.path.join(base, *suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured_chrome_path_valid(value) -> bool:
|
||||||
|
"""Return whether a configured Chrome executable can be launched safely."""
|
||||||
|
|
||||||
|
normalized = _normalize_chrome_path(value)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return True
|
||||||
|
text = str(value or "").strip().strip("\"")
|
||||||
|
return text.lower() == "chrome.exe" and bool(shutil.which(text))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_chrome_path() -> str:
|
||||||
|
"""Find an installed Google Chrome executable without probing other browsers."""
|
||||||
|
|
||||||
|
for candidate in _registry_chrome_candidates():
|
||||||
|
normalized = _normalize_chrome_path(candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
for candidate in _standard_chrome_candidates():
|
||||||
|
normalized = _normalize_chrome_path(candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
path_candidate = shutil.which("chrome.exe")
|
||||||
|
normalized = _normalize_chrome_path(path_candidate)
|
||||||
|
if normalized and os.path.isfile(normalized):
|
||||||
|
return normalized
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_configured_chrome_path(config=None, config_path=None) -> dict:
|
||||||
|
"""Persist an auto-detected path only when the configured path is unusable."""
|
||||||
|
|
||||||
|
path = config_path or (config or {}).get("config_path") or appconfig.CONFIG_PATH
|
||||||
|
current = appconfig.load_config(path) if config is None else config
|
||||||
|
configured_path = appconfig.chrome_path(current)
|
||||||
|
if is_configured_chrome_path_valid(configured_path):
|
||||||
|
return {
|
||||||
|
"changed": False,
|
||||||
|
"config": current,
|
||||||
|
"path": configured_path,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
detected_path = detect_chrome_path()
|
||||||
|
if not detected_path:
|
||||||
|
return {
|
||||||
|
"changed": False,
|
||||||
|
"config": current,
|
||||||
|
"path": configured_path,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = dict(current)
|
||||||
|
updated["chrome_path"] = detected_path
|
||||||
|
saved = appconfig.save_config(updated, path=path)
|
||||||
|
return {
|
||||||
|
"changed": True,
|
||||||
|
"config": saved,
|
||||||
|
"path": detected_path,
|
||||||
|
"message": f"已自动定位 Chrome:{detected_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _account_value(account, field, default=None):
|
def _account_value(account, field, default=None):
|
||||||
if isinstance(account, dict):
|
if isinstance(account, dict):
|
||||||
return account.get(field, default)
|
return account.get(field, default)
|
||||||
|
|||||||
+10
-2
@@ -6,7 +6,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
from .. import appconfig, diagnostics, update_check
|
from .. import appconfig, chrome, diagnostics, update_check
|
||||||
from ..version import APP_NAME, display_name
|
from ..version import APP_NAME, display_name
|
||||||
from . import widgets as _widgets
|
from . import widgets as _widgets
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
@@ -136,6 +136,14 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
if not _run_startup_update_gate():
|
if not _run_startup_update_gate():
|
||||||
return 1
|
return 1
|
||||||
window = MainWindow()
|
try:
|
||||||
|
startup = chrome.ensure_configured_chrome_path()
|
||||||
|
except (OSError, appconfig.ConfigError) as exc:
|
||||||
|
QMessageBox.critical(None, "启动配置错误", str(exc))
|
||||||
|
return 1
|
||||||
|
window = MainWindow(
|
||||||
|
config=startup["config"],
|
||||||
|
startup_status=startup["message"],
|
||||||
|
)
|
||||||
window.show()
|
window.show()
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
+10
-1
@@ -74,7 +74,14 @@ def _fit_and_center_window(
|
|||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window with the fixed five-tab workflow."""
|
"""Main application window with the fixed five-tab workflow."""
|
||||||
|
|
||||||
def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path=None,
|
||||||
|
config=None,
|
||||||
|
config_path=None,
|
||||||
|
ai_models_path=None,
|
||||||
|
startup_status="",
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._initial_window_fit_applied_after_show = False
|
self._initial_window_fit_applied_after_show = False
|
||||||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||||||
@@ -104,6 +111,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.setTabIcon(TAB_TITLES.index("③ 更新蝦皮"), _warning_dot_icon())
|
self.tabs.setTabIcon(TAB_TITLES.index("③ 更新蝦皮"), _warning_dot_icon())
|
||||||
self.setCentralWidget(self.tabs)
|
self.setCentralWidget(self.tabs)
|
||||||
self.show_status("就绪", level="muted")
|
self.show_status("就绪", level="muted")
|
||||||
|
if startup_status:
|
||||||
|
self.show_status(startup_status, level="success")
|
||||||
|
|
||||||
def showEvent(self, event):
|
def showEvent(self, event):
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from contextlib import contextmanager
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from ... import ai as ai_module
|
from ... import ai as ai_module
|
||||||
|
from ... import chrome
|
||||||
from ..widgets import *
|
from ..widgets import *
|
||||||
from ..workers import AIModelTestWorker as _RealAIModelTestWorker
|
from ..workers import AIModelTestWorker as _RealAIModelTestWorker
|
||||||
from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
|
from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
|
||||||
@@ -165,6 +166,8 @@ class SettingsTab(QWidget):
|
|||||||
self.chrome_path_edit.setObjectName("chromePathEdit")
|
self.chrome_path_edit.setObjectName("chromePathEdit")
|
||||||
self.chrome_path_browse_button = QPushButton("选择...")
|
self.chrome_path_browse_button = QPushButton("选择...")
|
||||||
self.chrome_path_browse_button.setObjectName("chromePathBrowseButton")
|
self.chrome_path_browse_button.setObjectName("chromePathBrowseButton")
|
||||||
|
self.chrome_path_detect_button = QPushButton("自动检测")
|
||||||
|
self.chrome_path_detect_button.setObjectName("chromePathAutoDetectButton")
|
||||||
self.chrome_path_widget = QWidget()
|
self.chrome_path_widget = QWidget()
|
||||||
self.chrome_path_widget.setObjectName("chromePathWidget")
|
self.chrome_path_widget.setObjectName("chromePathWidget")
|
||||||
chrome_path_layout = QHBoxLayout(self.chrome_path_widget)
|
chrome_path_layout = QHBoxLayout(self.chrome_path_widget)
|
||||||
@@ -172,6 +175,7 @@ class SettingsTab(QWidget):
|
|||||||
chrome_path_layout.setSpacing(8)
|
chrome_path_layout.setSpacing(8)
|
||||||
chrome_path_layout.addWidget(self.chrome_path_edit, 1)
|
chrome_path_layout.addWidget(self.chrome_path_edit, 1)
|
||||||
chrome_path_layout.addWidget(self.chrome_path_browse_button)
|
chrome_path_layout.addWidget(self.chrome_path_browse_button)
|
||||||
|
chrome_path_layout.addWidget(self.chrome_path_detect_button)
|
||||||
self.user_data_root_edit = QLineEdit()
|
self.user_data_root_edit = QLineEdit()
|
||||||
self.user_data_root_edit.setObjectName("userDataRootEdit")
|
self.user_data_root_edit.setObjectName("userDataRootEdit")
|
||||||
self.user_data_root_edit.setEnabled(False)
|
self.user_data_root_edit.setEnabled(False)
|
||||||
@@ -393,6 +397,7 @@ class SettingsTab(QWidget):
|
|||||||
self._update_response_timeout_label
|
self._update_response_timeout_label
|
||||||
)
|
)
|
||||||
self.chrome_path_browse_button.clicked.connect(self.browse_chrome_path)
|
self.chrome_path_browse_button.clicked.connect(self.browse_chrome_path)
|
||||||
|
self.chrome_path_detect_button.clicked.connect(self.detect_chrome_path)
|
||||||
self.save_config_button.clicked.connect(self.save_app_settings)
|
self.save_config_button.clicked.connect(self.save_app_settings)
|
||||||
self._connect_dirty_signals()
|
self._connect_dirty_signals()
|
||||||
|
|
||||||
@@ -535,6 +540,17 @@ class SettingsTab(QWidget):
|
|||||||
return
|
return
|
||||||
self.chrome_path_edit.setText(path)
|
self.chrome_path_edit.setText(path)
|
||||||
|
|
||||||
|
def detect_chrome_path(self, checked=False):
|
||||||
|
path = chrome.detect_chrome_path()
|
||||||
|
if not path:
|
||||||
|
self._set_status(
|
||||||
|
"未找到 Chrome,请点『选择...』手动指定,或先安装 Chrome",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.chrome_path_edit.setText(path)
|
||||||
|
self._set_status(f"已自动定位 Chrome:{path}", level="success")
|
||||||
|
|
||||||
def discard_unsaved_changes(self):
|
def discard_unsaved_changes(self):
|
||||||
try:
|
try:
|
||||||
saved = appconfig.load_config(self.config_path)
|
saved = appconfig.load_config(self.config_path)
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
|
|
||||||
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
|
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
|
||||||
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
||||||
|
- T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。⑤的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。
|
||||||
|
|
||||||
## 七、CDP 已验证事实(务必遵守)
|
## 七、CDP 已验证事实(务必遵守)
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -3,7 +3,7 @@ id: T-585
|
|||||||
title: Chrome 路径自动检测(注册表+标准目录,Chrome-only,仅空/失效才填)+ ⑤「自动检测」按钮
|
title: Chrome 路径自动检测(注册表+标准目录,Chrome-only,仅空/失效才填)+ ⑤「自动检测」按钮
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-557]
|
deps: [T-557]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-10
|
created: 2026-07-10
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,4 +67,7 @@ created: 2026-07-10
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
|
- 已完成:`appconfig.DEFAULT_CONFIG["chrome_path"]` 改为空;`app.chrome` 新增 Chrome-only 路径归一化、HKCU/HKLM App Paths(64/32 位视图)、标准目录和 PATH 检测,以及仅在当前配置无效时持久化自动定位结果的服务。
|
||||||
|
- 已完成:GUI 启动在数据目录和强制升级检查通过后执行自动定位,并把同一份配置传给主窗口;命中后状态栏保留“已自动定位 Chrome”。⑤设置页新增“自动检测”,只填输入框并标记未保存,不直接写配置;未命中不清空原值。
|
||||||
|
- 已同步:`docs/04-architecture.md` 记录路径来源与覆盖边界;`docs/troubleshooting.md` 增加 Chrome 路径排障步骤。
|
||||||
|
- 验证:干净工作树执行 `py -3.10 -m unittest discover -s tests`(335 项通过)、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`,全部通过。新增测试覆盖注册表视图/优先级、带引号环境变量值、LOCALAPPDATA、Edge 排除、PATH 保护、空/失效/有效配置,以及设置页和启动状态栏行为。
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
> 本文只记录可复用的本地排障步骤。T-538 后用户数据默认位于 `data/`,涉及 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/chrome_user_data_dir/`、`data/images/` 时,默认它们是本机敏感或业务数据,必须保持 gitignore,不把真实密码、API Key、Cookie、token 写入文档、日志或提交。
|
> 本文只记录可复用的本地排障步骤。T-538 后用户数据默认位于 `data/`,涉及 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/chrome_user_data_dir/`、`data/images/` 时,默认它们是本机敏感或业务数据,必须保持 gitignore,不把真实密码、API Key、Cookie、token 写入文档、日志或提交。
|
||||||
|
|
||||||
|
## ④启动登录提示 Chrome 路径无效或找不到 Chrome
|
||||||
|
|
||||||
|
### 推荐修复
|
||||||
|
|
||||||
|
打开⑤“设置”,在“Chrome路径”右侧先点“自动检测”。检测到后会填入完整路径并提示结果,随后点“保存设置”。
|
||||||
|
|
||||||
|
如果没有检测到,不会清空当前输入内容。请点“选择...”手动选择本机的 `chrome.exe`;便携版、任意目录解压版或没有注册表记录的 Chrome 都需要这一步。不要选择 Edge、Chromium 或文件夹路径,本软件只使用 Google Chrome 保持各账号独立登录态。
|
||||||
|
|
||||||
|
### 检查顺序
|
||||||
|
|
||||||
|
1. 确认本机已安装 Google Chrome,并能手动打开。
|
||||||
|
2. 在⑤先点“自动检测”,再保存设置。
|
||||||
|
3. 仍失败时,点“选择...”定位 `chrome.exe`,再回④点击“启动登录”。
|
||||||
|
|
||||||
|
程序首次启动只会在当前路径为空或已失效时自动定位 Chrome;已经保存且有效的自定义路径不会被自动覆盖。
|
||||||
|
|
||||||
## 启动时报 “AI 模型 category 必须是 text 或 image”
|
## 启动时报 “AI 模型 category 必须是 text 或 image”
|
||||||
|
|
||||||
### 现象
|
### 现象
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
config = appconfig.load_config(config_path)
|
config = appconfig.load_config(config_path)
|
||||||
self.assertTrue(os.path.exists(config_path))
|
self.assertTrue(os.path.exists(config_path))
|
||||||
|
self.assertEqual("", config["chrome_path"])
|
||||||
self.assertEqual(os.path.join(temp_dir, "images"), appconfig.image_dir(config))
|
self.assertEqual(os.path.join(temp_dir, "images"), appconfig.image_dir(config))
|
||||||
self.assertEqual(os.path.join(temp_dir, "chrome_user_data_dir"), appconfig.user_data_root(config))
|
self.assertEqual(os.path.join(temp_dir, "chrome_user_data_dir"), appconfig.user_data_root(config))
|
||||||
self.assertEqual(os.path.join(temp_dir, "cmshopee.db"), appconfig.db_path(config))
|
self.assertEqual(os.path.join(temp_dir, "cmshopee.db"), appconfig.db_path(config))
|
||||||
|
|||||||
+170
-1
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__))
|
|||||||
|
|
||||||
from _helpers import TempDirMixin
|
from _helpers import TempDirMixin
|
||||||
|
|
||||||
from app import chrome
|
from app import appconfig, chrome
|
||||||
from app import config as account_config
|
from app import config as account_config
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +51,175 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
|
|||||||
sock.close()
|
sock.close()
|
||||||
return port
|
return port
|
||||||
|
|
||||||
|
def make_executable(self, root, *parts):
|
||||||
|
path = os.path.join(root, *parts)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("chrome")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_registry_candidates_prioritize_current_user_and_all_views(self):
|
||||||
|
class FakeKey:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
class FakeWinreg:
|
||||||
|
HKEY_CURRENT_USER = "HKCU"
|
||||||
|
HKEY_LOCAL_MACHINE = "HKLM"
|
||||||
|
KEY_READ = 1
|
||||||
|
KEY_WOW64_64KEY = 16
|
||||||
|
KEY_WOW64_32KEY = 32
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.values = {
|
||||||
|
(self.HKEY_CURRENT_USER, self.KEY_READ | self.KEY_WOW64_32KEY): "user-chrome.exe",
|
||||||
|
(self.HKEY_LOCAL_MACHINE, self.KEY_READ): "machine-chrome.exe",
|
||||||
|
}
|
||||||
|
|
||||||
|
def OpenKey(self, hive, sub_key, _reserved, access):
|
||||||
|
self.calls.append((hive, sub_key, access))
|
||||||
|
try:
|
||||||
|
return FakeKey(self.values[(hive, access)])
|
||||||
|
except KeyError as exc:
|
||||||
|
raise FileNotFoundError() from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def QueryValueEx(key, name):
|
||||||
|
self.assertEqual("", name)
|
||||||
|
return key.value, 1
|
||||||
|
|
||||||
|
fake = FakeWinreg()
|
||||||
|
with mock.patch("app.chrome._load_winreg", return_value=fake):
|
||||||
|
candidates = list(chrome._registry_chrome_candidates())
|
||||||
|
|
||||||
|
self.assertEqual(["user-chrome.exe", "machine-chrome.exe"], candidates)
|
||||||
|
self.assertEqual("HKCU", fake.calls[0][0])
|
||||||
|
self.assertEqual(chrome.CHROME_APP_PATHS_KEY, fake.calls[0][1])
|
||||||
|
self.assertIn(("HKCU", chrome.CHROME_APP_PATHS_KEY, 1 | 16), fake.calls)
|
||||||
|
self.assertIn(("HKCU", chrome.CHROME_APP_PATHS_KEY, 1 | 32), fake.calls)
|
||||||
|
self.assertIn(("HKLM", chrome.CHROME_APP_PATHS_KEY, 1), fake.calls)
|
||||||
|
|
||||||
|
def test_detect_chrome_path_normalizes_registry_value(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
executable = self.make_executable(temp_dir, "Chrome", "chrome.exe")
|
||||||
|
environment_name = "CMSHOPEE_TEST_CHROME"
|
||||||
|
registry_value = f'"%{environment_name}%"'
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, {environment_name: executable}, clear=False), \
|
||||||
|
mock.patch(
|
||||||
|
"app.chrome._registry_chrome_candidates",
|
||||||
|
return_value=[registry_value],
|
||||||
|
), \
|
||||||
|
mock.patch("app.chrome._standard_chrome_candidates", return_value=[]), \
|
||||||
|
mock.patch("app.chrome.shutil.which", return_value=None):
|
||||||
|
detected = chrome.detect_chrome_path()
|
||||||
|
|
||||||
|
self.assertEqual(os.path.abspath(executable), detected)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_detect_chrome_path_uses_local_app_data_without_registry(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
executable = self.make_executable(
|
||||||
|
temp_dir,
|
||||||
|
"Google",
|
||||||
|
"Chrome",
|
||||||
|
"Application",
|
||||||
|
"chrome.exe",
|
||||||
|
)
|
||||||
|
with mock.patch.dict(os.environ, {"LOCALAPPDATA": temp_dir}, clear=True), \
|
||||||
|
mock.patch("app.chrome._registry_chrome_candidates", return_value=[]), \
|
||||||
|
mock.patch("app.chrome.shutil.which", return_value=None):
|
||||||
|
detected = chrome.detect_chrome_path()
|
||||||
|
|
||||||
|
self.assertEqual(os.path.abspath(executable), detected)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_detect_chrome_path_never_uses_edge_and_handles_missing_registry(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
self.make_executable(
|
||||||
|
temp_dir,
|
||||||
|
"Microsoft",
|
||||||
|
"Edge",
|
||||||
|
"Application",
|
||||||
|
"msedge.exe",
|
||||||
|
)
|
||||||
|
with mock.patch("app.chrome._load_winreg", return_value=None), \
|
||||||
|
mock.patch.dict(os.environ, {"LOCALAPPDATA": temp_dir}, clear=True), \
|
||||||
|
mock.patch("app.chrome.shutil.which", return_value=None):
|
||||||
|
detected = chrome.detect_chrome_path()
|
||||||
|
|
||||||
|
self.assertEqual("", detected)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_ensure_configured_chrome_path_persists_only_when_invalid(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config_path = os.path.join(temp_dir, "config.json")
|
||||||
|
detected = self.make_executable(temp_dir, "Chrome", "chrome.exe")
|
||||||
|
config = appconfig.load_config(config_path)
|
||||||
|
|
||||||
|
with mock.patch("app.chrome.detect_chrome_path", return_value=detected):
|
||||||
|
first = chrome.ensure_configured_chrome_path(config)
|
||||||
|
|
||||||
|
self.assertTrue(first["changed"])
|
||||||
|
self.assertEqual(os.path.abspath(detected), first["config"]["chrome_path"])
|
||||||
|
self.assertIn("已自动定位 Chrome", first["message"])
|
||||||
|
self.assertEqual(
|
||||||
|
os.path.abspath(detected),
|
||||||
|
appconfig.load_config(config_path)["chrome_path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = os.path.join(temp_dir, "missing", "chrome.exe")
|
||||||
|
invalid = appconfig.save_config(
|
||||||
|
{"chrome_path": missing},
|
||||||
|
path=config_path,
|
||||||
|
)
|
||||||
|
with mock.patch("app.chrome.detect_chrome_path", return_value=detected):
|
||||||
|
repaired = chrome.ensure_configured_chrome_path(invalid)
|
||||||
|
self.assertTrue(repaired["changed"])
|
||||||
|
self.assertEqual(os.path.abspath(detected), repaired["config"]["chrome_path"])
|
||||||
|
|
||||||
|
custom = self.make_executable(temp_dir, "Portable", "chrome.exe")
|
||||||
|
valid = appconfig.save_config(
|
||||||
|
{"chrome_path": custom},
|
||||||
|
path=config_path,
|
||||||
|
)
|
||||||
|
with mock.patch("app.chrome.detect_chrome_path") as detect:
|
||||||
|
unchanged = chrome.ensure_configured_chrome_path(valid)
|
||||||
|
self.assertFalse(unchanged["changed"])
|
||||||
|
self.assertEqual(custom, unchanged["config"]["chrome_path"])
|
||||||
|
detect.assert_not_called()
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_ensure_configured_chrome_path_preserves_path_command_and_no_match(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config_path = os.path.join(temp_dir, "config.json")
|
||||||
|
config = appconfig.save_config(
|
||||||
|
{"chrome_path": "chrome.exe"},
|
||||||
|
path=config_path,
|
||||||
|
)
|
||||||
|
resolved_path = self.make_executable(temp_dir, "PATH", "chrome.exe")
|
||||||
|
|
||||||
|
with mock.patch("app.chrome.shutil.which", return_value=resolved_path), \
|
||||||
|
mock.patch("app.chrome.detect_chrome_path") as detect:
|
||||||
|
unchanged = chrome.ensure_configured_chrome_path(config)
|
||||||
|
self.assertFalse(unchanged["changed"])
|
||||||
|
detect.assert_not_called()
|
||||||
|
|
||||||
|
empty = appconfig.save_config({"chrome_path": ""}, path=config_path)
|
||||||
|
with mock.patch("app.chrome.detect_chrome_path", return_value=""), \
|
||||||
|
mock.patch("app.chrome.appconfig.save_config") as save:
|
||||||
|
no_match = chrome.ensure_configured_chrome_path(empty)
|
||||||
|
self.assertFalse(no_match["changed"])
|
||||||
|
self.assertEqual("", no_match["message"])
|
||||||
|
save.assert_not_called()
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_build_launch_args_includes_required_flags(self):
|
def test_build_launch_args_includes_required_flags(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = {
|
cfg = {
|
||||||
|
|||||||
@@ -889,6 +889,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual("选择...", tab.chrome_path_browse_button.text())
|
self.assertEqual("选择...", tab.chrome_path_browse_button.text())
|
||||||
self.assertEqual("chromePathBrowseButton", tab.chrome_path_browse_button.objectName())
|
self.assertEqual("chromePathBrowseButton", tab.chrome_path_browse_button.objectName())
|
||||||
|
self.assertEqual("自动检测", tab.chrome_path_detect_button.text())
|
||||||
|
self.assertEqual(
|
||||||
|
"chromePathAutoDetectButton",
|
||||||
|
tab.chrome_path_detect_button.objectName(),
|
||||||
|
)
|
||||||
self.assertIs(
|
self.assertIs(
|
||||||
tab.chrome_path_widget.layout().itemAt(0).widget(),
|
tab.chrome_path_widget.layout().itemAt(0).widget(),
|
||||||
tab.chrome_path_edit,
|
tab.chrome_path_edit,
|
||||||
@@ -897,6 +902,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
tab.chrome_path_widget.layout().itemAt(1).widget(),
|
tab.chrome_path_widget.layout().itemAt(1).widget(),
|
||||||
tab.chrome_path_browse_button,
|
tab.chrome_path_browse_button,
|
||||||
)
|
)
|
||||||
|
self.assertIs(
|
||||||
|
tab.chrome_path_widget.layout().itemAt(2).widget(),
|
||||||
|
tab.chrome_path_detect_button,
|
||||||
|
)
|
||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
@@ -942,6 +951,53 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_settings_tab_auto_detects_chrome_without_saving(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
statuses = []
|
||||||
|
tab = SettingsTab(config=cfg, status_callback=statuses.append)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
detected = os.path.join(temp_dir, "Chrome", "chrome.exe")
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.settings.chrome.detect_chrome_path",
|
||||||
|
return_value=detected,
|
||||||
|
):
|
||||||
|
tab.chrome_path_detect_button.click()
|
||||||
|
|
||||||
|
self.assertEqual(detected, tab.chrome_path_edit.text())
|
||||||
|
self.assertTrue(tab.is_dirty())
|
||||||
|
self.assertFalse(os.path.exists(cfg["config_path"]))
|
||||||
|
self.assertIn("已自动定位 Chrome", statuses[-1])
|
||||||
|
|
||||||
|
tab._set_dirty(False)
|
||||||
|
previous = tab.chrome_path_edit.text()
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.settings.chrome.detect_chrome_path",
|
||||||
|
return_value="",
|
||||||
|
):
|
||||||
|
tab.chrome_path_detect_button.click()
|
||||||
|
|
||||||
|
self.assertEqual(previous, tab.chrome_path_edit.text())
|
||||||
|
self.assertFalse(tab.is_dirty())
|
||||||
|
self.assertIn("未找到 Chrome", statuses[-1])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_main_window_keeps_startup_chrome_detection_status(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
message = "已自动定位 Chrome:C:\\Chrome\\chrome.exe"
|
||||||
|
window = MainWindow(
|
||||||
|
config=self.make_config(temp_dir),
|
||||||
|
startup_status=message,
|
||||||
|
)
|
||||||
|
self.addCleanup(window.close)
|
||||||
|
|
||||||
|
self.assertEqual(message, window.statusBar().currentMessage())
|
||||||
|
self.assertIn(gui.COLOR_SUCCESS, window.statusBar().styleSheet())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_settings_tab_adds_saves_and_deletes_model(self):
|
def test_settings_tab_adds_saves_and_deletes_model(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self.make_config(temp_dir)
|
cfg = self.make_config(temp_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user