@techierathore/techieflow 1.0.4 → 1.1.1
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/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- 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/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -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 +2 -2
- 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-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- 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-phases-tmpl.md +2 -1
- 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 +6 -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-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -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 +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- 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-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -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 +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.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-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-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -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
|
|
@@ -10,6 +11,10 @@
|
|
|
10
11
|
// so a wide table inside a horizontal scroller is not read as covering its neighbour.
|
|
11
12
|
// Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
|
|
12
13
|
// the page: it waits for content to appear, knowing nothing about how the page is built.
|
|
14
|
+
// Themes (Lekhak TF-024): every screen is graded in light mode, then in dark mode when the app has
|
|
15
|
+
// one (its painted surface changes when dark is put on it; --themes light,dark|light|dark forces the
|
|
16
|
+
// choice). In both, the visual check measures text contrast (3.0 : 1 against what is painted behind
|
|
17
|
+
// it, disabled controls exempt) and, in dark mode, large light panels. tf-theme.mjs does both.
|
|
13
18
|
// At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
|
|
14
19
|
// visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
|
|
15
20
|
// zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
|
|
@@ -22,6 +27,9 @@
|
|
|
22
27
|
import { chromium } from 'playwright';
|
|
23
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
29
|
import { dirname, resolve } from 'node:path';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { parseThemes, useTheme, surface, differs, audit } from './tf-theme.mjs';
|
|
32
|
+
import { mockupMenus, walkMenus, placeholdersOnPage, reachability } from './tf-menus.mjs';
|
|
25
33
|
|
|
26
34
|
const argv = process.argv.slice(2);
|
|
27
35
|
const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
@@ -53,31 +61,147 @@ const ATTR = arg('--testid-attr', 'data-testid');
|
|
|
53
61
|
// own banner. A project on neither still gets every other render check; it simply cannot be
|
|
54
62
|
// told about a banner nobody named.
|
|
55
63
|
const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
|
|
64
|
+
// null = auto: light, and dark when the app has a dark theme (Lekhak TF-024)
|
|
65
|
+
const THEMES = parseThemes(arg('--themes', 'auto'));
|
|
66
|
+
const MIN_CONTRAST = parseFloat(arg('--min-contrast', '3'));
|
|
67
|
+
// Chatur TF-011: the menus are opened and every item clicked (tf-menus.mjs). --placeholder-text adds to
|
|
68
|
+
// the deny list, --no-click to the labels never clicked; --no-menus leaves the menus closed (said in the JSON).
|
|
69
|
+
const PLACEHOLDER_EXTRA = args('--placeholder-text');
|
|
70
|
+
const NO_CLICK_EXTRA = args('--no-click');
|
|
71
|
+
const WALK_MENUS = !argv.includes('--no-menus');
|
|
56
72
|
|
|
57
73
|
if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
|
|
58
74
|
|
|
59
75
|
// ---------------------------------------------------------------- screens
|
|
60
76
|
let screens = [];
|
|
77
|
+
// Sample values for route parameters (Lekhak TF-005): `/admin/llm-signin/{ProviderId:long}` opened as
|
|
78
|
+
// written answers 404. --route-value ProviderId=21 (repeatable), or "route_values" in the list, at its
|
|
79
|
+
// top or on one screen. A screen whose route still needs a value is not driven, and says which.
|
|
80
|
+
const ROUTE_VALUES = {};
|
|
81
|
+
for (const kv of args('--route-value')) { const m = kv.match(/^([^=]+)=(.*)$/); if (m) ROUTE_VALUES[m[1].trim().toLowerCase()] = m[2]; }
|
|
82
|
+
let listValues = {};
|
|
61
83
|
if (LIST) {
|
|
62
84
|
const l = JSON.parse(readFileSync(LIST, 'utf8'));
|
|
63
|
-
|
|
85
|
+
listValues = l.route_values || {};
|
|
86
|
+
screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [], route_values: s.route_values || {}, seed: s.seed || '' }));
|
|
64
87
|
}
|
|
65
88
|
for (const s of args('--screen')) {
|
|
66
89
|
const m = s.match(/^([^=]+)=(.+)$/);
|
|
67
90
|
if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
|
|
68
91
|
}
|
|
69
92
|
if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
|
|
93
|
+
const fillRoute = (s) => {
|
|
94
|
+
const vals = {};
|
|
95
|
+
for (const src of [listValues, s.route_values || {}]) for (const [k, v] of Object.entries(src)) vals[k.toLowerCase()] = String(v);
|
|
96
|
+
Object.assign(vals, ROUTE_VALUES);
|
|
97
|
+
const need = [];
|
|
98
|
+
const route = s.route.replace(/\/?\{\*{0,2}([A-Za-z_][\w]*)(?::[^}=?]*)?(?:=([^}?]*))?(\?)?\}/g, (whole, name, dflt, opt) => {
|
|
99
|
+
const lead = whole.startsWith('/') ? '/' : '';
|
|
100
|
+
const v = vals[name.toLowerCase()] ?? dflt;
|
|
101
|
+
if (v !== undefined && v !== '') return lead + encodeURIComponent(v);
|
|
102
|
+
if (opt) return '';
|
|
103
|
+
need.push(name); return whole;
|
|
104
|
+
});
|
|
105
|
+
return { route, need };
|
|
106
|
+
};
|
|
107
|
+
const skipped = [];
|
|
108
|
+
screens = screens.filter((s) => {
|
|
109
|
+
const f = fillRoute(s);
|
|
110
|
+
if (f.need.length) { skipped.push({ name: s.name, route: s.route, rows: s.rows, needs: f.need }); return false; }
|
|
111
|
+
if (f.route !== s.route) s.route_pattern = s.route;
|
|
112
|
+
s.route = f.route;
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
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
116
|
|
|
71
117
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
72
118
|
const anchorsOf = (mockup) => {
|
|
73
119
|
if (!mockup || !existsSync(mockup)) return null;
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
// Only an attribute ON AN ELEMENT is a control the page must carry. A stylesheet rule such as
|
|
121
|
+
// [data-testid="source-mode"] .tab{…}, a script string or a comment naming one is not: TfLens's
|
|
122
|
+
// shared mockup stylesheet made every screen owe a dialog it never draws (TF-026).
|
|
123
|
+
const html = readFileSync(mockup, 'utf8')
|
|
124
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
125
|
+
.replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
|
|
126
|
+
const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
|
|
76
127
|
const out = new Set(); let m;
|
|
77
128
|
while ((m = re.exec(html))) out.add(m[1]);
|
|
78
129
|
return [...out];
|
|
79
130
|
};
|
|
80
131
|
|
|
132
|
+
// the words a mockup draws, so placeholder text the design itself shows is not held against the app
|
|
133
|
+
const mockTexts = {};
|
|
134
|
+
const mockTextOf = (mockup) => {
|
|
135
|
+
if (!mockup || !existsSync(mockup)) return '';
|
|
136
|
+
return mockTexts[mockup] ??= readFileSync(mockup, 'utf8').replace(/<!--[\s\S]*?-->/g, '')
|
|
137
|
+
.replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
|
|
141
|
+
// menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
|
|
142
|
+
// TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
|
|
143
|
+
// hides at every width -- a closed dialog -- is still owed, as before. None is excused when the
|
|
144
|
+
// mockup cannot be drawn.
|
|
145
|
+
async function hiddenByWidth(b, mockup, anchors) {
|
|
146
|
+
const out = {};
|
|
147
|
+
if (!b || !mockup || !existsSync(mockup) || !anchors || !anchors.length) return out;
|
|
148
|
+
const shown = {};
|
|
149
|
+
for (const width of WIDTHS) {
|
|
150
|
+
let ctx;
|
|
151
|
+
try {
|
|
152
|
+
ctx = await b.newContext({ viewport: { width, height: width < 600 ? 844 : 800 } });
|
|
153
|
+
const p = await ctx.newPage();
|
|
154
|
+
await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
|
|
155
|
+
shown[width] = await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
|
|
156
|
+
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
157
|
+
if (!el) return false;
|
|
158
|
+
const r = el.getBoundingClientRect();
|
|
159
|
+
const seen = el.checkVisibility ? el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) : true;
|
|
160
|
+
return seen && r.width > 0 && r.height > 0;
|
|
161
|
+
}), { anchors, attr: ATTR });
|
|
162
|
+
} catch (e) { return {}; } finally { if (ctx) await ctx.close().catch(() => {}); }
|
|
163
|
+
}
|
|
164
|
+
const everShown = new Set(Object.values(shown).flat());
|
|
165
|
+
for (const width of WIDTHS) out[width] = anchors.filter((id) => everShown.has(id) && !shown[width].includes(id));
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
|
|
170
|
+
// shown only while the database is down, a result shown only after "Test". The mockup says so with
|
|
171
|
+
// 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
|
|
172
|
+
// first view; when it is drawn and visible it is graded like any other. A control on a box marked
|
|
173
|
+
// data-tf-sample (a sample row a fresh app has no data for) is treated the same way (Chatur TF-002).
|
|
174
|
+
async function stateOnlyOf(b, mockup, anchors) {
|
|
175
|
+
if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
|
|
176
|
+
if (b) {
|
|
177
|
+
let ctx;
|
|
178
|
+
try {
|
|
179
|
+
ctx = await b.newContext();
|
|
180
|
+
const p = await ctx.newPage();
|
|
181
|
+
await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
|
|
182
|
+
return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
|
|
183
|
+
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
184
|
+
return !!el && !!el.closest('[data-tf-state],[data-state-testid],[data-tf-sample]');
|
|
185
|
+
}), { anchors, attr: ATTR });
|
|
186
|
+
} catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
|
|
187
|
+
}
|
|
188
|
+
const html = readFileSync(mockup, 'utf8');
|
|
189
|
+
return anchors.filter((id) => new RegExp(`<[^>]*\\bdata-(?:tf-state|state-testid|tf-sample)\\b[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']|<[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bdata-(?:tf-state|state-testid|tf-sample)\\b`).test(html));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A screen whose mockup draws sample data the app has none of is compared against an empty view
|
|
193
|
+
// (Chatur TF-002). Its seed, tests/verify/seed/<mockup name>.sh (tf-verify-list.py finds it), puts the
|
|
194
|
+
// running app in the state the mockup draws, once, before the screen is first opened. It is given the
|
|
195
|
+
// app's address as TF_BASE. A seed that fails is the screen's render finding: the screen was not in
|
|
196
|
+
// the state it is graded in.
|
|
197
|
+
function runSeed(seed) {
|
|
198
|
+
if (!seed) return null;
|
|
199
|
+
if (!existsSync(seed)) return { path: seed, ok: false, output: 'the seed script is not on disk at that path' };
|
|
200
|
+
const r = spawnSync('bash', [seed], { env: { ...process.env, TF_BASE: BASE || CDP }, encoding: 'utf8', timeout: 120000 });
|
|
201
|
+
const output = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').slice(-3).join(' | ').slice(0, 300);
|
|
202
|
+
return { path: seed, ok: r.status === 0, exit: r.status, output: r.error ? String(r.error.message) : output };
|
|
203
|
+
}
|
|
204
|
+
|
|
81
205
|
// ---------------------------------------------------------------- browser
|
|
82
206
|
let browser, cdpPage = null;
|
|
83
207
|
if (CDP) {
|
|
@@ -89,13 +213,18 @@ if (CDP) {
|
|
|
89
213
|
} else {
|
|
90
214
|
browser = await chromium.launch();
|
|
91
215
|
}
|
|
216
|
+
// mockups are drawn in a browser of our own: an attached desktop head is the app's, not ours
|
|
217
|
+
const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
|
|
92
218
|
|
|
93
219
|
function writeOut(extra, results = [], login = null) {
|
|
94
220
|
const summary = {
|
|
95
221
|
mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
|
|
222
|
+
themes: THEMES || (darkOffered ? ['light', 'dark'] : ['light']),
|
|
96
223
|
screens: results,
|
|
224
|
+
skipped, // screens not driven because their route needs a value (TF-005)
|
|
97
225
|
summary: {
|
|
98
226
|
screens: results.length,
|
|
227
|
+
skipped: skipped.length,
|
|
99
228
|
render_ok: results.filter((r) => r.render === 'OK').length,
|
|
100
229
|
render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
|
|
101
230
|
unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
|
|
@@ -109,28 +238,18 @@ function writeOut(extra, results = [], login = null) {
|
|
|
109
238
|
return summary;
|
|
110
239
|
}
|
|
111
240
|
|
|
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
|
-
}
|
|
241
|
+
// The sign-in recipe (TF-033, TF-044) and the way a screen behind an in-page sign-in is reached
|
|
242
|
+
// (AppManager TF-006) live in tf-login.mjs since AppManager TF-011, shared with tf-mockup-parity
|
|
243
|
+
// and tf-assets so every check reaches a screen the same way.
|
|
244
|
+
import { signIn, signedOut as signedOutShared, navigateInApp as navigateInAppShared } from './tf-login.mjs';
|
|
245
|
+
const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, attr: ATTR, settle: SETTLE, renderWait: RENDER_WAIT };
|
|
246
|
+
const login = (page) => signIn(page, LOGIN_OPTS);
|
|
130
247
|
|
|
131
248
|
async function contextFor(width) {
|
|
132
249
|
if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
|
|
133
|
-
|
|
250
|
+
// a development head on HTTPS carries a certificate nothing trusts; tf-mockup-parity and tf-assets
|
|
251
|
+
// already accept it, and without it every screen was UNREACHABLE (Lekhak TF-002)
|
|
252
|
+
const opts = { viewport: { width, height: width < 600 ? 844 : 800 }, ignoreHTTPSErrors: true };
|
|
134
253
|
if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
|
|
135
254
|
const ctx = await browser.newContext(opts);
|
|
136
255
|
if (COOKIE) {
|
|
@@ -157,6 +276,13 @@ async function navigate(page, route) {
|
|
|
157
276
|
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
158
277
|
}
|
|
159
278
|
|
|
279
|
+
// Signed out: on the sign-in address, or drawing a sign-in form — a password field WITH a sign-in
|
|
280
|
+
// button. A password field alone is not the sign-in page: AppManager's signed-in "Create user" form
|
|
281
|
+
// has one for the new account and was graded unreachable (AppManager TF-012). tf-login.mjs decides.
|
|
282
|
+
const signedOut = (page) => signedOutShared(page, LOGIN_OPTS);
|
|
283
|
+
// Open a route from inside the page, the way its own links do (tf-login.mjs).
|
|
284
|
+
const navigateInApp = (page, route) => navigateInAppShared(page, LOGIN_OPTS, route);
|
|
285
|
+
|
|
160
286
|
// Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
|
|
161
287
|
// It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
|
|
162
288
|
// it stands, so a control that is genuinely missing is still reported (TF-021).
|
|
@@ -228,8 +354,21 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
228
354
|
if (!el) { out.anchors.push({ id, present: false }); continue; }
|
|
229
355
|
const tag = el.tagName.toLowerCase();
|
|
230
356
|
const r = el.getBoundingClientRect();
|
|
231
|
-
const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
232
|
-
|
|
357
|
+
const text = String(el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
358
|
+
// A progress bar shows its value as a filled width, not as text (Chatur TF-009): a native
|
|
359
|
+
// progress or meter, a role="progressbar" carrying aria-valuenow, or a track holding a fill
|
|
360
|
+
// that has size and paints something (a background, an image or a border).
|
|
361
|
+
const bar = '[role="progressbar"][aria-valuenow],progress,meter';
|
|
362
|
+
const paints = (c) => {
|
|
363
|
+
const cr = c.getBoundingClientRect();
|
|
364
|
+
if (cr.width <= 0 || cr.height <= 0) return false;
|
|
365
|
+
const cs = getComputedStyle(c);
|
|
366
|
+
return !/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor) || cs.backgroundImage !== 'none'
|
|
367
|
+
|| ['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none');
|
|
368
|
+
};
|
|
369
|
+
const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe', 'progress', 'meter'].includes(tag)
|
|
370
|
+
|| el.matches(bar) || el.querySelector(`img,svg,canvas,input,select,textarea,button,a,${bar}`) !== null
|
|
371
|
+
|| [...el.children].some(paints);
|
|
233
372
|
const c = clipRect(el);
|
|
234
373
|
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
374
|
cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
|
|
@@ -246,8 +385,20 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
246
385
|
els.forEach((el, i) => {
|
|
247
386
|
const r = el.getBoundingClientRect();
|
|
248
387
|
const c = clipRect(el); // what is painted, not what is laid out
|
|
249
|
-
|
|
250
|
-
|
|
388
|
+
// An inline element that wraps paints one fragment per line, and its bounding box is the union
|
|
389
|
+
// of them: two sentences sharing a line then "overlap" across the tile's whole width though no
|
|
390
|
+
// pixel of one is drawn over the other (TF-036, TfLens /effort 2026-09-11). Keep the fragments,
|
|
391
|
+
// each cut to the painted rectangle, and compare those.
|
|
392
|
+
let frags = null;
|
|
393
|
+
if (getComputedStyle(el).display === 'inline') {
|
|
394
|
+
const fs = [...el.getClientRects()].map((f) => {
|
|
395
|
+
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);
|
|
396
|
+
return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
|
|
397
|
+
}).filter((f) => f.w > 0 && f.h > 0);
|
|
398
|
+
if (fs.length > 1) frags = fs;
|
|
399
|
+
}
|
|
400
|
+
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: String(el.innerText || el.value || '').trim().slice(0, 40),
|
|
401
|
+
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
402
|
});
|
|
252
403
|
// ancestry so a button inside its card is never an "overlap"
|
|
253
404
|
out.rel = [];
|
|
@@ -258,15 +409,18 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
258
409
|
}, { anchors, attr, errorSelectors });
|
|
259
410
|
}
|
|
260
411
|
|
|
261
|
-
function grade(info, consoleErrors, width) {
|
|
412
|
+
function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
|
|
262
413
|
const findings = [];
|
|
414
|
+
// a control of another state is graded only when this view draws it, visible and filled (TF-005)
|
|
415
|
+
const soft = new Set(stateOnly);
|
|
416
|
+
info = { ...info, anchors: info.anchors.filter((a) => !soft.has(a.id) || (a.present && a.visible && a.filled && a.w > 0 && a.h > 0)) };
|
|
263
417
|
if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
|
|
264
418
|
// a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
|
|
265
419
|
const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
|
|
266
420
|
for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
|
|
267
421
|
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
422
|
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` });
|
|
423
|
+
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
424
|
else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
|
|
271
425
|
}
|
|
272
426
|
for (const t of info.tables) {
|
|
@@ -291,13 +445,32 @@ function grade(info, consoleErrors, width) {
|
|
|
291
445
|
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
292
446
|
const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
293
447
|
if (ix <= 4 || iy <= 4) continue;
|
|
294
|
-
|
|
448
|
+
let inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
|
|
449
|
+
if (a.frags || b.frags) { // a wrapped inline element: what its fragments share (TF-036)
|
|
450
|
+
const fa = a.frags || [a], fb = b.frags || [b];
|
|
451
|
+
const area = (fs) => fs.reduce((s, f) => s + f.w * f.h, 0);
|
|
452
|
+
inter = 0;
|
|
453
|
+
for (const p of fa) for (const q of fb) {
|
|
454
|
+
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);
|
|
455
|
+
if (px > 4 && py > 4) inter += px * py;
|
|
456
|
+
}
|
|
457
|
+
if (inter === 0) continue;
|
|
458
|
+
small = Math.min(area(fa), area(fb));
|
|
459
|
+
}
|
|
295
460
|
if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
|
|
296
461
|
if (inter / small < 0.25) continue; // a touch, not an overlap
|
|
297
462
|
overlaps++;
|
|
298
463
|
const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
|
|
299
464
|
findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
|
|
300
465
|
}
|
|
466
|
+
// Lekhak TF-024: text a reader cannot read, and a white panel left in a dark shell
|
|
467
|
+
const c = info.contrast;
|
|
468
|
+
if (c) {
|
|
469
|
+
for (const x of c.low) findings.push({ check: 'visual', class: 'low-contrast',
|
|
470
|
+
detail: `text "${x.text}" in ${x.where} is ${x.ratio} : 1 against its background (${x.fg} on ${x.bg}; ${MIN_CONTRAST} : 1 is the minimum)${c.low_n > c.low.length ? `, one of ${c.low_n}` : ''}` });
|
|
471
|
+
for (const x of c.light) findings.push({ check: 'visual', class: 'light-surface',
|
|
472
|
+
detail: `${x.where} paints a light panel (${x.bg}, ${x.share}% of the window) in dark mode${x.text ? ` ("${x.text}")` : ''}${c.light_n > c.light.length ? `, one of ${c.light_n}` : ''}` });
|
|
473
|
+
}
|
|
301
474
|
const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
|
|
302
475
|
: findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
|
|
303
476
|
const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
|
|
@@ -309,6 +482,7 @@ mkdirSync(SHOTS, { recursive: true });
|
|
|
309
482
|
const results = [];
|
|
310
483
|
let loginResult = null;
|
|
311
484
|
let firstUnreachable = false;
|
|
485
|
+
let darkOffered = false; // auto mode: has any screen shown a dark theme yet? (Lekhak TF-024)
|
|
312
486
|
for (const width of WIDTHS) {
|
|
313
487
|
const { page, close } = await contextFor(width);
|
|
314
488
|
const consoleErrors = [];
|
|
@@ -317,39 +491,162 @@ for (const width of WIDTHS) {
|
|
|
317
491
|
if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
|
|
318
492
|
for (const s of screens) {
|
|
319
493
|
let r = results.find((x) => x.name === s.name);
|
|
320
|
-
if (!r) {
|
|
494
|
+
if (!r) {
|
|
495
|
+
r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r);
|
|
496
|
+
r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
|
|
497
|
+
r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
|
|
498
|
+
if (s.route_pattern) r.route_pattern = s.route_pattern;
|
|
499
|
+
if (s.seed) r.seed = runSeed(s.seed);
|
|
500
|
+
}
|
|
501
|
+
const excused = (r.hidden_by_width || {})[width] || [];
|
|
502
|
+
const stateOnly = r.state_only || [];
|
|
321
503
|
consoleErrors.length = 0;
|
|
322
|
-
|
|
504
|
+
let nav = await navigate(page, s.route);
|
|
323
505
|
const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
|
|
324
506
|
const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
|
|
507
|
+
// --- AppManager TF-006: a sign-in the page keeps for itself, not in a cookie. The document for a
|
|
508
|
+
// signed-in screen answers 401 because the server cannot see the sign-in; the page then draws the
|
|
509
|
+
// screen once its live connection reads the session in the tab, or never, when the sign-in lives
|
|
510
|
+
// only in that connection. So a 401 or 403 is judged by what the page draws: signed in, the screen
|
|
511
|
+
// is graded; still signed out, the tool signs in again and opens the screen from inside the page.
|
|
512
|
+
if (!CDP && LOGIN_PATH && USER && (nav.status === 401 || nav.status === 403)) {
|
|
513
|
+
await waitForRender(page, [], ATTR);
|
|
514
|
+
let how = `answered HTTP ${nav.status}, then drew the screen signed in`;
|
|
515
|
+
if (await signedOut(page)) {
|
|
516
|
+
how = '';
|
|
517
|
+
const again = await login(page);
|
|
518
|
+
if (again.ok && await navigateInApp(page, s.route) && !(await signedOut(page))) how = `answered HTTP ${nav.status}; opened from inside the page after signing in`;
|
|
519
|
+
}
|
|
520
|
+
if (how) { entry.reached = how; nav = { status: 200, url: page.url() }; entry.url = nav.url; }
|
|
521
|
+
}
|
|
325
522
|
const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
|
|
326
523
|
if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
|
|
327
524
|
entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
|
|
328
525
|
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
526
|
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
527
|
+
try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
|
|
528
|
+
r.widths.push(entry);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
|
|
532
|
+
// one pass per theme on the same opened screen; each pass puts the page back as it found it, so an
|
|
533
|
+
// attached desktop head is left in the viewer's theme (Lekhak TF-024)
|
|
534
|
+
let lightSurface = null;
|
|
535
|
+
for (const theme of THEMES || ['light', 'dark']) {
|
|
536
|
+
const restore = await useTheme(page, theme);
|
|
537
|
+
try {
|
|
538
|
+
if (theme === 'light') lightSurface = await surface(page);
|
|
539
|
+
if (theme === 'dark' && !THEMES && !darkOffered) {
|
|
540
|
+
// auto: the app has a dark theme when dark changes what it paints. Asked screen by screen until
|
|
541
|
+
// one answers yes; from then on every screen is graded in dark, so a screen that ignores the
|
|
542
|
+
// dark theme altogether is graded in it too, and fails.
|
|
543
|
+
darkOffered = differs(lightSurface, await surface(page));
|
|
544
|
+
if (!darkOffered) { r.dark = 'not offered'; continue; }
|
|
545
|
+
}
|
|
546
|
+
const te = theme === 'light' ? entry : { ...entry, screenshot: `${SHOTS}/${slug(s.name)}-${width}-${theme}.png` };
|
|
547
|
+
te.theme = theme;
|
|
548
|
+
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
549
|
+
info.contrast = await audit(page, theme === 'dark', MIN_CONTRAST);
|
|
550
|
+
const g = grade(info, theme === 'light' ? consoleErrors : [], width, excused, stateOnly);
|
|
551
|
+
// a screen still showing placeholder text its mockup does not draw is not built (Chatur TF-011)
|
|
552
|
+
if (theme === (THEMES || ['light'])[0]) {
|
|
553
|
+
for (const p of await placeholdersOnPage(page, mockTextOf(s.mockup), PLACEHOLDER_EXTRA)) g.findings.push({ check: 'render', class: 'placeholder', detail: `the screen shows placeholder text "${p}"` });
|
|
554
|
+
if (g.render === 'OK' && g.findings.some((f) => f.class === 'placeholder')) g.render = 'EMPTY';
|
|
555
|
+
}
|
|
556
|
+
if (r.seed && !r.seed.ok) {
|
|
557
|
+
g.findings.unshift({ check: 'render', class: 'other', detail: `seed ${r.seed.path} failed (exit ${r.seed.exit ?? '?'}): ${r.seed.output}` });
|
|
558
|
+
if (g.render === 'OK') g.render = 'EMPTY';
|
|
559
|
+
}
|
|
560
|
+
if (theme === 'dark') for (const f of g.findings) { f.theme = 'dark'; f.detail += ' in dark mode'; }
|
|
561
|
+
if (excused.length) te.hidden_in_mockup = excused;
|
|
562
|
+
if (stateOnly.length) te.state_only = stateOnly;
|
|
563
|
+
Object.assign(te, g);
|
|
564
|
+
te.console_errors = theme === 'light' ? consoleErrors.slice(0, 5) : [];
|
|
565
|
+
te.anchors_present = (info.anchors || []).filter((a) => a.present).length;
|
|
566
|
+
try { await page.screenshot({ path: te.screenshot, fullPage: !CDP }); } catch (e) { te.screenshot = ''; }
|
|
567
|
+
r.widths.push(te);
|
|
568
|
+
if (theme === 'dark') r.dark = 'graded';
|
|
569
|
+
} finally { await restore(); }
|
|
337
570
|
}
|
|
338
|
-
try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
|
|
339
|
-
r.widths.push(entry);
|
|
340
571
|
}
|
|
341
572
|
await close();
|
|
342
573
|
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------- menus (Chatur TF-011)
|
|
576
|
+
// Every menu the mockups draw open is opened in the app and every item clicked, once per menu, on the
|
|
577
|
+
// first screen that draws it, at the first width. What a menu lacks or does wrong is a render finding
|
|
578
|
+
// on every screen whose mockup draws that menu. A screen a menu item links to, which no click reached,
|
|
579
|
+
// is UNREACHABLE.
|
|
580
|
+
let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
|
|
581
|
+
if (WALK_MENUS && mockBrowser) {
|
|
582
|
+
const byScreen = {};
|
|
583
|
+
{ const ctx = await mockBrowser.newContext(); const mp = await ctx.newPage();
|
|
584
|
+
for (const s of screens) {
|
|
585
|
+
if (!s.mockup || !existsSync(s.mockup)) continue;
|
|
586
|
+
try { await mp.goto('file://' + resolve(s.mockup), { waitUntil: 'load', timeout: 15000 }); byScreen[s.name] = await mockupMenus(mp, ATTR); } catch (e) { byScreen[s.name] = []; }
|
|
587
|
+
}
|
|
588
|
+
await ctx.close().catch(() => {}); }
|
|
589
|
+
const key = (m) => `${m.trigger}|${m.popup}`;
|
|
590
|
+
const done = new Map(); // menu key -> its findings
|
|
591
|
+
const width = WIDTHS[0];
|
|
592
|
+
const anyMockMenus = Object.values(byScreen).some((l) => l.length);
|
|
593
|
+
for (const s of screens) {
|
|
594
|
+
const r = results.find((x) => x.name === s.name);
|
|
595
|
+
if (!r || r.render === 'UNREACHABLE' || r.widths.every((w) => w.render === 'UNREACHABLE')) continue;
|
|
596
|
+
const todo = (byScreen[s.name] || []).filter((m) => !done.has(key(m)));
|
|
597
|
+
if (anyMockMenus && !todo.length) continue;
|
|
598
|
+
if (!anyMockMenus && done.size) break; // the app's own menus: walked once, on the first screen
|
|
599
|
+
const { page, close } = await contextFor(width);
|
|
600
|
+
try {
|
|
601
|
+
if (!CDP) await login(page);
|
|
602
|
+
const back = async () => { await navigate(page, s.route); await waitForRender(page, (r.anchors || []).slice(0, 20), ATTR); };
|
|
603
|
+
await back();
|
|
604
|
+
const pages = async () => CDP ? browser.contexts().flatMap((c) => c.pages()) : page.context().pages();
|
|
605
|
+
const w = await walkMenus(page, todo, { attr: ATTR, settle: SETTLE, back, pages, keepWindows: !!CDP, mockText: mockTextOf(s.mockup), placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
|
|
606
|
+
menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: s.name })));
|
|
607
|
+
menuWalk.clicks.push(...w.clicks.map(({ ids_before, ...c }) => ({ ...c, landed: c.landed ? { url: c.landed.url, window: c.landed.window } : null, screen: s.name })));
|
|
608
|
+
menuWalk._clicks = (menuWalk._clicks || []).concat(w.clicks);
|
|
609
|
+
if (!w.from_mockup) done.set('app|', w.findings);
|
|
610
|
+
for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
|
|
611
|
+
if (!w.from_mockup) { r.menu_findings = w.findings; }
|
|
612
|
+
} catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
|
|
613
|
+
finally { await close(); }
|
|
614
|
+
}
|
|
615
|
+
for (const r of results) {
|
|
616
|
+
const mine = (byScreen[r.name] || []).flatMap((m) => done.get(key(m)) || []).concat(r.menu_findings || []);
|
|
617
|
+
delete r.menu_findings;
|
|
618
|
+
if (!mine.length || !r.widths.length) continue;
|
|
619
|
+
const w0 = r.widths.find((w) => w.render !== 'UNREACHABLE') || r.widths[0];
|
|
620
|
+
w0.findings = [...(w0.findings || []), ...mine.map((f) => ({ check: 'render', class: f.class, detail: f.detail }))];
|
|
621
|
+
if (w0.render === 'OK') w0.render = 'EMPTY';
|
|
622
|
+
}
|
|
623
|
+
const reach = reachability(screens.map((s) => { const r = results.find((x) => x.name === s.name) || {}; return { ...s, anchors: r.anchors, state_only: r.state_only }; }), menuWalk._clicks || []);
|
|
624
|
+
delete menuWalk._clicks;
|
|
625
|
+
menuWalk.reach = reach;
|
|
626
|
+
for (const [name, v] of Object.entries(reach)) {
|
|
627
|
+
const r = results.find((x) => x.name === name);
|
|
628
|
+
if (!r || v.reached) { if (r) r.reached_by = v.by; continue; }
|
|
629
|
+
r.unreachable_by_menu = `no menu click reaches ${r.route}: ${v.tried.join('; ')}`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
343
633
|
for (const r of results) {
|
|
344
634
|
const rs = r.widths.map((w) => w.render);
|
|
345
635
|
r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
|
|
636
|
+
if (r.unreachable_by_menu) {
|
|
637
|
+
// the verdict reads the first finding of an UNREACHABLE screen as its reason
|
|
638
|
+
r.render = 'UNREACHABLE';
|
|
639
|
+
r.widths[0].findings = [{ check: 'render', class: 'unreachable', detail: r.unreachable_by_menu }, ...(r.widths[0].findings || [])];
|
|
640
|
+
}
|
|
346
641
|
r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
|
|
347
642
|
r.anchors_n = (r.anchors || []).length;
|
|
348
643
|
const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
|
|
349
|
-
|
|
644
|
+
r.themes = [...new Set(r.widths.map((w) => w.theme).filter(Boolean))];
|
|
645
|
+
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)' : ''}, ${r.themes.join(' + ') || 'no'} theme${r.themes.length === 1 ? '' : 's'}${r.dark === 'not offered' ? ' (the app has no dark theme)' : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
|
|
350
646
|
}
|
|
351
|
-
const out = writeOut({}, results, loginResult);
|
|
647
|
+
const out = writeOut({ menus: menuWalk }, results, loginResult);
|
|
352
648
|
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}`);
|
|
649
|
+
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}`);
|
|
650
|
+
if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});
|
|
354
651
|
if (!CDP) await browser.close(); else await browser.close().catch(() => {});
|
|
355
652
|
process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
|
|
@@ -7,12 +7,25 @@
|
|
|
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
|
+
# bash .tfcore/utils/tf-verify-screens.sh --list … --appium http://localhost:4723 # a MAUI Mac Catalyst head
|
|
13
|
+
# (tf-verify-native.mjs: AutomationId for data-testid, the app's window for the screenshot; the
|
|
14
|
+
# bundle id comes from boot.json or --bundle; no Playwright needed)
|
|
15
|
+
# A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
|
|
16
|
+
# of the screen: it is not owed on the first view.
|
|
17
|
+
# … --themes light,dark | light | dark # default auto: light, then dark once the app shows it has a
|
|
18
|
+
# # dark theme (what it paints changes when dark is put on it)
|
|
19
|
+
# … --min-contrast 3 # the text contrast floor, ratio : 1 (Lekhak TF-024)
|
|
10
20
|
#
|
|
11
21
|
# For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
|
|
12
22
|
# something, no header-only table, no blank page, no Blazor or console error (render); nothing
|
|
13
23
|
# overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
|
|
14
|
-
# (visual)
|
|
15
|
-
#
|
|
24
|
+
# (visual); every text run reaches 3.0 : 1 against the colour painted behind it (disabled controls
|
|
25
|
+
# exempt) and, in dark mode, no box over 2% of the window paints a light panel (visual). Each theme is
|
|
26
|
+
# put on the page through prefers-color-scheme and the light/dark value of its data-*theme attributes
|
|
27
|
+
# and mode classes, and taken off again after. Overlap and off-screen are measured on the rectangle an
|
|
28
|
+
# element paints in, clipped by every scrolling ancestor, and the page is read only after its first render (--render-wait,
|
|
16
29
|
# default 5000 ms). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
|
|
17
30
|
# writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
|
|
18
31
|
# row is written Implemented, and what the verdict script reads. It decides no row's status.
|
|
@@ -22,10 +35,15 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
22
35
|
# `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
|
|
23
36
|
# form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
|
|
24
37
|
case "${1:-}" in
|
|
25
|
-
-h|--help) sed -n '2,
|
|
26
|
-
"") sed -n '2,
|
|
38
|
+
-h|--help) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
39
|
+
"") sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
|
|
27
40
|
esac
|
|
28
41
|
command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
|
|
42
|
+
case " $* " in *" --appium "*)
|
|
43
|
+
source "$HERE/tf-lock.sh"; tf_take_lock verify-screens
|
|
44
|
+
node "$HERE/tf-verify-native.mjs" "$@"; exit $? ;;
|
|
45
|
+
esac
|
|
29
46
|
node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
|
|
30
47
|
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
|
-
|
|
48
|
+
source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
|
|
49
|
+
node "$HERE/tf-verify-screens.mjs" "$@"
|