#!/usr/bin/env python3 """ compare_framework_structure.py — Vue↔React demo 结构一致性对比工具 对 docs 站每个组件页分别在 vue / react 两态下真实渲染(playwright chromium), 按「语义骨架」(每个 .docs-section 的可见文本 token 集 + 语义元素轮廓)对比 Vue 与 React 的展示结构是否一致,产出自包含 HTML 报告。展示效果允许不同,结构(内容块是否 都渲染出来)需一致。 设计 spec: docs/superpowers/specs/2026-07-08-framework-structure-parity-tool-design.md 依赖(Python playwright,本机 Homebrew python 需 venv): python3 -m venv .fwvenv .fwvenv/bin/pip install playwright .fwvenv/bin/python -m playwright install chromium 用法(preview 需先起:pnpm exec vite preview --config vite.playground.config.ts --port 4173): .fwvenv/bin/python scripts/compare_framework_structure.py .fwvenv/bin/python scripts/compare_framework_structure.py --only topbar,tabs .fwvenv/bin/python scripts/compare_framework_structure.py --base-url http://localhost:4173/ --out framework-structure-report """ from __future__ import annotations import argparse import html import re import sys from pathlib import Path from playwright.sync_api import sync_playwright, Page ROOT = Path(__file__).resolve().parent.parent NAV = ROOT / "playground" / "docs" / "navigation.ts" PAGES_DIR = ROOT / "playground" / "docs" / "pages" # Foundation / 非组件页:无双框架 demo,跳过以省时(保留也只会 PASS)。 SKIP_IDS = {"overview", "color", "typography", "border", "effect", "icon"} STORAGE_KEY = "tvu-docs-framework" # 在 region 内提取每个 section 的跨框架语义签名。 EXTRACT_JS = r""" () => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); const sections = [...document.querySelectorAll('.docs-section')]; return sections.map((sec, idx) => { const title = norm(sec.querySelector('.docs-section__title')?.textContent) || ('section-' + idx); const demo = sec.querySelector('.docs-demo') || sec; // 文本节点级 token:每个渲染出的文本节点的规范化文本作为一个 token。 // 跨框架稳健 —— "Menu 1" 是一个 的整段文本 = 一个 token,不受 Vue/React // 空白/换行差异影响(避免 innerText 分词把 "Menu 1" 切成 Menu+1 的误报)。 const tokens = []; (function walk(el) { for (const node of el.childNodes) { if (node.nodeType === 3) { const t = norm(node.textContent); if (t) tokens.push(t); } else if (node.nodeType === 1) { walk(node); } } })(demo); const count = (sel) => demo.querySelectorAll(sel).length; const outline = { nav: count('nav'), link: count('a'), button: count('button'), input: count('input, textarea, select'), img: count('img'), svg: count('svg'), ce: [...demo.querySelectorAll('*')].filter((e) => e.tagName.includes('-')).length, }; return { title, idx, tokens, outline }; }); } """ class FrameworkComparator: def __init__(self, base_url: str, out_dir: Path, only=None, headed=False, settle_ms=2500): self.base_url = base_url.rstrip("/") + "/" self.out_dir = out_dir self.shots_dir = out_dir / "shots" self.only = set(only) if only else None self.headed = headed self.settle_ms = settle_ms # ---- discovery ---- def discover(self): ids = [] seen = set() for m in re.finditer(r"id:\s*'([a-z0-9-]+)'", NAV.read_text(encoding="utf-8")): rid = m.group(1) if rid in seen: continue seen.add(rid) if rid in SKIP_IDS: continue if self.only and rid not in self.only: continue ids.append(rid) # 双框架页:pages/*.vue 里 import('@demos/...') 的组件(用于报告标注)。 dual_demos = set() for vue in PAGES_DIR.glob("*.vue"): for m in re.finditer(r"@demos/([A-Za-z0-9]+)", vue.read_text(encoding="utf-8")): dual_demos.add(m.group(1).lower()) return ids, dual_demos # ---- render + extract ---- def _capture(self, browser, route_id: str, framework: str): ctx = browser.new_context(viewport={"width": 1280, "height": 900}) # 在 app boot 前注入框架偏好(boot 后设 + hash 导航不 reload 不生效 —— 实测坑)。 ctx.add_init_script( f"try{{localStorage.setItem('{STORAGE_KEY}','{framework}')}}catch(e){{}}" ) page = ctx.new_page() url = f"{self.base_url}#/{route_id}" page.goto(url, wait_until="networkidle") page.wait_for_timeout(self.settle_ms) sections = page.evaluate(EXTRACT_JS) # 逐 section 截图(用于报告并排展示)。 shots = {} locs = page.locator(".docs-section") for i in range(min(locs.count(), len(sections))): shot_path = self.shots_dir / f"{route_id}-{framework}-{i}.png" try: locs.nth(i).screenshot(path=str(shot_path)) shots[i] = shot_path.name except Exception: shots[i] = None ctx.close() return sections, shots def compare_page(self, browser, route_id: str): vue_secs, vue_shots = self._capture(browser, route_id, "vue") react_secs, react_shots = self._capture(browser, route_id, "react") vue_by_title = {s["title"]: s for s in vue_secs} react_by_title = {s["title"]: s for s in react_secs} common = [s["title"] for s in vue_secs if s["title"] in react_by_title] vue_only_titles = [t for t in vue_by_title if t not in react_by_title] react_only_titles = [t for t in react_by_title if t not in vue_by_title] section_results = [] for title in common: v, r = vue_by_title[title], react_by_title[title] vt, rt = set(v["tokens"]), set(r["tokens"]) # 展示用 token 差异:丢掉纯标点/符号碎片(无字母数字/中日韩),保留有语义者。 _has_word = lambda t: re.search(r"[0-9A-Za-z一-鿿]", t) only_vue = [t for t in sorted(vt - rt) if _has_word(t)] only_react = [t for t in sorted(rt - vt) if _has_word(t)] # 判定用「去所有空白的 section 全文本」—— 消除 Vue `{{}}` 插值 vs React JSX # 表达式在 readout/状态行上的文本节点切分/空白噪音("readonly: false" 两态拼接 # 去空白后一致 → PASS)。真内容缺失(Vue 全文含 ProductName、React 无)仍 DIFF。 v_full = re.sub(r"\s+", "", "".join(v["tokens"])) r_full = re.sub(r"\s+", "", "".join(r["tokens"])) # outline 仅作参考展示 —— ce(Vue 不用 CE / React 用)、img↔svg(logo 实现) # 等属框架/展示差异,不计入 DIFF 判定。判定只看可见文本 token 集。 outline_diff = { k: (v["outline"].get(k, 0), r["outline"].get(k, 0)) for k in v["outline"] if v["outline"].get(k, 0) != r["outline"].get(k, 0) } is_diff = v_full != r_full section_results.append({ "title": title, "diff": is_diff, "only_vue": only_vue, "only_react": only_react, "outline_diff": outline_diff, "vue_idx": v["idx"], "react_idx": r["idx"], "vue_shot": vue_shots.get(v["idx"]), "react_shot": react_shots.get(r["idx"]), }) page_diff = any(s["diff"] for s in section_results) or bool(vue_only_titles or react_only_titles) return { "id": route_id, "diff": page_diff, "sections": section_results, "vue_only_titles": vue_only_titles, "react_only_titles": react_only_titles, } # ---- run ---- def run(self): self.shots_dir.mkdir(parents=True, exist_ok=True) ids, dual_demos = self.discover() print(f"[fwcompare] base={self.base_url} pages={len(ids)} → {', '.join(ids)}") results = [] with sync_playwright() as pw: browser = pw.chromium.launch(headless=not self.headed) for i, rid in enumerate(ids, 1): print(f" [{i}/{len(ids)}] {rid} ...", end="", flush=True) try: res = self.compare_page(browser, rid) res["dual"] = rid.replace("-", "") in dual_demos or any( rid.replace("-", "") == d for d in dual_demos ) results.append(res) ndiff = sum(1 for s in res["sections"] if s["diff"]) print(f" {'DIFF' if res['diff'] else 'PASS'} ({ndiff} diff section)") except Exception as e: print(f" ERROR: {e}") results.append({"id": rid, "error": str(e), "diff": None, "sections": []}) browser.close() report_path = self.out_dir / "index.html" report_path.write_text(self._build_html(results), encoding="utf-8") n_diff = sum(1 for r in results if r.get("diff")) print(f"\n[fwcompare] done. {n_diff}/{len(results)} pages DIFF. report → {report_path}") return report_path # ---- report ---- def _build_html(self, results): def esc(x): return html.escape(str(x)) rows = [] details = [] for r in results: rid = r["id"] if r.get("error"): status = f'ERROR' summ = esc(r["error"]) elif r["diff"]: nd = sum(1 for s in r["sections"] if s["diff"]) status = f'DIFF' summ = f"{nd} section 有结构差异" else: status = f'PASS' summ = "结构一致" anchor = f"c-{rid}" rows.append( f'{esc(rid)}' f"{status}{summ}" ) details.append(self._detail_block(r, anchor, esc)) n_total = len(results) n_diff = sum(1 for r in results if r.get("diff")) n_pass = sum(1 for r in results if r.get("diff") is False) n_err = sum(1 for r in results if r.get("error")) return f""" Vue↔React demo 结构一致性报告

