product-planning ③段:闸门脚本下沉进技能包(框架一份、常量现读)
起因:③段只定义 Gate(自检表),不供给执行件 ⇒ 每个目标自建一份 (实测本区 执行会话/*/ui/_tools/ 下 check_skeleton.py 6 份 / 3 个版本,其中一版 还停在"八页骨架"常量上 ⇒ ②段收成七页后要么 SystemExit、要么静默比错)。 新增 references/stage-delivery/scripts/(跨项目共用一份): - _common.py 定位(按 docs/pm/*/prd|ui 形态找,⛔ 不写死项目名/文件名)+ 自检文本解析规则(唯一一份)+ node/harness 自动发现 - check_skeleton.py ②段骨架 ↔ 原型 FROZEN 表逐页逐位比对;节号/页集合/页数现读 - check_js.py <script> 段 node --check + 模板内 HTML 注释不得含反引号; 查不到覆盖面时给 SKIP 并写明(⛔ 不给假 PASS) - grab_selftest.py 取自检文本(容忍 IPC 5s 超时;复用同一标签) - run_viewports.py 逐视口抽密度/横向溢出/外壳高度链;--min-rows 默认 6 - mutate_test.py 变异对照框架(规则文件 mutate-rules.json 留在项目侧) - selftest_gates.py 闸门自身的变异对照(临时副本上造病,要求报红+一档不该报红) 文档:runbook 新增 §5.6(用法只写这一处);SKILL.md 只留指针。 验证(contentm_agent 实测):骨架 PASS 7 页 39 板块;语法 PASS(1720 行); 闸门自身对照 5/5(改名报红并点名 / 注释带反引号报红 / 原型少一块报数量不等 / 节号加后缀仍 PASS ⇒ 既非恒绿也非恒红);命名闸门 2061|0|27。 浏览器类三条(grab/run_viewports/mutate)因 9223 没在跑,只验到参数链与 "没跑就报没跑"(rc=2),未实跑。
This commit is contained in:
1 parent
510ba9192c
commit
799ceb749b
9 files changed
+1034
No files matched your search
@@ -296,6 +296,10 @@ Gate-1 方向门(硬闸,不可跳)——按本表逐项自检(字段定
|
||||
|
||||
真实渲染后(浏览器打开,非只看代码)肉眼过八项:裁切 / 重叠 / 失真 / 失效控件 / 缺状态 / 响应式破损 / 控制台报错 / 字体回退异常。渲染截图已代劳其中的溢出与响应式破损,其余仍需人眼(截图查不出「点了没反应」)。
|
||||
|
||||
> 🔴 **判据的执行件=本段自带脚本**(`references/stage-delivery/scripts/`):骨架逐位比对、JS 语法、
|
||||
> 逐视口读数、变异对照四条,跨项目共用同一份。**用法只写一处** ⇒ `references/execution-runbook.md` §5.6。
|
||||
> 脚本按目录形态定位 ②段与原型、节号与页数现读 ⇒ ②段改结构时不需要改脚本。
|
||||
|
||||
### 审计报告落盘
|
||||
|
||||
`designs/<项目>/3d-审查报告.md`,固定七节:范围 + 挡位 + 阻断项表 + 建议项表 + 状态覆盖核对表 + 结构核对表 + Gate 结论(含「本轮是否跑了脚本」一句)。
|
||||
|
||||
@@ -377,6 +377,44 @@
|
||||
|
||||
---
|
||||
|
||||
### 5.6 闸门脚本(Gate-1/2/3 的执行件,本段自带)
|
||||
|
||||
判据由**本段自带的脚本**执行,跨项目共用同一份(`references/stage-delivery/scripts/`)。
|
||||
各项目自己养一份时,同一条判据会长出多个版本(实测:某项目的骨架闸门已有 3 个版本,
|
||||
其中一版还停在旧页数常量上 ⇒ 跑起来要么当场报错、要么按错的页数**静默比错**)。
|
||||
|
||||
五条脚本(都在 `scripts/` 下):
|
||||
|
||||
1. `check_skeleton.py` —— ②段《界面布局》的页面骨架 ↔ 原型 `FROZEN` 表,逐页逐位比对。
|
||||
跑法:`python scripts/check_skeleton.py --ws <工作区>`
|
||||
2. `check_js.py` —— 原型 `<script>` 段的语法(`node --check`)+ 模板里的 HTML 注释不得含反引号。
|
||||
跑法:`python scripts/check_js.py --ws <工作区>`
|
||||
3. `grab_selftest.py` —— 取原型 `#/selftest` 的自检文本(容忍 IPC 5 s 超时;复用同一个标签)。
|
||||
跑法:`python scripts/grab_selftest.py --proto <原型> --viewport 1440x900 --out <落盘>`
|
||||
4. `run_viewports.py` —— 逐视口跑自检,抽密度/横向溢出/外壳高度链的读数表。
|
||||
跑法:`python scripts/run_viewports.py --ws <工作区> [--min-rows 6] [--list]`
|
||||
5. `mutate_test.py` —— 变异对照(框架):按规则文件注入假病,要求自检报红。
|
||||
跑法:`python scripts/mutate_test.py --ws <工作区>`
|
||||
规则文件放在**原型同目录**的 `mutate-rules.json`(框架跨项目共用,注入点跟着原型走)。
|
||||
6. `selftest_gates.py` —— **闸门自身的变异对照**:在临时副本上给 ②段与原型各造一处假病,
|
||||
要求前两条闸门报红,并加一档「不该报红」的对照(节号加后缀仍 PASS)。
|
||||
跑法:`python scripts/selftest_gates.py --ws <工作区>`
|
||||
用途:改完闸门或②段结构之后跑一次,证明判据既不是恒绿、也不是恒红。
|
||||
|
||||
三条共同约定:
|
||||
|
||||
1. 定位一律**按目录形态**(`docs/pm/*/prd/2b-*.md`、`docs/pm/*/ui/*.html`),节号/页集合/页数
|
||||
**现读文档** ⇒ ②段改结构(页数增减、节号重排)时脚本不用改;`--expect-pages` 只在要额外钉住时才给。
|
||||
2. 跑不了就报**没跑**(退出码 2)—— 例如 9223 上没有浏览器实例时,逐视口与变异对照会写「没跑」,
|
||||
⛔ 不写 PASS。
|
||||
3. 判据没覆盖面时给 **`SKIP` 并写明原因**(例:`<script>` 区里没有 HTML 注释可查)。
|
||||
⛔ 不写 PASS(扫了 0 个块等于什么都没查),⛔ 也不写 FAIL(把合法状态判成故障)。
|
||||
|
||||
自检输出的**骨架**(脚本据此解析,原型要照它打印)在 `scripts/_common.py` 头部;
|
||||
解析规则只写那一份,逐视口与变异对照都从它取。
|
||||
|
||||
---
|
||||
|
||||
## 6. 偏离单(本段收尾必须附)
|
||||
|
||||
| # | 原要求(文件:小节) | 实际做法 | 原因 | 影响 | 处置 |
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门脚本的共用件:定位、运行环境发现、自检文本的解析规则。
|
||||
|
||||
🔴 **解析规则只在这里写一份** —— `run_viewports.py` 与 `mutate_test.py` 都从这里取,
|
||||
⛔ 不许各自再写一遍正则(同一条判据两份实现 ⇒ 迟早一处改一处漏)。
|
||||
|
||||
## 原型自检输出要按这个骨架打印(③段的契约,⛔ 不是"某个项目的习惯")
|
||||
|
||||
原型自检区(`#selftest-out`)对每个列表页要打印**两行中的一行**:
|
||||
|
||||
P<n> <页名>:首屏从 <top>px 起,行距 <pitch>px,一屏装得下 <cap> 行 ← 达标时
|
||||
P<n> <页名>一屏只能装下 <cap> 行(首行顶 <top>px、行距 <pitch>px) ← 不达标时
|
||||
|
||||
还要打印这四类全局项:
|
||||
|
||||
七页逐页查横向溢出:全为 0(最差 <页>) / 有页面横向溢出:<页> 超出 <px>px
|
||||
顶栏高 <h>px · 侧栏 <w>×<h>px
|
||||
纵向滚动只由主区承担:.app 高 <h>px
|
||||
自检结果:PASS <n> / FAIL <n>
|
||||
|
||||
理由:闸门靠**读数**判达标(一屏行数、行距、横向溢出、外壳高度链),
|
||||
读数只能从自检文本里取;把骨架固定下来,解析规则就不必跟着项目改。
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
# Windows 控制台默认不是 utf-8 ⇒ 中文输出会炸在最后一步(退出码看着正常、日志是乱码)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 自检文本的解析规则(唯一一份)────────────────────────────────────────────
|
||||
PAT = {
|
||||
"summary": r"自检结果:PASS\s*(\d+)\s*/\s*FAIL\s*(\d+)",
|
||||
"row_ok": r"P(\d+)\s*[^\n::]*[::]\s*首屏从\s*(\d+)\s*px\s*起[,,]\s*行距\s*(\d+)\s*px"
|
||||
r"[,,]\s*一屏装得下\s*(\d+)\s*行",
|
||||
"row_bad": r"P(\d+)\s*[^\n]*?一屏只能装下\s*(\d+)\s*行",
|
||||
"overflow_ok": r"[^\n]*逐页查横向溢出:全为\s*0(最差\s*(\w+))",
|
||||
"overflow_bad": r"有页面横向溢出:(\w+)\s*超出\s*(\d+)\s*px",
|
||||
"topbar_nav": r"顶栏高\s*(\d+)\s*px\s*·\s*侧栏\s*(\d+)×(\d+)\s*px",
|
||||
"shell_ok": r"纵向滚动只由主区承担:\.app\s*高\s*(\d+)\s*px",
|
||||
"console": r"控制台报错:(.+)",
|
||||
}
|
||||
|
||||
|
||||
def parse_selftest(text, min_rows=6):
|
||||
"""把自检文本解析成一行一页的读数表。
|
||||
|
||||
@return dict:
|
||||
· `pass`/`fail`:自检自报的 PASS/FAIL 计数
|
||||
· `rows`:{页号 int: {"cap": 一屏行数, "top": 首行顶 px, "pitch": 行距 px, "ok": cap>=min_rows}}
|
||||
· `overflow_ok`/`shell_ok`:两条全局判据
|
||||
· `topbar`/`nav_w`/`nav_h`:几何读数
|
||||
· `console`:控制台报错摘要("?" 表示自检没打印这一项)
|
||||
"""
|
||||
text = text or ""
|
||||
d = {"rows": {}, "overflow_ok": None, "shell_ok": None, "console": "?"}
|
||||
m = re.search(PAT["summary"], text)
|
||||
if m:
|
||||
d["pass"], d["fail"] = int(m.group(1)), int(m.group(2))
|
||||
for m in re.finditer(PAT["row_ok"], text):
|
||||
p, top, pitch, cap = (int(x) for x in m.groups())
|
||||
d["rows"][p] = {"cap": cap, "top": top, "pitch": pitch, "ok": cap >= min_rows}
|
||||
for m in re.finditer(PAT["row_bad"], text):
|
||||
p, cap = int(m.group(1)), int(m.group(2))
|
||||
d["rows"].setdefault(p, {"cap": cap, "ok": cap >= min_rows})
|
||||
m = re.search(PAT["overflow_ok"], text)
|
||||
if m:
|
||||
d["overflow_ok"], d["overflow_worst"] = True, m.group(1)
|
||||
else:
|
||||
m = re.search(PAT["overflow_bad"], text)
|
||||
if m:
|
||||
d["overflow_ok"], d["overflow_worst"], d["overflow_px"] = False, m.group(1), int(m.group(2))
|
||||
m = re.search(PAT["topbar_nav"], text)
|
||||
if m:
|
||||
d["topbar"], d["nav_w"], d["nav_h"] = (int(x) for x in m.groups())
|
||||
m = re.search(PAT["shell_ok"], text)
|
||||
if m:
|
||||
d["shell_ok"], d["app_h"] = True, int(m.group(1))
|
||||
m = re.search(PAT["console"], text)
|
||||
if m:
|
||||
d["console"] = m.group(1).strip()
|
||||
return d
|
||||
|
||||
|
||||
# ── 运行环境发现(本机是托管运行时;换机器靠 env 覆盖)────────────────────────
|
||||
def find_node(explicit=""):
|
||||
"""定位 node:`--node` → `NODE_BIN` → PATH → 本机托管的最新一版。⛔ 不写死版本号。"""
|
||||
for c in (explicit, os.environ.get("NODE_BIN", "")):
|
||||
if c and os.path.exists(c):
|
||||
return c
|
||||
w = shutil.which("node")
|
||||
if w:
|
||||
return w
|
||||
cand = sorted(glob.glob(os.path.join(
|
||||
os.environ.get("WORKBUDDY_BIN_ROOT", r"E:\ProgramData\.workbuddy\binaries"),
|
||||
"node", "versions", "*", "node.exe")))
|
||||
return cand[-1] if cand else ""
|
||||
|
||||
|
||||
def find_harness(explicit=""):
|
||||
"""定位 browser-harness:`--harness` → `BH_BIN` → PATH → 用户 bin。⛔ 不写死路径。"""
|
||||
for c in (explicit, os.environ.get("BH_BIN", "")):
|
||||
if c and os.path.exists(c):
|
||||
return c
|
||||
w = shutil.which("browser-harness") or shutil.which("browser-harness.exe")
|
||||
if w:
|
||||
return w
|
||||
cand = os.path.expanduser("~/.local/bin/browser-harness.exe")
|
||||
return cand if os.path.exists(cand) else ""
|
||||
|
||||
|
||||
# ── 文档/原型定位(按**目录形态**找,⛔ 不写死文件名)──────────────────────
|
||||
def find_doc(ws):
|
||||
"""②段《界面布局》:`<ws>/docs/pm/<项目>/prd/2b-*.md` 里取**最新改过**的那份。"""
|
||||
hits = glob.glob(os.path.join(ws, "docs", "pm", "*", "prd", "2b-*.md"))
|
||||
return max(hits, key=os.path.getmtime) if hits else ""
|
||||
|
||||
|
||||
def find_proto(ws):
|
||||
"""原型:`<ws>/docs/pm/<项目>/ui/*.html` 里取最新改过的那份(跳过 `_` 开头的暂存件)。"""
|
||||
hits = [p for p in glob.glob(os.path.join(ws, "docs", "pm", "*", "ui", "*.html"))
|
||||
if not os.path.basename(p).startswith("_")]
|
||||
return max(hits, key=os.path.getmtime) if hits else ""
|
||||
|
||||
|
||||
def resolve(ws="", doc="", proto=""):
|
||||
"""把(可选的)三条入参统一成绝对路径;缺的按目录形态自动找,找不到返回空串。"""
|
||||
ws = os.path.abspath(ws or os.getcwd())
|
||||
return (ws,
|
||||
os.path.abspath(doc) if doc else find_doc(ws),
|
||||
os.path.abspath(proto) if proto else find_proto(ws))
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
sys.stderr.write("⛔ %s\n" % msg)
|
||||
return code
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门②:原型 JS/模板字面量的**语法门禁**(只读原型,⛔ 不写盘)。
|
||||
|
||||
为什么需要它(同一个坑踩了 4 次的真实事故,不是"为了严谨"):
|
||||
③段原型是单文件自包含,页面结构写在一堆 JS **模板字面量**里(反引号包起来的字符串)。
|
||||
只要在模板里的 **HTML 注释**(`<!-- … -->`)中写出一个反引号,模板字面量会被提前闭合,
|
||||
后面那段就被当成 JS 代码解析 —— 症状是**页面静默坏掉**(白屏 / `xxx is not defined`),
|
||||
而 `node --check 整份 html` **报不出来**(html 不是 js)⇒ 一路混到浏览器里才炸。
|
||||
|
||||
查两件事:
|
||||
① **语法**:把 `<script>` 段抠出来交给 `node --check`(⛔ 不是查整份 html)。
|
||||
② **模板里的反引号**:多行扫描 script 区内的每个 `<!-- … -->` 块,块内出现反引号即报红
|
||||
(⛔ 必须多行扫:反引号常落在注释的续行上,单行 grep 抓不到)。
|
||||
|
||||
用法:
|
||||
python check_js.py --ws <工作区> # 自动找原型
|
||||
python check_js.py --proto X.html --node <node.exe>
|
||||
退出码:0 = PASS;1 = FAIL;2 = 用法/定位失败。
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _common as C # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门②:原型 JS 语法 + 模板反引号")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找 docs/pm/*/ui/*.html)")
|
||||
ap.add_argument("--node", default="", help="node 可执行文件(缺省=自动发现)")
|
||||
a = ap.parse_args()
|
||||
_, _, proto = C.resolve(a.ws, "", a.proto)
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML")
|
||||
node = C.find_node(a.node)
|
||||
src = io.open(proto, encoding="utf-8").read()
|
||||
if "<script>" not in src or "</script>" not in src:
|
||||
return C.die("原型里没有 `<script>` 段:%s" % proto)
|
||||
i = src.index("<script>") + len("<script>")
|
||||
j = src.rindex("</script>")
|
||||
js = src[i:j]
|
||||
fails = []
|
||||
|
||||
# ① 语法
|
||||
if not node:
|
||||
fails.append("没找到 node(用 --node 或环境变量 NODE_BIN 指定)")
|
||||
print("FAIL 语法:没找到 node ⇒ 这一条没跑(⛔ 不是 PASS)")
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
|
||||
encoding="utf-8", newline="") as f:
|
||||
f.write(js)
|
||||
tmp = f.name
|
||||
try:
|
||||
r = subprocess.run([node, "--check", tmp], capture_output=True)
|
||||
if r.returncode == 0:
|
||||
print("PASS 语法:<script> 段 %d 行,node --check 通过" % js.count("\n"))
|
||||
else:
|
||||
base = src[:i].count("\n") + 1
|
||||
head = [l for l in r.stderr.decode("utf-8", "replace").split("\n") if l.strip()][:4]
|
||||
fails.append("语法不过:" + " / ".join(head))
|
||||
print("FAIL 语法:node --check 报错(下面的行号是 script 段内的,原文件约 +%d 行)" % base)
|
||||
for l in head:
|
||||
print(" " + l)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
# ② 模板里的反引号
|
||||
bad = []
|
||||
blocks = re.findall(r"<!--[\s\S]*?-->", js)
|
||||
for m in re.finditer(r"<!--[\s\S]*?-->", js):
|
||||
blk = m.group(0)
|
||||
if "`" in blk:
|
||||
line = js[:m.start()].count("\n") + src[:i].count("\n") + 2
|
||||
bad.append("原文件第 %d 行附近:模板里的 HTML 注释含 %d 个反引号 —— 「%s」"
|
||||
% (line, blk.count("`"), blk.split("\n")[0][:70]))
|
||||
skipped = False
|
||||
if bad:
|
||||
fails.extend(bad)
|
||||
print("FAIL 模板里的反引号:")
|
||||
for b in bad:
|
||||
print(" " + b)
|
||||
elif blocks:
|
||||
print("PASS 模板里的反引号:script 区内 %d 个 <!-- --> 块(多行扫描)都不含反引号" % len(blocks))
|
||||
else:
|
||||
# 🔴 判据没覆盖面时**明说不适用**(HARD):写 PASS 是假绿(扫了 0 个块,等于什么都没查),
|
||||
# 写 FAIL 是把合法状态判成故障 ⇒ 两者都会让下一个人读错。
|
||||
skipped = True
|
||||
print("SKIP 模板里的反引号:script 区内 0 个 HTML 注释块 ⇒ **本项无覆盖面**"
|
||||
"(成因:这一版原型的模板里没写 HTML 注释;等模板里再有注释,本项自动生效)")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("结论:FAIL %d 项 —— 每一条都会让**页面在浏览器里才炸**(`node --check` 整份 html 看不到)。" % len(fails))
|
||||
print("修法:注释里的反引号去掉(写 `.cls` 改写成「cls」或 class 名)。")
|
||||
return 1
|
||||
print("结论:全过。原型 %s" % proto)
|
||||
if skipped:
|
||||
print(" ⚠️ 其中「模板里的反引号」一项**无覆盖面**(见上面那行 SKIP)—— 它没有判过任何东西。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门①:②段《界面布局》的页面骨架 ↔ 原型里的 `FROZEN` 表,逐页逐位比对。
|
||||
|
||||
为什么单拎出来:页面内的自检只能证明「渲染出来的板块 == `FROZEN` 表」,
|
||||
证明不了「`FROZEN` 表 == ②段原文」。两张表各改一边就没人发现
|
||||
⇒ 本闸门直接把②段那份 Markdown 当权威读,逐页逐位比。
|
||||
|
||||
判据:**页面集合**相等、每页**板块数量**相等、**顺序**相等、名字剥掉「(…)」注解后**逐字相同**。
|
||||
|
||||
🔴 结构性事实(页集合、节号、页数、板块总数)**一律现读文档**,⛔ 不在代码里写死 ——
|
||||
②段改结构(例如页面增减、节号重排)时,写死常量的副本就会「读不到而报错」或
|
||||
「按旧页数静默比错」;这类副本一旦散在各项目里,同一条闸门会出现多个版本。
|
||||
|
||||
用法:
|
||||
python check_skeleton.py --ws <工作区> # 自动找 ②段 与 原型
|
||||
python check_skeleton.py --doc X.md --proto Y.html # 指定两份
|
||||
python check_skeleton.py --ws <工作区> --expect-pages 7 # 想额外钉住页数时才给
|
||||
退出码:0 = PASS;1 = FAIL(列出每一处不一致);2 = 用法/定位失败。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _common as C # noqa: E402
|
||||
|
||||
SEC_ANY = re.compile(r"^##\s+\S", re.M) # 任意二级标题(节的边界)
|
||||
HEADING = re.compile(r"^##\s+(.*\S)\s*$", re.M)
|
||||
PAGE = re.compile(r"^###\s*(P\d)\b")
|
||||
ITEM = re.compile(r"^\s*(\d+)\.\s*(.+)$")
|
||||
FROZEN = re.compile(r"^const FROZEN=\{(.*?)^\};", re.S | re.M)
|
||||
FROZEN_ROW = re.compile(r"^\s*(p\d):\[(.*?)\],?\s*$", re.M)
|
||||
|
||||
|
||||
def _pages_in(seg):
|
||||
out, cur = {}, None
|
||||
for line in seg.split("\n"):
|
||||
mp = PAGE.match(line)
|
||||
if mp:
|
||||
cur = mp.group(1).lower()
|
||||
out[cur] = []
|
||||
continue
|
||||
mi = ITEM.match(line)
|
||||
if cur and mi:
|
||||
out[cur].append(mi.group(2).split("——")[0].strip())
|
||||
return out
|
||||
|
||||
|
||||
def find_section(s):
|
||||
"""定位**骨架那一节**。
|
||||
|
||||
🔴 判据按「标题里含『页骨架』」取(`## 五、七页骨架` 这种)—— ⛔ 不写死节号、⛔ 不写死页数。
|
||||
先试「页骨架」,再退回「含骨架且里面**真解析得出页**的那一节」
|
||||
(理由:正文里会有「这一版骨架改了什么」这类**同词异义**的标题,只按「骨架」两字认会认错节)。
|
||||
"""
|
||||
heads = [(m.start(), m.group(1)) for m in HEADING.finditer(s)]
|
||||
for keys in (("页骨架",), ("骨架",)):
|
||||
for i, (pos, title) in enumerate(heads):
|
||||
if not any(k in title for k in keys):
|
||||
continue
|
||||
nxt = SEC_ANY.search(s, pos + 1)
|
||||
seg = s[pos:nxt.start() if nxt else len(s)]
|
||||
pg = _pages_in(seg)
|
||||
if pg:
|
||||
return seg, title, pg
|
||||
return None, "", {}
|
||||
|
||||
|
||||
def from_doc(path):
|
||||
s = open(path, encoding="utf-8").read()
|
||||
seg, title, out = find_section(s)
|
||||
if not out:
|
||||
raise SystemExit(C.die(
|
||||
"②段里找不到「骨架」那一节(期望标题形如 `## 五、七页骨架`,且节内用 `### P1 …` 列页):%s" % path))
|
||||
return out
|
||||
|
||||
|
||||
def from_proto(path):
|
||||
s = open(path, encoding="utf-8").read()
|
||||
m = FROZEN.search(s)
|
||||
if not m:
|
||||
raise SystemExit(C.die("原型里找不到 `const FROZEN` 表:%s" % path))
|
||||
out = {}
|
||||
for k, body in FROZEN_ROW.findall(m.group(1)):
|
||||
out[k] = re.findall(r"'([^']*)'", body)[1:] # 第 0 项是页面标题
|
||||
if not out:
|
||||
raise SystemExit(C.die("`FROZEN` 表解析为空(期望 `p1:['标题', '板块1', …]`):%s" % path))
|
||||
return out
|
||||
|
||||
|
||||
def norm(x):
|
||||
return x.split("(")[0].strip()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门①:②段骨架 ↔ 原型 FROZEN 表")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--doc", default="", help="②段界面布局(缺省=自动找 docs/pm/*/prd/2b-*.md)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找 docs/pm/*/ui/*.html)")
|
||||
ap.add_argument("--expect-pages", type=int, default=0, help="想额外钉住页数时才给")
|
||||
a = ap.parse_args()
|
||||
ws, doc, proto = C.resolve(a.ws, a.doc, a.proto)
|
||||
if not doc:
|
||||
return C.die("没找到 ②段界面布局(找过 %s/docs/pm/*/prd/2b-*.md)" % ws)
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML(找过 %s/docs/pm/*/ui/*.html)" % ws)
|
||||
|
||||
d, p = from_doc(doc), from_proto(proto)
|
||||
bad = []
|
||||
if a.expect_pages and len(d) != a.expect_pages:
|
||||
bad.append("②段页数 %d ≠ 期望 %d:%s" % (len(d), a.expect_pages, sorted(d)))
|
||||
if set(d) != set(p):
|
||||
bad.append("页面集合不同:②段 %s / 原型 %s" % (sorted(d), sorted(p)))
|
||||
for k in sorted(set(d) & set(p)):
|
||||
x, y = d[k], p[k]
|
||||
if len(x) != len(y):
|
||||
bad.append("%s 板块数 ②段 %d ≠ 原型 %d(②段 %s / 原型 %s)" % (k, len(x), len(y), x, y))
|
||||
continue
|
||||
for i, (u, v) in enumerate(zip(x, y)):
|
||||
nu, nv = norm(u), norm(v)
|
||||
if nu != nv:
|
||||
bad.append("%s 第 %d 块 ②段「%s」≠ 原型「%s」(剥注解后「%s」≠「%s」)"
|
||||
% (k, i + 1, u, v, nu, nv))
|
||||
n = sum(len(v) for v in d.values())
|
||||
if bad:
|
||||
print("FAIL 骨架与②段不一致(②段 %d 页 %d 个板块)" % (len(d), n))
|
||||
for x in bad:
|
||||
print(" · " + x)
|
||||
print("\n出处:②段 %s\n 原型 %s" % (doc, proto))
|
||||
return 1
|
||||
print("PASS 骨架冻结:%d 页 %d 个板块,②段与原型 `FROZEN` 表逐页逐位一致" % (len(d), n))
|
||||
print(" ②段 %s\n 原型 %s" % (doc, proto))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门③的取数件:取原型 `#/selftest` 的自检文本(容忍「自检太慢 → IPC 5 s 超时」)。
|
||||
|
||||
为什么不能直接用常规 DOM 抓取:browser-harness 的 IPC 客户端读超时是 **5 s**,
|
||||
而原型自检要逐个控件真驱动、再逐页量几何 ⇒ 主线程被占住 > 5 s ⇒ 应答回不来。
|
||||
⚠️ 这是**取证通道的限制,不是原型的缺陷**:客户端超时**不会**取消渲染进程里已经在跑的那段 JS
|
||||
⇒ 正确做法是「发一次、超时也别管,然后轮询直到结果出现」。
|
||||
|
||||
做两件事:
|
||||
① 轮询 `#selftest-out` 直到拿到文本(每次失败等 1 s 再试,最多 `--count` 次);
|
||||
② 顺带核视口(要多少必须是多少,⛔ 不接受近似)。
|
||||
|
||||
🔴 标签纪律:**一类任务只开一个标签页并复用**(复用 id 记在 `--tab-state`)。
|
||||
⛔ 不新开、⛔ 本脚本自己不关标签 —— 收工时由 `--close-tab` 或人工关一次。
|
||||
|
||||
用法:
|
||||
python grab_selftest.py --proto X.html --viewport 1440x900 --out out.txt
|
||||
python grab_selftest.py --proto X.html --close-tab # 收工关标签
|
||||
退出码:0 = 拿到文本;1 = 没拿到;2 = 没有可用浏览器实例/定位失败。
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _common as C # noqa: E402
|
||||
|
||||
BU_URL = os.environ.get("BU_CDP_URL", "http://127.0.0.1:9223")
|
||||
|
||||
SCRIPT = r"""
|
||||
import io, json, os, time
|
||||
BU = @BU@
|
||||
url = @URL@
|
||||
out = @OUT@
|
||||
state = @STATE@
|
||||
W, H = @W@, @H@
|
||||
|
||||
# --- 复用唯一一个标签(一类任务只开一个标签并复用)---
|
||||
tid = None
|
||||
try:
|
||||
if state and os.path.exists(state):
|
||||
tid = (io.open(state, encoding='utf-8').read().strip() or None)
|
||||
except Exception:
|
||||
tid = None
|
||||
live = {t['targetId']: t for t in list_tabs()}
|
||||
if tid in live:
|
||||
switch_tab(tid)
|
||||
else:
|
||||
tid = new_tab()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(state), exist_ok=True)
|
||||
io.open(state, 'w', encoding='utf-8').write(tid)
|
||||
except Exception:
|
||||
pass
|
||||
# 🔴 顺序要紧:**先定视口,再导航**。自检是页面 load 时跑完的,视口必须在此之前就位,
|
||||
# 否则页面会按上一个视口量一遍几何,出来的数是上一轮的。
|
||||
cdp("Emulation.setDeviceMetricsOverride", width=W, height=H, deviceScaleFactor=1, mobile=False)
|
||||
# ⚠️ ⛔ 不能用 wait_for_load():自检占住渲染主线程,`document.readyState` 的 IPC 读会超时,
|
||||
# 把整段取证打断。正确等法是下面的轮询:超时就当没出、过 1 秒再问。
|
||||
# 🔴 **必须先落脚空白页**:`#/selftest` 是同一个 URL,直接导航只改 hash、**不重新加载**
|
||||
# ⇒ 页面不重跑自检,读到的是上一次(可能是别的视口)的数。
|
||||
try:
|
||||
goto_url("about:blank")
|
||||
except Exception:
|
||||
pass
|
||||
wait(0.15)
|
||||
goto_url(url)
|
||||
wait(0.6)
|
||||
|
||||
WAIT = "(function(){{var e=document.getElementById('selftest-out');return e?e.textContent:null}})()"
|
||||
res = None
|
||||
t0 = time.time()
|
||||
for i in range(@COUNT@):
|
||||
try:
|
||||
v = js(WAIT)
|
||||
except Exception:
|
||||
v = None
|
||||
if v:
|
||||
res = v
|
||||
break
|
||||
wait(1.0)
|
||||
el = time.time() - t0
|
||||
try:
|
||||
vw = js("innerWidth"); vh = js("innerHeight")
|
||||
except Exception:
|
||||
vw = vh = -1
|
||||
if res:
|
||||
io.open(out, 'w', encoding='utf-8', newline='').write(res)
|
||||
print("ELAPSED %.1fs VIEWPORT %dx%d" % (el, vw, vh))
|
||||
else:
|
||||
print("NO-OUTPUT after %.1fs (viewport %dx%d)" % (el, vw, vh))
|
||||
cdp("Emulation.clearDeviceMetricsOverride")
|
||||
"""
|
||||
|
||||
CLOSE = r"""
|
||||
import io, os
|
||||
state = @STATE@
|
||||
try:
|
||||
tid = (io.open(state, encoding='utf-8').read().strip() or None) if state and os.path.exists(state) else None
|
||||
except Exception:
|
||||
tid = None
|
||||
if tid:
|
||||
try:
|
||||
close_tab(tid); print("CLOSED %s" % tid)
|
||||
except Exception as e:
|
||||
print("CLOSE-FAIL %r" % (e,))
|
||||
try:
|
||||
os.remove(state)
|
||||
except Exception:
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def _probe(host="127.0.0.1", port=9223):
|
||||
s = socket.socket()
|
||||
s.settimeout(1.5)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def build(vp, out, proto, count, state):
|
||||
vw, vh = [int(x) for x in vp.lower().replace(",", "x").split("x")]
|
||||
out = os.path.abspath(out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
url = "file:///" + quote(os.path.abspath(proto).replace("\\", "/"), safe="/:") + "#/selftest"
|
||||
return (SCRIPT.replace("@BU@", repr(BU_URL)).replace("@URL@", repr(url))
|
||||
.replace("@OUT@", repr(out)).replace("@STATE@", repr(os.path.abspath(state)))
|
||||
.replace("@W@", str(vw)).replace("@H@", str(vh)).replace("@COUNT@", str(count)))
|
||||
|
||||
|
||||
def _run(harness, code):
|
||||
env = dict(os.environ)
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
env.pop(k, None)
|
||||
env["NO_PROXY"] = "127.0.0.1,localhost"
|
||||
env["BU_CDP_URL"] = BU_URL
|
||||
tf = tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8")
|
||||
tf.write(code)
|
||||
tf.close()
|
||||
try:
|
||||
with open(tf.name, "rb") as f:
|
||||
return subprocess.run([harness], capture_output=True, env=env, input=f.read())
|
||||
finally:
|
||||
os.unlink(tf.name)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门③取数件:取原型自检文本")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
|
||||
ap.add_argument("--viewport", default="1440x900")
|
||||
ap.add_argument("--out", default="", help="自检文本落盘位置(`--close-tab` 时不需要)")
|
||||
ap.add_argument("--count", type=int, default=150, help="轮询次数(每次 1 s)")
|
||||
ap.add_argument("--tab-state", default="", help="标签 id 的记账文件(缺省=临时目录下的固定名)")
|
||||
ap.add_argument("--harness", default="", help="browser-harness 可执行文件(缺省=自动发现)")
|
||||
ap.add_argument("--close-tab", action="store_true", help="收工:关掉复用的那个标签")
|
||||
a = ap.parse_args()
|
||||
|
||||
harness = C.find_harness(a.harness)
|
||||
if not harness:
|
||||
return C.die("没找到 browser-harness(用 --harness 或环境变量 BH_BIN 指定)")
|
||||
state = a.tab_state or os.path.join(tempfile.gettempdir(), "bh_proto_tab.txt")
|
||||
host = urlparse(BU_URL).hostname or "127.0.0.1"
|
||||
port = urlparse(BU_URL).port or 9223
|
||||
if not _probe(host, port):
|
||||
sys.stderr.write("⛔ %s 上没有浏览器实例在跑(本工具只连不起)。\n" % BU_URL)
|
||||
return 2
|
||||
if a.close_tab:
|
||||
_run(harness, CLOSE.replace("@STATE@", repr(os.path.abspath(state))))
|
||||
return 0
|
||||
if not a.out:
|
||||
return C.die("--out 是必需的(除 `--close-tab` 外)")
|
||||
_, _, proto = C.resolve(a.ws, "", a.proto)
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML")
|
||||
if os.path.exists(a.out):
|
||||
os.remove(a.out)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(state)), exist_ok=True)
|
||||
r = _run(harness, build(a.viewport, a.out, proto, a.count, state))
|
||||
sys.stderr.write("[%s] %s\n" % (a.viewport, r.stdout.decode("utf-8", "replace").strip()))
|
||||
err = r.stderr.decode("utf-8", "replace")
|
||||
if "Traceback" in err:
|
||||
sys.stderr.write(err[-800:] + "\n")
|
||||
return 0 if os.path.exists(a.out) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门④:变异对照 —— 证明原型自检的判据**不是「恒绿」**。
|
||||
|
||||
做法:把原型复制一份,按**规则文件**往里注入一处已知的假病,再跑一次自检,
|
||||
要求它必须报红,并且红在**预期的那一条**上。测完即删临时件。
|
||||
|
||||
为什么必须有它:一个永远 PASS 的自检等于没有自检。只有「注病 → 报红 → 复原 → 回绿」
|
||||
这条链走通了,前面那串 PASS 才算证据。
|
||||
|
||||
🔴 **框架与数据分家**:本文件是框架(进技能包,跨项目共用);
|
||||
注入点表是**数据**,放在原型同目录的 `mutate-rules.json`(跟着原型走,⛔ 不进技能包)。
|
||||
用 `--rules` 指定别的位置。
|
||||
|
||||
规则文件格式(JSON 数组,每项一条变异):
|
||||
|
||||
[{"name": "M1 死控件",
|
||||
"why": "把 P2-01 的实现掏空 —— 点它不再有任何反馈",
|
||||
"find": "'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },",
|
||||
"repl": "'P2-01':()=>{},",
|
||||
"expect": "P2-01",
|
||||
"viewport": "1440x900"}]
|
||||
|
||||
⚠️ 两条会毁掉对照的写法(都踩过):
|
||||
· **注入点失效** ⇒ 对照变成假对照(让人以为这条判据有人看着)⇒ 本框架把它算**失败**,不算跳过;
|
||||
· **注入写坏原型**(注入进去但原型跑不起来)⇒ 输出整个拿不到 ⇒ 同样算失败。
|
||||
|
||||
用法:
|
||||
python mutate_test.py --ws <工作区> # 用原型同目录的 mutate-rules.json
|
||||
python mutate_test.py --proto X.html --rules R.json
|
||||
退出码:0 = 全部抓住且基线回绿;1 = 有没抓住的;2 = 基线不是全绿/定位失败/没有浏览器实例。
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _common as C # noqa: E402
|
||||
|
||||
|
||||
def load_rules(path):
|
||||
"""读规则文件:`.py`(含 `MUTANTS`)或 `.json` 都行;每项补齐成 6 元组。"""
|
||||
if path.endswith(".py"):
|
||||
ns = {}
|
||||
exec(compile(open(path, encoding="utf-8").read(), path, "exec"), ns)
|
||||
raw = ns.get("MUTANTS") or []
|
||||
else:
|
||||
raw = json.loads(open(path, encoding="utf-8").read())
|
||||
out = []
|
||||
for r in raw:
|
||||
if isinstance(r, dict):
|
||||
out.append((r["name"], r.get("why", ""), r["find"], r["repl"],
|
||||
r["expect"], r.get("viewport", "1440x900")))
|
||||
else:
|
||||
out.append(tuple(r) + (("1440x900",) if len(r) < 6 else ()))
|
||||
return out
|
||||
|
||||
|
||||
def run(proto, vp, out):
|
||||
"""跑一次自检取文本;拿不到返回 None。"""
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
grab = os.path.join(os.path.dirname(os.path.abspath(__file__)), "grab_selftest.py")
|
||||
r = subprocess.run([sys.executable, grab, "--proto", proto, "--viewport", vp,
|
||||
"--count", "40", "--out", out], capture_output=True, text=True)
|
||||
if r.returncode == 2:
|
||||
raise SystemExit(C.die("没有可用浏览器实例(9223 没在跑)⇒ 变异对照这一条没跑"))
|
||||
if not os.path.exists(out):
|
||||
return None
|
||||
return open(out, encoding="utf-8", errors="replace").read()
|
||||
|
||||
|
||||
def headline(text):
|
||||
m = re.search(C.PAT["summary"], text or "")
|
||||
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门④:变异对照(框架)")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
|
||||
ap.add_argument("--rules", default="", help="规则文件(缺省=原型同目录 mutate-rules.json)")
|
||||
ap.add_argument("--only", default="", help="只跑名字里含这个串的条目(排查单条用)")
|
||||
ap.add_argument("--out-dir", default="", help="自检文本落哪(缺省=临时目录)")
|
||||
a = ap.parse_args()
|
||||
ws, _, proto = C.resolve(a.ws, "", a.proto)
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML")
|
||||
rules = a.rules or os.path.join(os.path.dirname(proto), "mutate-rules.json")
|
||||
if not os.path.exists(rules):
|
||||
hits = glob.glob(os.path.join(os.path.dirname(proto), "mutate-rules.*"))
|
||||
if not hits:
|
||||
return C.die("没有规则文件(期望 %s,或在原型同目录放 mutate-rules.json)" % rules)
|
||||
rules = hits[0]
|
||||
MUTANTS = load_rules(rules)
|
||||
if a.only:
|
||||
MUTANTS = [m for m in MUTANTS if a.only in m[0]]
|
||||
if not MUTANTS:
|
||||
return C.die("规则文件里没有可跑的条目:%s" % rules)
|
||||
out_dir = a.out_dir or tempfile.gettempdir()
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out = os.path.join(out_dir, "mut-selftest.txt")
|
||||
|
||||
src = open(proto, encoding="utf-8").read()
|
||||
base = run(proto, "1440x900", out)
|
||||
hb = headline(base)
|
||||
print("基线(未注入):PASS %s / FAIL %s" % hb if hb else "基线读不到自检输出")
|
||||
if not hb or hb[1] != 0:
|
||||
print("⛔ 基线就不是全绿 ⇒ 先把原型修到全绿,再做变异对照(否则对照判不出来)。")
|
||||
return 2
|
||||
|
||||
bad = 0
|
||||
for name, why, find, repl, expect, vp in MUTANTS:
|
||||
if find not in src:
|
||||
print("⚠️ %s:找不到注入点 ⇒ **算失败**(注入点失效的对照比没有对照更坏)" % name)
|
||||
bad += 1
|
||||
continue
|
||||
tmp = os.path.join(out_dir, "mut-proto.html")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(src.replace(find, repl, 1))
|
||||
text = run(tmp, vp, out)
|
||||
h = headline(text)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
if not h:
|
||||
print("❌ %s:注入后自检跑不出来(连输出都没有)⇒ 算失败" % name)
|
||||
bad += 1
|
||||
continue
|
||||
caught = h[1] > 0 and expect in (text or "")
|
||||
print("%s %s:注入(%s) ⇒ PASS %d / FAIL %d | %s"
|
||||
% ("✅" if caught else "❌", name, why, h[0], h[1],
|
||||
"报红且红在预期处(%s,视口 %s)" % (expect, vp) if caught
|
||||
else "**没在预期处报红(期望含「%s」,视口 %s)**" % (expect, vp)))
|
||||
if not caught:
|
||||
bad += 1
|
||||
for line in (text or "").split("\n"):
|
||||
if line.startswith("FAIL"):
|
||||
print(" 实际:" + line)
|
||||
|
||||
print()
|
||||
print("变异对照:%d / %d 通过" % (len(MUTANTS) - bad, len(MUTANTS)))
|
||||
if bad == 0:
|
||||
print("⇒ %d 处假病全部被自检抓住,且基线回绿:这套判据不是恒绿的。" % len(MUTANTS))
|
||||
return 0 if bad == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门③:逐视口跑一遍原型自检,抽出密度/溢出/外壳高度链的读数表。
|
||||
|
||||
为什么自检本身就能出这张表:原型的 `#/selftest` 在量完几何后会把
|
||||
「首行顶 / 行距 / 一屏装得下几行 / 横向溢出 / 外壳高度链」逐条打进 `#selftest-out`
|
||||
⇒ 每个视口各跑一次,表就出来了(⛔ 不另起一套量法 —— 两套量法必然漂)。
|
||||
|
||||
判据(出处:③段 `layouts-tooling.md`):
|
||||
· 每个列表页**一屏可见行数 ≥ `--min-rows`(默认 6,对应 900px 高)**,⛔ 不分档;
|
||||
· 横向溢出全为 0;
|
||||
· 纵向滚动只由主区承担(外壳高度链成立);
|
||||
· 自检自身 FAIL 为 0。
|
||||
|
||||
⚠️ 解析规则取 `_common.parse_selftest()`(全包唯一一份)⇒ 原型自检输出要按
|
||||
`_common.py` 头部写的那套骨架打印,否则这里读不到数。
|
||||
|
||||
用法:
|
||||
python run_viewports.py --ws <工作区> --out-dir out/vp # 全量视口
|
||||
python run_viewports.py --ws <工作区> --list # 只列参数、不跑浏览器
|
||||
python run_viewports.py --ws <工作区> --viewports 1440x900,390x844
|
||||
退出码:0 = 全部达标;1 = 有不达标项;2 = 定位失败/没有浏览器实例。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _common as C # noqa: E402
|
||||
|
||||
# 默认视口:按契约的断点走(900px 高是密度判据的基准档;390x844/500x844 是留档档)
|
||||
DEFAULT_VPS = ["1440x900", "1280x900", "1024x900", "1000x900", "960x900", "920x900",
|
||||
"901x900", "900x900", "840x900", "820x900", "768x900", "700x900",
|
||||
"640x900", "560x900", "500x844", "390x844"]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门③:逐视口密度/溢出/外壳高度链")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
|
||||
ap.add_argument("--out-dir", default="", help="每个视口的自检文本落哪(缺省=临时目录)")
|
||||
ap.add_argument("--viewports", default=",".join(DEFAULT_VPS))
|
||||
ap.add_argument("--min-rows", type=int, default=6, help="一屏可见行数下限(契约默认 6)")
|
||||
ap.add_argument("--harness", default="", help="browser-harness 可执行文件(缺省=自动发现)")
|
||||
ap.add_argument("--list", action="store_true", help="只列解析出的路径与视口,不跑浏览器")
|
||||
a = ap.parse_args()
|
||||
|
||||
ws, _, proto = C.resolve(a.ws, "", a.proto)
|
||||
out_dir = a.out_dir or os.path.join(ws, "tmp", "vp")
|
||||
vps = [v.strip() for v in a.viewports.split(",") if v.strip()]
|
||||
if a.list:
|
||||
print("工作区 %s" % ws)
|
||||
print("原型 %s" % (proto or "(没找到)"))
|
||||
print("落盘 %s" % out_dir)
|
||||
print("行数下限 %d" % a.min_rows)
|
||||
print("视口 %d 个:%s" % (len(vps), " ".join(vps)))
|
||||
return 0
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
grab = os.path.join(os.path.dirname(os.path.abspath(__file__)), "grab_selftest.py")
|
||||
rows, bad = [], 0
|
||||
for vp in vps:
|
||||
out = os.path.join(out_dir, vp + ".txt")
|
||||
cmd = [sys.executable, grab, "--proto", proto, "--viewport", vp,
|
||||
"--count", "60", "--out", out]
|
||||
if a.harness:
|
||||
cmd += ["--harness", a.harness]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
|
||||
if r.returncode == 2:
|
||||
return C.die("没有可用浏览器实例(9223 没在跑)⇒ 逐视口这一条没跑")
|
||||
text = open(out, encoding="utf-8", errors="replace").read() if os.path.exists(out) else ""
|
||||
d = C.parse_selftest(text, a.min_rows)
|
||||
d["vp"] = vp
|
||||
rows.append(d)
|
||||
caps = ",".join("%s:%s" % (k, v.get("cap")) for k, v in sorted(d["rows"].items()))
|
||||
ok = (d.get("fail") == 0 and d.get("rows")
|
||||
and all(v.get("ok") for v in d["rows"].values())
|
||||
and d.get("overflow_ok") is not False and d.get("shell_ok") is not False)
|
||||
if not ok:
|
||||
bad += 1
|
||||
print("%s%-9s 自检 PASS %-3s/FAIL %-3s | 一屏行数 %s | 溢出 %s | 外壳 %s | 控制台 %s"
|
||||
% ("OK " if ok else "!! ", vp, d.get("pass", "?"), d.get("fail", "?"),
|
||||
caps or "?", d.get("overflow_ok"), d.get("shell_ok"), d.get("console")),
|
||||
flush=True)
|
||||
if r.stderr.strip():
|
||||
sys.stderr.write(" [stderr] " + r.stderr.strip().split("\n")[-1] + "\n")
|
||||
print("\n逐视口:不达标 %d / 共 %d(行数下限 %d)" % (bad, len(rows), a.min_rows))
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""③段闸门自身的变异对照:证明 `check_skeleton.py` 与 `check_js.py` **不是恒绿**。
|
||||
|
||||
做法:在**临时目录里**给 ②段与原型各造一处已知假病,要求对应闸门报红;
|
||||
再做一个「不该报红」的对照,要求它仍然 PASS(否则判据是恒红,一样没用)。
|
||||
⛔ 全程不碰工作区里的原件(改的每一处都写在临时副本上)。
|
||||
|
||||
四档对照:
|
||||
|
||||
| 档 | 造什么假病 | 期望 |
|
||||
|---|---|---|
|
||||
| 基线 | 什么都不动 | 两个闸门都 PASS |
|
||||
| 变异 1 | ②段骨架节里某块**改名**(改「——」前面的名字本体) | 骨架闸门 FAIL,且点出是哪一页第几块 |
|
||||
| 变异 2 | 原型 `<script>` 段里塞一个**含反引号的 HTML 注释** | 语法闸门 FAIL |
|
||||
| 变异 3 | 原型 `FROZEN` 表里砍掉某页最后一块 | 骨架闸门 FAIL,报「板块数不等」 |
|
||||
| 对照 4 | ②段骨架节的**节号加后缀**(不改内容) | 骨架闸门仍 PASS(证明节号没写死) |
|
||||
|
||||
⚠️ 「改名」必须改**「——」前面的名字本体**:`——` 后面是说明、`(…)` 是注解,
|
||||
两者按契约**不参与比对** ⇒ 往那里加字**本来就该 PASS**,拿它当变异会得出「闸门有洞」的错结论。
|
||||
|
||||
用法:`python scripts/selftest_gates.py --ws <工作区>`
|
||||
退出码:0 = 四档全对;1 = 有档位不符(判据有洞或定位过窄);2 = 定位失败。
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import _common as C # noqa: E402
|
||||
|
||||
|
||||
def _run(*args):
|
||||
r = subprocess.run([sys.executable, os.path.join(HERE, args[0])] + list(args[1:]),
|
||||
capture_output=True, text=True, errors="replace")
|
||||
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
||||
|
||||
|
||||
def _brief(out, n=2):
|
||||
return " / ".join([l.strip() for l in out.strip().split("\n") if l.strip()][:n])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="③段闸门自身的变异对照")
|
||||
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
|
||||
ap.add_argument("--doc", default="", help="②段界面布局(缺省=自动找)")
|
||||
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
|
||||
a = ap.parse_args()
|
||||
ws, doc, proto = C.resolve(a.ws, a.doc, a.proto)
|
||||
if not doc:
|
||||
return C.die("没找到 ②段界面布局(找过 %s/docs/pm/*/prd/2b-*.md)" % ws)
|
||||
if not proto:
|
||||
return C.die("没找到原型 HTML(找过 %s/docs/pm/*/ui/*.html)" % ws)
|
||||
tmp = tempfile.mkdtemp(prefix="gate-selftest-")
|
||||
rows = []
|
||||
try:
|
||||
print("工作区 %s\n②段 %s\n原型 %s\n" % (ws, doc, proto))
|
||||
|
||||
c_doc, o_doc = _run("check_skeleton.py", "--doc", doc, "--proto", proto)
|
||||
c_js, o_js = _run("check_js.py", "--proto", proto)
|
||||
rows.append(("基线:原件两闸门都 PASS", c_doc == 0 and c_js == 0,
|
||||
"骨架 rc=%d / 语法 rc=%d" % (c_doc, c_js)))
|
||||
print("== 基线 ==")
|
||||
print(" 骨架 rc=%d | %s" % (c_doc, _brief(o_doc, 1)))
|
||||
print(" 语法 rc=%d | %s" % (c_js, _brief(o_js, 4)))
|
||||
|
||||
s = io.open(doc, encoding="utf-8").read()
|
||||
h = io.open(proto, encoding="utf-8").read()
|
||||
|
||||
# 变异 1:②段骨架节里,第一页的第 2 条块改名(名字本体)
|
||||
seg = re.search(r"^##\s+[^\n]*页骨架[^\n]*$", s, re.M)
|
||||
p_first = re.search(r"^###\s*P\d[^\n]*$", s, re.M)
|
||||
if not (seg and p_first and p_first.start() > seg.start()):
|
||||
return C.die("②段里定位不到骨架节里的第一页 ⇒ 判据形状变了,先看 check_skeleton.py")
|
||||
i, j = p_first.start(), s.find("\n### ", p_first.end())
|
||||
seg_txt = s[i:j if j > 0 else len(s)]
|
||||
m = re.search(r"^(\s*)2\.\s*([^—\n]+)(.*)$", seg_txt, re.M)
|
||||
if not m:
|
||||
return C.die("骨架节的第一页里找不到「2. …」这条块 ⇒ 判据形状变了")
|
||||
mutated = ("%s2. %s改名%s" % (m.group(1), m.group(2), m.group(3)))
|
||||
doc_t = os.path.join(tmp, "2b-mut.md")
|
||||
io.open(doc_t, "w", encoding="utf-8", newline="").write(
|
||||
s[:i] + seg_txt.replace(m.group(0), mutated, 1) + s[i + len(seg_txt):])
|
||||
c3, o3 = _run("check_skeleton.py", "--doc", doc_t, "--proto", proto)
|
||||
hit = c3 == 1 and "≠" in o3
|
||||
rows.append(("变异 1:②段某块改名 ⇒ 骨架闸门报红并点名", hit,
|
||||
"rc=%d | %s" % (c3, _brief(o3, 2))))
|
||||
print("\n== 变异 1:②段第一页第 2 块改名(%s → %s)==" % (m.group(2).strip(), m.group(2).strip() + "改名"))
|
||||
print(" 骨架 rc=%d(期望 1)| %s" % (c3, _brief(o3, 2)))
|
||||
|
||||
# 变异 2:script 区里塞一个含反引号的 HTML 注释
|
||||
k = h.index("<script>")
|
||||
anchor = h.index("\n", k) + 1
|
||||
proto_t = os.path.join(tmp, "proto-mut.html")
|
||||
io.open(proto_t, "w", encoding="utf-8", newline="").write(
|
||||
h[:anchor] + "\n /* 探针 */ const _p=`<!-- 注释里带 ` 反引号 -->`;\n" + h[anchor:])
|
||||
c4, o4 = _run("check_js.py", "--proto", proto_t)
|
||||
rows.append(("变异 2:script 区注释里塞反引号 ⇒ 语法闸门报红", c4 == 1,
|
||||
"rc=%d | %s" % (c4, _brief(o4, 3))))
|
||||
print("\n== 变异 2:script 区内塞一个含反引号的 HTML 注释 ==")
|
||||
print(" 语法 rc=%d(期望 1)| %s" % (c4, _brief(o4, 3)))
|
||||
|
||||
# 变异 3:FROZEN 表砍掉某页最后一块
|
||||
mm = re.search(r"^const FROZEN=\{(?:.*?)^\};", h, re.S | re.M)
|
||||
if not mm:
|
||||
return C.die("原型里找不到 `const FROZEN` 表")
|
||||
body = mm.group(0)
|
||||
row = re.search(r"^\s*(p\d):\[(.*?)\],?\s*$", body, re.M)
|
||||
items = re.findall(r"'([^']*)'", row.group(2))
|
||||
if len(items) < 2:
|
||||
return C.die("`FROZEN` 表那一页少于 2 项 ⇒ 造不出「少一块」")
|
||||
cut = body.replace(row.group(0), row.group(0).replace("'" + items[-1] + "'", "", 1), 1)
|
||||
proto_t2 = os.path.join(tmp, "proto-mut2.html")
|
||||
io.open(proto_t2, "w", encoding="utf-8", newline="").write(h.replace(body, cut, 1))
|
||||
c5, o5 = _run("check_skeleton.py", "--doc", doc, "--proto", proto_t2)
|
||||
rows.append(("变异 3:原型某页少一块 ⇒ 骨架闸门报「板块数不等」", c5 == 1 and "板块数" in o5,
|
||||
"rc=%d | %s" % (c5, _brief(o5, 2))))
|
||||
print("\n== 变异 3:原型 %s 页少一块板块 ==" % row.group(1))
|
||||
print(" 骨架 rc=%d(期望 1)| %s" % (c5, _brief(o5, 2)))
|
||||
|
||||
# 对照 4:节号加后缀 ⇒ 仍应 PASS(证明节号没写死)
|
||||
doc_t2 = os.path.join(tmp, "2b-heading.md")
|
||||
io.open(doc_t2, "w", encoding="utf-8", newline="").write(
|
||||
s.replace(seg.group(0), seg.group(0) + "(改节号也不该失效)", 1))
|
||||
c6, o6 = _run("check_skeleton.py", "--doc", doc_t2, "--proto", proto)
|
||||
rows.append(("对照 4:骨架节号加后缀 ⇒ 闸门仍 PASS(节号没写死)", c6 == 0,
|
||||
"rc=%d | %s" % (c6, _brief(o6, 1))))
|
||||
print("\n== 对照 4:骨架节号加后缀 ⇒ 应仍 PASS ==")
|
||||
print(" 骨架 rc=%d(期望 0)| %s" % (c6, _brief(o6, 1)))
|
||||
|
||||
print()
|
||||
bad = 0
|
||||
for name, ok, info in rows:
|
||||
if not ok:
|
||||
bad += 1
|
||||
print("%s %s | %s" % ("✅" if ok else "❌", name, info))
|
||||
print("\n闸门自身对照:%d / %d 通过" % (len(rows) - bad, len(rows)))
|
||||
if bad == 0:
|
||||
print("⇒ 两闸门既不是恒绿(变异全报红),也不是恒红(节号对照仍 PASS)。")
|
||||
return 0 if bad == 0 else 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user