fix(accounts): keep first login launch in one window

This commit is contained in:
chengma
2026-07-14 17:22:52 +08:00
parent a4ac3f8ee5
commit 699625ea0e
10 changed files with 608 additions and 60 deletions
+159 -22
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Optional
from urllib.parse import urlparse
@@ -12,6 +13,15 @@ from . import config as account_config
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
INITIAL_LOGIN_TAB_WAIT_SECONDS = 2.0
INITIAL_LOGIN_TAB_POLL_SECONDS = 0.1
SAFE_STARTUP_PAGE_PREFIXES = (
"about:blank",
"chrome://newtab",
"chrome://new-tab-page",
"chrome://welcome",
"chrome://intro",
)
class AccountError(RuntimeError):
@@ -222,13 +232,19 @@ def _seller_portal_url(account) -> str:
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
def _find_login_tab(account, host):
def _page_tabs(host):
return [
tab
for tab in cdp.http_get("/json", host=host)
if tab.get("type") == "page"
]
def _find_login_tab_in_tabs(account, tabs):
region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
seller_tabs = []
login_tabs = []
for tab in cdp.http_get("/json", host=host):
if tab.get("type") != "page":
continue
for tab in tabs:
url = str(tab.get("url") or "")
lower_url = url.lower()
if region_host in lower_url:
@@ -238,38 +254,157 @@ def _find_login_tab(account, host):
return (seller_tabs or login_tabs or [None])[0]
def _open_or_activate_login_tab(account):
host = _debug_host(account)
portal_url = _seller_portal_url(account)
tab = _find_login_tab(account, host)
if tab:
target_id = tab.get("id")
if target_id:
cdp.activate_tab(target_id, host=host)
return {
"target_id": target_id,
"url": tab.get("url") or portal_url,
"created_tab": False,
}
def _find_login_tab(account, host):
return _find_login_tab_in_tabs(account, _page_tabs(host))
tab = cdp.create_tab_info(portal_url, host=host)
def _wait_for_initial_login_tab(account, host, timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS):
timeout = max(0.0, float(timeout))
deadline = time.monotonic() + timeout
last_tabs = []
probe_error = None
first_probe = True
while first_probe or time.monotonic() < deadline:
first_probe = False
try:
last_tabs = _page_tabs(host)
probe_error = None
except Exception as exc:
probe_error = exc.__class__.__name__
tab = _find_login_tab_in_tabs(account, last_tabs)
if tab:
return tab, last_tabs, probe_error
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(INITIAL_LOGIN_TAB_POLL_SECONDS, remaining))
return None, last_tabs, probe_error
def _safe_startup_page(tabs):
if len(tabs) != 1:
return None
tab = tabs[0]
url = str(tab.get("url") or "").strip().lower()
if not url or any(url.startswith(prefix) for prefix in SAFE_STARTUP_PAGE_PREFIXES):
return tab
return None
def _navigate_page_target(tab, url):
websocket_url = tab.get("webSocketDebuggerUrl")
if not websocket_url:
raise AccountError("Chrome 初始页面缺少 CDP 地址,无法复用")
client = cdp.CDP(websocket_url)
try:
result = client.send("Page.navigate", {"url": url})
finally:
client.close()
if result.get("errorText"):
raise AccountError("Chrome 初始页面导航到蝦皮卖家中心失败")
def _activate_login_tab_result(
tab,
host,
portal_url,
*,
created_tab=False,
startup_target_reused=False,
startup_page_navigated=False,
page_target_count=0,
fallback_reason=None,
target_probe_error=None,
):
target_id = tab.get("id")
if target_id:
cdp.activate_tab(target_id, host=host)
return {
"target_id": target_id,
"url": tab.get("url") or portal_url,
"created_tab": True,
"created_tab": bool(created_tab),
"startup_target_reused": bool(startup_target_reused),
"startup_page_navigated": bool(startup_page_navigated),
"page_target_count": int(page_target_count),
"fallback_reason": fallback_reason,
"target_probe_error": target_probe_error,
}
def _open_or_activate_login_tab(
account,
*,
newly_launched=False,
startup_wait_timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS,
):
host = _debug_host(account)
portal_url = _seller_portal_url(account)
if newly_launched:
tab, tabs, probe_error = _wait_for_initial_login_tab(
account,
host,
timeout=startup_wait_timeout,
)
else:
tabs = _page_tabs(host)
tab = _find_login_tab_in_tabs(account, tabs)
probe_error = None
if tab:
return _activate_login_tab_result(
tab,
host,
portal_url,
startup_target_reused=newly_launched,
page_target_count=len(tabs),
target_probe_error=probe_error,
)
fallback_reason = "existing_chrome_no_login_tab"
if newly_launched:
startup_tab = _safe_startup_page(tabs)
if startup_tab:
try:
_navigate_page_target(startup_tab, portal_url)
except Exception as exc:
fallback_reason = "startup_navigation_failed"
probe_error = probe_error or exc.__class__.__name__
else:
navigated_tab = dict(startup_tab)
navigated_tab["url"] = portal_url
return _activate_login_tab_result(
navigated_tab,
host,
portal_url,
startup_target_reused=True,
startup_page_navigated=True,
page_target_count=len(tabs),
target_probe_error=probe_error,
)
elif tabs:
fallback_reason = "startup_page_not_safe"
else:
fallback_reason = "startup_page_missing"
tab = cdp.create_tab_info(portal_url, host=host)
return _activate_login_tab_result(
tab,
host,
portal_url,
created_tab=True,
page_target_count=len(tabs) + 1,
fallback_reason=fallback_reason,
target_probe_error=probe_error,
)
def launch_for_login(account_or_alias, path=None, config=None):
account = resolve_account(account_or_alias, path=path, config=config)
port = normalize_debug_port(_account_value(account, "debug_port"))
alias = _account_value(account, "alias")
portal_url = _seller_portal_url(account)
if chrome.is_running(port):
tab = _open_or_activate_login_tab(account)
tab = _open_or_activate_login_tab(account, newly_launched=False)
return {
"ok": True,
"action": "reused",
@@ -278,14 +413,15 @@ def launch_for_login(account_or_alias, path=None, config=None):
"alias": alias,
"debug_port": port,
"pid": None,
"initial_url": portal_url,
**tab,
}
process = chrome.launch_chrome(account, config=config)
process = chrome.launch_chrome(account, config=config, initial_url=portal_url)
timeout = appconfig.cdp_ready_timeout(config)
if not chrome.wait_debug_ready(port, timeout=timeout):
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
tab = _open_or_activate_login_tab(account)
tab = _open_or_activate_login_tab(account, newly_launched=True)
return {
"ok": True,
"action": "launched",
@@ -294,6 +430,7 @@ def launch_for_login(account_or_alias, path=None, config=None):
"alias": alias,
"debug_port": port,
"pid": getattr(process, "pid", None),
"initial_url": portal_url,
**tab,
}
+30 -4
View File
@@ -10,6 +10,7 @@ import subprocess
import time
import urllib.error
import urllib.request
from urllib.parse import urlsplit
from . import appconfig
from . import config as account_config
@@ -209,7 +210,27 @@ def _user_data_dir(account, config=None) -> str:
return account_config.ensure_user_data_dir(slug, config=config)
def build_launch_args(account, config=None) -> list:
def _validated_initial_url(value):
text = str(value or "").strip()
if not text:
return None
try:
parsed = urlsplit(text)
except ValueError as exc:
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址") from exc
hostname = str(parsed.hostname or "").lower()
is_shopee_host = hostname.startswith("shopee.") or ".shopee." in hostname
if (
parsed.scheme.lower() not in {"http", "https"}
or not is_shopee_host
or parsed.username
or parsed.password
):
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址")
return text
def build_launch_args(account, config=None, initial_url=None) -> list:
"""Build Chrome command arguments for one account."""
chrome_path = appconfig.chrome_path(config)
@@ -217,12 +238,17 @@ def build_launch_args(account, config=None) -> list:
raise ChromeLaunchError("chrome_path 不能为空")
port = _debug_port(account)
user_data_dir = _user_data_dir(account, config=config)
return [
args = [
chrome_path,
f"--remote-debugging-port={port}",
"--remote-allow-origins=*",
f"--user-data-dir={user_data_dir}",
]
startup_url = _validated_initial_url(initial_url)
if startup_url:
args.extend(("--no-first-run", "--no-default-browser-check"))
args.append(startup_url)
return args
def _safe_shortcut_name(value) -> str:
@@ -300,10 +326,10 @@ def create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, co
return shortcut_path
def launch_chrome(account, config=None) -> subprocess.Popen:
def launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen:
"""Launch Chrome for one account and return the process handle."""
args = build_launch_args(account, config=config)
args = build_launch_args(account, config=config, initial_url=initial_url)
try:
return subprocess.Popen(args)
except OSError as exc:
+69 -4
View File
@@ -110,6 +110,9 @@ class AccountsTab(QWidget):
self.account_rows = []
self.login_statuses = {}
self.threads = []
self._launching_accounts = set()
self._launch_guard_until = {}
self._launch_run_ids = {}
self.table = QTableWidget(0, len(self.COLUMNS))
self.table.setHorizontalHeaderLabels(self.COLUMNS)
@@ -173,6 +176,9 @@ class AccountsTab(QWidget):
def _update_button_state(self):
has_selection = self._selected_account() is not None
launch_in_progress = bool(self._launching_accounts)
self.table.setEnabled(not launch_in_progress)
self.add_button.setEnabled(not launch_in_progress)
for button in (
self.edit_button,
self.delete_button,
@@ -180,7 +186,33 @@ class AccountsTab(QWidget):
self.check_button,
self.shortcut_button,
):
button.setEnabled(has_selection)
button.setEnabled(has_selection and not launch_in_progress)
def _launch_guard_seconds(self):
app = QApplication.instance()
if app is None:
return 0.5
return max(0.25, app.doubleClickInterval() / 1000.0)
def _ignore_reentrant_launch(self, account):
alias = account.alias
now = time.monotonic()
if (
alias not in self._launching_accounts
and now >= self._launch_guard_until.get(alias, 0.0)
):
return False
_safe_add_run_log_event(
self._launch_run_ids.get(alias),
(
f"step=launch_chrome result=ignored detail=账号 {alias} "
"reason=launch_in_progress"
),
db_path=self.db_path,
account=account,
)
self._set_status("该账号 Chrome 正在启动,请稍候", level="info")
return True
def refresh_accounts(self):
try:
@@ -297,12 +329,27 @@ class AccountsTab(QWidget):
account = self._selected_account()
if account is None:
return
if self._ignore_reentrant_launch(account):
return
run_id = _safe_create_run_log(
"chrome_launch",
db_path=self.db_path,
total=1,
options={"alias": account.alias, "debug_port": account.debug_port},
)
self._launching_accounts.add(account.alias)
self._launch_run_ids[account.alias] = run_id
self._update_button_state()
try:
self._launch_login_once(account, run_id)
finally:
self._launching_accounts.discard(account.alias)
self._launch_guard_until[account.alias] = (
time.monotonic() + self._launch_guard_seconds()
)
self._update_button_state()
def _launch_login_once(self, account, run_id):
started = time.monotonic()
_safe_add_run_log_event(
run_id,
@@ -346,13 +393,27 @@ class AccountsTab(QWidget):
action = (launch_result or {}).get("action") or "launched"
pid = (launch_result or {}).get("pid")
target_id = (launch_result or {}).get("target_id") or ""
url = (launch_result or {}).get("url") or ""
url = diagnostics.redact_log_text((launch_result or {}).get("url") or "")
created_tab = bool((launch_result or {}).get("created_tab"))
startup_target_reused = bool(
(launch_result or {}).get("startup_target_reused")
)
startup_page_navigated = bool(
(launch_result or {}).get("startup_page_navigated")
)
page_target_count = int((launch_result or {}).get("page_target_count") or 0)
fallback_reason = (launch_result or {}).get("fallback_reason") or ""
event_result = "reused" if action == "reused" else "launched"
_safe_add_run_log_event(
run_id,
(
f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
f"pid={pid or ''} target_id={target_id} url={url} elapsed_ms={elapsed_ms}"
f"pid={pid or ''} target_id={target_id} url={url} "
f"created_tab={int(created_tab)} "
f"startup_target_reused={int(startup_target_reused)} "
f"startup_page_navigated={int(startup_page_navigated)} "
f"page_target_count={page_target_count} "
f"fallback_reason={fallback_reason} elapsed_ms={elapsed_ms}"
),
db_path=self.db_path,
account=account,
@@ -372,6 +433,11 @@ class AccountsTab(QWidget):
"pid": pid,
"target_id": target_id,
"url": url,
"created_tab": created_tab,
"startup_target_reused": startup_target_reused,
"startup_page_navigated": startup_page_navigated,
"page_target_count": page_target_count,
"fallback_reason": fallback_reason or None,
},
)
self.login_statuses[account.alias] = "已启动"
@@ -447,4 +513,3 @@ class AccountsTab(QWidget):
PLAINTEXT_SECRET_TITLE,
PLAINTEXT_PASSWORD_WARNING,
)
+3 -3
View File
@@ -92,9 +92,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
## 四、多账号隔离方案(决策)
采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段)。
采用**每账号独立 user-data-dir**(非 Chrome profile)。`--remote-debugging-port` 绑定在 user-data-dir/进程上,profile 方案无法每账号独立 CDP、串号风险高。启动主路径用程序 `subprocess` 直启(`--remote-debugging-port` + `--remote-allow-origins=*` + `--user-data-dir`);冷启动登录入口还追加 `--no-first-run`、`--no-default-browser-check`,并把经过校验的 `https://<region_host>/portal/` 作为唯一启动 URL 放在参数末尾,避免全新 profile 的 `chrome://intro/` 另开首次运行窗口。可选生成 `.lnk` 快捷方式(PowerShell `WScript.Shell`,参数写在「目标」字段),快捷方式不携带上述冷启动专用参数和 URL,保持原行为。
同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;端口已响应则复用该账号现有 Chrome/CDP,打开或激活卖家中心登录 tab 供人工登录,并记录为复用,不创建第二个账号窗口。
同一账号重复点击④「启动登录」不得重复执行 `subprocess.Popen`。正确流程是先探测该账号 `debug_port` 的 `/json/version`:端口未响应才按上述启动参数新开 Chrome;CDP 就绪后在最多 2 秒的有界预算内等待初始卖家中心 page target,出现后直接激活。若只出现唯一的 `about:blank`、Chrome 新标签页或全新 profile 的 `chrome://intro/`,则导航并复用该 target;只有完全没有安全可复用 page target 时才兜底创建一次,并返回 `fallback_reason`。端口已响应时复用该账号现有 Chrome/CDP,优先激活卖家中心/登录 tab;没有时才新增一个 tab,绝不改写或关闭用户已有普通页面。④ GUI 在启动期间禁用账号选择和操作,同账号连续触发写 `result=ignored reason=launch_in_progress`,不进入第二次底层启动。
## 五、数据模型
@@ -464,7 +464,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。
- 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。
- ④「启动登录」只负责准备该账号独立 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;若端口未响应,才用卖家中心初始 URL 启动 Chrome,并优先复用本轮初始 page target,避免空白窗口后再建第二页。返回结果用 `created_tab/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason` 说明实际路径,`chrome_launch` 日志同步记录且不得包含密码、Cookie 或 token。「检测登录」只验证当前 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 已验证事实(务必遵守)
+5 -5
View File
@@ -222,7 +222,7 @@ login_status_text(status) -> str
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
- `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`;若端口未响应,才启动带该账号 user-data-dir 和 CDP 端口的 Chrome,等待 CDP 就绪并返回 `launched=true`。不会读取、填写或提交密码,不绕过验证码。
- `launch_for_login()` 是幂等入口。先检查 `chrome.is_running(account.debug_port)`;若该账号 CDP 端口已响应,不再调用 `subprocess.Popen`,而是复用已打开的账号 Chrome,优先激活/打开 `https://<region_host>/portal/` 或卖家中心登录 tab,并返回 `reused=true`,且不会改写已有普通页面。若端口未响应,才启动带该账号 user-data-dir、CDP 端口和唯一卖家中心初始 URL 的 Chrome;CDP 就绪后短时等待并复用初始 target,只有无安全可复用 page target 时才兜底创建一次。返回值除 `action/pid/target_id/url/created_tab` 外,还带 `initial_url/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason/target_probe_error` 供日志诊断。不会读取、填写或提交密码,不绕过验证码。
- ③ 更新蝦皮的账号预检不会自动调用 `launch_for_login()`;Chrome 未启动/端口不可达时只返回阻断原因,由 GUI 提示用户去④手动打开账号浏览器并登录。T-105b 的复用逻辑只作用于④用户主动点击「启动登录」这一入口。
- `create_shortcut()` 生成 `.lnk`,目标/参数复用 `chrome.build_launch_args()`,不包含密码。
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
@@ -231,15 +231,15 @@ login_status_text(status) -> str
```python
class ChromeLaunchError(RuntimeError): ...
build_launch_args(account, config=None) -> list[str]
build_launch_args(account, config=None, initial_url=None) -> list[str]
# chrome + --remote-debugging-port + --remote-allow-origins=* + --user-data-dir
launch_chrome(account, config=None) -> subprocess.Popen
launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen
create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, config=None) -> str
wait_debug_ready(port, timeout=60, host="127.0.0.1") -> bool
is_running(port, host="127.0.0.1") -> bool
```
`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`。
`build_launch_args()` 接受 dict 或对象形式账号;账号需有 `debug_port`,并有 `user_data_dir` 或 `slug/alias`。可选 `initial_url` 仅接受不含账号密码的蝦皮 `http/https` 地址;传入时追加 `--no-first-run`、`--no-default-browser-check`,并把该 URL 作为唯一 URL 参数放在末尾,避免全新 profile 的首次运行页占用独立窗口;不传时参数保持兼容。端口探测访问 `/json/version`,显式禁用环境代理。`create_shortcut()` 使用 PowerShell `WScript.Shell.CreateShortcut` 生成 `.lnk`,`TargetPath` 为 Chrome,`Arguments` 含 `--remote-debugging-port`、`--remote-allow-origins=*`、`--user-data-dir=<该账号目录>`,不自动追加上述冷启动专用参数或卖家中心 URL。
## cdp 模块(`app/cdp.py`,T-000 由根目录 `cdp.py` 迁入)
@@ -460,7 +460,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示;首次保存/变更非空密码前弹窗提示“本地明文保存”。
- 「启动登录」必须幂等:若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome;未打开时才新启动 Chrome,仍由用户人工登录。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
- 「启动登录」必须幂等:新增账号后不自动启动;若该账号 Chrome/CDP 已打开,则复用现有窗口并打开/激活卖家中心登录 tab,不重复启动 Chrome,也不覆盖普通页面;未打开时用卖家中心初始 URL 新启动并复用初始 page target,异常时最多兜底新建一个 tab。启动期间禁用账号选择及增删改/登录相关操作,连续触发只记 `ignored` 日志;成功或失败均恢复控件。`chrome_launch` 日志记录初始 target 是否复用、是否兜底建 tab 和原因。「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列;若当前 URL 跳到 `accounts.shopee.tw/seller/login`,状态必须显示未登录;「快捷方式」调用 `accounts.create_shortcut()` 生成默认桌面 `.lnk`。
⑤ 设置当前要点(T-501):
+3 -3
View File
@@ -13,7 +13,7 @@
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 |
@@ -51,7 +51,7 @@
- ① 导入采集 与 ③ 更新蝦皮 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
- ① 点击「采集旧标题/旧封面」后,会为本轮匹配到账号的店铺自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后逐账号检测蝦皮登录态。明确进入登录页的账号任务整组略过并在结束汇总中提示去④人工登录;`NO_SESSION_COOKIE` 或 CDP 短暂读取异常只视为登录状态暂不确定,重试后仍不确定也继续尝试采集,避免误判批量略过。
- ③ 点击「开始更新」后必须检查当前筛选结果涉及的账号;只要有账号 Chrome 未启动、CDP 端口不可访问或 Shopee 未登录,就弹窗列出账号并中止本轮更新,不创建真实更新 worker,不提交任何商品。
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时也必须先检查该账号 CDP 端口,已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen` 新窗口。
- 可以提供「打开账号管理」或「启动登录」入口辅助用户处理当前账号;③ 不要无提示批量启动所有账号 Chrome,避免在提交线上前开错账号或启动过多浏览器进程。用户主动点击④「启动登录」时必须先检查该账号 CDP 端口:已打开则复用现有 Chrome 并打开/激活卖家中心 tab,不重复 `Popen`;未打开则直接携带卖家中心 URL 冷启动并复用初始 page target,不先开空白页再额外创建页面。启动过程中账号列表和相关按钮禁用,连续点击不排队启动第二轮。
- 老用户账号已就绪则无感。
## ① 导入采集
@@ -234,7 +234,7 @@
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
+13 -2
View File
@@ -3,7 +3,7 @@ id: T-633
title: ④新账号首次启动登录只打开一个 Chrome 窗口
phase: 1
deps: [T-105b]
status: TODO
status: DONE
created: 2026-07-14
---
@@ -20,6 +20,7 @@ created: 2026-07-14
- `chrome.wait_debug_ready()` 等待 CDP;
- `_open_or_activate_login_tab()` 再查找蝦皮页面,未找到时调用 `cdp.create_tab_info(portal_url)`。
3. `chrome.build_launch_args()` 当前只传 Chrome 路径、调试端口、`remote-allow-origins` 和账号 `user-data-dir`,没有携带 `https://<region_host>/portal/`。因此新 Chrome 会先按自身启动策略创建空白/新标签页;CDP 就绪后代码又执行一次 `Target.createTarget` 打开卖家中心。部分 Chrome 版本或窗口状态下,这会表现为一个初始窗口加一个卖家中心窗口,而不是同一窗口内唯一页面。
- Windows 全新临时 profile 实测进一步确认:Chrome 会先创建独立的 `chrome://intro/` 首次运行窗口并忽略命令行 URL;旧判断不把该页视为安全启动页,随后兜底 `Target.createTarget`,最终稳定复现 2 个 page target 和 2 个 `Chrome_WidgetWin_1` 顶层窗口。
4. 现有 `tests/test_accounts.py` 的冷启动测试明确断言 `launch_chrome()` 后还会调用一次 `create_tab_info()`,说明当前自动回归把“双阶段建页”当成了正常行为,未覆盖“首次启动只产生一个可见窗口/页面”的产品要求。
5. 本地 `chrome_launch` 运行日志中,最近两次启动记录相隔约 25 秒,各自只有一次 `start -> launched`,并记录不同 PID。该证据不支持“单次 GUI 回调调用两次 `Popen`”,但说明还需要防止用户连续点击或首轮 CDP 消失后再次启动。任务必须同时区分:
- 一次启动进程内额外创建页面/窗口;
@@ -32,6 +33,7 @@ T-105b 已解决“同账号 Chrome 的 CDP 端口仍在运行时,重复点击
### 1. 冷启动直接携带卖家中心 URL
- 给 `chrome.build_launch_args()` / `chrome.launch_chrome()` 增加可选 `initial_url` 参数;不传时保持现有调用兼容,传入时把规范化 URL 作为唯一启动页面追加到 Chrome 参数末尾。
- 传入 `initial_url` 的冷启动登录路径同时追加 `--no-first-run` 和 `--no-default-browser-check`,避免全新 profile 的 `chrome://intro/`/默认浏览器提示抢占独立窗口;不传 `initial_url` 的快捷方式和其他入口不追加这些参数。
- `accounts.launch_for_login()` 在确认账号端口未运行后,计算 `https://<region_host>/portal/`,调用:
```python
@@ -47,7 +49,7 @@ chrome.launch_chrome(account, config=config, initial_url=portal_url)
- `wait_debug_ready()` 成功后,不立即把“当前没有已完成导航的蝦皮 URL”解释为必须新建 target。
- 对本轮刚启动的 Chrome,在一个短且有上限的预算内重新枚举 `/json`,等待命令行携带的初始卖家中心页面出现;建议总预算不超过 2~3 秒、短间隔轮询,不使用无条件固定长 `sleep`。
- 如果已经出现卖家中心或 `accounts.shopee.<区域>/seller/login` 页面,直接激活该 target,返回 `created_tab=false` / `startup_target_reused=true`,不得再调用 `Target.createTarget`。
- 如果初始 URL 尚未完成,但本轮新 Chrome 已有唯一的普通 page target(如 `about:blank` / `chrome://newtab`),优先通过该 target 导航到卖家中心并激活,避免另建窗口。该行为只适用于“本轮刚启动”的冷启动路径。
- 如果初始 URL 尚未完成,但本轮新 Chrome 已有唯一的普通启动 page target(如 `about:blank` / `chrome://newtab` / `chrome://intro/`),优先通过该 target 导航到卖家中心并激活,避免另建窗口。该行为只适用于“本轮刚启动”的冷启动路径;端口启动前已经运行的 Chrome 不得把 `chrome://intro/` 当作可覆盖页面。
- 只有在有界等待结束后仍没有任何可复用的 page target 时,才允许兜底创建一个卖家中心 target;返回值和运行日志必须标记 `created_tab=true` 与兜底原因。
- 对“端口启动前已经运行”的 Chrome 继续沿用 T-105b:优先激活现有卖家中心/登录页;若没有,新增一个卖家中心 tab。不得为了单窗口目标导航、覆盖或关闭用户已有的普通页面。
@@ -129,3 +131,12 @@ chrome.launch_chrome(account, config=config, initial_url=portal_url)
- 不修改 Shopee 商品详情页 CDP 选择器、采集、标题/封面更新、AI/cmhub、Excel 或自动升级流程。
- 不自动登录、不填写或提交密码、不读取 Cookie 值、不绕过验证码、风控或权限校验。
- 实现时同步更新 `docs/04-architecture.md`、`docs/api.md` 和必要的 `docs/routes.md`;不修改冻结的 `docs/06-tasks.md`。
## 执行记录
- 2026-07-14 完成。`chrome.build_launch_args()` / `launch_chrome()` 已支持经过校验的蝦皮 `initial_url`;冷启动登录同时使用 `--no-first-run`、`--no-default-browser-check`,卖家中心 URL 保持为唯一末尾 URL。未传 `initial_url` 的快捷方式和既有启动调用参数不变。
- `accounts.launch_for_login()` 已区分冷启动与已运行 Chrome:冷启动在 2 秒有界预算内复用初始卖家中心/登录 target,唯一 `about:blank`、新标签页或 `chrome://intro/` 可导航复用,完全无安全 target 时才创建一次并返回兜底原因;已运行 Chrome 不改写或关闭用户普通页面。
- ④账号管理已增加启动中状态和连续点击保护;启动期间禁用账号列表及增删改/登录操作,底层调用成功或失败后恢复控件。同账号启动中或系统队列紧接着投递的重复点击不会再次调用 `launch_for_login()`,并写 `result=ignored reason=launch_in_progress`。启动日志新增 `created_tab/startup_target_reused/startup_page_navigated/page_target_count/fallback_reason`,且继续统一脱敏。
- 已同步 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`;测试覆盖启动 URL/首次运行参数校验、初始 target 立即或延迟出现、空白/intro 复用、无 target 单次兜底、运行中普通页保护、新增账号不自动启动、GUI 重入及成功/失败控件恢复。
- Windows 全新临时 profile 初次探测复现了 `chrome://intro/` 导致 2 个 page target/2 个顶层窗口,补齐首次运行参数和 intro 复用后复测通过:首次启动为 `launched`、`created_tab=false`、1 个 page target、1 个 `Chrome_WidgetWin_1`;再次调用为 `reused` 且 target 不变;关闭后使用同一 profile 再次冷启动仍为 1 个页面、1 个窗口。验证未读取或填写凭据,临时 Chrome 已通过 CDP 关闭并清理。
- 最终干净验证工作树中 `py -3.10 -m unittest discover -s tests` 共 495 项全部通过;`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。工作区中用户未提交的提示词、版本号、历史任务归档和 HTML 样本未纳入验证快照或提交。
+186 -14
View File
@@ -84,18 +84,18 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
class FakeProcess:
pid = 1234
initial_tab = {
"type": "page",
"id": "target-initial",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-initial",
}
with mock.patch("app.accounts.chrome.is_running", return_value=False) as is_running, \
mock.patch("app.accounts.chrome.launch_chrome", return_value=FakeProcess()) as launch, \
mock.patch("app.accounts.chrome.wait_debug_ready", return_value=True) as wait_ready, \
mock.patch("app.accounts.cdp.http_get", return_value=[]), \
mock.patch(
"app.accounts.cdp.create_tab_info",
return_value={
"id": "target-new",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-new",
},
) as create_tab_info, \
mock.patch("app.accounts.cdp.http_get", return_value=[initial_tab]), \
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts.launch_for_login("alias", config=cfg)
@@ -103,18 +103,190 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
self.assertTrue(result["launched"])
self.assertFalse(result["reused"])
self.assertEqual(1234, result["pid"])
self.assertEqual("target-new", result["target_id"])
self.assertEqual("target-initial", result["target_id"])
self.assertFalse(result["created_tab"])
self.assertTrue(result["startup_target_reused"])
is_running.assert_called_once_with(9222)
launch.assert_called_once_with(account, config=cfg)
launch.assert_called_once_with(
account,
config=cfg,
initial_url="https://seller.shopee.tw/portal/",
)
wait_ready.assert_called_once_with(9222, timeout=60)
create_tab_info.assert_called_once_with(
"https://seller.shopee.tw/portal/",
create_tab_info.assert_not_called()
activate_tab.assert_called_once_with(
"target-initial",
host="127.0.0.1:9222",
)
activate_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
self.assert_removed(temp_dir)
def test_new_chrome_waits_for_delayed_initial_login_target(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
blank_tab = {
"type": "page",
"id": "target-blank",
"url": "about:blank",
"webSocketDebuggerUrl": "ws-blank",
}
portal_tab = {
"type": "page",
"id": "target-portal",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-portal",
}
probe_count = 0
def page_targets(*_args, **_kwargs):
nonlocal probe_count
probe_count += 1
return [blank_tab] if probe_count == 1 else [portal_tab]
with mock.patch("app.accounts.cdp.http_get", side_effect=page_targets), \
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0.3,
)
self.assertGreaterEqual(probe_count, 2)
self.assertEqual("target-portal", result["target_id"])
self.assertTrue(result["startup_target_reused"])
self.assertFalse(result["created_tab"])
create_tab_info.assert_not_called()
activate_tab.assert_called_once_with(
"target-portal",
host="127.0.0.1:9222",
)
def test_new_chrome_navigates_its_only_blank_page(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
blank_tab = {
"type": "page",
"id": "target-blank",
"url": "about:blank",
"webSocketDebuggerUrl": "ws-blank",
}
client = mock.Mock()
client.send.return_value = {}
with mock.patch("app.accounts.cdp.http_get", return_value=[blank_tab]), \
mock.patch("app.accounts.cdp.CDP", return_value=client) as cdp_client, \
mock.patch("app.accounts.cdp.create_tab_info") as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0,
)
cdp_client.assert_called_once_with("ws-blank")
client.send.assert_called_once_with(
"Page.navigate",
{"url": "https://seller.shopee.tw/portal/"},
)
client.close.assert_called_once_with()
self.assertFalse(result["created_tab"])
self.assertTrue(result["startup_target_reused"])
self.assertTrue(result["startup_page_navigated"])
create_tab_info.assert_not_called()
activate_tab.assert_called_once_with(
"target-blank",
host="127.0.0.1:9222",
)
def test_new_chrome_treats_intro_as_reusable_startup_page(self):
intro_tab = {
"type": "page",
"id": "target-intro",
"url": "chrome://intro/",
"webSocketDebuggerUrl": "ws-intro",
}
self.assertEqual(intro_tab, accounts._safe_startup_page([intro_tab]))
def test_new_chrome_without_page_creates_one_fallback_target(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
created_tab = {
"type": "page",
"id": "target-fallback",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-fallback",
}
with mock.patch("app.accounts.cdp.http_get", return_value=[]), \
mock.patch(
"app.accounts.cdp.create_tab_info",
return_value=created_tab,
) as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab") as activate_tab:
result = accounts._open_or_activate_login_tab(
account,
newly_launched=True,
startup_wait_timeout=0,
)
self.assertTrue(result["created_tab"])
self.assertEqual("startup_page_missing", result["fallback_reason"])
create_tab_info.assert_called_once_with(
"https://seller.shopee.tw/portal/",
host="127.0.0.1:9222",
)
activate_tab.assert_called_once_with(
"target-fallback",
host="127.0.0.1:9222",
)
def test_running_chrome_preserves_normal_page_and_opens_login_tab(self):
account = {
"alias": "alias",
"region_host": "seller.shopee.tw",
"debug_port": 9222,
}
normal_tab = {
"type": "page",
"id": "target-user",
"url": "https://example.com/",
"webSocketDebuggerUrl": "ws-user",
}
created_tab = {
"type": "page",
"id": "target-login",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-login",
}
with mock.patch("app.accounts.cdp.http_get", return_value=[normal_tab]), \
mock.patch("app.accounts.cdp.CDP") as cdp_client, \
mock.patch(
"app.accounts.cdp.create_tab_info",
return_value=created_tab,
) as create_tab_info, \
mock.patch("app.accounts.cdp.activate_tab"):
result = accounts._open_or_activate_login_tab(
account,
newly_launched=False,
)
self.assertTrue(result["created_tab"])
self.assertEqual("existing_chrome_no_login_tab", result["fallback_reason"])
create_tab_info.assert_called_once()
cdp_client.assert_not_called()
def test_launch_for_login_reuses_running_chrome_without_starting_process(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
+51 -1
View File
@@ -236,6 +236,8 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertIn("--remote-debugging-port=9222", args)
self.assertIn("--remote-allow-origins=*", args)
self.assertIn(f"--user-data-dir={user_data_dir}", args)
self.assertNotIn("--no-first-run", args)
self.assertNotIn("--no-default-browser-check", args)
self.assertTrue(os.path.isdir(user_data_dir))
self.assert_removed(temp_dir)
@@ -255,6 +257,45 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_build_launch_args_appends_valid_initial_url_once(self):
with self.make_temp_dir() as temp_dir:
cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir}
account = {"alias": "alias", "debug_port": 9222}
initial_url = "https://seller.shopee.tw/portal/"
args = chrome.build_launch_args(
account,
config=cfg,
initial_url=initial_url,
)
self.assertEqual(initial_url, args[-1])
self.assertEqual(1, args.count(initial_url))
self.assertEqual(1, args.count("--no-first-run"))
self.assertEqual(1, args.count("--no-default-browser-check"))
self.assert_removed(temp_dir)
def test_build_launch_args_rejects_untrusted_initial_url(self):
account = {"alias": "alias", "debug_port": 9222}
cfg = {"chrome_path": "chrome.exe"}
for initial_url in (
"ftp://seller.shopee.tw/portal/",
"https://example.com/portal/",
"https://evilshopee.tw/portal/",
"https://user:secret@seller.shopee.tw/portal/",
"https:///portal/",
):
with self.subTest(initial_url=initial_url), self.assertRaises(
chrome.ChromeLaunchError
):
chrome.build_launch_args(
account,
config=cfg,
initial_url=initial_url,
)
def test_build_launch_args_rejects_invalid_account(self):
with self.assertRaises(chrome.ChromeLaunchError):
chrome.build_launch_args(
@@ -268,8 +309,13 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
cfg = {"chrome_path": "chrome.exe", "user_data_root": temp_dir}
process = object()
initial_url = "https://seller.shopee.tw/portal/"
with mock.patch("app.chrome.subprocess.Popen", return_value=process) as popen:
result = chrome.launch_chrome(account, config=cfg)
result = chrome.launch_chrome(
account,
config=cfg,
initial_url=initial_url,
)
self.assertIs(process, result)
popen.assert_called_once()
@@ -277,6 +323,7 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertEqual("chrome.exe", args[0])
self.assertIn("--remote-debugging-port=9222", args)
self.assertIn("--remote-allow-origins=*", args)
self.assertEqual(initial_url, args[-1])
self.assert_removed(temp_dir)
@@ -309,6 +356,9 @@ class ChromeTests(TempDirMixin, unittest.TestCase):
self.assertIn("--remote-allow-origins=*", script)
self.assertIn("--user-data-dir=", script)
self.assertIn("profile with space", script)
self.assertNotIn("seller.shopee", script)
self.assertNotIn("--no-first-run", script)
self.assertNotIn("--no-default-browser-check", script)
self.assert_removed(temp_dir)
+89 -2
View File
@@ -7231,7 +7231,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
with mock.patch("app.gui.AccountDialog", FakeDialog), mock.patch(
"app.gui.QMessageBox.warning"
) as warning:
) as warning, mock.patch(
"app.gui.accounts.launch_for_login"
) as launch_for_login:
tab.add_account()
warning.assert_called_once()
@@ -7247,6 +7249,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertNotIn("plain-password", visible_values)
self.assertIn("账号已新增", statuses[-1])
self.assertNotIn("plain-password", statuses[-1])
launch_for_login.assert_not_called()
self.assert_removed(temp_dir)
@@ -9045,6 +9048,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"pid": 1234,
"target_id": "target-new",
"url": "https://seller.shopee.tw/portal/",
"created_tab": False,
"startup_target_reused": True,
"startup_page_navigated": False,
"page_target_count": 1,
},
) as launch_for_login:
tab.launch_login()
@@ -9061,6 +9068,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("step=launch_chrome result=launched", messages)
self.assertIn("pid=1234", messages)
self.assertIn("target_id=target-new", messages)
self.assertIn("created_tab=0", messages)
self.assertIn("startup_target_reused=1", messages)
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
self.assert_removed(temp_dir)
@@ -9083,10 +9094,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
},
) as launch_for_login:
tab.launch_login()
tab.table.selectRow(0)
tab.launch_login()
launch_for_login.assert_called_once_with(account, config=cfg)
self.assertEqual("已启动", tab.login_statuses["alias-a"])
self.assertIn("已复用现有窗口", statuses[-1])
self.assertTrue(any("已复用现有窗口" in message for message in statuses))
run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0]
self.assertEqual("done", run_log.status)
self.assertEqual(1, run_log.success_count)
@@ -9096,7 +9109,81 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("step=launch_chrome result=reused", messages)
self.assertIn("target_id=target-existing", messages)
self.assertIn("url=https://seller.shopee.tw/portal/", messages)
self.assertIn("result=ignored", messages)
self.assert_removed(temp_dir)
def test_accounts_tab_ignores_reentrant_launch_and_restores_controls(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
statuses = []
tab = AccountsTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
tab.table.selectRow(0)
def launch_once(selected_account, config=None):
self.assertEqual(account, selected_account)
self.assertEqual(cfg, config)
self.assertFalse(tab.table.isEnabled())
self.assertFalse(tab.add_button.isEnabled())
self.assertFalse(tab.launch_button.isEnabled())
tab.launch_login()
return {
"action": "launched",
"pid": 1234,
"target_id": "target-initial",
"url": "https://seller.shopee.tw/portal/",
"created_tab": False,
"startup_target_reused": True,
"page_target_count": 1,
}
with mock.patch(
"app.gui.accounts.launch_for_login",
side_effect=launch_once,
) as launch_for_login:
tab.launch_login()
launch_for_login.assert_called_once_with(account, config=cfg)
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
run_logs = db.list_run_logs(
limit=10,
run_type="chrome_launch",
path=cfg["db_path"],
)
self.assertEqual(1, len(run_logs))
events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("result=ignored", messages)
self.assertIn("reason=launch_in_progress", messages)
self.assertTrue(any("正在启动" in message for message in statuses))
self.assert_removed(temp_dir)
def test_accounts_tab_launch_failure_restores_controls(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
tab = AccountsTab(config=cfg)
self.addCleanup(tab.close)
tab.table.selectRow(0)
with mock.patch(
"app.gui.accounts.launch_for_login",
side_effect=RuntimeError("启动失败"),
), mock.patch("app.gui.QMessageBox.warning"):
tab.launch_login()
self.assertTrue(tab.table.isEnabled())
self.assertTrue(tab.add_button.isEnabled())
self.assertTrue(tab.launch_button.isEnabled())
run_log = db.list_run_logs(
limit=1,
run_type="chrome_launch",
path=cfg["db_path"],
)[0]
self.assertEqual("failed", run_log.status)
if __name__ == "__main__":
unittest.main()