@techierathore/techieflow 1.0.2 → 1.0.4
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 +5 -13
- package/.claude/commands/TechieFlow/agents/architect.md +1 -16
- package/.claude/commands/TechieFlow/agents/flow-master.md +42 -215
- package/.claude/commands/TechieFlow/agents/verifier.md +24 -64
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
- 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 +21 -143
- package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- 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/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
- package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
- package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +3 -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 +40 -13
- package/.tfcore/agents/analyst.md +5 -13
- package/.tfcore/agents/architect.md +1 -16
- package/.tfcore/agents/flow-master.md +42 -215
- package/.tfcore/agents/verifier.md +24 -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 +68 -0
- package/.tfcore/hooks/guard-status-html.sh +116 -71
- package/.tfcore/hooks/guard-status.sh +43 -16
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- 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 +86 -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/_owner-language.md +26 -0
- 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 +21 -143
- package/.tfcore/tasks/build-phase.md +14 -224
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- 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/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +10 -129
- package/.tfcore/tasks/generate-html.md +5 -1
- package/.tfcore/tasks/handoff-phase.md +18 -16
- package/.tfcore/tasks/log-miss.md +19 -158
- package/.tfcore/tasks/metrics-report.md +3 -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 +100 -8
- package/.tfcore/telemetry/tf-metrics.sh +564 -32
- 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-brief-tmpl.md +65 -0
- 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-decision-request-tmpl.md +85 -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-library-feedback-tmpl.md +86 -31
- 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/html-render-shell.md +1 -1
- 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 +211 -0
- package/.tfcore/utils/tf-build-list.sh +21 -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 +1369 -0
- package/.tfcore/utils/tf-doc-check.sh +29 -0
- package/.tfcore/utils/tf-emit.sh +545 -18
- package/.tfcore/utils/tf-fix-close.sh +115 -0
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +284 -66
- 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 +208 -0
- package/.tfcore/utils/tf-misses-md.sh +14 -0
- package/.tfcore/utils/tf-mockups-locate.py +66 -0
- package/.tfcore/utils/tf-mockups-locate.sh +18 -0
- package/.tfcore/utils/tf-model-pick.sh +475 -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 +92 -0
- package/.tfcore/utils/tf-render-html.py +20 -0
- package/.tfcore/utils/tf-routing-bind.sh +97 -6
- package/.tfcore/utils/tf-routing.sh +13 -7
- package/.tfcore/utils/tf-selfcheck.sh +323 -0
- package/.tfcore/utils/tf-split-brd.py +309 -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 +358 -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 +355 -0
- package/.tfcore/utils/tf-verify-screens.sh +31 -0
- package/.tfcore/utils/tf-verify-tests.sh +166 -0
- package/.tfcore/utils/tf-verify-verdict.py +292 -0
- package/.tfcore/utils/tf-verify-verdict.sh +15 -0
- package/.tfcore/utils/tf-yolo.sh +59 -0
- package/README.md +120 -1145
- package/docs/TechieFlow-Installation.md +1 -5
- package/opencode.jsonc +37 -33
- package/package.json +1 -5
- package/scripts/install.mjs +56 -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/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
- 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,355 @@
|
|
|
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 error banner; 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. Overlap and off-screen are measured
|
|
9
|
+
// on the rectangle an element PAINTS in — its box clipped by every scrolling ancestor —
|
|
10
|
+
// so a wide table inside a horizontal scroller is not read as covering its neighbour.
|
|
11
|
+
// Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
|
|
12
|
+
// the page: it waits for content to appear, knowing nothing about how the page is built.
|
|
13
|
+
// At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
|
|
14
|
+
// visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
|
|
15
|
+
// zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
|
|
16
|
+
// script reads and prints one line per screen. It never decides a row's status.
|
|
17
|
+
//
|
|
18
|
+
// Modes: --base URL (a served app, a new browser) or --cdp URL (attach to an already-running
|
|
19
|
+
// embedded-browser desktop head over its DevTools port; screens are reached by pushState, the way
|
|
20
|
+
// the router does).
|
|
21
|
+
|
|
22
|
+
import { chromium } from 'playwright';
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
28
|
+
const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
|
|
29
|
+
|
|
30
|
+
const BASE = (arg('--base') || '').replace(/\/$/, '');
|
|
31
|
+
const CDP = arg('--cdp') || '';
|
|
32
|
+
const LIST = arg('--list');
|
|
33
|
+
const MOCKUPS = arg('--mockups', 'docs/mockups');
|
|
34
|
+
const WIDTHS = (arg('--widths', '1280,390')).split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
|
|
35
|
+
const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
|
|
36
|
+
const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
|
|
37
|
+
const LOGIN_PATH = arg('--login-path');
|
|
38
|
+
const USER = arg('--user'); const PASS = arg('--password');
|
|
39
|
+
const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
|
|
40
|
+
const SETTLE = parseInt(arg('--wait', '1500'), 10);
|
|
41
|
+
// how long to keep waiting for the FIRST render after the page has settled. Plenty of stacks
|
|
42
|
+
// paint after the network goes quiet — a server-held connection that renders once it is up, a
|
|
43
|
+
// client router resolving its first route, a lazily loaded bundle — so measuring at
|
|
44
|
+
// domcontentloaded, at networkidle or even after --wait can read an empty document and report
|
|
45
|
+
// every anchored control as missing while the tool's own screenshot shows them (TF-021, TfLens
|
|
46
|
+
// 2026-09-09). Nothing here knows which stack it is looking at: it waits for CONTENT.
|
|
47
|
+
const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
|
|
48
|
+
const ATTR = arg('--testid-attr', 'data-testid');
|
|
49
|
+
// The banner a stack shows when its client side has fallen over. There is no neutral way to
|
|
50
|
+
// detect one -- every stack names its own -- so this is a LIST a project extends rather than a
|
|
51
|
+
// selector the framework assumes: --error-selector '.my-crash-banner', repeatable. The two
|
|
52
|
+
// defaults are one widely used framework's id and a plain attribute any project can put on its
|
|
53
|
+
// own banner. A project on neither still gets every other render check; it simply cannot be
|
|
54
|
+
// told about a banner nobody named.
|
|
55
|
+
const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
|
|
56
|
+
|
|
57
|
+
if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- screens
|
|
60
|
+
let screens = [];
|
|
61
|
+
if (LIST) {
|
|
62
|
+
const l = JSON.parse(readFileSync(LIST, 'utf8'));
|
|
63
|
+
screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [] }));
|
|
64
|
+
}
|
|
65
|
+
for (const s of args('--screen')) {
|
|
66
|
+
const m = s.match(/^([^=]+)=(.+)$/);
|
|
67
|
+
if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
|
|
68
|
+
}
|
|
69
|
+
if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
|
|
70
|
+
|
|
71
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
72
|
+
const anchorsOf = (mockup) => {
|
|
73
|
+
if (!mockup || !existsSync(mockup)) return null;
|
|
74
|
+
const html = readFileSync(mockup, 'utf8');
|
|
75
|
+
const re = new RegExp(`${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
|
|
76
|
+
const out = new Set(); let m;
|
|
77
|
+
while ((m = re.exec(html))) out.add(m[1]);
|
|
78
|
+
return [...out];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- browser
|
|
82
|
+
let browser, cdpPage = null;
|
|
83
|
+
if (CDP) {
|
|
84
|
+
try { browser = await chromium.connectOverCDP(CDP, { timeout: 15000 }); }
|
|
85
|
+
catch (e) { console.log(`UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); writeOut({ unreachable: true, error: e.message }); process.exit(2); }
|
|
86
|
+
const ctx = browser.contexts()[0];
|
|
87
|
+
cdpPage = ctx && ctx.pages()[0];
|
|
88
|
+
if (!cdpPage) { console.log(`UNREACHABLE cdp ${CDP}: no page in the attached app`); writeOut({ unreachable: true }); process.exit(2); }
|
|
89
|
+
} else {
|
|
90
|
+
browser = await chromium.launch();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeOut(extra, results = [], login = null) {
|
|
94
|
+
const summary = {
|
|
95
|
+
mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
|
|
96
|
+
screens: results,
|
|
97
|
+
summary: {
|
|
98
|
+
screens: results.length,
|
|
99
|
+
render_ok: results.filter((r) => r.render === 'OK').length,
|
|
100
|
+
render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
|
|
101
|
+
unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
|
|
102
|
+
visual_ok: results.filter((r) => r.visual === 'OK').length,
|
|
103
|
+
visual_fail: results.filter((r) => r.visual === 'FAIL').length,
|
|
104
|
+
},
|
|
105
|
+
...extra,
|
|
106
|
+
};
|
|
107
|
+
mkdirSync(dirname(resolve(OUT)), { recursive: true });
|
|
108
|
+
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
|
109
|
+
return summary;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function login(page) {
|
|
113
|
+
if (!LOGIN_PATH || !USER) return { attempted: false };
|
|
114
|
+
try {
|
|
115
|
+
await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
116
|
+
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();
|
|
117
|
+
const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
|
|
118
|
+
const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
|
|
119
|
+
await user.fill(USER, { timeout: 10000 });
|
|
120
|
+
await pass.fill(PASS || '', { timeout: 10000 });
|
|
121
|
+
await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
|
|
122
|
+
await page.waitForTimeout(SETTLE);
|
|
123
|
+
const url = page.url();
|
|
124
|
+
const still = url.startsWith(BASE + LOGIN_PATH);
|
|
125
|
+
return { attempted: true, ok: !still, url };
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return { attempted: true, ok: false, error: e.message.split('\n')[0] };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function contextFor(width) {
|
|
132
|
+
if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
|
|
133
|
+
const opts = { viewport: { width, height: width < 600 ? 844 : 800 } };
|
|
134
|
+
if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
|
|
135
|
+
const ctx = await browser.newContext(opts);
|
|
136
|
+
if (COOKIE) {
|
|
137
|
+
const u = new URL(BASE);
|
|
138
|
+
await ctx.addCookies(COOKIE.split(';').map((kv) => { const [k, ...v] = kv.trim().split('='); return { name: k, value: v.join('='), domain: u.hostname, path: '/' }; }));
|
|
139
|
+
}
|
|
140
|
+
const page = await ctx.newPage();
|
|
141
|
+
return { page, close: () => ctx.close() };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function navigate(page, route) {
|
|
145
|
+
if (CDP) {
|
|
146
|
+
try {
|
|
147
|
+
await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
|
|
148
|
+
await page.waitForTimeout(SETTLE);
|
|
149
|
+
return { status: 200, url: page.url() };
|
|
150
|
+
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const resp = await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
154
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
155
|
+
await page.waitForTimeout(SETTLE);
|
|
156
|
+
return { status: resp ? resp.status() : 0, url: page.url() };
|
|
157
|
+
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
|
|
161
|
+
// It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
|
|
162
|
+
// it stands, so a control that is genuinely missing is still reported (TF-021).
|
|
163
|
+
async function waitForRender(page, anchors, attr) {
|
|
164
|
+
if (RENDER_WAIT <= 0) return 0;
|
|
165
|
+
const t0 = Date.now();
|
|
166
|
+
try {
|
|
167
|
+
// The condition names nothing about how the page is built: either a control the
|
|
168
|
+
// mockup anchors has appeared, or the document has text or a form control in it.
|
|
169
|
+
// An error banner satisfies the second, so a page that failed to start is not
|
|
170
|
+
// waited on to the timeout either.
|
|
171
|
+
await page.waitForFunction(({ anchors, attr }) => {
|
|
172
|
+
if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
|
|
173
|
+
return (document.body && (document.body.innerText || '').trim().length > 0)
|
|
174
|
+
|| document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
|
|
175
|
+
}, { anchors, attr }, { timeout: RENDER_WAIT, polling: 100 });
|
|
176
|
+
} catch (e) { /* nothing appeared in time: grade what is there */ }
|
|
177
|
+
return Date.now() - t0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// what the page says about itself: anchors, tables, text, boxes
|
|
181
|
+
async function inspect(page, anchors, attr, errorSelectors) {
|
|
182
|
+
return page.evaluate(({ anchors, attr, errorSelectors }) => {
|
|
183
|
+
const vis = (el) => {
|
|
184
|
+
const cs = getComputedStyle(el);
|
|
185
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
186
|
+
if (el.getAttribute('aria-hidden') === 'true') return false;
|
|
187
|
+
const r = el.getBoundingClientRect();
|
|
188
|
+
if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
// The rectangle an element actually PAINTS in: its own box intersected with the clip
|
|
192
|
+
// rectangle of every ancestor that clips (any overflow other than `visible`). A table row
|
|
193
|
+
// inside a horizontal scroller measures 1167px wide in a container 492px wide, so the
|
|
194
|
+
// unclipped box reaches x=1480 while the pixels stop at x=805 — and the card beside it, at
|
|
195
|
+
// x=846, was reported as being overlapped by something nothing draws (TF-021, TfLens
|
|
196
|
+
// 2026-09-09). Overlap and off-screen are judged on this rectangle; zero-size still uses the
|
|
197
|
+
// element's own box, since a control scrolled out of view is not a collapsed one.
|
|
198
|
+
const clipRect = (el) => {
|
|
199
|
+
const r = el.getBoundingClientRect();
|
|
200
|
+
let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom, clipped = false;
|
|
201
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
202
|
+
const cs = getComputedStyle(p);
|
|
203
|
+
const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible';
|
|
204
|
+
if (!cx && !cy) continue;
|
|
205
|
+
const pr = p.getBoundingClientRect();
|
|
206
|
+
const nx1 = cx ? Math.max(x1, pr.left) : x1, nx2 = cx ? Math.min(x2, pr.right) : x2;
|
|
207
|
+
const ny1 = cy ? Math.max(y1, pr.top) : y1, ny2 = cy ? Math.min(y2, pr.bottom) : y2;
|
|
208
|
+
if (nx1 !== x1 || nx2 !== x2 || ny1 !== y1 || ny2 !== y2) clipped = true;
|
|
209
|
+
x1 = nx1; x2 = nx2; y1 = ny1; y2 = ny2;
|
|
210
|
+
}
|
|
211
|
+
return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1), clipped };
|
|
212
|
+
};
|
|
213
|
+
const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
|
|
214
|
+
const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
|
|
215
|
+
|| [...document.querySelectorAll('style')].some(sheetOk);
|
|
216
|
+
const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
|
|
217
|
+
const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
|
|
218
|
+
controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
|
|
219
|
+
error_banner: (errorSelectors || []).find((sel) => {
|
|
220
|
+
let el = null;
|
|
221
|
+
try { el = document.querySelector(sel); } catch (e) { return false; } // a bad selector is not a finding
|
|
222
|
+
return !!el && vis(el);
|
|
223
|
+
}) || '',
|
|
224
|
+
overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
|
|
225
|
+
styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
|
|
226
|
+
for (const id of anchors || []) {
|
|
227
|
+
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
228
|
+
if (!el) { out.anchors.push({ id, present: false }); continue; }
|
|
229
|
+
const tag = el.tagName.toLowerCase();
|
|
230
|
+
const r = el.getBoundingClientRect();
|
|
231
|
+
const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
232
|
+
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;
|
|
233
|
+
const c = clipRect(el);
|
|
234
|
+
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),
|
|
235
|
+
cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
|
|
236
|
+
}
|
|
237
|
+
for (const t of document.querySelectorAll('table')) {
|
|
238
|
+
if (!vis(t)) continue;
|
|
239
|
+
const rows = t.querySelectorAll('tbody tr').length;
|
|
240
|
+
const head = t.querySelector('thead') !== null;
|
|
241
|
+
const cells = [...t.querySelectorAll('tbody td')].filter((c) => (c.innerText || '').trim().length > 0).length;
|
|
242
|
+
out.tables.push({ id: t.getAttribute(attr) || t.id || '', rows, head, filled_cells: cells });
|
|
243
|
+
}
|
|
244
|
+
const sel = `[${attr}], a, button, input, select, textarea, [role="button"], h1, h2`;
|
|
245
|
+
const els = [...document.querySelectorAll(sel)].filter(vis);
|
|
246
|
+
els.forEach((el, i) => {
|
|
247
|
+
const r = el.getBoundingClientRect();
|
|
248
|
+
const c = clipRect(el); // what is painted, not what is laid out
|
|
249
|
+
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
|
|
250
|
+
x: c.x, y: c.y, w: c.w, h: c.h, raw_w: r.width, raw_h: r.height, clipped: c.clipped, anchored: el.hasAttribute(attr) });
|
|
251
|
+
});
|
|
252
|
+
// ancestry so a button inside its card is never an "overlap"
|
|
253
|
+
out.rel = [];
|
|
254
|
+
for (let a = 0; a < els.length; a++) for (let b = a + 1; b < els.length; b++) {
|
|
255
|
+
if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}, { anchors, attr, errorSelectors });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function grade(info, consoleErrors, width) {
|
|
262
|
+
const findings = [];
|
|
263
|
+
if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
|
|
264
|
+
// a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
|
|
265
|
+
const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
|
|
266
|
+
for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
|
|
267
|
+
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)` });
|
|
268
|
+
for (const a of info.anchors) {
|
|
269
|
+
if (!a.present) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
|
|
270
|
+
else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
|
|
271
|
+
}
|
|
272
|
+
for (const t of info.tables) {
|
|
273
|
+
if (t.head && t.rows === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `table ${t.id || '(unnamed)'} has a header and no rows` });
|
|
274
|
+
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` });
|
|
275
|
+
}
|
|
276
|
+
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)` : '') });
|
|
277
|
+
if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
|
|
278
|
+
for (const a of info.anchors) {
|
|
279
|
+
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'}` });
|
|
280
|
+
// judged on the painted rectangle: a control inside a scroller is reachable by scrolling
|
|
281
|
+
// that container, which is a layout decision, not a control that has fallen off the page
|
|
282
|
+
const ax = a.cx === undefined ? a.x : a.cx, aw = a.cw === undefined ? a.w : a.cw;
|
|
283
|
+
if (a.present && a.visible && a.w > 0 && aw > 0 && (ax + aw > info.vw + 2 || ax < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
|
|
284
|
+
}
|
|
285
|
+
const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
|
|
286
|
+
const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
|
|
287
|
+
let overlaps = 0;
|
|
288
|
+
for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
|
|
289
|
+
const a = boxes[i], b = boxes[j];
|
|
290
|
+
if (rel.has(`${a.i}-${b.i}`)) continue;
|
|
291
|
+
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
292
|
+
const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
293
|
+
if (ix <= 4 || iy <= 4) continue;
|
|
294
|
+
const inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
|
|
295
|
+
if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
|
|
296
|
+
if (inter / small < 0.25) continue; // a touch, not an overlap
|
|
297
|
+
overlaps++;
|
|
298
|
+
const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
|
|
299
|
+
findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
|
|
300
|
+
}
|
|
301
|
+
const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
|
|
302
|
+
: findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
|
|
303
|
+
const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
|
|
304
|
+
return { render, visual, findings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------- run
|
|
308
|
+
mkdirSync(SHOTS, { recursive: true });
|
|
309
|
+
const results = [];
|
|
310
|
+
let loginResult = null;
|
|
311
|
+
let firstUnreachable = false;
|
|
312
|
+
for (const width of WIDTHS) {
|
|
313
|
+
const { page, close } = await contextFor(width);
|
|
314
|
+
const consoleErrors = [];
|
|
315
|
+
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
|
316
|
+
page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
|
|
317
|
+
if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
|
|
318
|
+
for (const s of screens) {
|
|
319
|
+
let r = results.find((x) => x.name === s.name);
|
|
320
|
+
if (!r) { r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r); }
|
|
321
|
+
consoleErrors.length = 0;
|
|
322
|
+
const nav = await navigate(page, s.route);
|
|
323
|
+
const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
|
|
324
|
+
const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
|
|
325
|
+
const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
|
|
326
|
+
if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
|
|
327
|
+
entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
|
|
328
|
+
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}` }];
|
|
329
|
+
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
330
|
+
} else {
|
|
331
|
+
entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
|
|
332
|
+
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
333
|
+
const g = grade(info, consoleErrors, width);
|
|
334
|
+
Object.assign(entry, g);
|
|
335
|
+
entry.console_errors = consoleErrors.slice(0, 5);
|
|
336
|
+
entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
|
|
337
|
+
}
|
|
338
|
+
try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
|
|
339
|
+
r.widths.push(entry);
|
|
340
|
+
}
|
|
341
|
+
await close();
|
|
342
|
+
}
|
|
343
|
+
for (const r of results) {
|
|
344
|
+
const rs = r.widths.map((w) => w.render);
|
|
345
|
+
r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
|
|
346
|
+
r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
|
|
347
|
+
r.anchors_n = (r.anchors || []).length;
|
|
348
|
+
const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
|
|
349
|
+
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('; ') : ''}`);
|
|
350
|
+
}
|
|
351
|
+
const out = writeOut({}, results, loginResult);
|
|
352
|
+
if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
|
|
353
|
+
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}`);
|
|
354
|
+
if (!CDP) await browser.close(); else await browser.close().catch(() => {});
|
|
355
|
+
process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
|