@kw-yeh/vibeflow 4.0.6 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # VibeFlow
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@kw-yeh/vibeflow.svg)](https://www.npmjs.com/package/@kw-yeh/vibeflow)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@kw-yeh/vibeflow.svg)](https://www.npmjs.com/package/@kw-yeh/vibeflow)
5
+ [![node](https://img.shields.io/node/v/@kw-yeh/vibeflow.svg)](https://www.npmjs.com/package/@kw-yeh/vibeflow)
6
+
3
7
  **讓多個 coding agent 同時開工的本機看板。**
4
8
 
5
9
  每張卡片是一個任務。卡片一開始,VibeFlow 就幫它開一條獨立的 Git 分支與 worktree,在裡面啟動 Claude Code 或 Codex,並把即時終端機放在卡片旁邊。好幾張卡可以同時跑,彼此不會改到同一份檔案,你的專案資料夾本身也不會被切換分支。
@@ -22,6 +26,7 @@ vibeflow
22
26
  - **看得到 agent 做了什麼**:每張卡有 diff 檢視、artifacts,以及一份會保留下來的決策紀錄(agent 做了哪些決定、為什麼)。
23
27
  - **選你要的 agent**:Claude Code 或 Codex,每張卡可以各自指定 model、推理強度(effort)與 Auto Mode(是否免確認執行)。
24
28
  - **Web UI 與終端機 UI 共用同一個看板**:習慣瀏覽器用 Web UI,習慣終端機用 `vibeflow tui`。
29
+ - **內建 skill,可自行調整**:每個人的 Library(設定 → Library)都附帶 `visual-parity`、`pr` 兩個 skill,兩個 agent 都會載入。可以直接編輯、停用或刪除,隨時能還原成 VibeFlow 出貨的版本;沒改過的會跟著 VibeFlow 升級更新。`visual-parity` 需要 Python 與 `pip install playwright && playwright install chromium`。
25
30
  - **也能用指令操作**:`vibeflow task create` 不開 UI 直接建卡;agent 自己也能在看板上建立後續的卡片。
26
31
 
27
32
  ---
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: pr
3
+ description: Create or update a GitHub draft pull request for the current branch with gh — conventional-commit title, What / Why / How body in English, written only from this branch's diff. Use when the user says "open a PR", "create PR", "建立 PR", "開 PR", or "/pr #123" to update an existing one.
4
+ ---
5
+
6
+ # Pull request
7
+
8
+ Talk to the user in their language. Write the PR title and body in English.
9
+
10
+ ## 1. Mode
11
+
12
+ - Argument is a number or `#number` → **update** that PR (`gh pr edit`).
13
+ - Anything else (an issue reference, or nothing) → **create** a new draft PR.
14
+
15
+ ## 2. Check the branch
16
+
17
+ Run and read:
18
+
19
+ ```bash
20
+ git branch --show-current
21
+ git status --short
22
+ gh repo view --json defaultBranchRef -q .defaultBranchRef.name
23
+ ```
24
+
25
+ - On the default branch → stop and tell the user to create a feature branch first.
26
+ - Uncommitted changes → tell the user and ask whether to commit them first; do not commit on your own.
27
+ - Update mode: `gh pr view <n> --json headRefName,baseRefName` — if `headRefName` is not the current
28
+ branch, stop and tell the user; never rewrite another branch's PR with this diff.
29
+ - Base: in update mode the PR's `baseRefName`; otherwise the default branch.
30
+ - Push what the PR is about to describe:
31
+ - No upstream yet (`git rev-parse --abbrev-ref @{upstream}` fails) → `git push -u origin HEAD`.
32
+ - Upstream exists but lacks commits (`git rev-list --count @{upstream}..HEAD` is above 0) → `git push`.
33
+
34
+ ## 3. Read the change
35
+
36
+ ```bash
37
+ git fetch origin <base>
38
+ git log --oneline origin/<base>..HEAD
39
+ git diff --stat origin/<base>...HEAD
40
+ git diff origin/<base>...HEAD
41
+ ```
42
+
43
+ Describe **only** this diff — nothing the base branch already had. If the diff is too large to read
44
+ whole, use `--stat` to pick the files that matter and read those.
45
+
46
+ ## 4. Write it
47
+
48
+ **Title:** conventional commit — `<type>(<scope>): <summary>`, under 72 characters.
49
+ `type` is one of `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`.
50
+ Add `scope` only when one area clearly owns the change.
51
+
52
+ **Body:**
53
+
54
+ ```markdown
55
+ ## What
56
+ <one or two sentences: what changes for a user or a caller>
57
+
58
+ ## Why
59
+ <the problem or goal; link the issue if there is one>
60
+
61
+ ## How
62
+ - <the key decisions, not a file-by-file list>
63
+
64
+ ## Testing
65
+ - <what was run or checked, and the result>
66
+ ```
67
+
68
+ Issue link: if the argument or the branch name carries an issue reference (`#123`, `ABC-123`),
69
+ put it in **Why** (`Closes #123` for a GitHub issue in this repo).
70
+
71
+ ## 5. Create or update
72
+
73
+ Write the body to a temp file, then:
74
+
75
+ ```bash
76
+ gh pr create --draft --base <base> --title "<title>" --body-file <file>
77
+ gh pr edit <n> --title "<title>" --body-file <file>
78
+ ```
79
+
80
+ Report the PR URL and the title to the user.
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: visual-parity
3
+ description: 比對兩個已渲染網頁的樣式落差(新舊版對照 / 改版驗收),跑互動 E2E 流程並蒐集 console 與 network 錯誤,以及在 375/768/1440 三種寬度檢查 RWD 與水平溢出。當使用者提到「新舊頁面長得不一樣」「跟測試環境比對」「像素比對」「樣式對不上」「visual diff」「RWD 檢查」「開 dev server 驗收」「E2E 驗收」時使用。比對與驗收的結果寫進檔案,只回傳壓縮過的落差表。
4
+ ---
5
+
6
+ # Visual Parity
7
+
8
+ 一次跑完、產出完整落差清單。**禁止**「改一項 → 截圖 → 再改一項」的來回迴圈。
9
+
10
+ ## 為什麼存在
11
+
12
+ 改一項就截圖一次,會把每張截圖與每份 computed style 永久留在 context 裡。同一份工作在 600k context 下做,成本是 100k 時的六倍。本 skill 把比對結果寫進檔案,只回傳一張壓縮過的落差表。
13
+
14
+ ## 鐵則
15
+
16
+ 1. **不要 Read `scripts/parity.py`。** 它是黑箱,用 `--help` 就夠。下文的 `parity.py` 一律指本 SKILL.md 所在目錄下的 `scripts/parity.py`,用絕對路徑呼叫。
17
+ 2. **不要把報告全文讀進 context。** stdout 的摘要已含「最常出現的落差屬性」;需要細節時用 `grep` 撈特定屬性或 selector,不要 `Read` 整份。
18
+ 3. **不要逐項修。** 先看 stdout 的 top differing properties——落差通常是少數幾條系統性規則(一條 `margin-bottom`、一組 token)造成的,不是幾十個獨立問題。
19
+ 4. **改完重跑一次全量比對**,而不是只驗剛改的那一項。
20
+
21
+ ## 前置
22
+
23
+ - `<skill-dir>` 是本 SKILL.md 所在的目錄;script 在 `<skill-dir>/scripts/parity.py`。
24
+ - 下文指令寫 `python`;macOS / Linux 沒有 `python` 時改用 `python3`。
25
+ - 需要 `pip install playwright && playwright install chromium`(macOS / Linux 用 `pip3` 或 `python3 -m pip`);錄 `.mp4` 才需要 ffmpeg(選配)。
26
+ - 受測頁面要先跑起來。server 已經在跑就直接呼叫 `parity.py`;自簽憑證已預設接受(`ignore_https_errors`)。
27
+ - 頁面需要登入或一次性 token 時,`--ref` / `--local` 直接給帶 token 的完整 URL,或先用 `flow` 的步驟登入。
28
+ 打到登入頁或拒絕頁時比對結果毫無意義——`matched` 異常地少就先檢查這個。
29
+ - 新舊對照要同時起兩份 server(不同 port),各自當 `--ref` 與 `--local`。
30
+
31
+ ## diff — 新舊頁面樣式落差
32
+
33
+ ```bash
34
+ python <skill-dir>/scripts/parity.py diff \
35
+ --ref https://staging.example.com/faq/content?id=28473 \
36
+ --local https://localhost:3000/faq/content?id=28473 \
37
+ --out parity-diff.md
38
+ ```
39
+
40
+ 元素配對:先套 `--map` 的手動對照,再以「標籤+文字」自動配對,最後以「純文字」補配(吸收 SPA→SSR 的標籤變化,例如 `<p>` 變 `<div>`)。
41
+
42
+ stdout 會回報四個數字:
43
+
44
+ - `matched` — 成功配對並比對的元素數
45
+ - `diffs` — 屬性落差總數
46
+ - `missing-locally` — 舊頁有、新頁完全沒有的內容(**抓漏掉的區塊用這個**)
47
+ - `skipped-ambiguous` — 文字在頁面上重複、無法 1:1 配對,**未被比對**。數字大不代表有問題,但代表覆蓋率沒滿;關鍵區塊落在這裡就用 `--map` 補。
48
+
49
+ 其他參數:`--width` / `--widths`、`--wait-selector`(等內容渲染完)、`--extra-wait`(動畫或延遲載入)。
50
+ Exit code 1 表示有落差,0 表示完全一致。
51
+
52
+ ### --map:手動指定對照
53
+
54
+ DOM 結構差太多時(舊 iframe vs 新 SSR),用 JSON 補:
55
+
56
+ ```json
57
+ {
58
+ "#faqAnswer": ".faq-body",
59
+ ".uform_search": "[data-testid=search-hero]",
60
+ "#footer .crumb": "nav[aria-label=breadcrumb]"
61
+ }
62
+ ```
63
+
64
+ 左邊是 ref 的 selector、右邊是 local 的。支援尾綴比對,不必寫完整路徑。
65
+
66
+ ## responsive — 三種寬度 + 水平溢出
67
+
68
+ ```bash
69
+ python <skill-dir>/scripts/parity.py responsive \
70
+ --ref <URL> --local <URL> --out parity-responsive.md
71
+ ```
72
+
73
+ 等同在 375 / 768 / 1440 各跑一次 diff,另外檢查 local 是否出現水平溢出(`scrollWidth > clientWidth`),這是 RWD 破版最常見的徵兆。
74
+
75
+ ## flow — 互動驗收 + console/network 錯誤
76
+
77
+ 用 JSON 描述步驟,不要每次現寫 Playwright:
78
+
79
+ ```json
80
+ [
81
+ {"action": "goto", "url": "https://localhost:3000/products"},
82
+ {"action": "click", "selector": ".ba-free-download", "desc": "點 Free Download"},
83
+ {"action": "wait", "selector": "[role=dialog]"},
84
+ {"action": "expect_focused", "selector": "[role=dialog] input[type=email]", "desc": "focus 應落在 input 而非 close button"},
85
+ {"action": "fill", "selector": "input[type=email]", "value": "a@b.com"},
86
+ {"action": "click", "selector": "button[type=submit]"},
87
+ {"action": "expect_text", "selector": "[role=dialog]", "value": "Check your email"}
88
+ ]
89
+ ```
90
+
91
+ ```bash
92
+ python <skill-dir>/scripts/parity.py flow --steps steps.json --out parity-flow.md
93
+ ```
94
+
95
+ 可用 action:`goto` `click` `fill` `press` `wait` `expect_text` `expect_visible` `expect_focused` `screenshot`。
96
+ `wait` 吃 `selector`,或改吃 `{"action":"wait","ms":800}` 單純停一段時間——動畫沒有對應的 selector 可等時用這個。
97
+ 預設第一個失敗就停(`--keep-going` 可跑完全部)。console error/warning 與失敗的 request 一律蒐集,這類問題不會反映在樣式比對裡。
98
+ `--headed` 可開有頭瀏覽器讓人工旁觀。
99
+
100
+ ### --video:錄整段互動
101
+
102
+ 截圖看不出 transition、loading、hover 這類會動的東西,用錄影:
103
+
104
+ ```bash
105
+ python <skill-dir>/scripts/parity.py flow \
106
+ --steps steps.json --out parity-flow.md --video parity-flow.mp4
107
+ ```
108
+
109
+ - 副檔名 `.mp4` 會在錄完後用 ffmpeg 轉成 H.264(QuickTime 直接開),中間的 webm 自動刪除。**ffmpeg 不存在時不會失敗**,保留 `.webm` 並在 stdout 提示。副檔名寫 `.webm` 則完全不轉檔。
110
+ - `--video-size WxH`(預設 `1280x720`)同時決定錄影解析度與 viewport。
111
+ - `--video-tail <秒>`(預設 `1`)最後一步跑完後繼續錄的時間,避免收尾的動畫被切掉。
112
+ - 影格率是 Playwright screencast 的 **25fps**,判斷「動畫有沒有做對」夠用;要判斷「掉不掉幀」不夠,那種要用 macOS `screencapture -v` 錄 60fps。
113
+ - 影片路徑會同時寫進報告開頭與 stdout。
114
+
115
+ 把 flow 的 JSON 存進專案(例如 `e2e/`),下次改版直接重跑,就是回歸測試。
116
+
117
+ ## 維護
118
+
119
+ 改過 `parity.py` 後跑 `python <skill-dir>/scripts/parity.py selftest`,會用內建 fixture 驗證配對與 diff 邏輯仍正確。
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """visual-parity: rendered-page comparison and E2E flow runner.
3
+
4
+ Black-box script. Run with --help. Do not read this source into an agent
5
+ context window; every mode writes its full result to a report file and prints
6
+ only a compressed summary to stdout.
7
+ """
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from collections import Counter, defaultdict
17
+
18
+ try:
19
+ from playwright.sync_api import sync_playwright
20
+ except ImportError:
21
+ sys.exit("playwright not installed. Run: pip install playwright && playwright install chromium")
22
+
23
+ PROPS = [
24
+ "font-family", "font-size", "font-weight", "font-style", "line-height",
25
+ "letter-spacing", "text-align", "text-transform", "text-decoration-line",
26
+ "color", "background-color", "opacity", "visibility",
27
+ "margin-top", "margin-right", "margin-bottom", "margin-left",
28
+ "padding-top", "padding-right", "padding-bottom", "padding-left",
29
+ "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
30
+ "border-top-color", "border-radius",
31
+ "display", "flex-direction", "justify-content", "align-items", "gap",
32
+ "list-style-type", "white-space", "overflow-x", "box-shadow",
33
+ ]
34
+
35
+ # Box dimensions are compared with a tolerance; exact equality is noise.
36
+ BOX_TOLERANCE_PX = 2
37
+
38
+ COLLECT_JS = """
39
+ (props) => {
40
+ const SKIP = new Set(['SCRIPT','STYLE','META','LINK','HEAD','NOSCRIPT','TITLE','BR']);
41
+ const cssPath = (el) => {
42
+ const parts = [];
43
+ while (el && el.nodeType === 1 && parts.length < 8) {
44
+ if (el === document.body) { parts.unshift('body'); break; }
45
+ let p = el.tagName.toLowerCase();
46
+ if (el.id && /^[A-Za-z][\\w-]*$/.test(el.id)) { parts.unshift('#' + el.id); break; }
47
+ const par = el.parentElement;
48
+ if (par) {
49
+ const sibs = Array.from(par.children).filter(c => c.tagName === el.tagName);
50
+ if (sibs.length > 1) p += ':nth-of-type(' + (sibs.indexOf(el) + 1) + ')';
51
+ }
52
+ parts.unshift(p);
53
+ el = el.parentElement;
54
+ }
55
+ return parts.join(' > ');
56
+ };
57
+ const ownText = (el) => (el.children.length === 0
58
+ ? el.textContent
59
+ : Array.from(el.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join(' '));
60
+
61
+ const out = [];
62
+ for (const el of document.querySelectorAll('*')) {
63
+ if (SKIP.has(el.tagName)) continue;
64
+ const r = el.getBoundingClientRect();
65
+ if (r.width === 0 && r.height === 0) continue;
66
+ const cs = getComputedStyle(el);
67
+ const styles = {};
68
+ for (const p of props) styles[p] = cs.getPropertyValue(p).trim();
69
+ out.push({
70
+ path: cssPath(el),
71
+ tag: el.tagName.toLowerCase(),
72
+ text: ownText(el).replace(/\\s+/g, ' ').trim().slice(0, 120),
73
+ w: Math.round(r.width), h: Math.round(r.height),
74
+ styles,
75
+ });
76
+ if (out.length >= 2500) break;
77
+ }
78
+ return {
79
+ elements: out,
80
+ docScrollWidth: document.documentElement.scrollWidth,
81
+ clientWidth: document.documentElement.clientWidth,
82
+ };
83
+ }
84
+ """
85
+
86
+
87
+ def norm(t):
88
+ return re.sub(r"\s+", " ", t or "").strip().lower()
89
+
90
+
91
+ def collect(page, url, wait_selector, extra_wait):
92
+ page.goto(url, wait_until="domcontentloaded", timeout=60000)
93
+ try:
94
+ page.wait_for_load_state("networkidle", timeout=30000)
95
+ except Exception:
96
+ pass # some pages poll forever; the DOM is usable regardless
97
+ if wait_selector:
98
+ page.wait_for_selector(wait_selector, timeout=30000)
99
+ if extra_wait:
100
+ page.wait_for_timeout(int(extra_wait * 1000))
101
+ return page.evaluate(COLLECT_JS, PROPS)
102
+
103
+
104
+ def build_pairs(ref_els, loc_els, manual_map):
105
+ """Match ref elements to local elements. Returns (pairs, unmatched_ref)."""
106
+ pairs, used_loc = [], set()
107
+
108
+ ref_by_path = {e["path"]: e for e in ref_els}
109
+ loc_by_path = {e["path"]: e for e in loc_els}
110
+ for ref_sel, loc_sel in (manual_map or {}).items():
111
+ r = ref_by_path.get(ref_sel) or next((e for e in ref_els if e["path"].endswith(ref_sel)), None)
112
+ l = loc_by_path.get(loc_sel) or next((e for e in loc_els if e["path"].endswith(loc_sel)), None)
113
+ if r and l:
114
+ pairs.append((r, l, "manual"))
115
+ used_loc.add(l["path"])
116
+
117
+ def index(els, keyfn):
118
+ idx = defaultdict(list)
119
+ for e in els:
120
+ k = keyfn(e)
121
+ if k:
122
+ idx[k].append(e)
123
+ return {k: v[0] for k, v in idx.items() if len(v) == 1}
124
+
125
+ matched_ref = {id(r) for r, _, _ in pairs}
126
+ for keyfn, label in (
127
+ (lambda e: f"{e['tag']}|{norm(e['text'])}" if norm(e["text"]) else None, "tag+text"),
128
+ (lambda e: norm(e["text"]) if norm(e["text"]) else None, "text"),
129
+ ):
130
+ ridx, lidx = index(ref_els, keyfn), index(loc_els, keyfn)
131
+ for k, r in ridx.items():
132
+ if id(r) in matched_ref:
133
+ continue
134
+ l = lidx.get(k)
135
+ if l and l["path"] not in used_loc:
136
+ pairs.append((r, l, label))
137
+ matched_ref.add(id(r))
138
+ used_loc.add(l["path"])
139
+
140
+ # An unmatched ref element is only "missing" if its text appears nowhere
141
+ # locally. Text that exists but is ambiguous (repeated nav labels, table
142
+ # cells) simply cannot be paired 1:1 — reporting it as missing sends the
143
+ # reader chasing content that is actually on the page.
144
+ loc_texts = {norm(e["text"]) for e in loc_els if norm(e["text"])}
145
+ missing, ambiguous = [], []
146
+ for e in ref_els:
147
+ t = norm(e["text"])
148
+ if id(e) in matched_ref or not t:
149
+ continue
150
+ (missing if t not in loc_texts else ambiguous).append(e)
151
+ return pairs, missing, ambiguous
152
+
153
+
154
+ def diff_pair(r, l):
155
+ out = []
156
+ for p in PROPS:
157
+ a, b = r["styles"].get(p, ""), l["styles"].get(p, "")
158
+ if a != b:
159
+ out.append((p, a, b))
160
+ for dim in ("w", "h"):
161
+ if abs(r[dim] - l[dim]) > BOX_TOLERANCE_PX:
162
+ out.append((f"box-{dim}", f"{r[dim]}px", f"{l[dim]}px"))
163
+ return out
164
+
165
+
166
+ def run_diff(args):
167
+ manual_map = {}
168
+ if args.map:
169
+ manual_map = json.load(open(args.map, encoding="utf-8"))
170
+
171
+ widths = [int(w) for w in args.widths.split(",")] if args.widths else [args.width]
172
+ sections, stdout_rows = [], []
173
+ prop_counter = Counter()
174
+ total_pairs = total_diffs = 0
175
+ overflow_notes = []
176
+
177
+ with sync_playwright() as p:
178
+ browser = p.chromium.launch(headless=True)
179
+ ctx = browser.new_context(ignore_https_errors=True)
180
+ page = ctx.new_page()
181
+ for w in widths:
182
+ page.set_viewport_size({"width": w, "height": args.height})
183
+ ref = collect(page, args.ref, args.wait_selector, args.extra_wait)
184
+ loc = collect(page, args.local, args.wait_selector, args.extra_wait)
185
+
186
+ if loc["docScrollWidth"] > loc["clientWidth"] + 1:
187
+ overflow_notes.append(
188
+ f"{w}px: local has horizontal overflow "
189
+ f"(scrollWidth {loc['docScrollWidth']} > clientWidth {loc['clientWidth']})"
190
+ )
191
+
192
+ pairs, missing, ambiguous = build_pairs(ref["elements"], loc["elements"], manual_map)
193
+ total_pairs += len(pairs)
194
+
195
+ rows = []
196
+ for r, l, how in pairs:
197
+ for prop, a, b in diff_pair(r, l):
198
+ rows.append((prop, r["path"], l["path"], a, b, r["text"][:40], how))
199
+ prop_counter[prop] += 1
200
+ total_diffs += len(rows)
201
+
202
+ sec = [f"\n## viewport {w}px\n",
203
+ f"matched {len(pairs)} elements, {len(rows)} property diffs, "
204
+ f"{len(missing)} missing locally, "
205
+ f"{len(ambiguous)} not compared (text present but ambiguous)\n"]
206
+ if rows:
207
+ sec.append("\n| property | ref value | local value | ref selector | text |")
208
+ sec.append("|---|---|---|---|---|")
209
+ for prop, rp, lp, a, b, txt, how in sorted(rows):
210
+ sec.append(f"| {prop} | `{a}` | `{b}` | `{rp}` | {txt} |")
211
+ if missing:
212
+ sec.append("\n### present in ref, missing locally\n")
213
+ for e in missing[:60]:
214
+ sec.append(f"- `{e['tag']}` {e['text'][:90]}")
215
+ if len(missing) > 60:
216
+ sec.append(f"- ...and {len(missing) - 60} more")
217
+ if ambiguous:
218
+ sec.append(
219
+ f"\n### not compared: {len(ambiguous)} elements whose text is repeated on the page\n"
220
+ "Add entries to `--map` to compare these explicitly.\n")
221
+ sections.append("\n".join(sec))
222
+ stdout_rows.append((w, len(pairs), len(rows), len(missing), len(ambiguous)))
223
+
224
+ browser.close()
225
+
226
+ report = [f"# visual-parity diff\n", f"- ref: {args.ref}", f"- local: {args.local}",
227
+ f"- widths: {widths}"]
228
+ if overflow_notes:
229
+ report.append("\n**layout warnings**")
230
+ report += [f"- {n}" for n in overflow_notes]
231
+ report += sections
232
+ with open(args.out, "w", encoding="utf-8") as f:
233
+ f.write("\n".join(report))
234
+
235
+ print(f"report: {args.out}")
236
+ for w, np_, nd, nm, na in stdout_rows:
237
+ print(f" {w}px: {np_} matched, {nd} diffs, {nm} missing-locally, {na} skipped-ambiguous")
238
+ for n in overflow_notes:
239
+ print(f" WARN {n}")
240
+ if prop_counter:
241
+ print("\ntop differing properties (fix these systemically, not element by element):")
242
+ for prop, c in prop_counter.most_common(15):
243
+ print(f" {c:4d} {prop}")
244
+ return 1 if total_diffs else 0
245
+
246
+
247
+ def save_video(video, out_path):
248
+ """Persist a Playwright video; convert to MP4 when the caller asked for one.
249
+
250
+ Returns the path actually written, which falls back to the .webm original
251
+ if ffmpeg is missing or fails.
252
+ """
253
+ root, ext = os.path.splitext(out_path)
254
+ ext = ext.lower()
255
+ webm = out_path if ext == ".webm" else root + ".webm"
256
+ video.save_as(webm)
257
+ if ext != ".mp4":
258
+ return webm
259
+ ffmpeg = shutil.which("ffmpeg")
260
+ if not ffmpeg:
261
+ print(f" ffmpeg not found - kept {webm} (install with: brew install ffmpeg)")
262
+ return webm
263
+ try:
264
+ subprocess.run(
265
+ [ffmpeg, "-y", "-loglevel", "error", "-i", webm,
266
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
267
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
268
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart", out_path],
269
+ check=True, capture_output=True)
270
+ except subprocess.CalledProcessError as e:
271
+ print(f" ffmpeg failed - kept {webm}: {e.stderr.decode()[:200]}")
272
+ return webm
273
+ os.remove(webm)
274
+ return out_path
275
+
276
+
277
+ def run_flow(args):
278
+ steps = json.load(open(args.steps, encoding="utf-8"))
279
+ console_errors, failed_requests, results = [], [], []
280
+
281
+ video_path, tmpdir = None, None
282
+
283
+ with sync_playwright() as p:
284
+ browser = p.chromium.launch(headless=args.headless)
285
+ ctx_opts = {"ignore_https_errors": True}
286
+ if args.video:
287
+ w, h = (int(v) for v in args.video_size.lower().split("x"))
288
+ tmpdir = tempfile.mkdtemp(prefix="parity-video-")
289
+ ctx_opts.update(record_video_dir=tmpdir,
290
+ record_video_size={"width": w, "height": h},
291
+ viewport={"width": w, "height": h})
292
+ ctx = browser.new_context(**ctx_opts)
293
+ page = ctx.new_page()
294
+ page.on("console", lambda m: console_errors.append(f"[{m.type}] {m.text}"[:300])
295
+ if m.type in ("error", "warning") else None)
296
+ page.on("requestfailed", lambda r: failed_requests.append(f"{r.method} {r.url} — {r.failure}"[:300]))
297
+
298
+ for i, step in enumerate(steps):
299
+ act = step.get("action")
300
+ desc = step.get("desc", f"{act} {step.get('selector') or step.get('url') or ''}")
301
+ try:
302
+ if act == "goto":
303
+ page.goto(step["url"], wait_until="domcontentloaded", timeout=60000)
304
+ try:
305
+ page.wait_for_load_state("networkidle", timeout=20000)
306
+ except Exception:
307
+ pass
308
+ elif act == "click":
309
+ page.click(step["selector"], timeout=15000)
310
+ elif act == "fill":
311
+ page.fill(step["selector"], step["value"], timeout=15000)
312
+ elif act == "press":
313
+ page.press(step["selector"], step["key"], timeout=15000)
314
+ elif act == "wait":
315
+ if "ms" in step:
316
+ page.wait_for_timeout(step["ms"])
317
+ else:
318
+ page.wait_for_selector(step["selector"], timeout=step.get("timeout", 15000))
319
+ elif act == "expect_text":
320
+ got = page.inner_text(step["selector"], timeout=15000)
321
+ assert step["value"] in got, f"expected {step['value']!r} in {got[:120]!r}"
322
+ elif act == "expect_visible":
323
+ assert page.is_visible(step["selector"]), "not visible"
324
+ elif act == "expect_focused":
325
+ got = page.evaluate(
326
+ "s => document.activeElement === document.querySelector(s)", step["selector"])
327
+ assert got, "element is not document.activeElement"
328
+ elif act == "screenshot":
329
+ page.screenshot(path=step["path"], full_page=step.get("full_page", True))
330
+ else:
331
+ raise ValueError(f"unknown action {act!r}")
332
+ results.append((i, desc, "PASS", ""))
333
+ except Exception as e:
334
+ results.append((i, desc, "FAIL", str(e)[:300]))
335
+ if not args.keep_going:
336
+ break
337
+
338
+ if args.video and args.video_tail:
339
+ page.wait_for_timeout(int(args.video_tail * 1000))
340
+ video = page.video if args.video else None
341
+ ctx.close() # the video file is only finalized on context close
342
+ if video:
343
+ video_path = save_video(video, args.video)
344
+ browser.close()
345
+ if tmpdir:
346
+ shutil.rmtree(tmpdir, ignore_errors=True)
347
+
348
+ lines = ["# visual-parity flow\n", f"steps file: {args.steps}\n"]
349
+ if video_path:
350
+ lines.append(f"video: {video_path}\n")
351
+ lines += ["| # | step | result | detail |", "|---|---|---|---|"]
352
+ lines += [f"| {i} | {d} | {r} | {m} |" for i, d, r, m in results]
353
+ if console_errors:
354
+ lines += ["\n## console errors/warnings\n"] + [f"- {e}" for e in dict.fromkeys(console_errors)]
355
+ if failed_requests:
356
+ lines += ["\n## failed requests\n"] + [f"- {e}" for e in dict.fromkeys(failed_requests)]
357
+ with open(args.out, "w", encoding="utf-8") as f:
358
+ f.write("\n".join(lines))
359
+
360
+ failed = [r for r in results if r[2] == "FAIL"]
361
+ print(f"report: {args.out}")
362
+ if video_path:
363
+ print(f"video: {video_path}")
364
+ print(f" {len(results) - len(failed)}/{len(results)} steps passed")
365
+ for i, d, r, m in failed:
366
+ print(f" FAIL step {i}: {d} — {m}")
367
+ if console_errors:
368
+ print(f" {len(set(console_errors))} unique console errors/warnings")
369
+ if failed_requests:
370
+ print(f" {len(set(failed_requests))} failed requests")
371
+ return 1 if failed else 0
372
+
373
+
374
+ def selftest():
375
+ """Assert the matcher and differ actually catch a known difference."""
376
+ a = """<html><body><h1>Hello</h1><p class=x>Body copy</p>
377
+ <ul><li>Item one</li></ul><div>Only in ref 🎉</div></body></html>"""
378
+ b = """<html><body><h1 style="font-size:40px">Hello</h1>
379
+ <div class=y style="margin-bottom:0">Body copy</div>
380
+ <ul><li style="color:rgb(255,0,0)">Item one</li></ul></body></html>"""
381
+ d = tempfile.mkdtemp()
382
+ for name, html in (("a.html", a), ("b.html", b)):
383
+ open(os.path.join(d, name), "w", encoding="utf-8").write(html)
384
+ out = os.path.join(d, "r.md")
385
+ args = argparse.Namespace(
386
+ ref="file://" + os.path.join(d, "a.html"), local="file://" + os.path.join(d, "b.html"),
387
+ map=None, widths=None, width=1440, height=900, wait_selector=None, extra_wait=0, out=out)
388
+ run_diff(args)
389
+ body = open(out, encoding="utf-8").read()
390
+ assert "font-size" in body, "should detect the h1 font-size change"
391
+ assert "color" in body, "should detect the li color change"
392
+ assert "Only in ref" in body, "should report the ref-only element as missing locally"
393
+ assert "`div`" in body or "margin-bottom" in body, "should match p->div across tag change"
394
+ print("\nselftest OK")
395
+
396
+
397
+ def main():
398
+ # Windows consoles default to a legacy codepage (e.g. cp950); page text
399
+ # echoed to stdout must not crash the run.
400
+ for stream in (sys.stdout, sys.stderr):
401
+ if hasattr(stream, "reconfigure"):
402
+ stream.reconfigure(errors="replace")
403
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
404
+ sub = ap.add_subparsers(dest="cmd", required=True)
405
+
406
+ d = sub.add_parser("diff", help="compare computed styles of two rendered URLs")
407
+ d.add_argument("--ref", required=True, help="reference URL (old page / staging)")
408
+ d.add_argument("--local", required=True, help="URL under development")
409
+ d.add_argument("--map", help="JSON file: {\"<ref selector>\": \"<local selector>\"} manual overrides")
410
+ d.add_argument("--width", type=int, default=1440)
411
+ d.add_argument("--widths", help="comma list, e.g. 375,768,1440 (overrides --width)")
412
+ d.add_argument("--height", type=int, default=900)
413
+ d.add_argument("--wait-selector", help="wait for this selector before collecting")
414
+ d.add_argument("--extra-wait", type=float, default=0, help="extra seconds after networkidle")
415
+ d.add_argument("--out", default="parity-diff.md")
416
+
417
+ r = sub.add_parser("responsive", help="diff at 375/768/1440 plus horizontal-overflow check")
418
+ for a in ("--ref", "--local"):
419
+ r.add_argument(a, required=True)
420
+ r.add_argument("--map")
421
+ r.add_argument("--height", type=int, default=900)
422
+ r.add_argument("--wait-selector")
423
+ r.add_argument("--extra-wait", type=float, default=0)
424
+ r.add_argument("--out", default="parity-responsive.md")
425
+
426
+ f = sub.add_parser("flow", help="run an interaction flow, collect console + network errors")
427
+ f.add_argument("--steps", required=True, help="JSON array of step objects")
428
+ f.add_argument("--out", default="parity-flow.md")
429
+ f.add_argument("--headed", dest="headless", action="store_false", default=True)
430
+ f.add_argument("--keep-going", action="store_true", help="continue after a failing step")
431
+ f.add_argument("--video", help="record the run to this path; .mp4 converts via ffmpeg, .webm keeps the native output")
432
+ f.add_argument("--video-size", default="1280x720", help="WxH of both the recording and the viewport (default 1280x720)")
433
+ f.add_argument("--video-tail", type=float, default=1.0, help="seconds to keep recording after the last step so trailing animations finish (default 1)")
434
+
435
+ sub.add_parser("selftest", help="verify the matcher/differ still work")
436
+
437
+ args = ap.parse_args()
438
+ if args.cmd == "diff":
439
+ sys.exit(run_diff(args))
440
+ if args.cmd == "responsive":
441
+ args.widths, args.width = "375,768,1440", 1440
442
+ sys.exit(run_diff(args))
443
+ if args.cmd == "flow":
444
+ sys.exit(run_flow(args))
445
+ selftest()
446
+
447
+
448
+ if __name__ == "__main__":
449
+ main()