From 699625ea0e6b870a3d64b5e31144b8554e09f286 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 14 Jul 2026 17:22:52 +0800 Subject: [PATCH] fix(accounts): keep first login launch in one window --- app/accounts.py | 181 ++++++++++++++++++++++++++++++----- app/chrome.py | 34 ++++++- app/gui/tabs/accounts.py | 73 +++++++++++++- docs/04-architecture.md | 6 +- docs/api.md | 10 +- docs/routes.md | 6 +- docs/tasks/T-633.md | 15 ++- tests/test_accounts.py | 200 ++++++++++++++++++++++++++++++++++++--- tests/test_chrome.py | 52 +++++++++- tests/test_gui.py | 91 +++++++++++++++++- 10 files changed, 608 insertions(+), 60 deletions(-) diff --git a/app/accounts.py b/app/accounts.py index 55c5138..b8f9146 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -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, } diff --git a/app/chrome.py b/app/chrome.py index fab46a5..3749389 100644 --- a/app/chrome.py +++ b/app/chrome.py @@ -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: diff --git a/app/gui/tabs/accounts.py b/app/gui/tabs/accounts.py index a15c2b8..01bda9d 100644 --- a/app/gui/tabs/accounts.py +++ b/app/gui/tabs/accounts.py @@ -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, ) - diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3a1f8cc..9ab3d02 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -92,9 +92,9 @@ T-538 后统一数据根为 `data/`:打包版默认 `/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:///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///__new. # AI 生成的新 - 无 Shopee tab 时打开卖家中心根地址 `https:///`(默认 `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 已验证事实(务必遵守) diff --git a/docs/api.md b/docs/api.md index 1b8374c..15cde2c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -222,7 +222,7 @@ login_status_text(status) -> str - `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/` 并写 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:///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:///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): diff --git a/docs/routes.md b/docs/routes.md index f549529..4995e50 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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` 使用 | diff --git a/docs/tasks/T-633.md b/docs/tasks/T-633.md index 4af701e..f46dcf0 100644 --- a/docs/tasks/T-633.md +++ b/docs/tasks/T-633.md @@ -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:///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:///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 样本未纳入验证快照或提交。 diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 9bd72e2..e569c8d 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -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) diff --git a/tests/test_chrome.py b/tests/test_chrome.py index 2f4b305..1b90f9b 100644 --- a/tests/test_chrome.py +++ b/tests/test_chrome.py @@ -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) diff --git a/tests/test_gui.py b/tests/test_gui.py index 09c59fa..851a9c9 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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()