feat: expand full-flow diagnostics

This commit is contained in:
chengma
2026-07-01 15:32:27 +08:00
parent 84b35f1cdc
commit f80f00b5a2
7 changed files with 1085 additions and 53 deletions
+36 -2
View File
@@ -694,6 +694,19 @@ def _notify_collect_step(callback, step):
except Exception:
pass
def _notify_apply_step(callback, step, result="start", detail=None):
if callback is None:
return
payload = {"step": step, "result": result}
if detail:
payload["detail"] = str(detail)
try:
callback(payload)
except Exception:
pass
def _close_collected_product(cdp):
target_id = getattr(cdp, "target_id", None)
created_by_app = bool(getattr(cdp, "created_by_app", False))
@@ -1056,14 +1069,17 @@ def _confirm_update_modal(cdp, timeout=3):
return last_present or {"present": False, "clicked": False, "reason": "NO_UPDATE_CONFIRM_MODAL"}
def apply_task(account, task, close_success_tab=False) -> dict:
def apply_task(account, task, close_success_tab=False, on_step=None) -> dict:
"""Apply generated title/cover to Shopee.
The caller must perform the batch confirmation before calling this function.
"""
item_id = _item_id(task)
current_step = "open_product"
_notify_apply_step(on_step, current_step, "start")
cdp = open_product(account, item_id)
_notify_apply_step(on_step, current_step, "success")
committed = False
try:
title_result = None
@@ -1071,19 +1087,36 @@ def apply_task(account, task, close_success_tab=False) -> dict:
new_title = _get(task, "new_title")
new_cover_path = _get(task, "new_cover_path")
if new_title:
current_step = "change_title"
_notify_apply_step(on_step, current_step, "start")
title_result = change_title(cdp, new_title)
if not title_result.get("ok"):
_notify_apply_step(on_step, current_step, "failed", "标题写入后 value/modelvalue 未同步")
return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result}
_notify_apply_step(on_step, current_step, "success")
if new_cover_path:
current_step = "replace_cover"
_notify_apply_step(on_step, current_step, "start")
cover_result = replace_cover(
cdp,
new_cover_path,
old_cover_path=_get(task, "old_cover_path"),
)
if not cover_result.get("ok"):
return {"committed": False, "error": _cover_upload_error_message(cover_result), "cover": cover_result}
error = _cover_upload_error_message(cover_result)
_notify_apply_step(on_step, current_step, "failed", error)
return {"committed": False, "error": error, "cover": cover_result}
_notify_apply_step(on_step, current_step, "success")
current_step = "click_update"
_notify_apply_step(on_step, current_step, "start")
update_result = click_update(cdp)
committed = bool(update_result.get("clicked", False))
_notify_apply_step(
on_step,
current_step,
"success" if committed else "failed",
update_result.get("reason"),
)
return {
"committed": committed,
"error": update_result.get("reason"),
@@ -1092,6 +1125,7 @@ def apply_task(account, task, close_success_tab=False) -> dict:
"update": update_result,
}
except Exception as exc:
_notify_apply_step(on_step, current_step, "failed", str(exc))
return {"committed": False, "error": str(exc)}
finally:
_close_applied_product(cdp, close_success_tab=close_success_tab and committed)
+760 -34
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -278,20 +278,20 @@ CREATE TABLE run_log_events (
);
```
### 5.2c 诊断日志分层(T-207 已接入 ①,② AI生成已先补诊断,T-505 待扩展)
### 5.2c 诊断日志分层(T-207 + T-505 已接入全流程)
当前问题:① 采集旧标题/旧封面出现单条失败时,现有代码只把该任务写成 `status=failed`、`last_error=<错误>`,并在状态栏显示失败计数;但没有记录“卡在哪一步”,也没有完整 traceback,后续 debug 难以判断是打开商品页、页面就绪、读标题、读封面 URL、下载图片还是写库/回写失败。
采用两层日志:
- **SQLite 运行日志(业务可读)**:复用 `run_logs/run_log_events`,用于 GUI 查看和运营排查。`run_type` 扩展到 `collect/generate/import/write_back/apply/login_check/ai_model_test`。事件必须带 `task_id/alias/item_id`(能拿到时),`message` 统一包含 `step=<步骤>`、结果和简短错误;不新增敏感字段,不写 Cookie、密码、API Key、token。
- **SQLite 运行日志(业务可读)**:复用 `run_logs/run_log_events`,用于 GUI 查看和运营排查。`run_type` 扩展到 `collect/generate/import/write_back/apply/chrome_launch/login_check/ai_model_test`。事件必须带 `task_id/alias/item_id`(能拿到时),`message` 统一包含 `step=<步骤>`、结果和简短错误;不新增敏感字段,不写 Cookie、密码、API Key、token。
- **本地诊断 log 文件(开发调试)**:写入 `logs/cmshopee.log` 或按日期滚动文件,保存脱敏后的 traceback、异常类型、步骤、耗时和必要上下文。`logs/` 必须 gitignore;自由文本异常进入日志前要用 `appconfig.redact_secrets()`,结构化 payload 先过 `sanitize_for_log()`。
优先级:
1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `logs/cmshopee.log` 保存完整脱敏 traceback。
2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `logs/cmshopee.log` 都写脱敏错误。
3. **T-505 剩余扩展全流程**:Excel 导入/回写(缺列、脏行、文件锁、行定位)、③ 更新shopee(安全开关、账号预检、打开页面、改标题、换封面、点更新、回写)、④ 登录检测/Chrome 启动、⑤ AI 模型测试连接。
3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新shopee 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。
原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。
关键事实:
+1 -1
View File
@@ -88,7 +88,7 @@
| T-502 | 换封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;更新封面统一先删当前第一张,不再只限满 9 张;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE |
| T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | DONE |
| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | DONE |
| T-505 | 全流程诊断日志扩展 | T-207, T-504 | 在 T-207 采集日志机制稳定后,扩展到② AI生成、Excel 导入/回写、③ 更新shopee步骤级日志、④ Chrome 启动/登录检测、⑤ AI模型测试连接;数据库日志保存业务可读步骤和错误,本地 log 保存脱敏 traceback;不得记录 Cookie、密码、API Key、token | TODO |
| T-505 | 全流程诊断日志扩展 | T-207, T-504 | 在 T-207 采集日志机制稳定后,已扩展到② AI生成、Excel 导入/回写、③ 更新shopee步骤级日志、④ Chrome 启动/登录检测、⑤ AI模型测试连接;数据库日志保存业务可读步骤和错误,本地 log 保存脱敏 traceback;不得记录 Cookie、密码、API Key、token | DONE |
| T-506 | 正式使用批量更新体验:③ 检查本轮更新 + 分批更新全部 + ⑤ 设置页三列布局 | T-501c, T-504 | ③ 增加「检查本轮更新」按钮替代用户可见 dry-run;「开始更新」对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示总数/每批/预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页使用居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/Shopee 更新安全均使用响应式三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」合并为同一个横向组件,最大并行账号数紧跟其后且不换行 | DONE |
| T-507 | 正式批量更新:移除普通流程测试商品 ID 限制 | T-506 | 普通正式更新不再以 `test_item_id` 阻断当前筛选结果;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,仅保留历史/调试兼容字段;仍保留允许真实提交、允许更新封面、分批、二次确认、账号就绪预检和运行日志 | DONE |
| T-508 | ③ 更新shopee生产化操作区 | T-401, T-404a, T-506 | 「开始更新」作为主操作视觉强化;「重置更新状态」从底部按钮移到任务表右键菜单/高级入口,与批量更新物理分离,运行中禁用;更新安全拦截弹窗写明具体开关并提供「前往设置」深链到⑤,不改 Shopee/CDP 更新流程 | DONE |
+10 -10
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -917,9 +917,18 @@
- 新标题微调:②「新标题」列允许已生成、未提交线上、非运行中任务双击本地编辑;写回 `tasks.new_title`,保留 `stage=generated`,清空 `last_error` 并回到可更新状态,不触碰 Shopee/CDP/Excel/封面。
- 测试:新增 DB 与 GUI 回归测试,覆盖本地标题编辑、③ 右键重置入口、开始更新主按钮标识、安全拦截前往设置。
- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(145 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。
## 【2026-07-01】T-510 完成 · 「预览本轮更新」改名为「检查本轮更新」
- 文档:先同步 `docs/00-ai-start-here.md`、`docs/02-requirements.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,新增 T-510,明确用户可见按钮/弹窗/日志统一使用「检查本轮更新」。
- 代码:`app/gui.py` 将③按钮、风险提示、确认框标题、取消/开始/完成状态、完成弹窗和 dry-run 运行日志文案从「预览本轮更新」改为「检查本轮更新」;内部 `preview_update_button`、`preview_update()` 与 `dry_run` 字段保留,不改变执行语义。
- 测试:更新 `tests/test_gui.py` 的按钮文案、状态栏和 dry-run 日志断言。
- 验证:`python -m py_compile app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(57 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(145 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。
## 【2026-07-01】T-505 完成 · 全流程诊断日志扩展
- 范围:在 T-207 采集诊断和②AI生成诊断基础上,补齐 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接的业务运行日志与本地脱敏诊断日志。
- 代码:`app/gui.py` 新增安全 run log helper;`CollectTab.import_excel()` 写 `run_type=import`,记录选中文件、缺列、脏行和入库统计;`WriteBackWorker` 写 `run_type=write_back`,记录写入文件、行数和文件锁/保存异常;`ApplyWorker` 的 `run_type=apply` 改为步骤级事件,覆盖预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」写 `run_type=chrome_launch`,登录检测 worker 写 `run_type=login_check`;⑤AI模型测试连接写 `run_type=ai_model_test`。
- CDP:`app/editor.py` 只给 `apply_task()` 增加可选 `on_step` 回调,用于上报 `open_product/change_title/replace_cover/click_update`,不改选择器、上传方式、确认框点击或提交逻辑。
- 安全:DB 业务日志和本地 `logs/cmshopee.log` 均通过现有脱敏工具处理,不记录 Cookie、密码、API Key、token;异常路径本地日志保留脱敏 traceback。
- 测试:新增/更新 `tests/test_gui.py` 覆盖 import/write_back/apply/chrome_launch/login_check/ai_model_test 运行日志和本地日志脱敏;`python -m py_compile app\gui.py app\editor.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(63 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(151 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。
+265 -2
View File
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableVie
from app.gui import (
AccountDialog,
AccountLoginCheckWorker,
AccountsTab,
AIModelTestWorker,
ApplyTab,
@@ -1434,7 +1435,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
progress = []
rows = []
def fake_apply(account, task, close_success_tab=False):
def fake_apply(account, task, close_success_tab=False, on_step=None):
applied_aliases.append(account.alias)
close_flags.append(close_success_tab)
if account.alias == "alias-a":
@@ -1605,7 +1606,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
started = {"alias-a": threading.Event(), "alias-b": threading.Event()}
thread_names = set()
def fake_apply(account, task, close_success_tab=False):
def fake_apply(account, task, close_success_tab=False, on_step=None):
thread_names.add(threading.current_thread().name)
started[account.alias].set()
other = "alias-b" if account.alias == "alias-a" else "alias-a"
@@ -2769,5 +2770,267 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
def test_collect_tab_import_excel_writes_diagnostic_run_log(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
excel_path = os.path.join(temp_dir, "input.xlsx")
statuses = []
tab = CollectTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
def fake_import(file_paths, path=None):
self.assertEqual([excel_path], file_paths)
self.assertEqual(cfg["db_path"], path)
return {
"batch_id": None,
"rows": [],
"stats": {
"files": 1,
"total": 2,
"valid": 0,
"invalid": 1,
"inserted": 0,
"file_errors": [
{
"file": excel_path,
"sheet": "商品",
"error": "缺少必需列",
"missing_columns": ["别名"],
}
],
"row_errors": [
{
"file": excel_path,
"sheet": "商品",
"row": 3,
"error": "商品id必须是数字 token=SECRET",
}
],
},
}
with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), \
mock.patch("app.gui.excel.import_tasks", side_effect=fake_import):
tab.import_excel()
run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0]
self.assertEqual("done", run_log.status)
self.assertEqual(1, run_log.done)
self.assertEqual(2, run_log.failed_count)
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=parse_file result=failed", messages)
self.assertIn("missing=别名", messages)
self.assertIn("step=row_validate result=failed", messages)
self.assertIn("step=db_insert result=success", messages)
self.assertNotIn("SECRET", messages)
self.assertIn("token=***", messages)
self.assertIn("入库0", statuses[-1])
self.assert_removed(temp_dir)
def test_write_back_worker_writes_run_log_and_diagnostic_on_failure(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
diagnostic_log_dir = os.path.join(temp_dir, "logs")
with mock.patch(
"app.gui.excel.write_back",
side_effect=RuntimeError("Excel 文件被占用 token=SECRET"),
):
worker = WriteBackWorker(
"batch-1",
db_path=cfg["db_path"],
diagnostic_log_dir=diagnostic_log_dir,
)
with self.assertRaises(RuntimeError):
worker.execute()
run_log = db.list_run_logs(limit=1, run_type="write_back", path=cfg["db_path"])[0]
self.assertEqual("failed", run_log.status)
self.assertEqual(1, run_log.failed_count)
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=write_excel result=failed", messages)
self.assertNotIn("SECRET", messages)
self.assertIn("token=***", messages)
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
self.assertTrue(os.path.exists(log_path))
with open(log_path, "r", encoding="utf-8") as fh:
raw_log = fh.read()
self.assertNotIn("SECRET", raw_log)
entry = json.loads(raw_log.strip().splitlines()[-1])
self.assertEqual("write_excel", entry["step"])
self.assertEqual("RuntimeError", entry["exception"])
self.assert_removed(temp_dir)
def test_apply_worker_writes_step_run_log_and_diagnostic_on_failure(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)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
batch_id,
[
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": 2,
"account_name": "Excel主店",
"alias": "alias-a",
"item_id": "51100639510",
}
],
path=cfg["db_path"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
diagnostic_log_dir = os.path.join(temp_dir, "logs")
def fake_apply(account, task, close_success_tab=False, on_step=None):
on_step({"step": "open_product", "result": "start"})
on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"})
return {
"committed": False,
"error": "新封面上传失败 token=SECRET",
"cover": {"ok": False, "error": "token=SECRET"},
}
with mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
summary = ApplyWorker(
[task],
db_path=cfg["db_path"],
config=cfg,
preflight=False,
diagnostic_log_dir=diagnostic_log_dir,
).execute()
self.assertFalse(summary["ok"])
self.assertEqual(1, summary["failed"])
run_log = db.list_run_logs(limit=1, run_type="apply", path=cfg["db_path"])[0]
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=open_product result=start", messages)
self.assertIn("step=replace_cover result=failed", messages)
self.assertIn("step=db_write result=success", messages)
self.assertNotIn("SECRET", messages)
self.assertIn("token=***", messages)
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
self.assertTrue(os.path.exists(log_path))
with open(log_path, "r", encoding="utf-8") as fh:
raw_log = fh.read()
self.assertNotIn("SECRET", raw_log)
entry = json.loads(raw_log.strip().splitlines()[-1])
self.assertEqual("replace_cover", entry["step"])
self.assertEqual("alias-a", entry["alias"])
self.assertEqual("51100639510", entry["item_id"])
self.assert_removed(temp_dir)
def test_account_login_check_worker_writes_run_log(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)
diagnostic_log_dir = os.path.join(temp_dir, "logs")
with mock.patch(
"app.gui.accounts.detect_login",
return_value={"logged_in": False, "reason": "LOGIN_PAGE token=SECRET"},
) as detect_login:
result = AccountLoginCheckWorker(
account,
db_path=cfg["db_path"],
config=cfg,
diagnostic_log_dir=diagnostic_log_dir,
).execute()
detect_login.assert_called_once_with(
account,
timeout=8,
path=cfg["db_path"],
config=cfg,
)
self.assertEqual("alias-a", result["alias"])
self.assertFalse(result["status"]["logged_in"])
run_log = db.list_run_logs(limit=1, run_type="login_check", path=cfg["db_path"])[0]
self.assertEqual("done", run_log.status)
self.assertEqual(1, run_log.failed_count)
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=detect_login result=start", messages)
self.assertIn("step=detect_login result=failed", messages)
self.assertNotIn("SECRET", messages)
self.assertIn("token=***", messages)
self.assert_removed(temp_dir)
def test_ai_model_test_worker_writes_run_log(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
models_path = os.path.join(temp_dir, "ai_models.json")
worker = AIModelTestWorker(
"Text A",
ai_models_path=models_path,
db_path=cfg["db_path"],
diagnostic_log_dir=os.path.join(temp_dir, "logs"),
)
with mock.patch(
"app.gui.appconfig.test_ai_model",
return_value={"ok": False, "status": 401, "error": "api_key=SECRET"},
):
result = worker.execute()
self.assertFalse(result["ok"])
self.assertEqual("Text A", result["name"])
run_log = db.list_run_logs(limit=1, run_type="ai_model_test", path=cfg["db_path"])[0]
self.assertEqual("done", run_log.status)
self.assertEqual(1, run_log.failed_count)
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=test_connection result=start", messages)
self.assertIn("step=test_connection result=failed", messages)
self.assertNotIn("SECRET", messages)
self.assertIn("api_key=***", messages)
self.assert_removed(temp_dir)
def test_accounts_tab_launch_login_writes_chrome_launch_run_log(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)
class FakeProcess:
pid = 1234
with mock.patch(
"app.gui.accounts.launch_for_login",
return_value=FakeProcess(),
) as launch_for_login:
tab.launch_login()
launch_for_login.assert_called_once_with(account, config=cfg)
self.assertEqual("已启动", tab.login_statuses["alias-a"])
self.assertIn("Chrome 已启动", statuses[-1])
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)
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
messages = "\n".join(event.message for event in events)
self.assertIn("step=launch_chrome result=start", messages)
self.assertIn("step=launch_chrome result=success", messages)
self.assertIn("pid=1234", messages)
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()