@techierathore/techieflow 1.0.3 → 1.1.0
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 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- 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 +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- 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 +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- 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-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-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- 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
|
@@ -71,13 +71,21 @@ def entries_of(text):
|
|
|
71
71
|
out, current = {}, ""
|
|
72
72
|
lines = text.splitlines()
|
|
73
73
|
for i, line in enumerate(lines):
|
|
74
|
-
|
|
74
|
+
# a heading one level down (`### Page: …` under `## UI / Pages`) names the section too, and any
|
|
75
|
+
# other one (`### Cross-page: …`) ends the page above it
|
|
76
|
+
m = re.match(r"^#{2,3}\s+(.+)$", line)
|
|
75
77
|
if m:
|
|
76
78
|
current = re.sub(r"^(?:page|screen)\s*:\s*", "", m.group(1).strip(), flags=re.I)
|
|
77
79
|
continue
|
|
78
80
|
a = re.search(r"<a id=['\"]d-(req-[a-z]+-\d+)['\"]", line, re.I)
|
|
79
81
|
if a:
|
|
80
82
|
rid = a.group(1).upper()
|
|
83
|
+
# an anchor written above its own page heading belongs to that page, not the one before
|
|
84
|
+
# (AppManager's older entries: `<a id>` then `### Page:` then the row)
|
|
85
|
+
nxt = next((l for l in lines[i + 1:i + 3] if l.strip()), "")
|
|
86
|
+
h = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+)$", nxt, re.I)
|
|
87
|
+
if h:
|
|
88
|
+
current = h.group(1).strip()
|
|
81
89
|
e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""}
|
|
82
90
|
for j in range(i, min(i + 14, len(lines))):
|
|
83
91
|
l = lines[j]
|
|
@@ -117,6 +125,28 @@ def screens_of_uidesign(path):
|
|
|
117
125
|
return out
|
|
118
126
|
|
|
119
127
|
|
|
128
|
+
def screens_of_checklist(text, ent):
|
|
129
|
+
"""[{name, route, mockup}] from the checklist's own '## Page: Name (`/route`, …)' headings, at ## or
|
|
130
|
+
###, for a project with no UIDesign: the first route that can be opened as typed (no `{id}`),
|
|
131
|
+
the mockup from the first entry under the heading. A heading with no such route names no screen.
|
|
132
|
+
Without this every UI row of such a project was graded by its test only (AppManager TF-016)."""
|
|
133
|
+
out, seen = [], set()
|
|
134
|
+
for line in text.splitlines():
|
|
135
|
+
m = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+?)\s*\((.*)\)\s*$", line, re.I)
|
|
136
|
+
if not m or norm_name(m.group(1)) in seen:
|
|
137
|
+
continue
|
|
138
|
+
inner = m.group(2)
|
|
139
|
+
cands = re.findall(r"`([^`]+)`", inner) or re.split(r"\s*,\s*", inner)
|
|
140
|
+
route = next((c.strip() for c in cands if c.strip().startswith("/") and "{" not in c and " " not in c.strip()), "")
|
|
141
|
+
if not route:
|
|
142
|
+
continue
|
|
143
|
+
section = re.sub(r"^#+\s+(?:page|screen)\s*:\s*", "", line.strip(), flags=re.I)
|
|
144
|
+
mock = next((e["mockup"] for e in ent.values() if e["section"] == section and e["mockup"]), "")
|
|
145
|
+
seen.add(norm_name(m.group(1)))
|
|
146
|
+
out.append({"name": m.group(1).strip(), "route": route, "mockup": mock, "section": section})
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
120
150
|
def dialogs_of_brd(path):
|
|
121
151
|
"""name -> parent route, from the BRD screens table rows whose Route cell reads 'on /route'."""
|
|
122
152
|
out = {}
|
|
@@ -131,17 +161,47 @@ def dialogs_of_brd(path):
|
|
|
131
161
|
return out
|
|
132
162
|
|
|
133
163
|
|
|
164
|
+
def usage_guide(app):
|
|
165
|
+
"""docs/{App}-UsageGuide.md, or docs/{App}-Usage-Guide.md: the two spellings tf-doc-check.py
|
|
166
|
+
accepts. Only the first was looked for, so AppManager's guide read as having no test users
|
|
167
|
+
(AppManager TF-009)."""
|
|
168
|
+
for name in (f"{app}-UsageGuide.md", f"{app}-Usage-Guide.md"):
|
|
169
|
+
p = os.path.join("docs", name)
|
|
170
|
+
if os.path.isfile(p):
|
|
171
|
+
return p
|
|
172
|
+
return os.path.join("docs", f"{app}-UsageGuide.md")
|
|
173
|
+
|
|
174
|
+
|
|
134
175
|
def test_users(path):
|
|
176
|
+
"""The rows of the guide's Test users table. The heading may sit at ## or ### (a guide that
|
|
177
|
+
numbers its chapters nests it one level down), and the table may be the template's
|
|
178
|
+
`| # | User | Password source | Role |` or any table whose header names a user or email column
|
|
179
|
+
and a role column, read by those names (AppManager TF-009)."""
|
|
135
180
|
users = []
|
|
136
181
|
if not os.path.isfile(path):
|
|
137
182
|
return users
|
|
138
|
-
m = re.search(r"(?ms)
|
|
183
|
+
m = re.search(r"(?ms)^#{2,3}\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^#{1,3} |\Z)", read(path))
|
|
139
184
|
if not m:
|
|
140
185
|
return users
|
|
186
|
+
cols = None
|
|
141
187
|
for line in m.group(1).splitlines():
|
|
188
|
+
if not line.strip().startswith("|"):
|
|
189
|
+
continue
|
|
142
190
|
c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
|
|
143
|
-
if
|
|
144
|
-
|
|
191
|
+
if cols is None:
|
|
192
|
+
low = [x.lower() for x in c]
|
|
193
|
+
def col(*names):
|
|
194
|
+
return next((i for i, h in enumerate(low) if any(n in h for n in names)), None)
|
|
195
|
+
cols = {"user": col("user", "email", "login", "account"), "pw": col("password", "secret"), "role": col("role")}
|
|
196
|
+
if cols["user"] is None or cols["role"] is None:
|
|
197
|
+
cols = {"user": 1, "pw": 2, "role": 3} # the template's numbered shape
|
|
198
|
+
continue
|
|
199
|
+
if set("".join(c)) <= set("-: "):
|
|
200
|
+
continue # the header rule
|
|
201
|
+
if len(c) > max(cols["user"], cols["role"]) and c[cols["user"]] and not c[cols["user"]].isdigit():
|
|
202
|
+
users.append({"user": c[cols["user"]],
|
|
203
|
+
"password_source": c[cols["pw"]] if cols["pw"] is not None and len(c) > cols["pw"] else "",
|
|
204
|
+
"role": c[cols["role"]]})
|
|
145
205
|
return users
|
|
146
206
|
|
|
147
207
|
|
|
@@ -186,12 +246,15 @@ def main(argv):
|
|
|
186
246
|
if not rows:
|
|
187
247
|
die(f"{cl} has no REQ rows")
|
|
188
248
|
ent = entries_of(text)
|
|
189
|
-
screens = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md"))
|
|
249
|
+
screens, source = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md")), "UIDesign"
|
|
250
|
+
if not screens:
|
|
251
|
+
screens, source = screens_of_checklist(text, ent), "checklist's Page headings"
|
|
190
252
|
dialogs = dialogs_of_brd(os.path.join("docs", f"{pfx}BRD.md"))
|
|
191
|
-
users = test_users(
|
|
253
|
+
users = test_users(usage_guide(app))
|
|
192
254
|
by_name = {norm_name(s["name"]): s for s in screens}
|
|
193
255
|
by_mock = {s["mockup"]: s for s in screens if s["mockup"]}
|
|
194
256
|
by_route = {s["route"].lower(): s for s in screens}
|
|
257
|
+
by_section = {s["section"]: s for s in screens if s.get("section")}
|
|
195
258
|
|
|
196
259
|
work, skipped, unresolved = [], 0, []
|
|
197
260
|
for r in rows:
|
|
@@ -204,6 +267,10 @@ def main(argv):
|
|
|
204
267
|
row = dict(r, acceptance=e["acceptance"], mockup=e["mockup"], brd=e["brd"], perf_budget=e["perf_budget"],
|
|
205
268
|
section=e["section"], screen="", route="", dialog="")
|
|
206
269
|
cand = [screen_from_acceptance(e["acceptance"]), e["section"], r["title"]]
|
|
270
|
+
if e["section"] in by_section: # a screen read from the checklist: the heading the row sits under
|
|
271
|
+
s = by_section[e["section"]]
|
|
272
|
+
row["screen"], row["route"] = s["name"], s["route"]
|
|
273
|
+
cand = []
|
|
207
274
|
for c in cand:
|
|
208
275
|
n = norm_name(c)
|
|
209
276
|
if not n:
|
|
@@ -236,7 +303,7 @@ def main(argv):
|
|
|
236
303
|
if not work:
|
|
237
304
|
print("NOTHING: no row in scope. Run the status gate and stop.")
|
|
238
305
|
print()
|
|
239
|
-
print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the
|
|
306
|
+
print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the {source})")
|
|
240
307
|
for s in screen_list:
|
|
241
308
|
print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'} — rows {', '.join(s['rows'])}")
|
|
242
309
|
print()
|
|
@@ -259,7 +326,7 @@ def main(argv):
|
|
|
259
326
|
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
|
260
327
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
261
328
|
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,
|
|
329
|
+
"rows": work, "screens": screen_list, "screens_source": source, "unresolved": unresolved, "test_users": users,
|
|
263
330
|
"skipped_na": skipped, "total": len(rows)}, f, indent=1)
|
|
264
331
|
print()
|
|
265
332
|
print(f"JSON: {out_path}")
|
|
@@ -2,17 +2,23 @@
|
|
|
2
2
|
// Run through tf-verify-screens.sh, not directly. Sitting 4c, 2026-09-06.
|
|
3
3
|
//
|
|
4
4
|
// Two of the seven verify checks, over one browser session:
|
|
5
|
-
// render every control the mockup anchors (data-testid) is present and
|
|
6
|
-
//
|
|
5
|
+
// render every control the mockup anchors (data-testid) and shows at that width is present and
|
|
6
|
+
// shows something (one the mockup hides at that width only is not owed there); no
|
|
7
|
+
// table with a header and no rows; no blank page; no error banner; no console error.
|
|
7
8
|
// 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
|
+
// no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
|
|
10
|
+
// on the rectangle an element PAINTS in — its box clipped by every scrolling ancestor —
|
|
11
|
+
// so a wide table inside a horizontal scroller is not read as covering its neighbour.
|
|
12
|
+
// Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
|
|
13
|
+
// the page: it waits for content to appear, knowing nothing about how the page is built.
|
|
9
14
|
// At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
|
|
10
15
|
// visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
|
|
11
16
|
// zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
|
|
12
17
|
// script reads and prints one line per screen. It never decides a row's status.
|
|
13
18
|
//
|
|
14
|
-
// Modes: --base URL (a served app, a new browser) or --cdp URL (attach to
|
|
15
|
-
//
|
|
19
|
+
// Modes: --base URL (a served app, a new browser) or --cdp URL (attach to an already-running
|
|
20
|
+
// embedded-browser desktop head over its DevTools port; screens are reached by pushState, the way
|
|
21
|
+
// the router does).
|
|
16
22
|
|
|
17
23
|
import { chromium } from 'playwright';
|
|
18
24
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -33,32 +39,132 @@ const LOGIN_PATH = arg('--login-path');
|
|
|
33
39
|
const USER = arg('--user'); const PASS = arg('--password');
|
|
34
40
|
const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
|
|
35
41
|
const SETTLE = parseInt(arg('--wait', '1500'), 10);
|
|
42
|
+
// how long to keep waiting for the FIRST render after the page has settled. Plenty of stacks
|
|
43
|
+
// paint after the network goes quiet — a server-held connection that renders once it is up, a
|
|
44
|
+
// client router resolving its first route, a lazily loaded bundle — so measuring at
|
|
45
|
+
// domcontentloaded, at networkidle or even after --wait can read an empty document and report
|
|
46
|
+
// every anchored control as missing while the tool's own screenshot shows them (TF-021, TfLens
|
|
47
|
+
// 2026-09-09). Nothing here knows which stack it is looking at: it waits for CONTENT.
|
|
48
|
+
const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
|
|
36
49
|
const ATTR = arg('--testid-attr', 'data-testid');
|
|
50
|
+
// The banner a stack shows when its client side has fallen over. There is no neutral way to
|
|
51
|
+
// detect one -- every stack names its own -- so this is a LIST a project extends rather than a
|
|
52
|
+
// selector the framework assumes: --error-selector '.my-crash-banner', repeatable. The two
|
|
53
|
+
// defaults are one widely used framework's id and a plain attribute any project can put on its
|
|
54
|
+
// own banner. A project on neither still gets every other render check; it simply cannot be
|
|
55
|
+
// told about a banner nobody named.
|
|
56
|
+
const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
|
|
37
57
|
|
|
38
58
|
if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
|
|
39
59
|
|
|
40
60
|
// ---------------------------------------------------------------- screens
|
|
41
61
|
let screens = [];
|
|
62
|
+
// Sample values for route parameters (Lekhak TF-005): `/admin/llm-signin/{ProviderId:long}` opened as
|
|
63
|
+
// written answers 404. --route-value ProviderId=21 (repeatable), or "route_values" in the list, at its
|
|
64
|
+
// top or on one screen. A screen whose route still needs a value is not driven, and says which.
|
|
65
|
+
const ROUTE_VALUES = {};
|
|
66
|
+
for (const kv of args('--route-value')) { const m = kv.match(/^([^=]+)=(.*)$/); if (m) ROUTE_VALUES[m[1].trim().toLowerCase()] = m[2]; }
|
|
67
|
+
let listValues = {};
|
|
42
68
|
if (LIST) {
|
|
43
69
|
const l = JSON.parse(readFileSync(LIST, 'utf8'));
|
|
44
|
-
|
|
70
|
+
listValues = l.route_values || {};
|
|
71
|
+
screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [], route_values: s.route_values || {} }));
|
|
45
72
|
}
|
|
46
73
|
for (const s of args('--screen')) {
|
|
47
74
|
const m = s.match(/^([^=]+)=(.+)$/);
|
|
48
75
|
if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
|
|
49
76
|
}
|
|
50
77
|
if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
|
|
78
|
+
const fillRoute = (s) => {
|
|
79
|
+
const vals = {};
|
|
80
|
+
for (const src of [listValues, s.route_values || {}]) for (const [k, v] of Object.entries(src)) vals[k.toLowerCase()] = String(v);
|
|
81
|
+
Object.assign(vals, ROUTE_VALUES);
|
|
82
|
+
const need = [];
|
|
83
|
+
const route = s.route.replace(/\/?\{\*{0,2}([A-Za-z_][\w]*)(?::[^}=?]*)?(?:=([^}?]*))?(\?)?\}/g, (whole, name, dflt, opt) => {
|
|
84
|
+
const lead = whole.startsWith('/') ? '/' : '';
|
|
85
|
+
const v = vals[name.toLowerCase()] ?? dflt;
|
|
86
|
+
if (v !== undefined && v !== '') return lead + encodeURIComponent(v);
|
|
87
|
+
if (opt) return '';
|
|
88
|
+
need.push(name); return whole;
|
|
89
|
+
});
|
|
90
|
+
return { route, need };
|
|
91
|
+
};
|
|
92
|
+
const skipped = [];
|
|
93
|
+
screens = screens.filter((s) => {
|
|
94
|
+
const f = fillRoute(s);
|
|
95
|
+
if (f.need.length) { skipped.push({ name: s.name, route: s.route, rows: s.rows, needs: f.need }); return false; }
|
|
96
|
+
if (f.route !== s.route) s.route_pattern = s.route;
|
|
97
|
+
s.route = f.route;
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
for (const k of skipped) console.log(`SKIP ${k.name} (${k.route}) — not driven: the route needs a value; pass ${k.needs.map((n) => `--route-value ${n}=<a real ${n}>`).join(' ')}`);
|
|
51
101
|
|
|
52
102
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
53
103
|
const anchorsOf = (mockup) => {
|
|
54
104
|
if (!mockup || !existsSync(mockup)) return null;
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
// Only an attribute ON AN ELEMENT is a control the page must carry. A stylesheet rule such as
|
|
106
|
+
// [data-testid="source-mode"] .tab{…}, a script string or a comment naming one is not: TfLens's
|
|
107
|
+
// shared mockup stylesheet made every screen owe a dialog it never draws (TF-026).
|
|
108
|
+
const html = readFileSync(mockup, 'utf8')
|
|
109
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
110
|
+
.replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
|
|
111
|
+
const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
|
|
57
112
|
const out = new Set(); let m;
|
|
58
113
|
while ((m = re.exec(html))) out.add(m[1]);
|
|
59
114
|
return [...out];
|
|
60
115
|
};
|
|
61
116
|
|
|
117
|
+
// The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
|
|
118
|
+
// menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
|
|
119
|
+
// TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
|
|
120
|
+
// hides at every width -- a closed dialog -- is still owed, as before. None is excused when the
|
|
121
|
+
// mockup cannot be drawn.
|
|
122
|
+
async function hiddenByWidth(b, mockup, anchors) {
|
|
123
|
+
const out = {};
|
|
124
|
+
if (!b || !mockup || !existsSync(mockup) || !anchors || !anchors.length) return out;
|
|
125
|
+
const shown = {};
|
|
126
|
+
for (const width of WIDTHS) {
|
|
127
|
+
let ctx;
|
|
128
|
+
try {
|
|
129
|
+
ctx = await b.newContext({ viewport: { width, height: width < 600 ? 844 : 800 } });
|
|
130
|
+
const p = await ctx.newPage();
|
|
131
|
+
await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
|
|
132
|
+
shown[width] = await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
|
|
133
|
+
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
134
|
+
if (!el) return false;
|
|
135
|
+
const r = el.getBoundingClientRect();
|
|
136
|
+
const seen = el.checkVisibility ? el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) : true;
|
|
137
|
+
return seen && r.width > 0 && r.height > 0;
|
|
138
|
+
}), { anchors, attr: ATTR });
|
|
139
|
+
} catch (e) { return {}; } finally { if (ctx) await ctx.close().catch(() => {}); }
|
|
140
|
+
}
|
|
141
|
+
const everShown = new Set(Object.values(shown).flat());
|
|
142
|
+
for (const width of WIDTHS) out[width] = anchors.filter((id) => everShown.has(id) && !shown[width].includes(id));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
|
|
147
|
+
// shown only while the database is down, a result shown only after "Test". The mockup says so with
|
|
148
|
+
// data-tf-state="<state>" (or data-state-testid="<id>", Lekhak TF-011) on the control or on a box around it. Such a control is not owed on the
|
|
149
|
+
// first view; when it is drawn and visible it is graded like any other.
|
|
150
|
+
async function stateOnlyOf(b, mockup, anchors) {
|
|
151
|
+
if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
|
|
152
|
+
if (b) {
|
|
153
|
+
let ctx;
|
|
154
|
+
try {
|
|
155
|
+
ctx = await b.newContext();
|
|
156
|
+
const p = await ctx.newPage();
|
|
157
|
+
await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
|
|
158
|
+
return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
|
|
159
|
+
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
160
|
+
return !!el && !!el.closest('[data-tf-state],[data-state-testid]');
|
|
161
|
+
}), { anchors, attr: ATTR });
|
|
162
|
+
} catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
|
|
163
|
+
}
|
|
164
|
+
const html = readFileSync(mockup, 'utf8');
|
|
165
|
+
return anchors.filter((id) => new RegExp(`<[^>]*\\bdata-(?:tf-state|state-testid)\\b[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']|<[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bdata-(?:tf-state|state-testid)\\b`).test(html));
|
|
166
|
+
}
|
|
167
|
+
|
|
62
168
|
// ---------------------------------------------------------------- browser
|
|
63
169
|
let browser, cdpPage = null;
|
|
64
170
|
if (CDP) {
|
|
@@ -70,13 +176,17 @@ if (CDP) {
|
|
|
70
176
|
} else {
|
|
71
177
|
browser = await chromium.launch();
|
|
72
178
|
}
|
|
179
|
+
// mockups are drawn in a browser of our own: an attached desktop head is the app's, not ours
|
|
180
|
+
const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
|
|
73
181
|
|
|
74
182
|
function writeOut(extra, results = [], login = null) {
|
|
75
183
|
const summary = {
|
|
76
184
|
mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
|
|
77
185
|
screens: results,
|
|
186
|
+
skipped, // screens not driven because their route needs a value (TF-005)
|
|
78
187
|
summary: {
|
|
79
188
|
screens: results.length,
|
|
189
|
+
skipped: skipped.length,
|
|
80
190
|
render_ok: results.filter((r) => r.render === 'OK').length,
|
|
81
191
|
render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
|
|
82
192
|
unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
|
|
@@ -90,28 +200,18 @@ function writeOut(extra, results = [], login = null) {
|
|
|
90
200
|
return summary;
|
|
91
201
|
}
|
|
92
202
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
}
|
|
203
|
+
// The sign-in recipe (TF-033, TF-044) and the way a screen behind an in-page sign-in is reached
|
|
204
|
+
// (AppManager TF-006) live in tf-login.mjs since AppManager TF-011, shared with tf-mockup-parity
|
|
205
|
+
// and tf-assets so every check reaches a screen the same way.
|
|
206
|
+
import { signIn, signedOut as signedOutShared, navigateInApp as navigateInAppShared } from './tf-login.mjs';
|
|
207
|
+
const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, attr: ATTR, settle: SETTLE, renderWait: RENDER_WAIT };
|
|
208
|
+
const login = (page) => signIn(page, LOGIN_OPTS);
|
|
111
209
|
|
|
112
210
|
async function contextFor(width) {
|
|
113
211
|
if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
|
|
114
|
-
|
|
212
|
+
// a development head on HTTPS carries a certificate nothing trusts; tf-mockup-parity and tf-assets
|
|
213
|
+
// already accept it, and without it every screen was UNREACHABLE (Lekhak TF-002)
|
|
214
|
+
const opts = { viewport: { width, height: width < 600 ? 844 : 800 }, ignoreHTTPSErrors: true };
|
|
115
215
|
if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
|
|
116
216
|
const ctx = await browser.newContext(opts);
|
|
117
217
|
if (COOKIE) {
|
|
@@ -138,9 +238,36 @@ async function navigate(page, route) {
|
|
|
138
238
|
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
139
239
|
}
|
|
140
240
|
|
|
241
|
+
// Signed out: on the sign-in address, or drawing a sign-in form — a password field WITH a sign-in
|
|
242
|
+
// button. A password field alone is not the sign-in page: AppManager's signed-in "Create user" form
|
|
243
|
+
// has one for the new account and was graded unreachable (AppManager TF-012). tf-login.mjs decides.
|
|
244
|
+
const signedOut = (page) => signedOutShared(page, LOGIN_OPTS);
|
|
245
|
+
// Open a route from inside the page, the way its own links do (tf-login.mjs).
|
|
246
|
+
const navigateInApp = (page, route) => navigateInAppShared(page, LOGIN_OPTS, route);
|
|
247
|
+
|
|
248
|
+
// Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
|
|
249
|
+
// It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
|
|
250
|
+
// it stands, so a control that is genuinely missing is still reported (TF-021).
|
|
251
|
+
async function waitForRender(page, anchors, attr) {
|
|
252
|
+
if (RENDER_WAIT <= 0) return 0;
|
|
253
|
+
const t0 = Date.now();
|
|
254
|
+
try {
|
|
255
|
+
// The condition names nothing about how the page is built: either a control the
|
|
256
|
+
// mockup anchors has appeared, or the document has text or a form control in it.
|
|
257
|
+
// An error banner satisfies the second, so a page that failed to start is not
|
|
258
|
+
// waited on to the timeout either.
|
|
259
|
+
await page.waitForFunction(({ anchors, attr }) => {
|
|
260
|
+
if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
|
|
261
|
+
return (document.body && (document.body.innerText || '').trim().length > 0)
|
|
262
|
+
|| document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
|
|
263
|
+
}, { anchors, attr }, { timeout: RENDER_WAIT, polling: 100 });
|
|
264
|
+
} catch (e) { /* nothing appeared in time: grade what is there */ }
|
|
265
|
+
return Date.now() - t0;
|
|
266
|
+
}
|
|
267
|
+
|
|
141
268
|
// what the page says about itself: anchors, tables, text, boxes
|
|
142
|
-
async function inspect(page, anchors, attr) {
|
|
143
|
-
return page.evaluate(({ anchors, attr }) => {
|
|
269
|
+
async function inspect(page, anchors, attr, errorSelectors) {
|
|
270
|
+
return page.evaluate(({ anchors, attr, errorSelectors }) => {
|
|
144
271
|
const vis = (el) => {
|
|
145
272
|
const cs = getComputedStyle(el);
|
|
146
273
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
@@ -149,13 +276,39 @@ async function inspect(page, anchors, attr) {
|
|
|
149
276
|
if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
|
|
150
277
|
return true;
|
|
151
278
|
};
|
|
279
|
+
// The rectangle an element actually PAINTS in: its own box intersected with the clip
|
|
280
|
+
// rectangle of every ancestor that clips (any overflow other than `visible`). A table row
|
|
281
|
+
// inside a horizontal scroller measures 1167px wide in a container 492px wide, so the
|
|
282
|
+
// unclipped box reaches x=1480 while the pixels stop at x=805 — and the card beside it, at
|
|
283
|
+
// x=846, was reported as being overlapped by something nothing draws (TF-021, TfLens
|
|
284
|
+
// 2026-09-09). Overlap and off-screen are judged on this rectangle; zero-size still uses the
|
|
285
|
+
// element's own box, since a control scrolled out of view is not a collapsed one.
|
|
286
|
+
const clipRect = (el) => {
|
|
287
|
+
const r = el.getBoundingClientRect();
|
|
288
|
+
let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom, clipped = false;
|
|
289
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
290
|
+
const cs = getComputedStyle(p);
|
|
291
|
+
const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible';
|
|
292
|
+
if (!cx && !cy) continue;
|
|
293
|
+
const pr = p.getBoundingClientRect();
|
|
294
|
+
const nx1 = cx ? Math.max(x1, pr.left) : x1, nx2 = cx ? Math.min(x2, pr.right) : x2;
|
|
295
|
+
const ny1 = cy ? Math.max(y1, pr.top) : y1, ny2 = cy ? Math.min(y2, pr.bottom) : y2;
|
|
296
|
+
if (nx1 !== x1 || nx2 !== x2 || ny1 !== y1 || ny2 !== y2) clipped = true;
|
|
297
|
+
x1 = nx1; x2 = nx2; y1 = ny1; y2 = ny2;
|
|
298
|
+
}
|
|
299
|
+
return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1), clipped };
|
|
300
|
+
};
|
|
152
301
|
const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
|
|
153
302
|
const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
|
|
154
303
|
|| [...document.querySelectorAll('style')].some(sheetOk);
|
|
155
304
|
const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
|
|
156
305
|
const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
|
|
157
306
|
controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
|
|
158
|
-
|
|
307
|
+
error_banner: (errorSelectors || []).find((sel) => {
|
|
308
|
+
let el = null;
|
|
309
|
+
try { el = document.querySelector(sel); } catch (e) { return false; } // a bad selector is not a finding
|
|
310
|
+
return !!el && vis(el);
|
|
311
|
+
}) || '',
|
|
159
312
|
overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
|
|
160
313
|
styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
|
|
161
314
|
for (const id of anchors || []) {
|
|
@@ -165,7 +318,9 @@ async function inspect(page, anchors, attr) {
|
|
|
165
318
|
const r = el.getBoundingClientRect();
|
|
166
319
|
const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
167
320
|
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
|
-
|
|
321
|
+
const c = clipRect(el);
|
|
322
|
+
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),
|
|
323
|
+
cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
|
|
169
324
|
}
|
|
170
325
|
for (const t of document.querySelectorAll('table')) {
|
|
171
326
|
if (!vis(t)) continue;
|
|
@@ -178,8 +333,21 @@ async function inspect(page, anchors, attr) {
|
|
|
178
333
|
const els = [...document.querySelectorAll(sel)].filter(vis);
|
|
179
334
|
els.forEach((el, i) => {
|
|
180
335
|
const r = el.getBoundingClientRect();
|
|
336
|
+
const c = clipRect(el); // what is painted, not what is laid out
|
|
337
|
+
// An inline element that wraps paints one fragment per line, and its bounding box is the union
|
|
338
|
+
// of them: two sentences sharing a line then "overlap" across the tile's whole width though no
|
|
339
|
+
// pixel of one is drawn over the other (TF-036, TfLens /effort 2026-09-11). Keep the fragments,
|
|
340
|
+
// each cut to the painted rectangle, and compare those.
|
|
341
|
+
let frags = null;
|
|
342
|
+
if (getComputedStyle(el).display === 'inline') {
|
|
343
|
+
const fs = [...el.getClientRects()].map((f) => {
|
|
344
|
+
const x1 = Math.max(f.left, c.x), x2 = Math.min(f.right, c.x + c.w), y1 = Math.max(f.top, c.y), y2 = Math.min(f.bottom, c.y + c.h);
|
|
345
|
+
return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
|
|
346
|
+
}).filter((f) => f.w > 0 && f.h > 0);
|
|
347
|
+
if (fs.length > 1) frags = fs;
|
|
348
|
+
}
|
|
181
349
|
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
|
|
182
|
-
x:
|
|
350
|
+
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), frags });
|
|
183
351
|
});
|
|
184
352
|
// ancestry so a button inside its card is never an "overlap"
|
|
185
353
|
out.rel = [];
|
|
@@ -187,18 +355,21 @@ async function inspect(page, anchors, attr) {
|
|
|
187
355
|
if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
|
|
188
356
|
}
|
|
189
357
|
return out;
|
|
190
|
-
}, { anchors, attr });
|
|
358
|
+
}, { anchors, attr, errorSelectors });
|
|
191
359
|
}
|
|
192
360
|
|
|
193
|
-
function grade(info, consoleErrors, width) {
|
|
361
|
+
function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
|
|
194
362
|
const findings = [];
|
|
195
|
-
|
|
363
|
+
// a control of another state is graded only when this view draws it, visible and filled (TF-005)
|
|
364
|
+
const soft = new Set(stateOnly);
|
|
365
|
+
info = { ...info, anchors: info.anchors.filter((a) => !soft.has(a.id) || (a.present && a.visible && a.filled && a.w > 0 && a.h > 0)) };
|
|
366
|
+
if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
|
|
196
367
|
// a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
|
|
197
368
|
const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
|
|
198
369
|
for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
|
|
199
370
|
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
371
|
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` });
|
|
372
|
+
if (!a.present && !excused.includes(a.id)) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
|
|
202
373
|
else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
|
|
203
374
|
}
|
|
204
375
|
for (const t of info.tables) {
|
|
@@ -209,7 +380,10 @@ function grade(info, consoleErrors, width) {
|
|
|
209
380
|
if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
|
|
210
381
|
for (const a of info.anchors) {
|
|
211
382
|
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
|
-
|
|
383
|
+
// judged on the painted rectangle: a control inside a scroller is reachable by scrolling
|
|
384
|
+
// that container, which is a layout decision, not a control that has fallen off the page
|
|
385
|
+
const ax = a.cx === undefined ? a.x : a.cx, aw = a.cw === undefined ? a.w : a.cw;
|
|
386
|
+
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` });
|
|
213
387
|
}
|
|
214
388
|
const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
|
|
215
389
|
const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
|
|
@@ -220,7 +394,18 @@ function grade(info, consoleErrors, width) {
|
|
|
220
394
|
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
221
395
|
const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
222
396
|
if (ix <= 4 || iy <= 4) continue;
|
|
223
|
-
|
|
397
|
+
let inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
|
|
398
|
+
if (a.frags || b.frags) { // a wrapped inline element: what its fragments share (TF-036)
|
|
399
|
+
const fa = a.frags || [a], fb = b.frags || [b];
|
|
400
|
+
const area = (fs) => fs.reduce((s, f) => s + f.w * f.h, 0);
|
|
401
|
+
inter = 0;
|
|
402
|
+
for (const p of fa) for (const q of fb) {
|
|
403
|
+
const px = Math.min(p.x + p.w, q.x + q.w) - Math.max(p.x, q.x), py = Math.min(p.y + p.h, q.y + q.h) - Math.max(p.y, q.y);
|
|
404
|
+
if (px > 4 && py > 4) inter += px * py;
|
|
405
|
+
}
|
|
406
|
+
if (inter === 0) continue;
|
|
407
|
+
small = Math.min(area(fa), area(fb));
|
|
408
|
+
}
|
|
224
409
|
if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
|
|
225
410
|
if (inter / small < 0.25) continue; // a touch, not an overlap
|
|
226
411
|
overlaps++;
|
|
@@ -246,19 +431,44 @@ for (const width of WIDTHS) {
|
|
|
246
431
|
if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
|
|
247
432
|
for (const s of screens) {
|
|
248
433
|
let r = results.find((x) => x.name === s.name);
|
|
249
|
-
if (!r) {
|
|
434
|
+
if (!r) {
|
|
435
|
+
r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r);
|
|
436
|
+
r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
|
|
437
|
+
r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
|
|
438
|
+
if (s.route_pattern) r.route_pattern = s.route_pattern;
|
|
439
|
+
}
|
|
440
|
+
const excused = (r.hidden_by_width || {})[width] || [];
|
|
441
|
+
const stateOnly = r.state_only || [];
|
|
250
442
|
consoleErrors.length = 0;
|
|
251
|
-
|
|
443
|
+
let nav = await navigate(page, s.route);
|
|
252
444
|
const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
|
|
253
445
|
const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
|
|
446
|
+
// --- AppManager TF-006: a sign-in the page keeps for itself, not in a cookie. The document for a
|
|
447
|
+
// signed-in screen answers 401 because the server cannot see the sign-in; the page then draws the
|
|
448
|
+
// screen once its live connection reads the session in the tab, or never, when the sign-in lives
|
|
449
|
+
// only in that connection. So a 401 or 403 is judged by what the page draws: signed in, the screen
|
|
450
|
+
// is graded; still signed out, the tool signs in again and opens the screen from inside the page.
|
|
451
|
+
if (!CDP && LOGIN_PATH && USER && (nav.status === 401 || nav.status === 403)) {
|
|
452
|
+
await waitForRender(page, [], ATTR);
|
|
453
|
+
let how = `answered HTTP ${nav.status}, then drew the screen signed in`;
|
|
454
|
+
if (await signedOut(page)) {
|
|
455
|
+
how = '';
|
|
456
|
+
const again = await login(page);
|
|
457
|
+
if (again.ok && await navigateInApp(page, s.route) && !(await signedOut(page))) how = `answered HTTP ${nav.status}; opened from inside the page after signing in`;
|
|
458
|
+
}
|
|
459
|
+
if (how) { entry.reached = how; nav = { status: 200, url: page.url() }; entry.url = nav.url; }
|
|
460
|
+
}
|
|
254
461
|
const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
|
|
255
462
|
if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
|
|
256
463
|
entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
|
|
257
464
|
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
465
|
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
259
466
|
} else {
|
|
260
|
-
|
|
261
|
-
const
|
|
467
|
+
entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
|
|
468
|
+
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
469
|
+
const g = grade(info, consoleErrors, width, excused, stateOnly);
|
|
470
|
+
if (excused.length) entry.hidden_in_mockup = excused;
|
|
471
|
+
if (stateOnly.length) entry.state_only = stateOnly;
|
|
262
472
|
Object.assign(entry, g);
|
|
263
473
|
entry.console_errors = consoleErrors.slice(0, 5);
|
|
264
474
|
entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
|
|
@@ -278,6 +488,7 @@ for (const r of results) {
|
|
|
278
488
|
}
|
|
279
489
|
const out = writeOut({}, results, loginResult);
|
|
280
490
|
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}`);
|
|
491
|
+
console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} not driven: route needs a value)` : ''}: 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}`);
|
|
492
|
+
if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});
|
|
282
493
|
if (!CDP) await browser.close(); else await browser.close().catch(() => {});
|
|
283
494
|
process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
|