@techierathore/techieflow 1.0.4 → 1.1.1
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/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- package/.tfcore/utils/tf-goal.sh +12 -12
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -14,7 +14,10 @@ For each row, the seven checks in order, and the first that fails is the verdict
|
|
|
14
14
|
render every anchored control on its screen shows something — screens.json
|
|
15
15
|
assets every stylesheet and script its screen declares arrived — assets.json
|
|
16
16
|
visual nothing overlaps, nothing clipped or off-screen — screens.json
|
|
17
|
-
mockup the screen carries the structure its mockup draws — parity.json
|
|
17
|
+
mockup the screen carries the structure its mockup draws — parity.json, found by the screen's
|
|
18
|
+
name, its mockup's file name or its route ({id} in the row's route = one path segment, so
|
|
19
|
+
/processes/6/run counts for /processes/{id}/run); a driven screen with a mockup and no
|
|
20
|
+
comparison found FAILs this check, it is never skipped (Chatur TF-008)
|
|
18
21
|
speed within the row's perf-budget, only when it declares one — perf/<REQ>.json
|
|
19
22
|
(standards has no script yet and is never listed as run)
|
|
20
23
|
|
|
@@ -22,7 +25,9 @@ Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL
|
|
|
22
25
|
PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
|
|
23
26
|
checks passed or did not run) ·
|
|
24
27
|
NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
|
|
25
|
-
the head could not be booted, or has no driver)
|
|
28
|
+
the head could not be booted, or has no driver) · DEFECT-OPEN (every check passed, but the old Remarks
|
|
29
|
+
carry a defect no verify can see, tf_defect.py: the row goes to Needs re-verify, and so to a fix, with
|
|
30
|
+
the defect kept; AppManager TF-021). A row is Verified only on PASS. NOT-* rows keep their
|
|
26
31
|
status and get a Remark saying so; nothing is ever written as a static-only pass.
|
|
27
32
|
|
|
28
33
|
Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
|
|
@@ -38,6 +43,9 @@ import re
|
|
|
38
43
|
import sys
|
|
39
44
|
|
|
40
45
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
46
|
+
sys.path.insert(0, HERE)
|
|
47
|
+
import tf_defect # noqa: E402
|
|
48
|
+
import tf_stack_check # noqa: E402
|
|
41
49
|
FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
|
|
42
50
|
"VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
|
|
43
51
|
STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
|
|
@@ -71,6 +79,42 @@ def norm_route(r):
|
|
|
71
79
|
return r or "/"
|
|
72
80
|
|
|
73
81
|
|
|
82
|
+
def route_matches(pattern, route):
|
|
83
|
+
"""A filled-in route against its pattern: /processes/6/run matches /processes/{id}/run, one path
|
|
84
|
+
segment per {…} (Chatur TF-008). A pattern with no {…} matches only itself. A query string on the
|
|
85
|
+
opened route (/start?all=1) is not part of the screen."""
|
|
86
|
+
p, r = norm_route(pattern), norm_route((route or "").split("?")[0].split("#")[0])
|
|
87
|
+
if "{" not in p:
|
|
88
|
+
return p == r
|
|
89
|
+
rx = "".join("[^/]+" if part.startswith("{") else re.escape(part) for part in re.split(r"(\{[^}]*\})", p))
|
|
90
|
+
return re.fullmatch(rx, r) is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def stem(path):
|
|
94
|
+
return os.path.basename(path or "")[:-5].lower() if (path or "").lower().endswith(".html") else ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parity_for(r, mockup, comparisons):
|
|
98
|
+
"""The mockup comparison that graded a row's screen: named for the screen, for its mockup file
|
|
99
|
+
(the Mockup parity: line names each screen by its mockup's stem), or opened at the screen's route,
|
|
100
|
+
a {value} in it standing for any one segment. The worst one when several match: one per value
|
|
101
|
+
(Chatur TF-008)."""
|
|
102
|
+
screen, route = (r.get("screen") or "").lower(), r.get("route") or ""
|
|
103
|
+
hits = [s for s in comparisons
|
|
104
|
+
if (s.get("screen") or "").lower() in (screen, stem(mockup))
|
|
105
|
+
or (route and s.get("route") and route_matches(route, s.get("route")))]
|
|
106
|
+
order = {"FAIL": 0, "ERROR": 1, "UNGRADEABLE": 2, "NO-MOCKUP": 3, "PASS": 4}
|
|
107
|
+
return min(hits, key=lambda s: order.get(s.get("verdict"), 5)) if hits else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def quoted(s, n=None):
|
|
111
|
+
"""A test's own words — its title or its failure message — in backticks, so the Remark shows them as
|
|
112
|
+
quoted and tf-doc-check reads them as quoted: a title saying "Found/Not found" failed the checker's
|
|
113
|
+
not-present rule on every verify (Lekhak TF-012)."""
|
|
114
|
+
s = re.sub(r"\s+", " ", (s or "").replace("`", "'").replace("|", "/")).strip()
|
|
115
|
+
return f"`{s[:n] if n else s}`" if s else ""
|
|
116
|
+
|
|
117
|
+
|
|
74
118
|
def words(s, n=60):
|
|
75
119
|
w = s.split()
|
|
76
120
|
return s if len(w) <= n else " ".join(w[:n]) + "…" # the ellipsis rides on the last word: the checker counts words, and a bare "…" was a 61st (miss 29)
|
|
@@ -92,15 +136,25 @@ def main(argv):
|
|
|
92
136
|
tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
|
|
93
137
|
screens = load(os.path.join(d, "screens.json")) or {}
|
|
94
138
|
scr_by_name = {s["name"]: s for s in screens.get("screens", [])}
|
|
139
|
+
# screens tf-verify-screens did not drive because their route needs a sample value (Lekhak TF-005)
|
|
140
|
+
scr_skipped = {s["name"]: s for s in screens.get("skipped", []) or []}
|
|
95
141
|
assets = load(os.path.join(d, "assets.json")) or {}
|
|
96
142
|
pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
|
|
97
143
|
parity = load(os.path.join(d, "parity.json")) or {}
|
|
98
|
-
|
|
99
|
-
|
|
144
|
+
comparisons = parity.get("screens", []) or []
|
|
145
|
+
# the mockup each listed screen is graded against; a row's own *Mockup:* line when its screen has none
|
|
146
|
+
mock_of = {s.get("name"): s.get("mockup") or "" for s in lst.get("screens", []) or []}
|
|
100
147
|
grade = perf_grader()
|
|
101
148
|
|
|
102
149
|
booted = boot.get("mode") not in (None, "none")
|
|
103
150
|
build_failed = boot.get("reason_kind") == "build-error"
|
|
151
|
+
# the packages the Architecture requires are in the code (tf_stack_check.py): a model once replaced the
|
|
152
|
+
# mandated UI library with plain components and had every row Verified
|
|
153
|
+
stack = tf_stack_check.check(os.getcwd(), app)
|
|
154
|
+
if stack["verdict"] == "FAIL":
|
|
155
|
+
build_failed = True
|
|
156
|
+
boot = dict(boot, reason=f"the Architecture requires {', '.join(stack['missing'])} and no project file references "
|
|
157
|
+
+ ("it" if len(stack["missing"]) == 1 else "them") + " (tf-stack-check.sh): add the package, never a workaround")
|
|
104
158
|
boot_text = (f"{boot.get('head')} via {boot.get('rung')} at {boot.get('url')}" if booted
|
|
105
159
|
else f"not booted: {boot.get('reason')}")
|
|
106
160
|
checks_ran = set()
|
|
@@ -114,6 +168,7 @@ def main(argv):
|
|
|
114
168
|
notes = []
|
|
115
169
|
scr = scr_by_name.get(r.get("screen") or "")
|
|
116
170
|
driven = bool(scr) and scr.get("render") != "UNREACHABLE"
|
|
171
|
+
nonames = " (names not measured on a Mac)" if scr and scr.get("anchors_not_measured") else ""
|
|
117
172
|
t = tests.get(rid)
|
|
118
173
|
# A row whose every clause was SKIPPED is NOT-TESTED: the acceptance gate did not run on
|
|
119
174
|
# it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
|
|
@@ -122,6 +177,21 @@ def main(argv):
|
|
|
122
177
|
if skipped_only:
|
|
123
178
|
notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
|
|
124
179
|
t = None
|
|
180
|
+
# the OS zoom keys (coding-standards-dotnet.md §9b): a row whose acceptance carries "zoom-check: keys"
|
|
181
|
+
# or "zoom-check: kept" is graded by tf-verify-zoom.sh --json-out zoom/<REQ>.json, which stands as its
|
|
182
|
+
# test; a failing zoom outranks a passing test, and "kept" counts only a run made after a restart
|
|
183
|
+
if r.get("zoom_check"):
|
|
184
|
+
zf = load(os.path.join(d, "zoom", f"{rid}.json"))
|
|
185
|
+
if zf is None:
|
|
186
|
+
notes.append("zoom not measured (no tf-verify-zoom run for this row)")
|
|
187
|
+
elif r["zoom_check"] == "kept" and not zf.get("kept_checked"):
|
|
188
|
+
notes.append("zoom kept across a restart not measured (run with --expect-restored after a restart)")
|
|
189
|
+
else:
|
|
190
|
+
zfail = [c for c in zf.get("checks", []) if not c.get("ok")]
|
|
191
|
+
zt = {"result": "FAIL" if zfail else "PASS", "tests": [f"tf-verify-zoom {zf.get('head', '')}".strip()],
|
|
192
|
+
"reason": f"zoom: {zfail[0]['text']}" if zfail else "", "screenshot": zf.get("evidence", "")}
|
|
193
|
+
if not t or (zfail and t.get("result") == "PASS"):
|
|
194
|
+
t = zt
|
|
125
195
|
# every check on its own: (name, ran, failed, verdict, class, detail, evidence); the first failure wins
|
|
126
196
|
checks = []
|
|
127
197
|
|
|
@@ -129,7 +199,7 @@ def main(argv):
|
|
|
129
199
|
checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
|
|
130
200
|
# 2 acceptance
|
|
131
201
|
if t:
|
|
132
|
-
checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", t.get("reason") or "test failed", t.get("screenshot") or ""))
|
|
202
|
+
checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", quoted(t.get("reason")) or "test failed", t.get("screenshot") or ""))
|
|
133
203
|
# 3 render
|
|
134
204
|
if scr:
|
|
135
205
|
if scr.get("render") == "UNREACHABLE":
|
|
@@ -140,6 +210,10 @@ def main(argv):
|
|
|
140
210
|
w, f = bad[0] if bad else (None, None)
|
|
141
211
|
checks.append(("render", True, bool(bad), "RENDER-FAIL", f["class"] if f else None,
|
|
142
212
|
f"{f['detail']} on {r['screen']} @{w['width']}" if f else "", w.get("screenshot", "") if w else ""))
|
|
213
|
+
# a Mac web view head: blank, error bar, overlap and the picture were checked, the
|
|
214
|
+
# controls by name were not (owner decision A, 2026-10-07): said, never counted as a pass
|
|
215
|
+
if scr.get("anchors_not_measured") and bad:
|
|
216
|
+
notes.append(f"control names not measured on {r['screen']} (a Mac web view does not show them)")
|
|
143
217
|
# 4 assets
|
|
144
218
|
pg = pages.get(norm_route(r.get("route"))) if r.get("route") else None
|
|
145
219
|
if pg and pg.get("graded", 0) > 0:
|
|
@@ -155,16 +229,40 @@ def main(argv):
|
|
|
155
229
|
checks.append(("visual", True, bool(bad), "VISUAL-FAIL", f["class"] if f else None,
|
|
156
230
|
f"{f['detail']} on {r['screen']}" if f else "", w.get("screenshot", "") if w else ""))
|
|
157
231
|
# 6 mockup parity
|
|
158
|
-
|
|
232
|
+
mockup = mock_of.get(r.get("screen")) or r.get("mockup") or ""
|
|
233
|
+
ps = parity_for(r, mockup, comparisons) if r.get("screen") else None
|
|
234
|
+
if not ps and driven and mockup and boot.get("mode") == "appium":
|
|
235
|
+
# a Mac head: tf-mockup-parity does not run there (verify-phase step 5), so no comparison is
|
|
236
|
+
# expected, and the row is never failed for its absence; said, not counted as a pass
|
|
237
|
+
notes.append("mockup not compared on a Mac head (compare it on the web or Windows head)")
|
|
238
|
+
elif not ps and driven and mockup:
|
|
239
|
+
# Chatur TF-008: /processes/6/run was compared and FAILed, but the row's route reads
|
|
240
|
+
# /processes/{id}/run and the comparison was named process-run, so it was never found and
|
|
241
|
+
# 11 rows were Verified without it. A driven screen that has a mockup and no comparison fails.
|
|
242
|
+
checks.append(("mockup-parity", True, True, "MOCKUP-FAIL", "other",
|
|
243
|
+
f"mockup-parity not found: {r['screen']} ({r.get('route')}) has mockup {mockup} and "
|
|
244
|
+
+ ("parity.json has no comparison for it" if comparisons else "no mockup comparison ran")
|
|
245
|
+
+ f"; run tf-mockup-parity.sh --screen {stem(mockup) or 'name'}={r.get('route')} with its value filled in",
|
|
246
|
+
os.path.join(d, "parity.json") if comparisons else ""))
|
|
159
247
|
if ps:
|
|
160
248
|
v = ps.get("verdict")
|
|
161
249
|
if v in ("PASS", "FAIL"):
|
|
162
250
|
f = (ps.get("findings") or [{}])[0]
|
|
163
251
|
checks.append(("mockup-parity", True, v == "FAIL", "MOCKUP-FAIL", "mockup-drift",
|
|
164
|
-
f"{f.get('class', 'drift')} on {f.get('key', ps.get('screen'))} @{f.get('width', '')}
|
|
252
|
+
f"{f.get('class', 'drift')} on {f.get('key', ps.get('screen'))} @{f.get('width', '')}"
|
|
253
|
+
+ (f" in {f['theme']} mode" if f.get("theme") else "") + f": {str(f.get('detail', ''))[:80]}",
|
|
254
|
+
os.path.join(d, "parity.json")))
|
|
255
|
+
elif v == "ERROR":
|
|
256
|
+
checks.append(("mockup-parity", True, True, "MOCKUP-FAIL", "other",
|
|
257
|
+
f"mockup-parity could not open {ps.get('route')}: "
|
|
258
|
+
+ str(next((w.get("error") for w in ps.get("widths", []) if w.get("error")), "every width failed"))[:80],
|
|
165
259
|
os.path.join(d, "parity.json")))
|
|
166
260
|
elif v == "UNGRADEABLE":
|
|
167
261
|
notes.append("mockup-parity UNGRADEABLE (add data-testid anchors to the mockup)")
|
|
262
|
+
nm = (ps.get("coverage") or {}).get("not_measured") or []
|
|
263
|
+
if nm: # sample rows the app had no data for (Chatur TF-002)
|
|
264
|
+
notes.append(f"mockup-parity: {len(nm)} sample box(es) not measured, the app drew no data there "
|
|
265
|
+
"(a tests/verify/seed/ script puts it in the drawn state)")
|
|
168
266
|
elif v == "NO-MOCKUP":
|
|
169
267
|
notes.append("no mockup to compare")
|
|
170
268
|
# 7 speed
|
|
@@ -192,42 +290,62 @@ def main(argv):
|
|
|
192
290
|
detail = "NFR row with no perf-budget and no unit test carrying its id"
|
|
193
291
|
elif not t and r.get("screen") and not driven:
|
|
194
292
|
verdict = "NOT-DRIVEN"
|
|
293
|
+
sk = scr_skipped.get(r["screen"])
|
|
195
294
|
detail = (f"{boot.get('head', 'the app')} not driven: {boot.get('reason')}" if not booted
|
|
295
|
+
else f"screen {r['screen']} was not driven: {sk['reason']}" if sk and sk.get("reason")
|
|
296
|
+
else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
|
|
297
|
+
+ ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
|
|
196
298
|
else f"screen {r['screen']} was not driven in this run")
|
|
197
299
|
elif not t:
|
|
198
300
|
verdict = "NOT-TESTED"
|
|
199
301
|
detail = (f"every test named {rid} was skipped" if skipped_only else f"no test named {rid} ran") \
|
|
200
|
-
+ (f"; screen {r['screen']} renders and looks right" if driven else "")
|
|
201
|
-
elif r.get("screen") and not driven
|
|
302
|
+
+ (f"; screen {r['screen']} renders and looks right{nonames}" if driven else "")
|
|
303
|
+
elif r.get("screen") and not driven:
|
|
304
|
+
# a passing test is not a screen check: a row on a screen that was not driven is never
|
|
305
|
+
# Verified, booted or not. The Settings rows of /settings/{tab} were (Chatur TF-003).
|
|
202
306
|
verdict = "NOT-DRIVEN"
|
|
203
|
-
|
|
307
|
+
sk = scr_skipped.get(r["screen"])
|
|
308
|
+
detail = ("test passed but " + (f"{boot.get('head', 'the app')} was not driven: {boot.get('reason')}" if not booted
|
|
309
|
+
else f"screen {r['screen']} was not driven: {sk['reason']}" if sk and sk.get("reason")
|
|
310
|
+
else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
|
|
311
|
+
+ ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
|
|
312
|
+
else f"screen {r['screen']} was not driven in this run"))
|
|
204
313
|
else:
|
|
205
314
|
verdict = "PASS"
|
|
206
|
-
parts = [f"test {t['tests'][0]
|
|
315
|
+
parts = [f"test {quoted(t['tests'][0], 60)}" if t and t.get("tests") else "test passed"]
|
|
207
316
|
if driven:
|
|
208
|
-
|
|
317
|
+
ws = list(dict.fromkeys(str(w['width']) for w in scr['widths']))
|
|
318
|
+
th = list(dict.fromkeys(w['theme'] for w in scr['widths'] if w.get('theme')))
|
|
319
|
+
parts.append(f"{r['screen']} renders and looks right @{'/'.join(ws)}" + (f" in {' and '.join(th)} mode" if len(th) > 1 else "") + nonames)
|
|
209
320
|
if "mockup-parity" in ran:
|
|
210
321
|
parts.append("matches its mockup")
|
|
211
322
|
if "perf" in ran:
|
|
212
323
|
parts.append("within its speed budget")
|
|
213
324
|
detail = "; ".join(parts)
|
|
214
325
|
|
|
215
|
-
|
|
326
|
+
# A defect found by the guide, the owner's testing or production is not in the acceptance line, so
|
|
327
|
+
# a pass cannot clear it and a rewrite must not erase it (TF-021). tests/regression/run.sh am_021.
|
|
328
|
+
kept = tf_defect.unseen(r.get("remarks"))
|
|
329
|
+
if kept and verdict == "PASS":
|
|
330
|
+
verdict = "DEFECT-OPEN"
|
|
331
|
+
status = STATUS.get(verdict, "Needs re-verify" if verdict == "DEFECT-OPEN" else prior)
|
|
216
332
|
pct = r.get("pct")
|
|
217
333
|
if verdict == "PASS":
|
|
218
334
|
pct = 100
|
|
219
|
-
elif verdict in ("RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
|
|
335
|
+
elif verdict in ("DEFECT-OPEN", "RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
|
|
220
336
|
pct = min(pct if pct is not None else 75, 75)
|
|
221
337
|
gate = FAILING.get(verdict)
|
|
222
338
|
marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
|
|
223
339
|
"VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
|
|
224
|
-
"NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS"
|
|
340
|
+
"NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS",
|
|
341
|
+
"DEFECT-OPEN": "checks pass, defect open"}
|
|
225
342
|
remark = f"{today} verify: {marks[verdict]} — {detail}"
|
|
226
343
|
if evidence:
|
|
227
344
|
remark += f" ({evidence})"
|
|
228
345
|
if notes:
|
|
229
346
|
remark += "; " + "; ".join(notes[:2])
|
|
230
|
-
|
|
347
|
+
carried = words(" ".join(kept).replace("|", "/"), 30) if kept else ""
|
|
348
|
+
remark = words(remark.replace("|", "/"), 60 - len(carried.split())) + (f" {carried}" if carried else "")
|
|
231
349
|
emit = verdict == "PASS" or gate is not None
|
|
232
350
|
row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
|
|
233
351
|
"prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
|
|
@@ -240,13 +358,37 @@ def main(argv):
|
|
|
240
358
|
for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
|
|
241
359
|
if any(c in x["gates_run"] for x in rows_out):
|
|
242
360
|
checks.append(c)
|
|
243
|
-
|
|
244
|
-
|
|
361
|
+
# Lekhak TF-007: a targeted verify rewrote the ledger with its own rows only (395 became 1), so every
|
|
362
|
+
# other row lost its last verdict. The ledger is kept row by row: a row this run graded is replaced,
|
|
363
|
+
# with the date and run it was graded in; the rest keep theirs. A scope-all run starts it afresh.
|
|
364
|
+
# The verify guard unlocks a Verified only for a row graded today, and fix-close reads only the rows
|
|
365
|
+
# graded since its fix began, by these dates.
|
|
366
|
+
# Sevak TF-005: every row carries the phase whose checklist it was graded in, and a scope-all run
|
|
367
|
+
# starts afresh only for its own phase. Phase 2's run used to wipe phase 1's verdicts. A row from an
|
|
368
|
+
# older ledger that names no phase is kept.
|
|
369
|
+
phase = lst.get("phase") or 1
|
|
370
|
+
all_rows, row_dates, row_runs = dict(ledger_rows), {k: today for k in ledger_rows}, {k: started for k in ledger_rows}
|
|
371
|
+
row_phases = {k: phase for k in ledger_rows}
|
|
372
|
+
prev = load(os.path.join("docs", ".last-verify.json")) if os.path.isfile(os.path.join("docs", ".last-verify.json")) else None
|
|
373
|
+
if isinstance(prev, dict) and prev.get("app") in (None, app) \
|
|
374
|
+
and isinstance(prev.get("rows"), dict) and prev.get("mode") != "reconcile":
|
|
375
|
+
for k, v in prev["rows"].items():
|
|
376
|
+
was = (prev.get("row_phases") or {}).get(k, prev.get("phase"))
|
|
377
|
+
if k not in all_rows and (lst.get("scope", "all") != "all" or was != phase):
|
|
378
|
+
all_rows[k] = v
|
|
379
|
+
row_dates[k] = (prev.get("row_dates") or {}).get(k, prev.get("date", ""))
|
|
380
|
+
row_runs[k] = (prev.get("row_runs") or {}).get(k, prev.get("run_id", ""))
|
|
381
|
+
if was is not None:
|
|
382
|
+
row_phases[k] = was
|
|
383
|
+
ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "phase": phase, "checklist": lst.get("checklist"),
|
|
384
|
+
"booted": boot_text, "gates": checks,
|
|
385
|
+
"evidence": d, "run_id": started, "rows": all_rows, "row_dates": row_dates, "row_runs": row_runs,
|
|
386
|
+
"row_phases": row_phases, "this_run": sorted(ledger_rows)}
|
|
245
387
|
os.makedirs("docs", exist_ok=True)
|
|
246
388
|
with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
|
|
247
389
|
json.dump(ledger, f, indent=1)
|
|
248
390
|
with open(os.path.join(d, "verdicts.json"), "w", encoding="utf-8") as f:
|
|
249
|
-
json.dump({"app": app, "scope": lst.get("scope", "all"), "date": today, "started": started, "booted": boot_text,
|
|
391
|
+
json.dump({"app": app, "scope": lst.get("scope", "all"), "phase": phase, "date": today, "started": started, "booted": boot_text,
|
|
250
392
|
"build_result": "fail" if build_failed else ("pass" if booted else "not-run"), "checks": checks,
|
|
251
393
|
"checklist": lst.get("checklist"), "rows": rows_out}, f, indent=1)
|
|
252
394
|
|
|
@@ -275,7 +417,7 @@ def main(argv):
|
|
|
275
417
|
counts = {}
|
|
276
418
|
for x in rows_out:
|
|
277
419
|
counts[x["verdict"]] = counts.get(x["verdict"], 0) + 1
|
|
278
|
-
print(f"# tf-verify-verdict — {app} — scope {lst.get('scope')} — booted: {boot_text}")
|
|
420
|
+
print(f"# tf-verify-verdict — {app} — phase {phase} ({lst.get('checklist')}) — scope {lst.get('scope')} — booted: {boot_text}")
|
|
279
421
|
print(f"Checks that ran: {', '.join(checks) or 'none'}. Rows: " + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())))
|
|
280
422
|
print()
|
|
281
423
|
print("| ID | Verdict | First failing check | Status | Detail |")
|
|
@@ -283,7 +425,7 @@ def main(argv):
|
|
|
283
425
|
for t in table:
|
|
284
426
|
print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
|
|
285
427
|
print()
|
|
286
|
-
print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows). Verdicts: {os.path.join(d, 'verdicts.json')}."
|
|
428
|
+
print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows graded now, {len(all_rows)} in all). Verdicts: {os.path.join(d, 'verdicts.json')}."
|
|
287
429
|
+ (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
|
|
288
430
|
return 0
|
|
289
431
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// tf-verify-zoom.mjs — presses the OS zoom keys on a Blazor Hybrid head and measures what they did
|
|
2
|
+
// (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11). Called by tf-verify-zoom.sh; see there.
|
|
3
|
+
//
|
|
4
|
+
// Mac (--appium): mac2 presses Cmd+= / Cmd+- / Cmd+0 as real keys, so they go through the menu bar the
|
|
5
|
+
// way a person's do; macOS takes the window alone after each. The accessibility tree does not follow a
|
|
6
|
+
// WKWebView's page zoom (its text heights stayed 13 px at 130% on BlogAdmin), so the level is measured
|
|
7
|
+
// from the pictures: the scale that best maps the actual-size picture onto the zoomed one, from the
|
|
8
|
+
// first row where the two differ (the web view's top edge; the title bar above it does not zoom).
|
|
9
|
+
// Windows and any Chromium web view (--cdp): Playwright presses Ctrl+= / Ctrl+- / Ctrl+0 in the page
|
|
10
|
+
// and reads the zoom it paints at (CSS zoom on <html> times the device pixel ratio).
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { inflateSync } from 'node:zlib';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const opt = (k, d = '') => { const i = args.indexOf(k); return i >= 0 && i + 1 < args.length ? args[i + 1] : d; };
|
|
19
|
+
const flag = (k) => args.includes(k);
|
|
20
|
+
const OUT = opt('--out', 'tests/.artifacts/verify/zoom');
|
|
21
|
+
const LEAVE = parseInt(opt('--leave', '0'), 10) || 0;
|
|
22
|
+
const STEPS = [
|
|
23
|
+
// what is pressed, how often, the level expected afterwards (1.0 = actual size)
|
|
24
|
+
{ key: 'reset', n: 1, want: 1.0, say: 'actual size' },
|
|
25
|
+
{ key: 'in', n: 3, want: 1.3, say: 'zoom in 3 times' },
|
|
26
|
+
{ key: 'out', n: 1, want: 1.2, say: 'zoom out once' },
|
|
27
|
+
{ key: 'reset', n: 1, want: 1.0, say: 'actual size again' },
|
|
28
|
+
];
|
|
29
|
+
const TOL = 0.05;
|
|
30
|
+
|
|
31
|
+
// ---- PNG, 8-bit RGB or RGBA, not interlaced: what screencapture and sips write -------------------
|
|
32
|
+
export function decodePng(buf) {
|
|
33
|
+
let p = 8, w = 0, h = 0, ct = 0; const idat = [];
|
|
34
|
+
while (p < buf.length) {
|
|
35
|
+
const len = buf.readUInt32BE(p); const type = buf.toString('latin1', p + 4, p + 8);
|
|
36
|
+
const data = buf.subarray(p + 8, p + 8 + len);
|
|
37
|
+
if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; if (data[8] !== 8 || data[12]) throw new Error('only 8-bit, non-interlaced PNG'); }
|
|
38
|
+
if (type === 'IDAT') idat.push(data);
|
|
39
|
+
if (type === 'IEND') break;
|
|
40
|
+
p += 12 + len;
|
|
41
|
+
}
|
|
42
|
+
const bpp = ct === 6 ? 4 : ct === 2 ? 3 : ct === 0 ? 1 : ct === 4 ? 2 : 0;
|
|
43
|
+
if (!bpp) throw new Error(`PNG colour type ${ct} not read`);
|
|
44
|
+
const raw = inflateSync(Buffer.concat(idat)); const stride = w * bpp;
|
|
45
|
+
const px = Buffer.alloc(h * stride); const gray = new Float32Array(w * h);
|
|
46
|
+
for (let y = 0; y < h; y++) {
|
|
47
|
+
const f = raw[y * (stride + 1)]; const src = y * (stride + 1) + 1; const row = y * stride;
|
|
48
|
+
for (let x = 0; x < stride; x++) {
|
|
49
|
+
const a = x >= bpp ? px[row + x - bpp] : 0; const b = y ? px[row - stride + x] : 0; const c = x >= bpp && y ? px[row - stride + x - bpp] : 0;
|
|
50
|
+
let v = raw[src + x];
|
|
51
|
+
if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
|
|
52
|
+
else if (f === 4) { const pp = a + b - c; const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
|
53
|
+
px[row + x] = v & 255;
|
|
54
|
+
}
|
|
55
|
+
for (let x = 0; x < w; x++) { const o = row + x * bpp; gray[y * w + x] = bpp >= 3 ? 0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2] : px[o]; }
|
|
56
|
+
}
|
|
57
|
+
return { w, h, gray };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// a picture shrunk to 640 px wide by sips (macOS), so the search stays quick
|
|
61
|
+
function load(file) {
|
|
62
|
+
const small = file.replace(/\.png$/, '.small.png');
|
|
63
|
+
try { execFileSync('sips', ['-Z', '1000', file, '--out', small], { stdio: 'ignore' }); } catch { /* not macOS: read it whole */ }
|
|
64
|
+
return decodePng(readFileSync(existsSync(small) ? small : file));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The page zoom that turns picture `base` (actual size) into picture `zoomed`. Page zoom scales every
|
|
68
|
+
// horizontal position from the web view's left edge, but not always vertical ones: a header or sidebar
|
|
69
|
+
// sized to the window height keeps its height (BlogAdmin's did), so a 2-D match read 121% for 130%.
|
|
70
|
+
// So the two pictures are compared across the window only: for each column, how much vertical edge
|
|
71
|
+
// (a border, a box side, the strokes of text) it holds below the title bar, and the scale that best
|
|
72
|
+
// lines the actual-size profile up with the zoomed one. The title bar is the rows the two share.
|
|
73
|
+
export function scaleBetween(zoomed, base) {
|
|
74
|
+
const { w, h } = base; if (zoomed.w !== w || zoomed.h !== h) return { scale: 0, why: 'the two pictures differ in size' };
|
|
75
|
+
let top = 0;
|
|
76
|
+
for (; top < h; top++) { let d = 0; for (let x = 0; x < w; x++) d += Math.abs(zoomed.gray[top * w + x] - base.gray[top * w + x]); if (d / w > 2) break; }
|
|
77
|
+
if (top >= h - 10) return { scale: 1, top, why: 'the pictures are the same' };
|
|
78
|
+
const profile = (img) => {
|
|
79
|
+
const p = new Float64Array(w);
|
|
80
|
+
for (let y = top; y < h; y++) for (let x = 0; x + 1 < w; x++) p[x] += Math.abs(img.gray[y * w + x + 1] - img.gray[y * w + x]);
|
|
81
|
+
return p;
|
|
82
|
+
};
|
|
83
|
+
const pz = profile(zoomed), pb = profile(base);
|
|
84
|
+
let best = { scale: 0, r: -2 };
|
|
85
|
+
for (let s = 0.5; s <= 3.0001; s += 0.01) {
|
|
86
|
+
let n = 0, sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
|
|
87
|
+
for (let x = 0; x < w; x++) {
|
|
88
|
+
const bx = x / s; const i = Math.floor(bx); if (i + 1 >= w) break;
|
|
89
|
+
const a = pz[x], b = pb[i] + (pb[i + 1] - pb[i]) * (bx - i);
|
|
90
|
+
n++; sa += a; sb += b; saa += a * a; sbb += b * b; sab += a * b;
|
|
91
|
+
}
|
|
92
|
+
if (n < w / 4) continue;
|
|
93
|
+
const r = (sab - (sa * sb) / n) / Math.sqrt(Math.max(1e-9, (saa - (sa * sa) / n) * (sbb - (sb * sb) / n)));
|
|
94
|
+
if (r > best.r) best = { scale: Math.round(s * 100) / 100, r };
|
|
95
|
+
}
|
|
96
|
+
return { scale: best.scale, top };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const RESULT = { checks: [], kept_checked: false, head: '', evidence: '' };
|
|
100
|
+
const line = (ok, text) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${text}`); RESULT.checks.push({ ok, text }); return ok; };
|
|
101
|
+
|
|
102
|
+
async function mac() {
|
|
103
|
+
const { openSession, parseSource, appPid, windowShot } = await import('./tf-appium.mjs');
|
|
104
|
+
let boot = {}; try { boot = JSON.parse(readFileSync('tests/.artifacts/verify/boot.json', 'utf8')); } catch { /* flags only */ }
|
|
105
|
+
const appPath = opt('--app', boot.app_path || ''); const bundleId = opt('--bundle', boot.bundle || '');
|
|
106
|
+
let s;
|
|
107
|
+
try { s = await openSession(opt('--appium'), { bundleId, appPath }); }
|
|
108
|
+
catch (e) { line(false, `no session on the app at ${opt('--appium')}: ${e.message} (boot it with bash .tfcore/utils/tf-verify-boot.sh start --head maccatalyst)`); return 2; }
|
|
109
|
+
const pid = appPid(appPath);
|
|
110
|
+
let ok = true; let k = 0;
|
|
111
|
+
const shot = (name, same = false) => { const f = join(OUT, `${String(same ? k : k++).padStart(2, '0')}-${name}-mac.png`); return windowShot(pid, f) ? f : ''; };
|
|
112
|
+
const press = async (key) => {
|
|
113
|
+
await s.call('POST', '/execute/sync', { script: 'macos: keys', args: [{ keys: [{ key: { in: '=', out: '-', reset: '0' }[key], modifierFlags: 1 << 4 }] }] });
|
|
114
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
115
|
+
};
|
|
116
|
+
// the View menu names the three commands
|
|
117
|
+
const nodes = parseSource(await s.source());
|
|
118
|
+
const view = nodes.find((n) => n.type === 'MenuBarItem' && /^view$/i.test(n.title || n.label));
|
|
119
|
+
const items = []; if (view) { const todo = [...view.kids]; while (todo.length) { const n = nodes[todo.shift()]; if (n.type === 'MenuItem') items.push(n.title || n.label); todo.push(...n.kids); } }
|
|
120
|
+
const missing = ['Zoom In', 'Zoom Out', 'Actual Size'].filter((t) => !items.includes(t));
|
|
121
|
+
ok = line(!missing.length, missing.length ? `the View menu has no ${missing.join(', ')} (it has: ${items.filter(Boolean).join(', ') || 'no View menu'})` : 'View › Zoom In, Zoom Out, Actual Size are in the menu bar') && ok;
|
|
122
|
+
// A mac2 session starts the app again: wait for a drawn page that holds still for two pictures
|
|
123
|
+
// (the first ones on BlogAdmin were the bare window while Blazor started)
|
|
124
|
+
const ink = (f) => { const g = load(f); let e = 0; for (let i = 1; i < g.gray.length; i++) e += Math.abs(g.gray[i] - g.gray[i - 1]); return e / g.gray.length; };
|
|
125
|
+
let opened = ''; let last = -1;
|
|
126
|
+
for (let t = Date.now(); Date.now() - t < 60000;) {
|
|
127
|
+
const f = shot('as-opened', true); const e = f ? ink(f) : 0; // a bare window reads 0.04, a drawn page 0.96
|
|
128
|
+
if (e > 0.2 && last > 0 && Math.abs(e - last) / last < 0.02) { opened = f; break; }
|
|
129
|
+
last = e; await new Promise((r) => setTimeout(r, 1500));
|
|
130
|
+
}
|
|
131
|
+
k++;
|
|
132
|
+
if (!opened) { await s.close(); line(false, 'the app showed no drawn page within 60 s'); return 2; }
|
|
133
|
+
await press('reset'); const actual = shot('actual-size');
|
|
134
|
+
if (!opened || !actual) { await s.close(); return line(false, 'could not take the window (the terminal needs Screen Recording)') ? 0 : 5; }
|
|
135
|
+
RESULT.head = 'maccatalyst'; RESULT.evidence = actual;
|
|
136
|
+
const base = load(actual);
|
|
137
|
+
const restored = scaleBetween(load(opened), base).scale;
|
|
138
|
+
console.log(`INFO opened at ${Math.round(restored * 100)}% (the level the last run left; run again after a restart with --leave 3 before it to check the level is kept)`);
|
|
139
|
+
if (flag('--expect-restored')) RESULT.kept_checked = true;
|
|
140
|
+
if (flag('--expect-restored')) ok = line(Math.abs(restored - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round(restored * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}% from the last run`) && ok;
|
|
141
|
+
for (const st of STEPS.slice(1)) {
|
|
142
|
+
for (let i = 0; i < st.n; i++) await press(st.key);
|
|
143
|
+
const f = shot(st.key); const got = f ? scaleBetween(load(f), base).scale : 0;
|
|
144
|
+
ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Cmd+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
|
|
145
|
+
}
|
|
146
|
+
for (let i = 0; i < LEAVE; i++) await press('in');
|
|
147
|
+
if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
|
|
148
|
+
await s.close();
|
|
149
|
+
return ok ? 0 : 5;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function cdp() {
|
|
153
|
+
const { chromium } = await import('playwright');
|
|
154
|
+
let browser;
|
|
155
|
+
try { browser = await chromium.connectOverCDP(opt('--cdp')); }
|
|
156
|
+
catch (e) { line(false, `nothing answers at ${opt('--cdp')}: ${e.message.split('\n')[0]}`); return 2; }
|
|
157
|
+
const page = browser.contexts().flatMap((c) => c.pages()).find((p) => !p.url().startsWith('devtools:')) || null;
|
|
158
|
+
if (!page) { line(false, 'no page to drive at ' + opt('--cdp')); return 2; }
|
|
159
|
+
const level = () => page.evaluate(() => (parseFloat(getComputedStyle(document.documentElement).zoom) || 1) * window.devicePixelRatio);
|
|
160
|
+
let ok = true; let k = 0;
|
|
161
|
+
const press = async (key) => { await page.keyboard.press({ in: 'Control+Equal', out: 'Control+Minus', reset: 'Control+Digit0' }[key]); await page.waitForTimeout(400); };
|
|
162
|
+
RESULT.head = 'cdp';
|
|
163
|
+
const opened = await level(); await press('reset'); const base = await level();
|
|
164
|
+
console.log(`INFO opened at ${Math.round((opened / base) * 100)}% (the level the last run left)`);
|
|
165
|
+
if (flag('--expect-restored')) RESULT.kept_checked = true;
|
|
166
|
+
if (flag('--expect-restored')) ok = line(Math.abs(opened / base - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round((opened / base) * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}%`) && ok;
|
|
167
|
+
for (const st of STEPS.slice(1)) {
|
|
168
|
+
for (let i = 0; i < st.n; i++) await press(st.key);
|
|
169
|
+
await page.screenshot({ path: join(OUT, `${String(k++).padStart(2, '0')}-${st.key}-cdp.png`) }).catch(() => {});
|
|
170
|
+
const got = (await level()) / base;
|
|
171
|
+
ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Ctrl+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
|
|
172
|
+
}
|
|
173
|
+
// Ctrl+wheel up is one step in
|
|
174
|
+
await page.mouse.move(200, 200); await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await page.waitForTimeout(400);
|
|
175
|
+
const wheel = (await level()) / base;
|
|
176
|
+
ok = line(Math.abs(wheel - 1.1) <= TOL, `Ctrl+wheel up: ${Math.round(wheel * 100)}%, expected 110%`) && ok;
|
|
177
|
+
await press('reset');
|
|
178
|
+
for (let i = 0; i < LEAVE; i++) await press('in');
|
|
179
|
+
if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
|
|
180
|
+
await browser.close().catch(() => {});
|
|
181
|
+
return ok ? 0 : 5;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (process.argv[1] && process.argv[1].endsWith('tf-verify-zoom.mjs')) {
|
|
185
|
+
if (flag('--scale')) { // tf-verify-zoom.mjs --scale <zoomed.png> <actual.png>: the measure alone
|
|
186
|
+
console.log(scaleBetween(load(args[args.indexOf('--scale') + 1]), load(args[args.indexOf('--scale') + 2])).scale);
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
mkdirSync(OUT, { recursive: true });
|
|
190
|
+
const rc = flag('--appium') ? await mac() : flag('--cdp') ? await cdp() : (console.error('tf-verify-zoom: --appium <url> or --cdp <url>'), 3);
|
|
191
|
+
if (opt('--json-out')) writeFileSync(opt('--json-out'), JSON.stringify({ ...RESULT, verdict: rc === 0 ? 'PASS' : 'FAIL' }, null, 1));
|
|
192
|
+
process.exit(rc);
|
|
193
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tf-verify-zoom.sh — presses the OS zoom keys on a booted Blazor Hybrid head and says what they did
|
|
3
|
+
# (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11).
|
|
4
|
+
#
|
|
5
|
+
# bash .tfcore/utils/tf-verify-zoom.sh --appium http://localhost:4723 [--leave 3] [--expect-restored 1.3]
|
|
6
|
+
# a Mac Catalyst head booted by tf-verify-boot.sh (its .app and bundle come from boot.json)
|
|
7
|
+
# bash .tfcore/utils/tf-verify-zoom.sh --cdp <the boot url> [--leave 3] [--expect-restored 1.3]
|
|
8
|
+
# a Windows (WebView2) head, or any Chromium web view, over its debugging address
|
|
9
|
+
#
|
|
10
|
+
# Checks, in order: (Mac) View › Zoom In, Zoom Out and Actual Size are in the menu bar; the level the
|
|
11
|
+
# app opened at is printed, then Cmd/Ctrl+0 is actual size; zoom in 3 times reads 130%, zoom out once
|
|
12
|
+
# 120%, actual size 100%; (Windows) Ctrl+wheel up reads 110%. The kept level across a restart is two
|
|
13
|
+
# runs: the first with --leave 3 (it ends at 130%), then stop and start the head, then the second with
|
|
14
|
+
# --expect-restored 1.3. Pictures go to tests/.artifacts/verify/zoom/. The Mac level is measured from
|
|
15
|
+
# the window's pictures, because a WKWebView's accessibility tree does not follow its page zoom.
|
|
16
|
+
# Exit 0 every check passed · 5 one failed · 2 nothing to drive · 3 usage · 4 Playwright missing (--cdp).
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
case "${1:-}" in
|
|
20
|
+
-h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
21
|
+
"") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
|
|
22
|
+
esac
|
|
23
|
+
command -v node >/dev/null 2>&1 || { echo "tf-verify-zoom: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
|
|
24
|
+
case " $* " in *" --cdp "*)
|
|
25
|
+
node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
|
|
26
|
+
echo "tf-verify-zoom: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; } ;;
|
|
27
|
+
esac
|
|
28
|
+
source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one check drives the app at a time
|
|
29
|
+
node "$HERE/tf-verify-zoom.mjs" "$@"
|