Compare commits

...

4 Commits

Author SHA1 Message Date
7d0ec94210 feat: take over host delete so profile dirs are removed
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-10-01 09:56:55 +08:00
1ba7a0f4a1 feat: open browser with stable CDP port for scrape attach
All checks were successful
技能自动化发布 / release (push) Successful in 11s
2026-09-16 15:40:50 +08:00
71b20e6e4a fix: use --restore-last-session instead of unsigned Preferences restore_on_startup
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-09-16 14:38:03 +08:00
7709292f1c fix: recognize modern Chrome Sessions so open restores login
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-09-16 14:13:38 +08:00
10 changed files with 364 additions and 45 deletions

View File

@@ -3,6 +3,22 @@
本文件对应技能市场 **「更新日志」** Tab。
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节。
## 2.0.31
- 数据管理「删除」改为技能接管(`replaces: delete`):删账号行和凭证的同时删除硬盘 Profile 目录,避免再建同名账号带回旧登录态
## 2.0.30
- 「打开浏览器」为本账号 Profile 附加稳定本机调试口(与科力普等采集技能约定同一端口算法),便于采集挂接已打开的原生窗口;仍使用 `--restore-last-session` 恢复登录态
## 2.0.29
- 「打开浏览器」有可恢复会话时改用 `--restore-last-session` 强制恢复标签与 Session Cookie;不再裸写受 Secure Preferences HMAC 保护的 `restore_on_startup`(该写法会被 Chrome 否掉并落到新标签页)
## 2.0.28
- 「打开浏览器」识别现代 Chrome `Default/Sessions/Session_*`(及 `Tabs_*`),有可恢复会话时不再强制带平台首页 URL,避免冲掉 Session Cookie 登录态
## 2.0.27
- 「打开浏览器」:Profile 已占用时复用窗口;加固 Preferences/Local State「继续上次会话」;有历史会话时冷启动不带 URL,避免冲掉登录态

View File

@@ -1,7 +1,7 @@
---
name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.27
version: 2.0.31
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -32,19 +32,20 @@ allowed-tools:
3. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
4. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
5. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
6. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
7. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
8. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
9. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
11. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
12. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
13. **管理 lease**:`lease release/force-release/list/cleanup` — 防止 RPA 并发;数据管理「账号租约」行右键可强制释放。
14. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
16. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
17. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
18. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
6. **删除账号**:`delete id <id>` — 删除账号行、关联凭证,以及硬盘上的 `profile_dir`。数据管理「删除」已绑定(`replaces: delete`),勿用宿主直写库,否则会留下旧 Profile / 登录态。
7. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
8. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
9. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
10. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
11. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
12. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
13. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
14. **管理 lease**:`lease release/force-release/list/cleanup` — 防止 RPA 并发;数据管理「账号租约」行右键可强制释放。
15. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
16. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
17. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
18. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
19. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
## CLI 调用

View File

@@ -1,6 +1,6 @@
# assets
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」:新增/编辑/删除接管,行内打开目录 / 打开浏览器 / 复制)。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。

View File

