# 08 · demo Playwright 冒烟(图谱编辑器交互) - **Type:** task - **Status:** resolved - **Blocked by:** 06, 07 ## Question 新建 `demo_smoke_graph.py`(`.venv10` Playwright,对齐 demo_smoke_r2.py 模式): 1. 列表/图谱双视图切换 + 元素断言(公司节点固定居中、顶层标识、未识别层级区、计数)。 2. **编辑器闭环**:绘制自定义关系进入编辑(按钮变橙)→ 拖拽卡片位移 → 从底部圆点拖出连线(mouse down/move/up 序列)→ 保存 → 重进图谱断言边持久 → × 删除(系统边二次确认弹窗)→ 保存 → 重进断言否决生效。 3. 重置确认弹窗、完成绘制退出、未保存拦截三选(继续编辑/放弃/保存)各过一遍。 4. 悬停气泡出现(只读)。 5. 列表侧:页内编辑(改一行→撤销修改→保存全部)、快速添加多行、脱敏点击明文(断言明文出现 + API/DB 侧日志落库)、批量导出触发下载。 6. 导入 UI 三段式一轮(含一行失败明细展示)。 **验收**:全步 PASS + console/page errors=0 + 截图落 `shots/` + 残留正门清理(对齐先例);BUDDY 归属解耦纪律(写操作挂自建客户)。 ## Answer **交付**:`demo_smoke_graph.py`(系统 python + Playwright chromium headless,570 行)+ `shots-demo/` 13 张截图 + `demo_smoke_graph-results.json`。**40 checks 全 PASS,两轮幂等重跑稳定**,console/page error=0、网络层零 PUT/DELETE(27 条 /api/** 全 GET/POST)、teardown DB 正门清扫(e2c-cgd* 残留含诊断件全清)。 **票面逐条(修订⑧适配后)**: 1. **双视图 + 元素**:列表 4 行 ⇄ 图谱切换;`.gcard.company` 唯一 + COMPANY_POS 固定位 {1080,184};等级归行(董事长 10 在市场经理 6 上方,经理在专员 3 上方);其他职务卡停 `#unrecZone` 右栏(「未识别层级区」按修订⑧ = 未设置等级栏);lv-chip=3/4 卡;计数「共 4 位联系人」。 2. **编辑器闭环**:`#btnDraw` →「完成绘制」+ btn-orange + graphEditing;真实鼠标拖卡同行位移(x 950→1241 等级保持 3,同格释放 tryLevelDrop no-op);**`.dot-bottom` 真实鼠标拖线**(mouse down/move steps=12/up → tryConnect 自动定向 parent=市场经理)→ pending 渲染 → 保存 toast「版本 2」+ **DB customer_contact_edge 落库取证** → reload 断言 committed=1 → × 删边 → 保存 → reload 删净。**修订⑧适配**:公司边派生无 ×(「系统边二次确认弹窗」作废);「否决生效」→ 手动边删除持久断言。 3. **三弹窗**:重置确认(#dlgTitle=重置 → 确定 → pending 清零);视图切换未保存三选(labels=[继续编辑,放弃修改,保存修改]——「继续编辑」保持编辑态、「放弃修改」退出+切列表+恢复快照);完成绘制退出(「保存修改」→ 边落库 + 退编辑 + reload 持久)。 4. **悬停气泡**:#bubble display=block + 姓名 + 人脉关系情况/是否内线/礼品备注字段;移出隐藏。 5. **列表侧**:页内编辑(改名 → dirty-row + 撤销链接 → 撤销恢复 + toast → 改名 → 保存全部 toast + **DB name 更新取证**,脱敏电话原样回传=不改);quickAdd 三行(第 3 行重复电话 → **前端查重拦截** qa-dup +「疑似重复…确认知悉」hint → 勾 ack 重提 → **服务端部分成功 2 成 1 败**,失败行保留 hint=「手机号已被该客户内联系人使用」)+ DB 2 条新联系人;脱敏点击明文(.phone-masked → .phone-plain=13800001002 + **DB customer_contact_reveal_log 埋点取证**);模板下载(联系人导入模板-V1.xlsx)+ 勾选解锁导出 + 下载(联系人导出.xlsx)。 6. **导入三段式**:openpyxl 造 2 行(好行 chairman / 坏行无手机号)→ upload 预检(「共 2 行,预计新增 1、更新 0、失败 1」+ 明细含 FAIL「手机号必填(导入查重键)」)→ 确认执行 → 轮询「已完成:新增 1」→ 失败明细 1 行 → **DB 好行入库坏行未入** → 计数 7 位。 **三件基建经验(冒烟路径踩坑)**: 1. **认证反代**:demo 无登录 UI、fetch credentials:'include' → `page.route('**/api/**')` Python 侧 urllib 原样转发(去 origin/Authorization/host/content-length 头 + 注入 Bearer)。CORS 语义:credentials 模式下 fulfill 的 ACAO 不能是 `*`,须**回显请求 origin + `Access-Control-Allow-Credentials: true`**,否则浏览器吞响应(ERR_FAILED)。 2. **Chromium post_data_buffer 不含上传文件内容**(本票最大坑):`set_input_files` + FormData upload 的请求体缓存只有 572B 头部(4.6KB xlsx 的文件字节缺失,Chromium DevTools 只回文件引用不回内容),route.fetch / urllib 转发均丢 file part → 服务端 67023「请选择导入文件」。**解法 = upload 请求分流**:handle_api 检测 `/import/upload` → 正则抽表单字段(customerId/importMode/duplicateStrategy)→ Python 侧重读同一 xlsx 手工构造完整 multipart 直调真实 API(`_py_upload`)→ 真实预检结果 fulfill 给页面渲染;confirm/result/failures 无文件请求继续走通用转发,UI 三段式语义完整。 3. **demo 状态机细节**:「保存全部」成功后 demo **保持页内编辑态**(listEditing=true,单元格仍是 input 控件)——reveal/勾选导出前须先点退出编辑;reload 后回默认列表视图,图谱操作前须先 `#pillGraph`;视口 1720×1080 保证图谱卡片整体在视口内(bounding_box 返回视口坐标,卡片出视口则 mouse 序列落空);toast 断言前清残留(`#toast` className='')。 **纪律核对**:夹具 e2c-cgd-{TS} 客户 ownerUserId=ADMIN(BUDDY 归属解耦);teardown 正门(contact_import_fail 按 task_id 先删,无 customer_id 列);无后端变更 → 无 ADR、CONTEXT.md 不动。诊断中间件(_diag_route/_diag_upload/_diag_upload_ui + _diag-import.xlsx)已清理。 **解锁**:票 09(Bruno 文档同步 + e2e-report 聚合)——Map 最后一张。