Vue↔React demo 结构一致性报告

语义骨架对比(可见文本 token 集 + 元素轮廓)· 展示效果可不同、结构需一致 · 生成自 scripts/compare_framework_structure.py
共 {n_total} 页 DIFF {n_diff} PASS {n_pass} {'ERROR ' + str(n_err) + '' if n_err else ''}
{''.join(rows)}
组件状态摘要
{''.join(details)} """ def _detail_block(self, r, anchor, esc): rid = r["id"] if r.get("error"): return f'

{esc(rid)} — ERROR

{esc(r["error"])}
' if not r["diff"]: return f'

{esc(rid)} PASS

全部 section 结构一致。
' parts = [f'

{esc(rid)} DIFF

'] if r["vue_only_titles"]: parts.append(f'
仅 Vue 有的 section:{esc(", ".join(r["vue_only_titles"]))}
') if r["react_only_titles"]: parts.append(f'
仅 React 有的 section:{esc(", ".join(r["react_only_titles"]))}
') for s in r["sections"]: cls = "sec diff" if s["diff"] else "sec pass" parts.append(f'

{esc(s["title"])} — {"⚠ DIFF" if s["diff"] else "✓ 一致"}

') if s["diff"]: if s["only_vue"]: parts.append(f'
Vue 有 / React 缺:{esc(" · ".join(s["only_vue"][:40]))}
') if s["only_react"]: parts.append(f'
React 有 / Vue 缺:{esc(" · ".join(s["only_react"][:40]))}
') if s["outline_diff"]: od = ", ".join(f"{k}: vue={v[0]} react={v[1]}" for k, v in s["outline_diff"].items()) parts.append(f'
元素轮廓(参考,不计入判定 · ce/img↔svg 属框架实现差异):{esc(od)}
') shots = [] if s["vue_shot"]: shots.append(f'
Vue
') if s["react_shot"]: shots.append(f'
React
') if shots: parts.append(f'
{"".join(shots)}
') parts.append("
") parts.append("
") return "".join(parts) def main(): ap = argparse.ArgumentParser(description="Compare Vue vs React demo rendering structure") ap.add_argument("--base-url", default="http://localhost:4173/") ap.add_argument("--out", default=str(ROOT / "framework-structure-report")) ap.add_argument("--only", default=None, help="逗号分隔的 route id(如 topbar,tabs)") ap.add_argument("--headed", action="store_true") ap.add_argument("--settle-ms", type=int, default=2500) args = ap.parse_args() only = [x.strip() for x in args.only.split(",")] if args.only else None cmp = FrameworkComparator(args.base_url, Path(args.out), only=only, headed=args.headed, settle_ms=args.settle_ms) report = cmp.run() print(f"open: {report}") if __name__ == "__main__": main()