# -*- coding: utf-8 -*- """dict-alignment · demo 浏览器级 E2E v3(Playwright, chromium headless) 被测: .scratch/dict-alignment/demo/index.html?api=http://localhost:8080 覆盖: 原型 v3(20260909 票 11 拍板)—— ① 键值字段删除:新增/编辑弹窗无键值输入、无默认勾选、复合弹窗子项行含编码列; saveOrUpdate 载荷零 value / 零 isDefault(网络层断言);名称组内唯一 63007 ② 默认值入分组列表:分组行「设置默认值」只列最低级项(两级组仅二级项), 换设唯一(同事务取消原默认),分组列表「默认值」列回显「父名 / 子名」 ③ 最低级默认 63013 禁停两路(/status + saveOrUpdate) 沿承: v2 列表一级化+children 展开 / 换组放开+被引用拦截 63009 / 票07 ref_count 联测 / 票04 两级树 附带断言: 网络层零 PUT/DELETE、零 /api/dict// 路径变量、console/page error 零 清理: 演示分组/字典项 API 正门删除,演示客户归档 """ import json, re, time, pathlib, sys, io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') from playwright.sync_api import sync_playwright ROOT = pathlib.Path(__file__).resolve().parent SHOTS = ROOT / "shots"; SHOTS.mkdir(exist_ok=True) DEMO = ROOT / "demo" / "index.html" API = "http://localhost:8080" ADMIN = "739564171091247104" TS = time.strftime("%H%M%S") GA_CODE = f"e2d_group_a_{TS}" GA_NAME = f"e2d-演示分组A-{TS}" GB_CODE = f"e2d_group_b_{TS}" GB_NAME = f"e2d-演示分组B-{TS}" results = [] def step(name, ok, note=""): results.append({"step": name, "ok": bool(ok), "note": note}) print(("PASS " if ok else "FAIL ") + name + (" | " + note if note else "")) import urllib.request, urllib.parse def api_post(url, data=None, token=None): body = urllib.parse.urlencode(data or {}).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") if token: req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode()) def _del_item(it, tok): """删字典项:被引用(残留归档客户)时普通删除 63009 → force-delete 兜底""" try: api_post(f"{API}/api/dict/item/delete", {"id": str(it["id"])}, tok); return True except Exception: try: api_post(f"{API}/api/dict/item/force-delete?id=" + str(it["id"]), None, tok); return True except Exception: return False def pre_clean(): """中断轮次残留清理(幂等重跑保障):上轮死在中途时演示分组/项/客户会污染本轮 (实例:残留分组B 里有同名项 → 本轮换组撞 63007 → 保存失败无 toast)""" try: tok = json.loads(urllib.request.urlopen(f"{API}/api/auth/debug/token?userId={ADMIN}", timeout=10).read().decode())["data"] except Exception as ex: print("pre_clean token fail:", ex); return removed = {"items": 0, "groups": 0, "customers": 0} for cur in (1, 2, 3, 4, 5): pg = api_post(f"{API}/api/dict/item/page", {"current": cur, "size": 100}, tok) for it in (pg.get("data") or {}).get("content", []): if str(it.get("name", "")).startswith("e2d-") or str(it.get("code", "")).startswith("e2d_"): if _del_item(it, tok): removed["items"] += 1 if cur >= int((pg.get("data") or {}).get("pages", 1) or 1): break for kw in ("e2d_group_", "e2d_probe_"): gp = api_post(f"{API}/api/dict/group/page", {"current": 1, "size": 50, "keyword": kw}, tok) for g in (gp.get("data") or {}).get("content", []): if not str(g.get("code", "")).startswith(("e2d_group_", "e2d_probe_")): continue ip = api_post(f"{API}/api/dict/item/page", {"current": 1, "size": 200, "groupId": str(g["id"])}, tok) for it in (ip.get("data") or {}).get("content", []): _del_item(it, tok) try: api_post(f"{API}/api/dict/group/delete", {"id": str(g["id"])}, tok); removed["groups"] += 1 except Exception: pass for ws in ("mine", "pool"): found = api_post(f"{API}/api/customer/workspace/page", {"workspace": ws, "keyword": "e2d-引用联测", "current": 1, "size": 50}, tok) for c in (found.get("data") or {}).get("content", []): cid = str(c["id"]) try: try: api_post(f"{API}/api/customer/claim?id=" + cid, {}, tok) except Exception: pass api_post(f"{API}/api/customer/archive", {"id": cid}, tok); removed["customers"] += 1 except Exception: pass print("pre_clean:", removed) pre_clean() console_errors, page_errors, api_urls, save_bodies = [], [], [], [] def clear_toasts(page): page.evaluate("document.getElementById('toast').innerHTML = ''") def opt_val(page, sel, pat): pairs = page.eval_on_selector_all(sel + " option", "os => os.map(o => [o.value, o.textContent])") for v, t in pairs: if re.search(pat, t): return v return "" def ensure_expanded(page): """行内展开受异步渲染竞态影响可能被收起:子项行交互前确保展开态""" if page.locator("#iBody tr.lv2row:has-text('e2d-子项1')").count() == 0: page.click("#iBody .expander") page.wait_for_selector("#iBody tr.lv2row:has-text('e2d-子项1')", timeout=10000) with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.set_default_timeout(20000) page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: page_errors.append(str(e))) page.on("dialog", lambda d: d.accept()) # demo 的 confirm(删除/换组)一律接受 def on_resp(r): u = r.url if "/api/dict" in u or "/api/customer" in u or "/api/auth" in u: api_urls.append(f"{r.request.method} {u}") if "/api/dict/item/saveOrUpdate" in u: save_bodies.append(r.request.post_data or "") page.on("response", on_resp) # 0. 打开 demo page.goto(DEMO.as_uri() + "?api=" + API) page.wait_for_load_state("networkidle") page.screenshot(path=str(SHOTS / "00-open.png")) # 1. 登录 page.click("button:has-text('uid 登录')") page.wait_for_selector(".toast-item:has-text('登录成功')", timeout=15000) step("登录 debug/token + /auth/me", True) # 2. 分组列表加载(含 v3 默认值列) page.wait_for_selector("#gBody tr td:not(.empty)", timeout=15000) g_rows = page.locator("#gBody tr").count() has_def_col = page.locator("#tab-group th:has-text('默认值')").count() == 1 step("分组分页 group/page(含 v3 默认值列)", g_rows > 0 and has_def_col, f"rows={g_rows} 默认值列={has_def_col}") # 3. 新建分组A(编码必填·全局唯一) page.click("button:has-text('+ 新建分组')") page.wait_for_selector("#fgName") page.fill("#fgName", GA_NAME) page.fill("#fgCode", GA_CODE) page.fill("#fgDesc", "dict-alignment e2e 演示分组A") page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000) page.fill("#gKeyword", GA_CODE) page.click("#tab-group .viewbar button:has-text('查询')") page.wait_for_selector(f"#gBody tr:has-text('{GA_NAME}')", timeout=15000) ga_row = page.locator(f"#gBody tr:has-text('{GA_NAME}')") step("新建分组A saveOrUpdate", ga_row.count() == 1, f"{GA_CODE}") page.screenshot(path=str(SHOTS / "01-group-created.png")) ga_id = re.search(r"dlgGroup\('(\d+)'", ga_row.locator("td.ops button:has-text('编辑')").get_attribute("onclick")).group(1) # 4. 新建分组B(换组目标) page.fill("#gKeyword", "") page.click("button:has-text('+ 新建分组')") page.wait_for_selector("#fgName") page.fill("#fgName", GB_NAME) page.fill("#fgCode", GB_CODE) clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000) step("新建分组B(换组目标)", True, GB_CODE) # 5. 字典项:弹窗 v3 结构断言(无键值输入 / 无默认勾选 / 子项行含编码列)+ 名称空白 63001 page.click("nav.tabs button[data-tab='item']") page.wait_for_selector(f"#iGroup option[value='{ga_id}']", state="attached", timeout=15000) page.select_option("#iGroup", ga_id) page.wait_for_timeout(400) page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") no_value_input = page.locator("#fiValue").count() == 0 no_default_chk = page.locator("#fiDefault").count() == 0 kid_code_col = page.locator("#dlg th:has-text('子项编码')").count() == 1 page.select_option("#fiGroup", ga_id) # 显式钉分组A,防下拉默认值漂移 page.click(".dfoot button.ok") # 名称留空 → 63001 page.wait_for_selector("#fiOut .errbox", timeout=15000) err1 = page.text_content("#fiOut .errbox") ok1 = "63001" in err1 and "名称" in err1 step("弹窗 v3 结构(无键值/无默认勾选/子项编码列)", no_value_input and no_default_chk and kid_code_col, f"键值输入={no_value_input} 默认勾选={no_default_chk} 子项编码列={kid_code_col}") step("名称必填拦截 63001(键值已删,名称承接)", ok1, err1.strip()[:60]) page.screenshot(path=str(SHOTS / "02-dialog-v3-structure.png")) # 6. 填名称保存 → 编码自动生成 dict_ 前缀(键值概念已不存在) page.fill("#fiName", "e2d-项1") clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) page.wait_for_timeout(600) row1 = page.locator(f"#iBody tr:has-text('e2d-项1')") code1 = row1.locator("td").nth(2).text_content().strip() auto_code = code1.startswith("dict_") step("新建字典项(无键值)编码自动生成 dict_", row1.count() == 1 and auto_code, f"code={code1}") # 7. 新建项2(自定义编码) page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") page.select_option("#fiGroup", ga_id) # 显式钉分组A page.fill("#fiName", "e2d-项2") page.fill("#fiCode", f"e2d_item2_{TS}") clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) page.wait_for_timeout(600) step("新建项2(自定义编码)", page.locator(f"#iBody tr:has-text('e2d-项2')").count() == 1) # 8. 名称组内唯一(v3 Q1):再建同名 e2d-项1 → 63007 page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") page.select_option("#fiGroup", ga_id) page.fill("#fiName", "e2d-项1") page.click(".dfoot button.ok") page.wait_for_selector("#fiOut .errbox", timeout=15000) err_dup = page.text_content("#fiOut .errbox") step("名称组内唯一拦截 63007(v3 Q1)", "63007" in err_dup and "名称" in err_dup, err_dup.strip()[:70]) page.click(".dfoot button:has-text('取消')") # 9. 项1 复合编辑加子项(子项编码留空自动生成)→ GA 变两级 page.click(f"#iBody tr:has-text('e2d-项1') td.ops button:has-text('编辑')") page.wait_for_selector("#kidsBody") page.click("button:has-text('+ 添加二级字典项')") page.fill("#kidsBody tr input", "e2d-子项1") clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) page.wait_for_timeout(600) page.click("#iBody .expander") # 展开项1 page.wait_for_selector("#iBody tr.lv2row:has-text('e2d-子项1')", timeout=10000) kid_row = page.locator("#iBody tr.lv2row:has-text('e2d-子项1')") kid_code = kid_row.locator("td").nth(2).text_content().strip() step("复合编辑加子项(编码留空自动生成)", kid_code.startswith("dict_"), f"子项code={kid_code}") page.screenshot(path=str(SHOTS / "03-composite-edit-v3.png")) # 10. 分组列表「设置默认值」:两级组只列二级项 → 选子项1 → 默认值列回显「父名 / 子名」 page.click("nav.tabs button[data-tab='group']") page.fill("#gKeyword", GA_CODE) page.click("#tab-group .viewbar button:has-text('查询')") page.wait_for_selector(f"#gBody tr:has-text('{GA_NAME}')", timeout=15000) page.click(f"#gBody tr:has-text('{GA_NAME}') td.ops button:has-text('设置默认值')") page.wait_for_selector("input[name=sdpick]", timeout=10000) dlg_title = page.text_content("#dlgTitle") two_level_title = "只能选二级项" in dlg_title radios = page.eval_on_selector_all("input[name=sdpick]", "os => os.map(o => o.value)") item1_id = page.evaluate("""() => { const l = [...document.querySelectorAll('#dlg label')].find(x => x.textContent.includes('e2d-子项1')); return l ? l.querySelector('input').value : ''; }""") page.check(f"input[name=sdpick][value='{item1_id}']") clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('已设为分组默认值')", timeout=15000) page.wait_for_timeout(600) def_cell = page.locator(f"#gBody tr:has-text('{GA_NAME}') td .badge.b5").text_content().strip() step("分组设默认(两级组仅二级项可选)", two_level_title and len(radios) == 1, f"标题含两级提示={two_level_title} radio数={len(radios)}") step("分组列表默认值列回显「父名 / 子名」", def_cell == "e2d-项1 / e2d-子项1", f"cell={def_cell}") page.screenshot(path=str(SHOTS / "04-group-default-v3.png")) # 11. 分组默认项禁停(默认=子项1):/status 路 63013 page.click("nav.tabs button[data-tab='item']") page.wait_for_timeout(400) page.select_option("#iGroup", opt_val(page, "#iGroup", re.escape(GA_NAME))) page.wait_for_timeout(400) page.click("#iBody .expander") page.wait_for_selector("#iBody tr.lv2row:has-text('e2d-子项1')", timeout=10000) ensure_expanded(page) clear_toasts(page) page.click("#iBody tr.lv2row:has-text('e2d-子项1') td.ops button:has-text('停用')") page.wait_for_selector(".toast-item:has-text('63013')", timeout=15000) t1 = page.text_content(".toast-item:has-text('63013')") step("分组默认项禁停 /status 路 63013(拍板2①)", True, t1.strip()[:70]) # 12. 编辑弹窗改停用(saveOrUpdate 路)→ 63013 ensure_expanded(page) page.click("#iBody tr.lv2row:has-text('e2d-子项1') td.ops button:has-text('编辑')") page.wait_for_selector("#fiStatus") page.select_option("#fiStatus", "0") page.click(".dfoot button.ok") page.wait_for_selector("#fiOut .errbox", timeout=15000) err2 = page.text_content("#fiOut .errbox") step("分组默认项禁停 saveOrUpdate 路 63013(拍板2②)", "63013" in err2 and "默认项" in err2, err2.strip()[:70]) page.screenshot(path=str(SHOTS / "05-default-disable-blocked.png")) page.click(".dfoot button:has-text('取消')") # 13. 换组放开(拍板6):项2 → 分组B(未被引用) page.click(f"#iBody tr:has-text('e2d-项2') td.ops button:has-text('编辑')") page.wait_for_selector("#fiGroup") page.select_option("#fiGroup", opt_val(page, "#fiGroup", re.escape(GB_NAME))) clear_toasts(page) page.click(".dfoot button.ok") # confirm 换组 → dialog handler 接受 page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) page.wait_for_timeout(800) page.select_option("#iGroup", opt_val(page, "#iGroup", re.escape(GB_NAME))) page.wait_for_selector("#iBody tr:has-text('e2d-项2')", timeout=10000) moved = page.locator("#iBody tr:has-text('e2d-项2')").count() step("未被引用换组放行 + 计数行随迁(拍板6①)", moved == 1, f"项2已出现在分组B列表 rows={moved}") page.screenshot(path=str(SHOTS / "06-move-group-ok.png")) # 14. 两级树(票04 新端点,industry 分组有二级) page.click("nav.tabs button[data-tab='tree']") page.wait_for_selector("#tGroup option", state="attached") ind_label = page.locator("#tGroup option", has_text="行业").first.get_attribute("value") if not ind_label: for i in range(page.locator("#tGroup option").count()): o = page.locator("#tGroup option").nth(i) if "industry" in (o.get_attribute("value") or "") or "行业" in o.text_content(): ind_label = o.get_attribute("value"); break page.select_option("#tGroup", ind_label) page.wait_for_selector("#treeBox .lv1", timeout=15000) page.wait_for_timeout(800) lv1 = page.locator("#treeBox .lv1").count() lv2 = page.locator("#treeBox .lv2").count() step("两级树 GET /api/dict/item/tree(票04增项)", lv1 > 0, f"一级={lv1}, 二级={lv2}") page.screenshot(path=str(SHOTS / "07-tree.png")) # 15. 引用计数联测:步骤0 准备演示项(customer_type 组,v3 载荷无 value) page.click("nav.tabs button[data-tab='ref']") page.evaluate("document.getElementById('refOut').innerHTML = ''") page.click("button:has-text('准备演示字典项')") page.wait_for_selector("#refOut .okbox:has-text('就绪')", timeout=20000) step("联测步骤0 演示项幂等创建(v3 载荷)", True, page.text_content("#refOut .okbox").strip()[:80]) # 16. 步骤1 新建客户引用 → referenced=true(票07 入账) page.evaluate("document.getElementById('refOut').innerHTML = ''") page.click("button:has-text('新建客户引用此字典')") page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=30000) out1 = page.text_content("#refOut") ok_ref1 = "referenced=true" in out1 and "(接线生效)" in out1 step("联测步骤1 新建客户 ref_count 入账(票07)", ok_ref1, out1.strip()[:90]) page.screenshot(path=str(SHOTS / "08-ref-created.png")) # 17. 步骤2 换引用 → a=false / b=true(票07 随迁) page.evaluate("document.getElementById('refOut').innerHTML = ''") page.click("button:has-text('编辑客户换引用到 e2d_type_b')") page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=30000) out2 = page.text_content("#refOut") ok_ref2 = "e2d_type_a.referenced=false" in out2 and "e2d_type_b.referenced=true" in out2 step("联测步骤2 换引用随迁(票07 applyFieldChange)", ok_ref2, out2.strip()[:110]) page.screenshot(path=str(SHOTS / "09-ref-swapped.png")) # 18. 步骤3 删除被引用项 → 63009 阻断(票04/07 引用阻断生效) page.evaluate("document.getElementById('refOut').innerHTML = ''") page.click("button:has-text('尝试删除当前被引用项')") page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=20000) out3 = page.text_content("#refOut") ok_ref3 = "63009" in out3 and "阻断生效" in out3 step("联测步骤3 删除被引用项 63009 阻断", ok_ref3, out3.strip()[:90]) # 19. 名称被引用仍可改(键值已删,改名即业务回显语义):e2d_type_b 已被引用,改名放行 page.click("nav.tabs button[data-tab='item']") page.wait_for_timeout(800) page.select_option("#iGroup", "") # 清掉步骤13残留的分组B过滤 page.wait_for_timeout(400) page.fill("#iKeyword", "e2d_type_b") page.click("#tab-item .viewbar button:has-text('查询')") row_b = page.locator("#iBody tr:has-text('e2d_type_b')") for _ in range(3): # 长链路后偶发渲染竞态:查询重试 try: row_b.wait_for(state="visible", timeout=8000); break except Exception: page.click("#tab-item .viewbar button:has-text('查询')") page.click("#iBody tr:has-text('e2d_type_b') td.ops button:has-text('编辑')") page.wait_for_selector("#fiName") page.fill("#fiName", "e2d-演示类型B-已改名") clear_toasts(page) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) step("被引用项改名仍放行(v3:名称承接回显)", True, "e2d_type_b name→e2d-演示类型B-已改名") page.screenshot(path=str(SHOTS / "10-name-mutable.png")) # 20. 被引用项换组拦截 63009(拍板6②)——e2d_type_b 被引用,换组应拦 page.click("#iBody tr:has-text('e2d_type_b') td.ops button:has-text('编辑')") page.wait_for_selector("#fiGroup") page.select_option("#fiGroup", opt_val(page, "#fiGroup", re.escape(GA_NAME))) page.click(".dfoot button.ok") # confirm 接受 page.wait_for_selector("#fiOut .errbox", timeout=15000) err4 = page.text_content("#fiOut .errbox") ok4 = "63009" in err4 and ("已被引用" in err4 or "不可修改分组" in err4) step("被引用换组拦截 63009(拍板6②)", ok4, err4.strip()[:70]) page.screenshot(path=str(SHOTS / "11-move-blocked.png")) page.click(".dfoot button:has-text('取消')") # 21. 网络层断言:零 PUT/DELETE、零路径变量、saveOrUpdate 载荷零 value/isDefault bad_method = [u for u in api_urls if u.startswith(("PUT ", "DELETE "))] old_style = [u for u in api_urls if re.search(r"/api/dict/\d+/", u)] bad_payload = [b for b in save_bodies if "value=" in b or "isDefault" in b] step("网络层零旧契约形态 + saveOrUpdate 载荷零 value/isDefault", not bad_method and not old_style and not bad_payload and len(save_bodies) >= 3, f"bad_method={bad_method[:2]} old_style={old_style[:2]} bad_payload={bad_payload[:1]} save提交={len(save_bodies)}次") browser.close() # ---- API 清理:演示分组/项正门删除,演示客户归档 ---- def post(url, data=None, token=None): body = urllib.parse.urlencode(data or {}).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") if token: req.add_header("Authorization", "Bearer " + token) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode()) cleanup = {"groups": [], "items": [], "customers": []} try: tok = json.loads(urllib.request.urlopen(f"{API}/api/auth/debug/token?userId={ADMIN}", timeout=10).read().decode())["data"] for ws in ("mine", "pool"): found = post(f"{API}/api/customer/workspace/page", {"workspace": ws, "keyword": "e2d-引用联测", "current": 1, "size": 50}, tok) for c in (found.get("data") or {}).get("content", []): cid = str(c["id"]) if cid in cleanup["customers"]: continue try: try: post(f"{API}/api/customer/claim?id=" + cid, {}, tok) except Exception: pass post(f"{API}/api/customer/archive", {"id": cid}, tok) cleanup["customers"].append(cid) except Exception as ex: print("archive fail", cid, ex) for gcode in (GB_CODE, GA_CODE): gp = post(f"{API}/api/dict/group/page", {"current": 1, "size": 20, "keyword": gcode}, tok) g = next((x for x in (gp.get("data") or {}).get("content", []) if x.get("code") == gcode), None) if not g: continue ip = post(f"{API}/api/dict/item/page", {"current": 1, "size": 100, "groupId": str(g["id"])}, tok) for it in (ip.get("data") or {}).get("content", []): try: post(f"{API}/api/dict/item/delete", {"id": str(it["id"])}, tok); cleanup["items"].append(it.get("code")) except Exception as ex: print("item delete fail", it.get("code"), ex) try: post(f"{API}/api/dict/group/delete", {"id": str(g["id"])}, tok); cleanup["groups"].append(gcode) except Exception as ex: print("group delete fail", gcode, ex) except Exception as ex: print("cleanup error:", ex) print("cleanup:", json.dumps(cleanup, ensure_ascii=False)) # ---- 汇总 ---- summary = {"ts": TS, "group_a": GA_CODE, "group_b": GB_CODE, "console_errors": console_errors, "page_errors": page_errors, "cleanup": cleanup, "results": results, "all_pass": all(r["ok"] for r in results) and not console_errors and not page_errors} (ROOT / "demo-smoke-result.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8") print("\n== SUMMARY ==") for r in results: print(("PASS " if r["ok"] else "FAIL ") + r["step"] + (" | " + r["note"] if r["note"] else "")) print("console_errors:", len(console_errors), "page_errors:", len(page_errors)) print("ALL_PASS:", summary["all_pass"])