@techierathore/techieflow 1.0.2 → 1.0.3

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.
Files changed (174) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +0 -5
  2. package/.claude/commands/TechieFlow/agents/architect.md +0 -6
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +41 -215
  4. package/.claude/commands/TechieFlow/agents/verifier.md +23 -64
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
  8. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
  9. package/.claude/commands/TechieFlow/tasks/amend-docs.md +20 -143
  10. package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
  11. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
  12. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
  13. package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
  14. package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
  15. package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
  16. package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
  17. package/.claude/commands/TechieFlow/tasks/metrics-report.md +1 -1
  18. package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
  19. package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
  20. package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
  21. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
  22. package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
  23. package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
  24. package/.opencode/plugin/techieflow.js +39 -12
  25. package/.tfcore/agents/analyst.md +0 -5
  26. package/.tfcore/agents/architect.md +0 -6
  27. package/.tfcore/agents/flow-master.md +41 -215
  28. package/.tfcore/agents/verifier.md +23 -64
  29. package/.tfcore/core-config.yaml +10 -0
  30. package/.tfcore/hooks/block-git.sh +4 -4
  31. package/.tfcore/hooks/guard-artifacts.sh +2 -2
  32. package/.tfcore/hooks/guard-build.sh +76 -0
  33. package/.tfcore/hooks/guard-db.sh +90 -0
  34. package/.tfcore/hooks/guard-metrics.sh +56 -0
  35. package/.tfcore/hooks/guard-status-html.sh +116 -71
  36. package/.tfcore/hooks/guard-status.sh +43 -16
  37. package/.tfcore/hooks/guard-verify.sh +70 -48
  38. package/.tfcore/hooks/sweep-artifacts.sh +2 -2
  39. package/.tfcore/install-manifest.yaml +0 -18
  40. package/.tfcore/routing.yaml +25 -14
  41. package/.tfcore/standards/coding-standards-core.md +61 -0
  42. package/.tfcore/standards/coding-standards-dotnet.md +93 -0
  43. package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
  44. package/.tfcore/tasks/_smoke-test-policy.md +20 -84
  45. package/.tfcore/tasks/_status-update-gate.md +10 -74
  46. package/.tfcore/tasks/_yolo-mode.md +20 -58
  47. package/.tfcore/tasks/amend-docs.md +20 -143
  48. package/.tfcore/tasks/build-phase.md +14 -224
  49. package/.tfcore/tasks/day1-brownfield.md +31 -505
  50. package/.tfcore/tasks/day1-greenfield.md +28 -213
  51. package/.tfcore/tasks/deploy-checklist.md +19 -0
  52. package/.tfcore/tasks/devguide.md +10 -219
  53. package/.tfcore/tasks/fix-issues.md +10 -129
  54. package/.tfcore/tasks/log-miss.md +19 -158
  55. package/.tfcore/tasks/metrics-report.md +1 -1
  56. package/.tfcore/tasks/mockups.md +12 -117
  57. package/.tfcore/tasks/refresh-status.md +9 -130
  58. package/.tfcore/tasks/split-brd.md +10 -126
  59. package/.tfcore/tasks/triage-and-fix.md +14 -0
  60. package/.tfcore/tasks/triage-issues.md +11 -152
  61. package/.tfcore/tasks/verify-phase.md +36 -572
  62. package/.tfcore/telemetry/SCHEMA.md +43 -7
  63. package/.tfcore/telemetry/tf-metrics.sh +152 -17
  64. package/.tfcore/templates/project-brief-tmpl.yaml +1 -1
  65. package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
  66. package/.tfcore/templates/stack-questions.md +77 -0
  67. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
  68. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
  69. package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
  70. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
  71. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
  72. package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
  73. package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
  74. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
  75. package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
  76. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
  77. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
  78. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
  79. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
  80. package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
  81. package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
  82. package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
  83. package/.tfcore/user-guide.md +0 -23
  84. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  90. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  91. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  92. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  93. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  94. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  95. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  96. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/tf-brd-status.py +176 -0
  103. package/.tfcore/utils/tf-brd-status.sh +19 -0
  104. package/.tfcore/utils/tf-build-list.py +191 -0
  105. package/.tfcore/utils/tf-build-list.sh +20 -0
  106. package/.tfcore/utils/tf-build.sh +143 -0
  107. package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
  108. package/.tfcore/utils/tf-checklist-edit.py +133 -0
  109. package/.tfcore/utils/tf-day1-files.py +208 -0
  110. package/.tfcore/utils/tf-day1-files.sh +20 -0
  111. package/.tfcore/utils/tf-devguide-list.py +249 -0
  112. package/.tfcore/utils/tf-devguide-list.sh +18 -0
  113. package/.tfcore/utils/tf-doc-check.py +1267 -0
  114. package/.tfcore/utils/tf-doc-check.sh +29 -0
  115. package/.tfcore/utils/tf-emit.sh +203 -13
  116. package/.tfcore/utils/tf-fix-close.sh +83 -0
  117. package/.tfcore/utils/tf-goal.sh +193 -62
  118. package/.tfcore/utils/tf-harness.sh +4 -9
  119. package/.tfcore/utils/tf-log-miss.py +197 -0
  120. package/.tfcore/utils/tf-log-miss.sh +14 -0
  121. package/.tfcore/utils/tf-misses-md.py +211 -0
  122. package/.tfcore/utils/tf-misses-md.sh +13 -0
  123. package/.tfcore/utils/tf-mockups-locate.py +66 -0
  124. package/.tfcore/utils/tf-mockups-locate.sh +18 -0
  125. package/.tfcore/utils/tf-opencode-audit.py +23 -7
  126. package/.tfcore/utils/tf-perf-grade.py +97 -0
  127. package/.tfcore/utils/tf-perf-grade.sh +12 -0
  128. package/.tfcore/utils/tf-phase.sh +74 -0
  129. package/.tfcore/utils/tf-routing-bind.sh +10 -5
  130. package/.tfcore/utils/tf-routing.sh +6 -7
  131. package/.tfcore/utils/tf-split-brd.py +304 -0
  132. package/.tfcore/utils/tf-split-brd.sh +22 -0
  133. package/.tfcore/utils/tf-status-evidence.sh +68 -0
  134. package/.tfcore/utils/tf-status-facts.py +309 -0
  135. package/.tfcore/utils/tf-status-facts.sh +20 -0
  136. package/.tfcore/utils/tf-triage.py +243 -0
  137. package/.tfcore/utils/tf-triage.sh +16 -0
  138. package/.tfcore/utils/tf-verify-boot.sh +250 -0
  139. package/.tfcore/utils/tf-verify-emit.sh +110 -0
  140. package/.tfcore/utils/tf-verify-env.sh +88 -0
  141. package/.tfcore/utils/tf-verify-list.py +270 -0
  142. package/.tfcore/utils/tf-verify-list.sh +13 -0
  143. package/.tfcore/utils/tf-verify-screens.mjs +283 -0
  144. package/.tfcore/utils/tf-verify-screens.sh +22 -0
  145. package/.tfcore/utils/tf-verify-tests.sh +123 -0
  146. package/.tfcore/utils/tf-verify-verdict.py +283 -0
  147. package/.tfcore/utils/tf-verify-verdict.sh +15 -0
  148. package/.tfcore/utils/tf-yolo.sh +59 -0
  149. package/README.md +114 -1149
  150. package/docs/TechieFlow-Installation.md +1 -5
  151. package/opencode.jsonc +13 -33
  152. package/package.json +1 -5
  153. package/scripts/install.mjs +52 -46
  154. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
  155. package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
  156. package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
  157. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
  158. package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
  159. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
  160. package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
  161. package/.codex/config.toml +0 -46
  162. package/.codex/hooks.json +0 -63
  163. package/.codex/rules/techieflow.rules +0 -8
  164. package/.tfcore/hooks/codex-adapter.py +0 -248
  165. package/.tfcore/tasks/advanced-elicitation.md +0 -117
  166. package/.tfcore/tasks/author-brd.md +0 -79
  167. package/.tfcore/tasks/document-project.md +0 -343
  168. package/.tfcore/tasks/execute-checklist.md +0 -86
  169. package/.tfcore/tasks/index-docs.md +0 -173
  170. package/.tfcore/tasks/kb-mode-interaction.md +0 -75
  171. package/.tfcore/tasks/shard-doc.md +0 -185
  172. package/.tfcore/utils/tf-codex-bind.py +0 -196
  173. package/.tfcore/utils/tf-codex-telemetry.py +0 -60
  174. package/WORKFLOW.html +0 -1986
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """tf-verify-list.py — the working list of a verify run (see tf-verify-list.sh).
3
+
4
+ bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
5
+
6
+ scope: ui (REQ-UI rows) · functional (REQ-FN, REQ-NFR, REQ-RAG) · all · a comma list of ids.
7
+ Reads the phase's checklist, the UIDesign, the BRD screens table and the UsageGuide, and prints:
8
+ - every row in scope with its status, acceptance line, screen, route, mockup and perf budget;
9
+ N/A rows are skipped, every other status is graded (a Verified row is re-confirmed)
10
+ - the screens to drive, each with its route, its mockup and the rows it owns; a dialog row
11
+ is listed under its parent screen (Schemas §2: a dialog is verified on its page)
12
+ - rows whose screen could not be resolved (graded by their test only)
13
+ - the test users from the UsageGuide
14
+ Writes the same as JSON (default tests/.artifacts/verify/list.json) for the other verify scripts.
15
+ Python 3 standard library only. Exit 0 printed, 2 could not run.
16
+ """
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ NA = {"n/a"}
23
+
24
+
25
+ def die(msg):
26
+ print(f"tf-verify-list: {msg}", file=sys.stderr)
27
+ sys.exit(2)
28
+
29
+
30
+ def read(p):
31
+ with open(p, encoding="utf-8-sig", errors="replace") as f:
32
+ return f.read()
33
+
34
+
35
+ def cfg(key, default):
36
+ p = os.path.join(".tfcore", "core-config.yaml")
37
+ if os.path.isfile(p):
38
+ m = re.search(rf"(?m)^{key}:\s*(\S+)", read(p))
39
+ if m and m.group(1) not in ("null", "~"):
40
+ return m.group(1).strip("'\"")
41
+ return default
42
+
43
+
44
+ def norm_status(s):
45
+ s = s.strip().strip("`* ").lower()
46
+ return re.sub(r"\s*\(.*$", "", s).strip()
47
+
48
+
49
+ def norm_name(s):
50
+ s = re.sub(r"\(.*?\)", "", s or "") # "Today (home)" -> "Today"
51
+ s = re.sub(r"\b(screen|page|dialog)\b", "", s, flags=re.I)
52
+ return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
53
+
54
+
55
+ def rows_of(text):
56
+ rows = []
57
+ for line in text.splitlines():
58
+ if re.match(r"^\s*\|\s*`?REQ-", line):
59
+ c = [x.strip() for x in line.strip().strip("|").split("|")]
60
+ if len(c) >= 3:
61
+ rid = c[0].strip("`* ").upper()
62
+ pct = re.sub(r"[^0-9]", "", c[3]) if len(c) > 3 else ""
63
+ rows.append({"id": rid, "class": re.match(r"REQ-([A-Z]+)-", rid).group(1) if re.match(r"REQ-([A-Z]+)-", rid) else "FN",
64
+ "title": c[1].strip("`* "), "status_raw": c[2].strip(), "status": norm_status(c[2]),
65
+ "pct": int(pct) if pct.isdigit() else None, "remarks": c[4] if len(c) > 4 else ""})
66
+ return rows
67
+
68
+
69
+ def entries_of(text):
70
+ """id -> {section, acceptance, mockup, brd, perf_budget}"""
71
+ out, current = {}, ""
72
+ lines = text.splitlines()
73
+ for i, line in enumerate(lines):
74
+ m = re.match(r"^##\s+(.+)$", line)
75
+ if m:
76
+ current = re.sub(r"^(?:page|screen)\s*:\s*", "", m.group(1).strip(), flags=re.I)
77
+ continue
78
+ a = re.search(r"<a id=['\"]d-(req-[a-z]+-\d+)['\"]", line, re.I)
79
+ if a:
80
+ rid = a.group(1).upper()
81
+ e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""}
82
+ for j in range(i, min(i + 14, len(lines))):
83
+ l = lines[j]
84
+ if re.search(r"\*Acceptance:?\*|^\s*[-*]\s*Acceptance:", l, re.I) and not e["acceptance"]:
85
+ e["acceptance"] = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", l).strip()
86
+ mm = re.search(r"((?:docs/)?mockups/[\w./-]+\.html)", l)
87
+ if mm and not e["mockup"]:
88
+ e["mockup"] = mm.group(1) if mm.group(1).startswith("docs/") else "docs/" + mm.group(1)
89
+ bm = re.search(r"BRD-\d+", l)
90
+ if bm and not e["brd"]:
91
+ e["brd"] = bm.group(0)
92
+ pb = re.search(r"perf-budget:\s*([^|`]+)", l)
93
+ if pb and not e["perf_budget"]:
94
+ e["perf_budget"] = pb.group(1).strip()
95
+ if j > i and re.match(r"^\s*(?:[-*]\s*)?<a id=|^## ", l):
96
+ break # the next entry starts: nothing below belongs to this row
97
+ out[rid] = e
98
+ return out
99
+
100
+
101
+ def screens_of_uidesign(path):
102
+ """[{name, route, mockup}] from '### Screen: Name (/route)' sections."""
103
+ out = []
104
+ if not os.path.isfile(path):
105
+ return out
106
+ txt = read(path)
107
+ parts = re.split(r"(?m)^(?=###\s+Screen:)", txt)
108
+ for p in parts:
109
+ m = re.match(r"###\s+Screen:\s*(.+?)\s*\(`?([^)`]+)`?\)\s*$", p.splitlines()[0] if p.strip() else "")
110
+ if not m:
111
+ continue
112
+ mk = re.search(r"((?:docs/)?mockups/[\w./-]+\.html)", p)
113
+ mock = mk.group(1) if mk else ""
114
+ if mock and not mock.startswith("docs/"):
115
+ mock = "docs/" + mock
116
+ out.append({"name": m.group(1).strip(), "route": m.group(2).strip(), "mockup": mock})
117
+ return out
118
+
119
+
120
+ def dialogs_of_brd(path):
121
+ """name -> parent route, from the BRD screens table rows whose Route cell reads 'on /route'."""
122
+ out = {}
123
+ if not os.path.isfile(path):
124
+ return out
125
+ for line in read(path).splitlines():
126
+ if not line.startswith("|"):
127
+ continue
128
+ c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
129
+ if len(c) >= 2 and re.match(r"(?i)^on\s+/", c[1]):
130
+ out[norm_name(c[0])] = re.sub(r"(?i)^on\s+", "", c[1]).strip()
131
+ return out
132
+
133
+
134
+ def test_users(path):
135
+ users = []
136
+ if not os.path.isfile(path):
137
+ return users
138
+ m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(path))
139
+ if not m:
140
+ return users
141
+ for line in m.group(1).splitlines():
142
+ c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
143
+ if len(c) >= 4 and c[0].isdigit():
144
+ users.append({"user": c[1], "password_source": c[2], "role": c[3]})
145
+ return users
146
+
147
+
148
+ def in_scope(rid, scope):
149
+ s = scope.strip().lower()
150
+ if s == "all":
151
+ return True
152
+ if s == "ui":
153
+ return rid.startswith("REQ-UI-")
154
+ if s == "functional":
155
+ return rid.startswith(("REQ-FN-", "REQ-NFR-", "REQ-RAG-"))
156
+ ids = {x.strip().upper() for x in re.split(r"[,\s]+", scope) if x.strip()}
157
+ return rid in ids
158
+
159
+
160
+ def screen_from_acceptance(acc):
161
+ """'When the user taps Save on Entry Editor, then ...' -> 'Entry Editor'."""
162
+ if not acc:
163
+ return ""
164
+ head = re.split(r",\s*then\b|\bthen\b", acc, maxsplit=1)[0]
165
+ hits = re.findall(r"\bon\s+(?:the\s+)?([A-Z][\w'()/&-]*(?:\s+[\w'()/&-]+){0,4}?)(?=\s*(?:,|$|\s+(?:and|with|for|then)\b))", head)
166
+ return hits[-1].strip() if hits else ""
167
+
168
+
169
+ def main(argv):
170
+ if len(argv) < 3 or argv[1] in ("-h", "--help"):
171
+ print(__doc__)
172
+ return 0
173
+ app, scope = argv[1], argv[2]
174
+ phase = int(cfg("appPhase", "1"))
175
+ if "--phase" in argv:
176
+ phase = int(argv[argv.index("--phase") + 1])
177
+ out_path = os.path.join("tests", ".artifacts", "verify", "list.json")
178
+ if "--json-out" in argv:
179
+ out_path = argv[argv.index("--json-out") + 1]
180
+ pfx = f"{app}-" if phase <= 1 else f"{app}-P{phase}-"
181
+ cl = os.path.join("docs", f"{pfx}Checklist.md")
182
+ if not os.path.isfile(cl):
183
+ die(f"{cl} does not exist")
184
+ text = read(cl)
185
+ rows = rows_of(text)
186
+ if not rows:
187
+ die(f"{cl} has no REQ rows")
188
+ ent = entries_of(text)
189
+ screens = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md"))
190
+ dialogs = dialogs_of_brd(os.path.join("docs", f"{pfx}BRD.md"))
191
+ users = test_users(os.path.join("docs", f"{app}-UsageGuide.md"))
192
+ by_name = {norm_name(s["name"]): s for s in screens}
193
+ by_mock = {s["mockup"]: s for s in screens if s["mockup"]}
194
+ by_route = {s["route"].lower(): s for s in screens}
195
+
196
+ work, skipped, unresolved = [], 0, []
197
+ for r in rows:
198
+ if not in_scope(r["id"], scope):
199
+ continue
200
+ if r["status"] in NA:
201
+ skipped += 1
202
+ continue
203
+ e = ent.get(r["id"], {"section": "", "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""})
204
+ row = dict(r, acceptance=e["acceptance"], mockup=e["mockup"], brd=e["brd"], perf_budget=e["perf_budget"],
205
+ section=e["section"], screen="", route="", dialog="")
206
+ cand = [screen_from_acceptance(e["acceptance"]), e["section"], r["title"]]
207
+ for c in cand:
208
+ n = norm_name(c)
209
+ if not n:
210
+ continue
211
+ if n in by_name:
212
+ row["screen"], row["route"] = by_name[n]["name"], by_name[n]["route"]
213
+ break
214
+ if n in dialogs and dialogs[n].lower() in by_route:
215
+ s = by_route[dialogs[n].lower()]
216
+ row["screen"], row["route"], row["dialog"] = s["name"], s["route"], c.strip()
217
+ break
218
+ if not row["screen"] and e["mockup"] in by_mock:
219
+ s = by_mock[e["mockup"]]
220
+ row["screen"], row["route"] = s["name"], s["route"]
221
+ if not row["screen"] and row["class"] != "NFR":
222
+ unresolved.append(row["id"])
223
+ work.append(row)
224
+
225
+ screen_list = []
226
+ for s in screens:
227
+ owned = [r["id"] for r in work if r["screen"] == s["name"]]
228
+ if owned:
229
+ screen_list.append(dict(s, rows=owned))
230
+
231
+ print(f"# tf-verify-list — {app} — {cl} — phase {phase} — scope {scope}")
232
+ counts = {}
233
+ for r in work:
234
+ counts[r["status_raw"] or "?"] = counts.get(r["status_raw"] or "?", 0) + 1
235
+ print(f"Rows to grade: {len(work)} ({', '.join(f'{v} {k}' for k, v in sorted(counts.items()))}); {skipped} N/A skipped; {len(rows)} in the checklist")
236
+ if not work:
237
+ print("NOTHING: no row in scope. Run the status gate and stop.")
238
+ print()
239
+ print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the UIDesign)")
240
+ for s in screen_list:
241
+ print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'} — rows {', '.join(s['rows'])}")
242
+ print()
243
+ print("## Rows")
244
+ for r in work:
245
+ where = f"{r['screen']} {r['route']}" + (f" — dialog {r['dialog']}" if r["dialog"] else "") if r["screen"] else "no screen resolved"
246
+ extra = f" — perf-budget: {r['perf_budget']}" if r["perf_budget"] else ""
247
+ print(f"- {r['id']} [{r['status_raw']}] {r['title']} — {where}{extra}")
248
+ print(f" acceptance: {r['acceptance'] or 'MISSING — the row cannot be graded; fix the checklist'}")
249
+ if unresolved:
250
+ print()
251
+ print(f"## No screen resolved ({len(unresolved)}): graded by their test only — " + ", ".join(unresolved))
252
+ print()
253
+ print("## Test users (UsageGuide)")
254
+ for u in users:
255
+ print(f"- {u['user']} — {u['role']} — password: {u['password_source']}")
256
+ if not users:
257
+ print("- none in the UsageGuide: the smoke policy says what to do")
258
+
259
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
260
+ with open(out_path, "w", encoding="utf-8") as f:
261
+ json.dump({"app": app, "scope": scope, "phase": phase, "kind": cfg("appKind", "app"), "checklist": cl,
262
+ "rows": work, "screens": screen_list, "unresolved": unresolved, "test_users": users,
263
+ "skipped_na": skipped, "total": len(rows)}, f, indent=1)
264
+ print()
265
+ print(f"JSON: {out_path}")
266
+ return 0
267
+
268
+
269
+ if __name__ == "__main__":
270
+ sys.exit(main(sys.argv))
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-list.sh — the working list of a verify run (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
5
+ #
6
+ # scope: ui · functional · all · a comma list of REQ ids. Prints every row to grade with its
7
+ # acceptance line, screen, route, mockup and perf budget, the screens to drive, and the test
8
+ # users; writes tests/.artifacts/verify/list.json for the other verify scripts. Skips N/A rows
9
+ # only. Writes nothing else. See tf-verify-list.py for the details.
10
+ set -u
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ command -v python3 >/dev/null 2>&1 || { echo "tf-verify-list: python3 is required" >&2; exit 2; }
13
+ exec python3 "$HERE/tf-verify-list.py" "$@"
@@ -0,0 +1,283 @@
1
+ // tf-verify-screens.mjs — drive every screen and check that it renders and looks right.
2
+ // Run through tf-verify-screens.sh, not directly. Sitting 4c, 2026-09-06.
3
+ //
4
+ // Two of the seven verify checks, over one browser session:
5
+ // render every control the mockup anchors (data-testid) is present and shows something; no
6
+ // table with a header and no rows; no blank page; no Blazor error; no console error.
7
+ // visual no two visible controls overlap; no anchored control has zero size or sits off-screen;
8
+ // no horizontal overflow; the page has a stylesheet.
9
+ // At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
10
+ // visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
11
+ // zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
12
+ // script reads and prints one line per screen. It never decides a row's status.
13
+ //
14
+ // Modes: --base URL (a served app, a new browser) or --cdp URL (attach to a running Blazor Hybrid
15
+ // app's WebView2 over its DevTools port; screens are reached by pushState, the way the router does).
16
+
17
+ import { chromium } from 'playwright';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+
21
+ const argv = process.argv.slice(2);
22
+ const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
23
+ const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
24
+
25
+ const BASE = (arg('--base') || '').replace(/\/$/, '');
26
+ const CDP = arg('--cdp') || '';
27
+ const LIST = arg('--list');
28
+ const MOCKUPS = arg('--mockups', 'docs/mockups');
29
+ const WIDTHS = (arg('--widths', '1280,390')).split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
30
+ const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
31
+ const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
32
+ const LOGIN_PATH = arg('--login-path');
33
+ const USER = arg('--user'); const PASS = arg('--password');
34
+ const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
35
+ const SETTLE = parseInt(arg('--wait', '1500'), 10);
36
+ const ATTR = arg('--testid-attr', 'data-testid');
37
+
38
+ if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
39
+
40
+ // ---------------------------------------------------------------- screens
41
+ let screens = [];
42
+ if (LIST) {
43
+ const l = JSON.parse(readFileSync(LIST, 'utf8'));
44
+ screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [] }));
45
+ }
46
+ for (const s of args('--screen')) {
47
+ const m = s.match(/^([^=]+)=(.+)$/);
48
+ if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
49
+ }
50
+ if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
51
+
52
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
53
+ const anchorsOf = (mockup) => {
54
+ if (!mockup || !existsSync(mockup)) return null;
55
+ const html = readFileSync(mockup, 'utf8');
56
+ const re = new RegExp(`${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
57
+ const out = new Set(); let m;
58
+ while ((m = re.exec(html))) out.add(m[1]);
59
+ return [...out];
60
+ };
61
+
62
+ // ---------------------------------------------------------------- browser
63
+ let browser, cdpPage = null;
64
+ if (CDP) {
65
+ try { browser = await chromium.connectOverCDP(CDP, { timeout: 15000 }); }
66
+ catch (e) { console.log(`UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); writeOut({ unreachable: true, error: e.message }); process.exit(2); }
67
+ const ctx = browser.contexts()[0];
68
+ cdpPage = ctx && ctx.pages()[0];
69
+ if (!cdpPage) { console.log(`UNREACHABLE cdp ${CDP}: no page in the attached app`); writeOut({ unreachable: true }); process.exit(2); }
70
+ } else {
71
+ browser = await chromium.launch();
72
+ }
73
+
74
+ function writeOut(extra, results = [], login = null) {
75
+ const summary = {
76
+ mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
77
+ screens: results,
78
+ summary: {
79
+ screens: results.length,
80
+ render_ok: results.filter((r) => r.render === 'OK').length,
81
+ render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
82
+ unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
83
+ visual_ok: results.filter((r) => r.visual === 'OK').length,
84
+ visual_fail: results.filter((r) => r.visual === 'FAIL').length,
85
+ },
86
+ ...extra,
87
+ };
88
+ mkdirSync(dirname(resolve(OUT)), { recursive: true });
89
+ writeFileSync(OUT, JSON.stringify(summary, null, 2));
90
+ return summary;
91
+ }
92
+
93
+ async function login(page) {
94
+ if (!LOGIN_PATH || !USER) return { attempted: false };
95
+ try {
96
+ await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
97
+ const user = page.locator(`[${ATTR}*="user" i], [${ATTR}*="email" i], input[type="email"], input[name*="user" i], input[name*="email" i], input[id*="user" i], input[id*="email" i], input[type="text"]`).first();
98
+ const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
99
+ const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
100
+ await user.fill(USER, { timeout: 10000 });
101
+ await pass.fill(PASS || '', { timeout: 10000 });
102
+ await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
103
+ await page.waitForTimeout(SETTLE);
104
+ const url = page.url();
105
+ const still = url.startsWith(BASE + LOGIN_PATH);
106
+ return { attempted: true, ok: !still, url };
107
+ } catch (e) {
108
+ return { attempted: true, ok: false, error: e.message.split('\n')[0] };
109
+ }
110
+ }
111
+
112
+ async function contextFor(width) {
113
+ if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
114
+ const opts = { viewport: { width, height: width < 600 ? 844 : 800 } };
115
+ if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
116
+ const ctx = await browser.newContext(opts);
117
+ if (COOKIE) {
118
+ const u = new URL(BASE);
119
+ await ctx.addCookies(COOKIE.split(';').map((kv) => { const [k, ...v] = kv.trim().split('='); return { name: k, value: v.join('='), domain: u.hostname, path: '/' }; }));
120
+ }
121
+ const page = await ctx.newPage();
122
+ return { page, close: () => ctx.close() };
123
+ }
124
+
125
+ async function navigate(page, route) {
126
+ if (CDP) {
127
+ try {
128
+ await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
129
+ await page.waitForTimeout(SETTLE);
130
+ return { status: 200, url: page.url() };
131
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
132
+ }
133
+ try {
134
+ const resp = await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
135
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
136
+ await page.waitForTimeout(SETTLE);
137
+ return { status: resp ? resp.status() : 0, url: page.url() };
138
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
139
+ }
140
+
141
+ // what the page says about itself: anchors, tables, text, boxes
142
+ async function inspect(page, anchors, attr) {
143
+ return page.evaluate(({ anchors, attr }) => {
144
+ const vis = (el) => {
145
+ const cs = getComputedStyle(el);
146
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
147
+ if (el.getAttribute('aria-hidden') === 'true') return false;
148
+ const r = el.getBoundingClientRect();
149
+ if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
150
+ return true;
151
+ };
152
+ const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
153
+ const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
154
+ || [...document.querySelectorAll('style')].some(sheetOk);
155
+ const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
156
+ const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
157
+ controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
158
+ blazor_error: !!document.querySelector('#blazor-error-ui') && vis(document.querySelector('#blazor-error-ui')),
159
+ overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
160
+ styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
161
+ for (const id of anchors || []) {
162
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
163
+ if (!el) { out.anchors.push({ id, present: false }); continue; }
164
+ const tag = el.tagName.toLowerCase();
165
+ const r = el.getBoundingClientRect();
166
+ const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
167
+ const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe'].includes(tag) || el.querySelector('img,svg,canvas,input,select,textarea,button,a') !== null;
168
+ out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top) });
169
+ }
170
+ for (const t of document.querySelectorAll('table')) {
171
+ if (!vis(t)) continue;
172
+ const rows = t.querySelectorAll('tbody tr').length;
173
+ const head = t.querySelector('thead') !== null;
174
+ const cells = [...t.querySelectorAll('tbody td')].filter((c) => (c.innerText || '').trim().length > 0).length;
175
+ out.tables.push({ id: t.getAttribute(attr) || t.id || '', rows, head, filled_cells: cells });
176
+ }
177
+ const sel = `[${attr}], a, button, input, select, textarea, [role="button"], h1, h2`;
178
+ const els = [...document.querySelectorAll(sel)].filter(vis);
179
+ els.forEach((el, i) => {
180
+ const r = el.getBoundingClientRect();
181
+ out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
182
+ x: r.left, y: r.top, w: r.width, h: r.height, anchored: el.hasAttribute(attr) });
183
+ });
184
+ // ancestry so a button inside its card is never an "overlap"
185
+ out.rel = [];
186
+ for (let a = 0; a < els.length; a++) for (let b = a + 1; b < els.length; b++) {
187
+ if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
188
+ }
189
+ return out;
190
+ }, { anchors, attr });
191
+ }
192
+
193
+ function grade(info, consoleErrors, width) {
194
+ const findings = [];
195
+ if (info.blazor_error) findings.push({ check: 'render', class: 'exception', detail: 'the Blazor error bar is showing' });
196
+ // a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
197
+ const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
198
+ for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
199
+ if (info.text_len < 15 && info.controls === 0 && !info.anchors.some((a) => a.present)) findings.push({ check: 'render', class: 'blank-data', detail: `the page is blank (${info.text_len} characters of text, no control)` });
200
+ for (const a of info.anchors) {
201
+ if (!a.present) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
202
+ else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
203
+ }
204
+ for (const t of info.tables) {
205
+ if (t.head && t.rows === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `table ${t.id || '(unnamed)'} has a header and no rows` });
206
+ else if (t.rows > 0 && t.filled_cells === 0) findings.push({ check: 'render', class: 'blank-data', detail: `table ${t.id || '(unnamed)'} has ${t.rows} rows and every cell is blank` });
207
+ }
208
+ if (!info.styled) findings.push({ check: 'visual', class: 'other', detail: 'no stylesheet is loaded: the page is unstyled' + (info.failed_sheets.length ? ` (${info.failed_sheets.join(', ')} did not load)` : '') });
209
+ if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
210
+ for (const a of info.anchors) {
211
+ if (a.present && a.visible && (a.w === 0 || a.h === 0)) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${a.id}" has zero ${a.w === 0 ? 'width' : 'height'}` });
212
+ if (a.present && a.visible && a.w > 0 && (a.x + a.w > info.vw + 2 || a.x < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
213
+ }
214
+ const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
215
+ const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
216
+ let overlaps = 0;
217
+ for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
218
+ const a = boxes[i], b = boxes[j];
219
+ if (rel.has(`${a.i}-${b.i}`)) continue;
220
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
221
+ const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
222
+ if (ix <= 4 || iy <= 4) continue;
223
+ const inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
224
+ if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
225
+ if (inter / small < 0.25) continue; // a touch, not an overlap
226
+ overlaps++;
227
+ const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
228
+ findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
229
+ }
230
+ const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
231
+ : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
232
+ const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
233
+ return { render, visual, findings };
234
+ }
235
+
236
+ // ---------------------------------------------------------------- run
237
+ mkdirSync(SHOTS, { recursive: true });
238
+ const results = [];
239
+ let loginResult = null;
240
+ let firstUnreachable = false;
241
+ for (const width of WIDTHS) {
242
+ const { page, close } = await contextFor(width);
243
+ const consoleErrors = [];
244
+ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
245
+ page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
246
+ if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
247
+ for (const s of screens) {
248
+ let r = results.find((x) => x.name === s.name);
249
+ if (!r) { r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r); }
250
+ consoleErrors.length = 0;
251
+ const nav = await navigate(page, s.route);
252
+ const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
253
+ const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
254
+ const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
255
+ if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
256
+ entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
257
+ entry.findings = [{ check: 'render', class: 'other', detail: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}` : redirectedToLogin ? `${s.route} redirected to the sign-in page (not signed in)` : `${s.route} answered HTTP ${nav.status}` }];
258
+ if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
259
+ } else {
260
+ const info = await inspect(page, r.anchors || [], ATTR);
261
+ const g = grade(info, consoleErrors, width);
262
+ Object.assign(entry, g);
263
+ entry.console_errors = consoleErrors.slice(0, 5);
264
+ entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
265
+ }
266
+ try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
267
+ r.widths.push(entry);
268
+ }
269
+ await close();
270
+ }
271
+ for (const r of results) {
272
+ const rs = r.widths.map((w) => w.render);
273
+ r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
274
+ r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
275
+ r.anchors_n = (r.anchors || []).length;
276
+ const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
277
+ console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
278
+ }
279
+ const out = writeOut({}, results, loginResult);
280
+ if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
281
+ console.log(`screens ${out.summary.screens}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed; screenshots ${SHOTS}; JSON ${OUT}`);
282
+ if (!CDP) await browser.close(); else await browser.close().catch(() => {});
283
+ process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-screens.sh — drive every screen and check render and visual truth (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-screens.sh --list tests/.artifacts/verify/list.json --base http://localhost:5099 \
5
+ # [--login-path /login --user ravi --password '…'] [--cookie 'k=v'] [--storage-state file]
6
+ # bash .tfcore/utils/tf-verify-screens.sh --screen home=/ --screen posts=/posts --base URL # a smoke: changed screens only
7
+ # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # a Blazor Hybrid Windows head
8
+ #
9
+ # For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
10
+ # something, no header-only table, no blank page, no Blazor or console error (render); nothing
11
+ # overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
12
+ # (visual). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
13
+ # writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
14
+ # row is written Implemented, and what the verdict script reads. It decides no row's status.
15
+ # Exit 0 every screen OK · 5 a screen failed · 2 the app was unreachable · 4 Playwright missing.
16
+ set -uo pipefail
17
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ case "${1:-}" in -h|--help|"") sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:+0}${1:-3}" ;; esac
19
+ command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
20
+ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
21
+ echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
22
+ exec node "$HERE/tf-verify-screens.mjs" "$@"