@techierathore/techieflow 1.0.4 → 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/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +1 -1
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- package/.claude/commands/TechieFlow/tasks/mockups.md +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/.opencode/plugin/techieflow.js +179 -7
- 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-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +1 -1
- package/.tfcore/tasks/day1-greenfield.md +1 -1
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- 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/telemetry/SCHEMA.md +4 -0
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -2
- 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-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-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.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 +80 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- 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 +136 -6
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +92 -35
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +79 -29
- package/.tfcore/utils/tf-goal.sh +12 -12
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +15 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +60 -2
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +72 -32
- 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 +171 -32
- package/.tfcore/utils/tf-verify-screens.sh +8 -3
- package/.tfcore/utils/tf-verify-tests.sh +215 -14
- package/.tfcore/utils/tf-verify-verdict.py +48 -9
- 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 +4 -0
- package/docs/TechieFlow-Installation.md +5 -3
- package/package.json +1 -1
|
@@ -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,7 +2,8 @@
|
|
|
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
|
|
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
|
|
6
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
9
|
// no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
|
|
@@ -58,26 +59,112 @@ if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL i
|
|
|
58
59
|
|
|
59
60
|
// ---------------------------------------------------------------- screens
|
|
60
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 = {};
|
|
61
68
|
if (LIST) {
|
|
62
69
|
const l = JSON.parse(readFileSync(LIST, 'utf8'));
|
|
63
|
-
|
|
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 || {} }));
|
|
64
72
|
}
|
|
65
73
|
for (const s of args('--screen')) {
|
|
66
74
|
const m = s.match(/^([^=]+)=(.+)$/);
|
|
67
75
|
if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
|
|
68
76
|
}
|
|
69
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(' ')}`);
|
|
70
101
|
|
|
71
102
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
72
103
|
const anchorsOf = (mockup) => {
|
|
73
104
|
if (!mockup || !existsSync(mockup)) return null;
|
|
74
|
-
|
|
75
|
-
|
|
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');
|
|
76
112
|
const out = new Set(); let m;
|
|
77
113
|
while ((m = re.exec(html))) out.add(m[1]);
|
|
78
114
|
return [...out];
|
|
79
115
|
};
|
|
80
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
|
+
|
|
81
168
|
// ---------------------------------------------------------------- browser
|
|
82
169
|
let browser, cdpPage = null;
|
|
83
170
|
if (CDP) {
|
|
@@ -89,13 +176,17 @@ if (CDP) {
|
|
|
89
176
|
} else {
|
|
90
177
|
browser = await chromium.launch();
|
|
91
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;
|
|
92
181
|
|
|
93
182
|
function writeOut(extra, results = [], login = null) {
|
|
94
183
|
const summary = {
|
|
95
184
|
mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
|
|
96
185
|
screens: results,
|
|
186
|
+
skipped, // screens not driven because their route needs a value (TF-005)
|
|
97
187
|
summary: {
|
|
98
188
|
screens: results.length,
|
|
189
|
+
skipped: skipped.length,
|
|
99
190
|
render_ok: results.filter((r) => r.render === 'OK').length,
|
|
100
191
|
render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
|
|
101
192
|
unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
|
|
@@ -109,28 +200,18 @@ function writeOut(extra, results = [], login = null) {
|
|
|
109
200
|
return summary;
|
|
110
201
|
}
|
|
111
202
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
}
|
|
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);
|
|
130
209
|
|
|
131
210
|
async function contextFor(width) {
|
|
132
211
|
if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
|
|
133
|
-
|
|
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 };
|
|
134
215
|
if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
|
|
135
216
|
const ctx = await browser.newContext(opts);
|
|
136
217
|
if (COOKIE) {
|
|
@@ -157,6 +238,13 @@ async function navigate(page, route) {
|
|
|
157
238
|
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
158
239
|
}
|
|
159
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
|
+
|
|
160
248
|
// Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
|
|
161
249
|
// It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
|
|
162
250
|
// it stands, so a control that is genuinely missing is still reported (TF-021).
|
|
@@ -246,8 +334,20 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
246
334
|
els.forEach((el, i) => {
|
|
247
335
|
const r = el.getBoundingClientRect();
|
|
248
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
|
+
}
|
|
249
349
|
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) });
|
|
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 });
|
|
251
351
|
});
|
|
252
352
|
// ancestry so a button inside its card is never an "overlap"
|
|
253
353
|
out.rel = [];
|
|
@@ -258,15 +358,18 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
258
358
|
}, { anchors, attr, errorSelectors });
|
|
259
359
|
}
|
|
260
360
|
|
|
261
|
-
function grade(info, consoleErrors, width) {
|
|
361
|
+
function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
|
|
262
362
|
const findings = [];
|
|
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)) };
|
|
263
366
|
if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
|
|
264
367
|
// a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
|
|
265
368
|
const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
|
|
266
369
|
for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
|
|
267
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)` });
|
|
268
371
|
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` });
|
|
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` });
|
|
270
373
|
else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
|
|
271
374
|
}
|
|
272
375
|
for (const t of info.tables) {
|
|
@@ -291,7 +394,18 @@ function grade(info, consoleErrors, width) {
|
|
|
291
394
|
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
292
395
|
const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
293
396
|
if (ix <= 4 || iy <= 4) continue;
|
|
294
|
-
|
|
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
|
+
}
|
|
295
409
|
if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
|
|
296
410
|
if (inter / small < 0.25) continue; // a touch, not an overlap
|
|
297
411
|
overlaps++;
|
|
@@ -317,20 +431,44 @@ for (const width of WIDTHS) {
|
|
|
317
431
|
if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
|
|
318
432
|
for (const s of screens) {
|
|
319
433
|
let r = results.find((x) => x.name === s.name);
|
|
320
|
-
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 || [];
|
|
321
442
|
consoleErrors.length = 0;
|
|
322
|
-
|
|
443
|
+
let nav = await navigate(page, s.route);
|
|
323
444
|
const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
|
|
324
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
|
+
}
|
|
325
461
|
const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
|
|
326
462
|
if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
|
|
327
463
|
entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
|
|
328
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}` }];
|
|
329
465
|
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
330
466
|
} else {
|
|
331
|
-
entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
|
|
467
|
+
entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
|
|
332
468
|
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
333
|
-
const g = grade(info, consoleErrors, width);
|
|
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;
|
|
334
472
|
Object.assign(entry, g);
|
|
335
473
|
entry.console_errors = consoleErrors.slice(0, 5);
|
|
336
474
|
entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
|
|
@@ -350,6 +488,7 @@ for (const r of results) {
|
|
|
350
488
|
}
|
|
351
489
|
const out = writeOut({}, results, loginResult);
|
|
352
490
|
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}`);
|
|
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(() => {});
|
|
354
493
|
if (!CDP) await browser.close(); else await browser.close().catch(() => {});
|
|
355
494
|
process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
# bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
|
|
8
8
|
# … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
|
|
9
9
|
# # (repeatable; defaults cover one framework's id and [data-error-ui])
|
|
10
|
+
# … --route-value ProviderId=21 # a sample for /x/{ProviderId:long} (repeatable; or "route_values"
|
|
11
|
+
# # in the list); a screen whose route still needs one is SKIPped
|
|
12
|
+
# A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
|
|
13
|
+
# of the screen: it is not owed on the first view.
|
|
10
14
|
#
|
|
11
15
|
# For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
|
|
12
16
|
# something, no header-only table, no blank page, no Blazor or console error (render); nothing
|
|
@@ -22,10 +26,11 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
22
26
|
# `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
|
|
23
27
|
# form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
|
|
24
28
|
case "${1:-}" in
|
|
25
|
-
-h|--help) sed -n '2,
|
|
26
|
-
"") sed -n '2,
|
|
29
|
+
-h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
30
|
+
"") sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
|
|
27
31
|
esac
|
|
28
32
|
command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
|
|
29
33
|
node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
|
|
30
34
|
echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
|
|
31
|
-
|
|
35
|
+
source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
|
|
36
|
+
node "$HERE/tf-verify-screens.mjs" "$@"
|