@@ -162,6 +162,39 @@
]
}
},
{
"id": "account-delete",
"label": "删除账号",
"description": "删除账号记录、关联凭证,以及硬盘上的浏览器 Profile 目录。再建同名账号会得到空 Profile,不会带回旧登录态。",
"replaces": "delete",
"placements": [],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": {
"message": "将删除这条账号、关联凭证,以及硬盘上的浏览器 Profile 目录。登录态不可恢复。是否继续?"
},
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"readOnly": true
}
}
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "delete",
"args": ["id", "{{accountId}}"]
}
},
{
"id": "account-copy",
"label": "复制",

View File

@@ -91,7 +91,7 @@
| `pick-web <platform>` | `account pick-web --platform <platform>`(无 lease 参数) |
| `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | 映射到 `account add-web`(`login_id_type=phone`),不传 v2 扩展参数 |
| `delete id\|platform <...>` | 删除账号 |
| `delete id\|platform <...>` | 删除账号(含硬盘 `profile_dir`;数据管理「删除」走 `delete id`) |
---
@@ -122,7 +122,7 @@ python main.py account update --id 4 --label "抖音 备用" --login-id 13900001
- `copy`:复制平台、环境、角色、入口链接、登录策略;**新建空 Profile**;登录账号留空;名称自动「副本」/「副本 2」。不复制凭证、设备指纹、会话状态。
- `update`:只改名称、登录账号、标识类型、入口链接、状态。空入口链接回退为平台默认 URL。
- 数据管理「新增」只填平台 / 账号名称 / 登录账号;「编辑」走 `replaces: update` 短表单;行内「复制」为 `interaction: direct`。
- 数据管理「新增」只填平台 / 账号名称 / 登录账号;「编辑」走 `replaces: update` 短表单;「删除」走 `replaces: delete`(`delete id <id>`,含硬盘 Profile);行内「复制」为 `interaction: direct`。
---

View File

@@ -14,7 +14,10 @@ import sys
from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db
from service.chrome_profile_prefs import ensure_session_restore
from service.chrome_profile_prefs import (
ensure_session_restore,
profile_has_restorable_session,
)
from util.logging_config import get_skill_logger
from util.platforms import PLATFORMS
@@ -84,21 +87,50 @@ HUMAN_CHROME_FLAGS = (
"--disable-search-engine-choice-screen",
)
# 强制恢复上次会话(含 Session Cookie)。不依赖 Preferences.restore_on_startup
#(该项受 Secure Preferences HMAC 保护,裸写会被 Chrome 否掉)。
RESTORE_LAST_SESSION_FLAG = "--restore-last-session"
def human_chrome_launch_args(profile_dir, url=None):
# 与 scrape-product-colipu 等业务技能约定同一算法,便于 CDP 挂接已打开的账号窗口。
_CDP_PORT_BASE = 9330
_CDP_PORT_SPAN = 60
def cdp_port_for_profile(profile_dir: str) -> int:
"""按 profile 绝对路径派生稳定本机调试端口(9330–9389)。"""
import hashlib
key = os.path.abspath(profile_dir).replace("/", "\\").lower().encode("utf-8")
digest = hashlib.sha1(key).hexdigest()
return _CDP_PORT_BASE + (int(digest[:8], 16) % _CDP_PORT_SPAN)
def human_chrome_launch_args(profile_dir, url=None, restore_session=False, *, enable_cdp=True):
"""chrome.exe 之后的参数(Popen 列表形式,路径不额外加引号)。"""
profile_abs = os.path.abspath(profile_dir)
args = [f"--user-data-dir={profile_abs}", *HUMAN_CHROME_FLAGS]
if restore_session:
args.append(RESTORE_LAST_SESSION_FLAG)
if enable_cdp:
port = cdp_port_for_profile(profile_abs)
args.append(f"--remote-debugging-port={port}")
args.append("--remote-debugging-address=127.0.0.1")
cleaned = (url or "").strip()
if cleaned:
args.append(cleaned)
return args
def human_chrome_shortcut_arguments(profile_dir, url=None):
def human_chrome_shortcut_arguments(profile_dir, url=None, restore_session=False, *, enable_cdp=True):
"""Windows .lnk Arguments 字符串(user-data-dir 带引号)。"""
profile_abs = os.path.abspath(profile_dir)
parts = [f'--user-data-dir="{profile_abs}"', *HUMAN_CHROME_FLAGS]
if restore_session:
parts.append(RESTORE_LAST_SESSION_FLAG)
if enable_cdp:
port = cdp_port_for_profile(profile_abs)
parts.append(f"--remote-debugging-port={port}")
parts.append("--remote-debugging-address=127.0.0.1")
cleaned = (url or "").strip()
if cleaned:
parts.append(cleaned)
@@ -304,7 +336,7 @@ def cmd_open(account_id):
)
return
# 独立 Profile 默认不会「继续上次会话」,Session Cookie 关窗即丢;打开前写入 Preferences
# 清理 exit_type 等(不写 MAC 保护的 restore_on_startup);有会话时靠 --restore-last-session
prefs_result = ensure_session_restore(profile_abs)
log.info("browser_open_prefs %s", prefs_result)
@@ -314,24 +346,28 @@ def cmd_open(account_id):
finally:
conn.close()
# 有可恢复会话时不带 URL;首次/空 Profile 仍打开平台首页便于登录
session_dir = os.path.join(profile_abs, "Default")
has_session = any(
os.path.isfile(os.path.join(session_dir, name))
for name in ("Current Session", "Last Session", "Current Tabs", "Last Tabs")
)
# 有可恢复会话:不带 URL + --restore-last-session(恢复标签与 Session Cookie)。
# 首次/空 Profile:仍打开平台首页便于登录。
has_session = profile_has_restorable_session(profile_abs)
launch_url = None if has_session else url
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, launch_url)]
cdp_port = cdp_port_for_profile(profile_abs)
cmd = [
browser_exe,
*human_chrome_launch_args(
profile_abs, launch_url, restore_session=has_session, enable_cdp=True
),
]
log.info(
"browser_open_launch exe=%s profile_dir=%s launch_url=%s has_session=%s "
"platform=%s restore_on_startup=%s",
"restore_last_session=%s cdp_port=%s platform=%s",
browser_exe,
profile_abs,
launch_url or "(restore)",
has_session,
has_session,
cdp_port,
target.get("platform_key"),
prefs_result.get("restore_after"),
)
try:
@@ -381,6 +417,9 @@ def cmd_open(account_id):
"profile_dir": profile_abs,
"url": url,
"pid": child.pid,
"has_session": has_session,
"restore_last_session": has_session,
"cdp_port": cdp_port,
"session_restore_on_startup": prefs_result.get("restore_after"),
},
},

View File

@@ -1,4 +1,9 @@
"""Chrome/Edge 独立 Profile:启用「继续上次会话」,让 Session Cookie 可跨次打开保留。"""
"""Chrome/Edge 独立 Profile:启动前清理退出标记,便于会话文件可被恢复。
注意:``session.restore_on_startup`` 受 Secure Preferences HMAC 保护,
裸写 Preferences 会被 Chrome 校验否掉并回落到新标签页。真正强制恢复会话
应在启动参数使用 ``--restore-last-session``(见 browser_service)。
"""
from __future__ import annotations
@@ -8,7 +13,40 @@ from typing import Any, Dict
from util.logging_config import get_skill_logger
RESTORE_CONTINUE_WHERE_LEFT_OFF = 1
# Chromium ≤ roughly mid-era: session files lived under Default/
_LEGACY_SESSION_FILES = (
"Current Session",
"Last Session",
"Current Tabs",
"Last Tabs",
)
def profile_has_restorable_session(profile_dir: str) -> bool:
"""True when the profile has on-disk session data Chrome can restore.
Modern Chrome/Edge write ``Default/Sessions/Session_*`` (and ``Tabs_*``).
Older builds used ``Default/Current Session`` etc. Either form counts.
"""
default_dir = os.path.join(os.path.abspath(profile_dir), "Default")
if any(
os.path.isfile(os.path.join(default_dir, name))
for name in _LEGACY_SESSION_FILES
):
return True
sessions_dir = os.path.join(default_dir, "Sessions")
if not os.path.isdir(sessions_dir):
return False
try:
names = os.listdir(sessions_dir)
except OSError:
return False
return any(
name.startswith("Session_") or name.startswith("Tabs_")
for name in names
if os.path.isfile(os.path.join(sessions_dir, name))
)
def preferences_path(profile_dir: str) -> str:
@@ -20,13 +58,10 @@ def local_state_path(profile_dir: str) -> str:
def _patch_preferences_dict(data: Dict[str, Any]) -> Dict[str, Any]:
before = int((data.get("session") or {}).get("restore_on_startup") or 0)
"""只改不受 Secure Preferences MAC 保护的退出标记;不动 restore_on_startup。"""
restore_before = int((data.get("session") or {}).get("restore_on_startup") or 0)
exit_before = str((data.get("profile") or {}).get("exit_type") or "")
session = data.setdefault("session", {})
session["restore_on_startup"] = RESTORE_CONTINUE_WHERE_LEFT_OFF
session.pop("startup_urls", None)
profile = data.setdefault("profile", {})
if str(profile.get("exit_type") or "").lower() == "crashed":
profile["exit_type"] = "Normal"
@@ -36,7 +71,7 @@ def _patch_preferences_dict(data: Dict[str, Any]) -> Dict[str, Any]:
browser["has_seen_welcome_page"] = True
browser["check_default_browser"] = False
return {"restore_before": before, "exit_type_before": exit_before}
return {"restore_before": restore_before, "exit_type_before": exit_before}
def _patch_local_state(profile_dir: str, log) -> Dict[str, Any]:
@@ -67,7 +102,7 @@ def _patch_local_state(profile_dir: str, log) -> Dict[str, Any]:
def ensure_session_restore(profile_dir: str) -> Dict[str, Any]:
"""启动浏览器前写入 session.restore_on_startup=1;清 startup_urls;Local State 干净退出。"""
"""启动前清理 exit_type / exited_cleanly(不受 MAC 保护);不写 restore_on_startup。"""
log = get_skill_logger()
profile_abs = os.path.abspath(profile_dir)
prefs = preferences_path(profile_abs)
@@ -105,12 +140,11 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]:
exit_after = str((data.get("profile") or {}).get("exit_type") or "")
local = _patch_local_state(profile_abs, log)
log.info(
"chrome_session_restore_ensured profile_dir=%s prefs=%s existed=%s "
"restore_on_startup %s->%s exit_type %s->%s local_state=%s",
"chrome_session_restore_prepared profile_dir=%s prefs=%s existed=%s "
"restore_on_startup_unchanged=%s exit_type %s->%s local_state=%s",
profile_abs,
prefs,
existed,
before,
after,
exit_before or "-",
exit_after or "-",
@@ -125,4 +159,5 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]:
"exit_type_before": exit_before,
"exit_type_after": exit_after,
"local_state": local,
"prefs_restore_on_startup_touched": False,
}

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""delete id 会删库记录和硬盘 Profile,避免假删除残留登录态。"""
from __future__ import annotations
import os
from db.accounts_repo import find_accounts, get_account_by_id
def _add_web(**kwargs):
from service.account_service import cmd_account_add_web
defaults = dict(
platform_input="maersk",
login_id="demo@maersk.com",
login_id_type="email",
label="Maersk delete test",
tenant_id="tenant-demo",
environment="simulator",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
defaults.update(kwargs)
cmd_account_add_web(**defaults)
def test_delete_by_id_removes_row_and_profile(clean_db, fake_env):
from service.account_service import cmd_delete_by_id
_add_web()
acc = find_accounts(platform_key="maersk")[0]
profile_dir = acc["profile_dir"]
marker = os.path.join(profile_dir, "Default", "Cookies")
os.makedirs(os.path.dirname(marker), exist_ok=True)
with open(marker, "w", encoding="utf-8") as handle:
handle.write("session")
assert os.path.isfile(marker)
cmd_delete_by_id(acc["id"])
assert get_account_by_id(acc["id"]) is None
assert find_accounts(platform_key="maersk") == []
assert not os.path.exists(profile_dir)
def test_delete_by_id_missing_account(clean_db, capsys):
from service.account_service import cmd_delete_by_id
cmd_delete_by_id(99999)
out = capsys.readouterr().out
assert "ERROR:ACCOUNT_NOT_FOUND" in out

View File

@@ -18,16 +18,76 @@ class TestPlaywrightLaunchHelpers:
assert "--start-maximized" in args
class TestEnsureSessionRestoreDoesNotTouchProtectedPref:
def test_leaves_restore_on_startup_unchanged(self, tmp_path, fake_env):
from pathlib import Path
from service.chrome_profile_prefs import ensure_session_restore, preferences_path
from util.logging_config import setup_skill_logging
setup_skill_logging("account-manager", "openclaw.skill.account_manager_test")
profile = tmp_path / "prof"
prefs = Path(preferences_path(str(profile)))
prefs.parent.mkdir(parents=True, exist_ok=True)
prefs.write_text(
json.dumps(
{
"session": {"restore_on_startup": 5},
"profile": {"exit_type": "Crashed", "exited_cleanly": False},
}
),
encoding="utf-8",
)
result = ensure_session_restore(str(profile))
assert result["ok"] is True
assert result["prefs_restore_on_startup_touched"] is False
data = json.loads(prefs.read_text(encoding="utf-8"))
assert data["session"]["restore_on_startup"] == 5
assert data["profile"]["exit_type"] == "Normal"
assert data["profile"]["exited_cleanly"] is True
class TestProfileHasRestorableSession:
def test_empty_profile_false(self, tmp_path):
from service.chrome_profile_prefs import profile_has_restorable_session
assert profile_has_restorable_session(str(tmp_path / "prof")) is False
def test_legacy_current_session(self, tmp_path):
from service.chrome_profile_prefs import profile_has_restorable_session
default = tmp_path / "prof" / "Default"
default.mkdir(parents=True)
(default / "Current Session").write_bytes(b"x")
assert profile_has_restorable_session(str(tmp_path / "prof")) is True
def test_modern_sessions_dir(self, tmp_path):
from service.chrome_profile_prefs import profile_has_restorable_session
sessions = tmp_path / "prof" / "Default" / "Sessions"
sessions.mkdir(parents=True)
(sessions / "Session_13434010951759956").write_bytes(b"sess")
assert profile_has_restorable_session(str(tmp_path / "prof")) is True
def test_modern_tabs_only(self, tmp_path):
from service.chrome_profile_prefs import profile_has_restorable_session
sessions = tmp_path / "prof" / "Default" / "Sessions"
sessions.mkdir(parents=True)
(sessions / "Tabs_13434010952144072").write_bytes(b"tabs")
assert profile_has_restorable_session(str(tmp_path / "prof")) is True
class TestCmdOpenDirectBrowser:
"""account open = same-process Chrome/Edge launch (like reveal-profile)."""
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
def _seed_account(self, clean_db, tmp_path, profile):
from db.accounts_repo import insert_account
from service import browser_service
from util.platforms import seed_platforms
seed_platforms(clean_db)
profile = tmp_path / "prof"
aid = insert_account(
clean_db,
platform_key="sohu",
@@ -45,7 +105,9 @@ class TestCmdOpenDirectBrowser:
auth_strategy="password_auto",
)
clean_db.commit()
return aid
def _patch_launch(self, monkeypatch, browser_service, tmp_path):
fake_exe = str(tmp_path / "chrome.exe")
monkeypatch.setattr(
browser_service,
@@ -64,6 +126,14 @@ class TestCmdOpenDirectBrowser:
return FakeProc()
monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen)
return fake_exe, spawned
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
from service import browser_service
profile = tmp_path / "prof"
aid = self._seed_account(clean_db, tmp_path, profile)
fake_exe, spawned = self._patch_launch(monkeypatch, browser_service, tmp_path)
browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip()
@@ -88,6 +158,30 @@ class TestCmdOpenDirectBrowser:
assert "open_child_runner" not in joined
assert "python" not in joined.lower()
def test_cmd_open_skips_url_when_modern_sessions_exist(
self, clean_db, tmp_path, capsys, monkeypatch
):
"""Modern Chrome Sessions/ must restore via --restore-last-session, no homepage URL."""
from service import browser_service
profile = tmp_path / "prof"
sessions = profile / "Default" / "Sessions"
sessions.mkdir(parents=True)
(sessions / "Session_13434010951759956").write_bytes(b"sess")
aid = self._seed_account(clean_db, tmp_path, profile)
_fake_exe, spawned = self._patch_launch(monkeypatch, browser_service, tmp_path)
browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["data"]["has_session"] is True
assert payload["data"]["restore_last_session"] is True
assert "https://example.com" not in spawned["cmd"]
assert "--restore-last-session" in spawned["cmd"]
assert spawned["cmd"][-1] == "--restore-last-session" or spawned["cmd"][-1].startswith("--")
class TestHumanChromeLaunchArgs:
def test_flags_then_url(self, tmp_path):
@@ -98,9 +192,19 @@ class TestHumanChromeLaunchArgs:
assert args[0].startswith("--user-data-dir=")
for flag in HUMAN_CHROME_FLAGS:
assert flag in args
assert "--restore-last-session" not in args
assert any(a.startswith("--remote-debugging-port=") for a in args)
assert args[-1] == "https://chat.deepseek.com"
assert "--enable-automation" not in args
def test_restore_session_flag_without_url(self, tmp_path):
from service.browser_service import human_chrome_launch_args
args = human_chrome_launch_args(str(tmp_path / "prof"), None, restore_session=True)
assert "--restore-last-session" in args
assert any(a.startswith("--remote-debugging-port=") for a in args)
assert not any(a.startswith("http") for a in args)
def test_shortcut_arguments_quote_profile_and_include_url(self, tmp_path):
from service.browser_service import human_chrome_shortcut_arguments
@@ -110,8 +214,25 @@ class TestHumanChromeLaunchArgs:
assert '--user-data-dir="' in text
assert "--start-maximized" in text
assert "--no-first-run" in text
assert "--restore-last-session" not in text
assert "--remote-debugging-port=" in text
assert text.endswith("https://example.com")
def test_shortcut_restore_session(self, tmp_path):
from service.browser_service import human_chrome_shortcut_arguments
text = human_chrome_shortcut_arguments(str(tmp_path / "p"), None, restore_session=True)
assert "--restore-last-session" in text
assert "--remote-debugging-port=" in text
assert "http" not in text
def test_cdp_port_stable_for_profile(self, tmp_path):
from service.browser_service import cdp_port_for_profile
p = str(tmp_path / "prof")
assert cdp_port_for_profile(p) == cdp_port_for_profile(p)
assert 9330 <= cdp_port_for_profile(p) < 9390
class TestResolveChromiumChannel:
"""resolve_chromium_channel returns a channel string when browsers exist."""

View File

@@ -72,6 +72,26 @@ class TestRowOpenActions(unittest.TestCase):
self.assertIn("enum", props["login_id_type"])
self.assertIn("enum", props["status"])
def test_delete_takes_over_host_delete(self) -> None:
delete = _by_id(_load_actions(), "account-delete")
self.assertEqual(delete.get("replaces"), "delete")
self.assertEqual(delete.get("placements") or [], [])
self.assertEqual(delete.get("interaction"), "confirm")
self.assertEqual(delete.get("label"), "删除账号")
bind = delete.get("bind") or {}
self.assertEqual(bind.get("tables"), ["accounts"])
self.assertEqual(bind.get("inputMapping"), {"accountId": "$row.$pk"})
self.assertEqual(
(delete.get("entrypoint") or {}).get("command"),
"delete",
)
self.assertEqual(
(delete.get("entrypoint") or {}).get("args"),
["id", "{{accountId}}"],
)
steps = delete.get("steps") or []
self.assertTrue(any(s.get("do") == "reloadTable" for s in steps))
def test_copy_is_direct_row_action(self) -> None:
copy = _by_id(_load_actions(), "account-copy")
self.assertEqual(copy.get("label"), "复制")