@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.
- package/.claude/commands/TechieFlow/agents/analyst.md +0 -5
- package/.claude/commands/TechieFlow/agents/architect.md +0 -6
- package/.claude/commands/TechieFlow/agents/flow-master.md +41 -215
- package/.claude/commands/TechieFlow/agents/verifier.md +23 -64
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +20 -143
- package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
- package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
- package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +1 -1
- package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
- package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
- package/.opencode/plugin/techieflow.js +39 -12
- package/.tfcore/agents/analyst.md +0 -5
- package/.tfcore/agents/architect.md +0 -6
- package/.tfcore/agents/flow-master.md +41 -215
- package/.tfcore/agents/verifier.md +23 -64
- package/.tfcore/core-config.yaml +10 -0
- package/.tfcore/hooks/block-git.sh +4 -4
- package/.tfcore/hooks/guard-artifacts.sh +2 -2
- package/.tfcore/hooks/guard-build.sh +76 -0
- package/.tfcore/hooks/guard-db.sh +90 -0
- package/.tfcore/hooks/guard-metrics.sh +56 -0
- package/.tfcore/hooks/guard-status-html.sh +116 -71
- package/.tfcore/hooks/guard-status.sh +43 -16
- package/.tfcore/hooks/guard-verify.sh +70 -48
- package/.tfcore/hooks/sweep-artifacts.sh +2 -2
- package/.tfcore/install-manifest.yaml +0 -18
- package/.tfcore/routing.yaml +25 -14
- package/.tfcore/standards/coding-standards-core.md +61 -0
- package/.tfcore/standards/coding-standards-dotnet.md +93 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
- package/.tfcore/tasks/_smoke-test-policy.md +20 -84
- package/.tfcore/tasks/_status-update-gate.md +10 -74
- package/.tfcore/tasks/_yolo-mode.md +20 -58
- package/.tfcore/tasks/amend-docs.md +20 -143
- package/.tfcore/tasks/build-phase.md +14 -224
- package/.tfcore/tasks/day1-brownfield.md +31 -505
- package/.tfcore/tasks/day1-greenfield.md +28 -213
- package/.tfcore/tasks/deploy-checklist.md +19 -0
- package/.tfcore/tasks/devguide.md +10 -219
- package/.tfcore/tasks/fix-issues.md +10 -129
- package/.tfcore/tasks/log-miss.md +19 -158
- package/.tfcore/tasks/metrics-report.md +1 -1
- package/.tfcore/tasks/mockups.md +12 -117
- package/.tfcore/tasks/refresh-status.md +9 -130
- package/.tfcore/tasks/split-brd.md +10 -126
- package/.tfcore/tasks/triage-and-fix.md +14 -0
- package/.tfcore/tasks/triage-issues.md +11 -152
- package/.tfcore/tasks/verify-phase.md +36 -572
- package/.tfcore/telemetry/SCHEMA.md +43 -7
- package/.tfcore/telemetry/tf-metrics.sh +152 -17
- package/.tfcore/templates/project-brief-tmpl.yaml +1 -1
- package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
- package/.tfcore/templates/stack-questions.md +77 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
- package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
- package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
- package/.tfcore/user-guide.md +0 -23
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.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-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.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-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.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-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-brd-status.py +176 -0
- package/.tfcore/utils/tf-brd-status.sh +19 -0
- package/.tfcore/utils/tf-build-list.py +191 -0
- package/.tfcore/utils/tf-build-list.sh +20 -0
- package/.tfcore/utils/tf-build.sh +143 -0
- package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
- package/.tfcore/utils/tf-checklist-edit.py +133 -0
- package/.tfcore/utils/tf-day1-files.py +208 -0
- package/.tfcore/utils/tf-day1-files.sh +20 -0
- package/.tfcore/utils/tf-devguide-list.py +249 -0
- package/.tfcore/utils/tf-devguide-list.sh +18 -0
- package/.tfcore/utils/tf-doc-check.py +1267 -0
- package/.tfcore/utils/tf-doc-check.sh +29 -0
- package/.tfcore/utils/tf-emit.sh +203 -13
- package/.tfcore/utils/tf-fix-close.sh +83 -0
- package/.tfcore/utils/tf-goal.sh +193 -62
- package/.tfcore/utils/tf-harness.sh +4 -9
- package/.tfcore/utils/tf-log-miss.py +197 -0
- package/.tfcore/utils/tf-log-miss.sh +14 -0
- package/.tfcore/utils/tf-misses-md.py +211 -0
- package/.tfcore/utils/tf-misses-md.sh +13 -0
- package/.tfcore/utils/tf-mockups-locate.py +66 -0
- package/.tfcore/utils/tf-mockups-locate.sh +18 -0
- package/.tfcore/utils/tf-opencode-audit.py +23 -7
- package/.tfcore/utils/tf-perf-grade.py +97 -0
- package/.tfcore/utils/tf-perf-grade.sh +12 -0
- package/.tfcore/utils/tf-phase.sh +74 -0
- package/.tfcore/utils/tf-routing-bind.sh +10 -5
- package/.tfcore/utils/tf-routing.sh +6 -7
- package/.tfcore/utils/tf-split-brd.py +304 -0
- package/.tfcore/utils/tf-split-brd.sh +22 -0
- package/.tfcore/utils/tf-status-evidence.sh +68 -0
- package/.tfcore/utils/tf-status-facts.py +309 -0
- package/.tfcore/utils/tf-status-facts.sh +20 -0
- package/.tfcore/utils/tf-triage.py +243 -0
- package/.tfcore/utils/tf-triage.sh +16 -0
- package/.tfcore/utils/tf-verify-boot.sh +250 -0
- package/.tfcore/utils/tf-verify-emit.sh +110 -0
- package/.tfcore/utils/tf-verify-env.sh +88 -0
- package/.tfcore/utils/tf-verify-list.py +270 -0
- package/.tfcore/utils/tf-verify-list.sh +13 -0
- package/.tfcore/utils/tf-verify-screens.mjs +283 -0
- package/.tfcore/utils/tf-verify-screens.sh +22 -0
- package/.tfcore/utils/tf-verify-tests.sh +123 -0
- package/.tfcore/utils/tf-verify-verdict.py +283 -0
- package/.tfcore/utils/tf-verify-verdict.sh +15 -0
- package/.tfcore/utils/tf-yolo.sh +59 -0
- package/README.md +114 -1149
- package/docs/TechieFlow-Installation.md +1 -5
- package/opencode.jsonc +13 -33
- package/package.json +1 -5
- package/scripts/install.mjs +52 -46
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
- package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
- package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
- package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
- package/.codex/config.toml +0 -46
- package/.codex/hooks.json +0 -63
- package/.codex/rules/techieflow.rules +0 -8
- package/.tfcore/hooks/codex-adapter.py +0 -248
- package/.tfcore/tasks/advanced-elicitation.md +0 -117
- package/.tfcore/tasks/author-brd.md +0 -79
- package/.tfcore/tasks/document-project.md +0 -343
- package/.tfcore/tasks/execute-checklist.md +0 -86
- package/.tfcore/tasks/index-docs.md +0 -173
- package/.tfcore/tasks/kb-mode-interaction.md +0 -75
- package/.tfcore/tasks/shard-doc.md +0 -185
- package/.tfcore/utils/tf-codex-bind.py +0 -196
- package/.tfcore/utils/tf-codex-telemetry.py +0 -60
- 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" "$@"
|