@techierathore/techieflow 1.1.0 → 1.2.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/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/_smoke-test-policy.md +7 -5
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +15 -7
- 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 +25 -2
- package/.tfcore/hooks/guard-db.sh +106 -9
- package/.tfcore/hooks/guard-status-html.sh +5 -1
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- 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/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_smoke-test-policy.md +7 -5
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +2 -2
- package/.tfcore/tasks/day1-greenfield.md +4 -4
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +1 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +6 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -0
- package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
- package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.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-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-brd-status.py +10 -1
- package/.tfcore/utils/tf-build-list.py +16 -1
- package/.tfcore/utils/tf-build.sh +75 -0
- package/.tfcore/utils/tf-checklist-edit.py +28 -3
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +6 -2
- package/.tfcore/utils/tf-doc-check.py +124 -21
- package/.tfcore/utils/tf-doc-tests.sh +40 -3
- package/.tfcore/utils/tf-emit.sh +40 -10
- package/.tfcore/utils/tf-fix-close.sh +13 -7
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
- package/.tfcore/utils/tf-mockup-parity.sh +14 -3
- package/.tfcore/utils/tf-owner-text.py +27 -5
- package/.tfcore/utils/tf-phase.sh +24 -4
- package/.tfcore/utils/tf-render-html.py +19 -3
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-facts.py +90 -15
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +30 -4
- package/.tfcore/utils/tf-verify-boot.sh +296 -21
- package/.tfcore/utils/tf-verify-emit.sh +37 -17
- package/.tfcore/utils/tf-verify-list.py +219 -20
- 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 +184 -20
- package/.tfcore/utils/tf-verify-screens.sh +17 -4
- package/.tfcore/utils/tf-verify-tests.sh +107 -17
- package/.tfcore/utils/tf-verify-verdict.py +118 -15
- 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_feedback.py +86 -18
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +2 -2
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +14 -3
- 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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// tf-verify-native.mjs — the render and visual checks on a MAUI Mac Catalyst head, over Appium mac2.
|
|
2
|
+
// Run through tf-verify-screens.sh --appium <url>, not directly.
|
|
3
|
+
//
|
|
4
|
+
// The native twin of tf-verify-screens.mjs: the same list.json in, the same screens.json out (mode
|
|
5
|
+
// "appium"), so tf-verify-verdict.sh grades a Mac screen exactly as a web one. The mockup's
|
|
6
|
+
// data-testid anchors are looked for as AutomationId (the accessibility identifier the Mac shows).
|
|
7
|
+
// render every anchored control is present and shows something (text, a value, or content
|
|
8
|
+
// inside it); an anchored list has rows; the window is not blank; no error bar.
|
|
9
|
+
// visual no two visible controls overlap; no anchored control has zero size or sits outside
|
|
10
|
+
// the window. (A native window has no stylesheet and no sideways page scroll to check.)
|
|
11
|
+
// A web view head (Blazor Hybrid; boot.json webview, or --no-names): on a Mac neither data-testid
|
|
12
|
+
// nor an HTML id reaches mac2, so controls are not matched by name. Everything else runs: blank
|
|
13
|
+
// window, error bar (--error-text, repeatable; the defaults are the stock Blazor messages), overlap,
|
|
14
|
+
// and the screenshot. Each screen records anchors_not_measured, and the verdict says the name check
|
|
15
|
+
// was not measured (owner decision A, 2026-10-07).
|
|
16
|
+
// A screen is reached by clicking, inside the app, the control whose AutomationId is
|
|
17
|
+
// nav-<screen name> or, failing that, the tab, button, link or list item labelled with the screen's
|
|
18
|
+
// name, or by clicking in turn the labels the list read from the code for it (list.json `nav`, from
|
|
19
|
+
// tf-verify-list.sh: "Images", or "Users" then "+ Add New User"). Only a screen whose route is "/" may be
|
|
20
|
+
// the one the app opens on; any other screen nothing reached is UNREACHABLE and is never graded on
|
|
21
|
+
// whatever was on view (Lekhak TF-027: Search results passed on BlogAdmin's Dashboard). A screen the
|
|
22
|
+
// list says another project serves (a website page, when the Mac head is the desktop app) is skipped
|
|
23
|
+
// as another head's. The session opens the app by its .app path (boot.json app_path, or --app-path)
|
|
24
|
+
// when known, else by bundle id. Each screen is measured at every --widths (default 1280,390): the
|
|
25
|
+
// window is resized through macOS, the width it really took is recorded (an app's minimum size holds),
|
|
26
|
+
// and the screenshot is that window alone, taken by macOS by its window number.
|
|
27
|
+
// --from-source a.xml,b.xml grades saved page sources instead of a running app (one screen per
|
|
28
|
+
// file, no screenshot): how the self-tests check the grading where there is no Mac.
|
|
29
|
+
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { basename, dirname, join, normalize, resolve } from 'node:path';
|
|
32
|
+
import { openSession, parseSource, descendants, appPid, resizeWindow, windowShot, macWindow } from './tf-appium.mjs';
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
36
|
+
const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
|
|
37
|
+
const APPIUM = (arg('--appium') || '').replace(/\/$/, '');
|
|
38
|
+
const BOOT = arg('--boot', 'tests/.artifacts/verify/boot.json');
|
|
39
|
+
const boot = existsSync(BOOT) ? JSON.parse(readFileSync(BOOT, 'utf8')) : {};
|
|
40
|
+
const BUNDLE = arg('--bundle') || boot.bundle_id || '';
|
|
41
|
+
const APP_PATH = arg('--app-path') || boot.app_path || '';
|
|
42
|
+
const LIST = arg('--list');
|
|
43
|
+
const MOCKUPS = arg('--mockups', 'docs/mockups');
|
|
44
|
+
const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
|
|
45
|
+
const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
|
|
46
|
+
const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
|
|
47
|
+
const SETTLE = parseInt(arg('--wait', '1500'), 10); // after a click, before the first look
|
|
48
|
+
const ATTR = arg('--testid-attr', 'data-testid');
|
|
49
|
+
const FROM_SOURCE = (arg('--from-source') || '').split(',').filter(Boolean);
|
|
50
|
+
const NO_NAMES = argv.includes('--no-names') || boot.webview === true;
|
|
51
|
+
const ERROR_TEXTS = [...args('--error-text'), 'An unhandled error has occurred', 'An error has occurred. This application may no longer respond']
|
|
52
|
+
.map((t) => t.toLowerCase());
|
|
53
|
+
if (!FROM_SOURCE.length && (!APPIUM || !(BUNDLE || APP_PATH))) { console.error('tf-verify-native: --appium URL and the app (boot.json from tf-verify-boot.sh, --bundle or --app-path) are required'); process.exit(3); }
|
|
54
|
+
|
|
55
|
+
let screens = [];
|
|
56
|
+
if (LIST && !existsSync(LIST)) { console.error(`tf-verify-native: ${LIST} does not exist; make it first with bash .tfcore/utils/tf-verify-list.sh <App> ui`); process.exit(3); }
|
|
57
|
+
if (LIST) screens = (JSON.parse(readFileSync(LIST, 'utf8')).screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [],
|
|
58
|
+
nav: s.nav || null, project: s.project || '' }));
|
|
59
|
+
for (const s of args('--screen')) {
|
|
60
|
+
const m = s.match(/^([^=]+)=(.*)$/);
|
|
61
|
+
if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
|
|
62
|
+
}
|
|
63
|
+
if (FROM_SOURCE.length && !screens.length) screens = FROM_SOURCE.map((f) => ({ name: basename(f).replace(/\.xml$/, ''), route: '', mockup: '', rows: [] }));
|
|
64
|
+
if (!screens.length) { console.error('tf-verify-native: no screens (--list <json> or --screen name=route)'); process.exit(3); }
|
|
65
|
+
|
|
66
|
+
const WIDTHS = String(arg('--widths', '1280,390')).split(',').map((x) => parseInt(x, 10)).filter((x) => x > 0);
|
|
67
|
+
// The booted project and every project it references: a screen another project serves is another head's.
|
|
68
|
+
const heads = (() => {
|
|
69
|
+
const out = new Set(); const todo = boot.project ? [dirname(boot.project)] : [];
|
|
70
|
+
while (todo.length) {
|
|
71
|
+
const d = todo.pop().replace(/\/$/, '');
|
|
72
|
+
if (out.has(d)) continue; out.add(d);
|
|
73
|
+
try {
|
|
74
|
+
const proj = readdirSync(d).find((f) => f.endsWith('.csproj'));
|
|
75
|
+
for (const m of readFileSync(`${d}/${proj}`, 'utf8').matchAll(/<ProjectReference\s+Include="([^"]+)"/g))
|
|
76
|
+
todo.push(normalize(join(d, dirname(m[1].replace(/\\/g, '/')))));
|
|
77
|
+
} catch { /* not a project folder */ }
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
})();
|
|
81
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
82
|
+
const anchorsOf = (mockup) => { // as tf-verify-screens.mjs: an attribute on an element, not in a style or script
|
|
83
|
+
if (!mockup || !existsSync(mockup)) return null;
|
|
84
|
+
const html = readFileSync(mockup, 'utf8').replace(/<!--[\s\S]*?-->/g, '').replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
|
|
85
|
+
const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
|
|
86
|
+
const out = new Set(); let m;
|
|
87
|
+
while ((m = re.exec(html))) out.add(m[1]);
|
|
88
|
+
return [...out];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const LISTS = new Set(['CollectionView', 'Table', 'Outline']);
|
|
92
|
+
const CHROME = new Set(['TabBar', 'Toolbar', 'NavigationBar', 'MenuBar', 'Menu']);
|
|
93
|
+
const LEAF = new Set(['Button', 'StaticText', 'TextField', 'SecureTextField', 'TextView', 'Image', 'CheckBox', 'Switch', 'Slider',
|
|
94
|
+
'PopUpButton', 'ComboBox', 'Link', 'RadioButton', 'SegmentedControl', 'Stepper', 'DatePicker', 'ProgressIndicator', 'Cell']);
|
|
95
|
+
const WEB_CONTROLS = new Set(['Button', 'Link', 'TextField', 'SecureTextField', 'TextView', 'CheckBox', 'RadioButton', 'PopUpButton', 'ComboBox', 'Heading']);
|
|
96
|
+
const shows = (n) => [n.label, n.value, n.title].some((t) => (t || '').trim().length > 0);
|
|
97
|
+
|
|
98
|
+
function windowOf(nodes) { return nodes.find((n) => n.type === 'Window'); }
|
|
99
|
+
// chrome: bars, menus, and what sits directly on the window (its title, its three buttons)
|
|
100
|
+
function inChrome(nodes, n) {
|
|
101
|
+
if (n.parent >= 0 && nodes[n.parent].type === 'Window') return true;
|
|
102
|
+
for (let p = n.parent; p >= 0; p = nodes[p].parent) if (CHROME.has(nodes[p].type)) return true;
|
|
103
|
+
return CHROME.has(n.type);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// the app has drawn something: text or a control in its content, the window's own title not counted
|
|
107
|
+
function hasContent(nodes) {
|
|
108
|
+
const win = windowOf(nodes);
|
|
109
|
+
return !!win && descendants(nodes, win.i).some((n) => !inChrome(nodes, n) && ((n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText')));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function grade(nodes, anchors) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const win = windowOf(nodes);
|
|
115
|
+
const inside = win ? descendants(nodes, win.i) : [];
|
|
116
|
+
const content = inside.filter((n) => !inChrome(nodes, n));
|
|
117
|
+
const out = { anchors: [] };
|
|
118
|
+
for (const id of NO_NAMES ? [] : anchors || []) {
|
|
119
|
+
const n = inside.find((x) => x.id === id);
|
|
120
|
+
if (!n) { out.anchors.push({ id, present: false }); findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" is not on the screen (no AutomationId="${id}")` }); continue; }
|
|
121
|
+
const sub = descendants(nodes, n.i);
|
|
122
|
+
const lists = [n, ...sub].filter((x) => LISTS.has(x.type));
|
|
123
|
+
const cells = sub.filter((x) => x.type === 'Cell').length;
|
|
124
|
+
const filled = shows(n) || sub.some((x) => shows(x) || x.type === 'Cell' || x.type === 'Image' || x.type === 'TextField');
|
|
125
|
+
out.anchors.push({ id, present: true, type: n.type, filled, x: n.x, y: n.y, w: n.w, h: n.h, rows: lists.length ? cells : undefined });
|
|
126
|
+
if (lists.length && cells === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `list "${id}" (${lists[0].type}) has no rows` });
|
|
127
|
+
else if (!filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" (${n.type}) is empty` });
|
|
128
|
+
if (n.w === 0 || n.h === 0) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${id}" has zero ${n.w === 0 ? 'width' : 'height'}` });
|
|
129
|
+
else if (win && (n.x < win.x - 2 || n.y < win.y - 2 || n.x + n.w > win.x + win.w + 2 || n.y + n.h > win.y + win.h + 2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${id}" sits outside the window` });
|
|
130
|
+
}
|
|
131
|
+
// the error bar: shown text naming the app's own crash (a hidden bar is not in the tree at all)
|
|
132
|
+
const bar = content.find((n) => n.w > 0 && n.h > 0 && ERROR_TEXTS.some((t) => [n.label, n.value].some((v) => (v || '').toLowerCase().includes(t))));
|
|
133
|
+
if (bar) findings.push({ check: 'render', class: 'exception', detail: `the application's error bar is showing ("${(bar.label || bar.value).slice(0, 80)}")` });
|
|
134
|
+
if (!win) findings.push({ check: 'render', class: 'blank-data', detail: 'the app has no window open' });
|
|
135
|
+
else if (!content.some((n) => (n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText'))) findings.push({ check: 'render', class: 'blank-data', detail: 'the window is blank (no text, no control)' });
|
|
136
|
+
// overlap: visible leaf controls, never a control and one inside it; the same thresholds as the web
|
|
137
|
+
// check, on the rectangle each one PAINTS in. The tree gives a text its full size even where its box
|
|
138
|
+
// cuts it off: a table cell's comment, drawn as "Seeded approved…" in a 50 pt row, reported 170 pt
|
|
139
|
+
// and "overlapped" the button under the table (Lekhak Comments, 2026-10-07). So each rectangle is
|
|
140
|
+
// cut to every container's; the tree says nothing of overflow, so text that truly spills out of its
|
|
141
|
+
// box is not seen here.
|
|
142
|
+
const clip = (n) => {
|
|
143
|
+
let x1 = n.x, y1 = n.y, x2 = n.x + n.w, y2 = n.y + n.h;
|
|
144
|
+
for (let p = n.parent; p >= 0 && nodes[p].type !== 'Window'; p = nodes[p].parent) {
|
|
145
|
+
const a = nodes[p]; if (a.w <= 0 || a.h <= 0) continue;
|
|
146
|
+
x1 = Math.max(x1, a.x); y1 = Math.max(y1, a.y); x2 = Math.min(x2, a.x + a.w); y2 = Math.min(y2, a.y + a.h);
|
|
147
|
+
}
|
|
148
|
+
return { ...n, x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
|
|
149
|
+
};
|
|
150
|
+
// In a web view plain text is not compared, as the web check compares controls only (links,
|
|
151
|
+
// buttons, fields, headings): the tree does not say a box scrolls, so the lines of a scrolled log
|
|
152
|
+
// report places under the next card (Lekhak AI Story Studio, 2026-10-07).
|
|
153
|
+
const kinds = NO_NAMES ? WEB_CONTROLS : LEAF;
|
|
154
|
+
const boxes = content.filter((n) => kinds.has(n.type) && n.w > 0 && n.h > 0).map(clip).filter((n) => n.w > 0 && n.h > 0);
|
|
155
|
+
const anc = (a, b) => { for (let p = b.parent; p >= 0; p = nodes[p].parent) if (p === a.i) return true; return false; };
|
|
156
|
+
let overlaps = 0;
|
|
157
|
+
for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
|
|
158
|
+
const a = boxes[i], b = boxes[j];
|
|
159
|
+
if (anc(a, b) || anc(b, a)) continue;
|
|
160
|
+
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x), iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
161
|
+
if (ix <= 4 || iy <= 4) continue;
|
|
162
|
+
const r = (ix * iy) / Math.min(a.w * a.h, b.w * b.h);
|
|
163
|
+
if (r > 0.9 || r < 0.25) continue;
|
|
164
|
+
overlaps++;
|
|
165
|
+
const nm = (e) => e.id || `${e.type} "${(e.label || e.value || '').slice(0, 40)}"`;
|
|
166
|
+
findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)}` });
|
|
167
|
+
}
|
|
168
|
+
const render = findings.some((f) => f.class === 'exception') ? 'ERROR' : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
|
|
169
|
+
const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
|
|
170
|
+
return { render, visual, findings, anchors_present: out.anchors.filter((a) => a.present).length, window: win ? { w: win.w, h: win.h } : null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const skipped = [];
|
|
174
|
+
function writeOut(results, extra = {}, width = 0) {
|
|
175
|
+
const summary = {
|
|
176
|
+
mode: 'appium', base: APPIUM, bundle_id: BUNDLE, widths: width ? [width] : [], login: null, screens: results, skipped,
|
|
177
|
+
anchors_not_measured: NO_NAMES,
|
|
178
|
+
summary: {
|
|
179
|
+
screens: results.length, skipped: skipped.length,
|
|
180
|
+
render_ok: results.filter((r) => r.render === 'OK').length,
|
|
181
|
+
render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
|
|
182
|
+
unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
|
|
183
|
+
visual_ok: results.filter((r) => r.visual === 'OK').length,
|
|
184
|
+
visual_fail: results.filter((r) => r.visual === 'FAIL').length,
|
|
185
|
+
},
|
|
186
|
+
...extra,
|
|
187
|
+
};
|
|
188
|
+
mkdirSync(dirname(resolve(OUT)), { recursive: true });
|
|
189
|
+
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let s = null;
|
|
194
|
+
if (!FROM_SOURCE.length) {
|
|
195
|
+
try { s = await openSession(APPIUM, { bundleId: BUNDLE, appPath: APP_PATH }); }
|
|
196
|
+
catch (e) {
|
|
197
|
+
console.log(`UNREACHABLE appium ${APPIUM} (${APP_PATH || BUNDLE}): ${e.message}`);
|
|
198
|
+
console.log(' a mac2 session that cannot start is usually a permission: System Settings > Privacy & Security > Accessibility (the terminal, and WebDriverAgentRunner-Runner)');
|
|
199
|
+
writeOut(screens.map((x) => ({ ...x, anchors: anchorsOf(x.mockup), render: 'UNREACHABLE', visual: 'n/a', widths: [{ width: 0, render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `no Appium session: ${e.message}` }] }] })), { unreachable: true, error: e.message });
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
205
|
+
const q = (t) => t.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
206
|
+
// Button 9, RadioButton 10, Link 42 (a web view's menu), Cell 75: the control labelled `text`, else
|
|
207
|
+
// the one whose label holds it (a menu link reads "📷 Images" for the code's "Images")
|
|
208
|
+
async function control(win, text) {
|
|
209
|
+
for (const value of [`elementType IN {9, 10, 42, 75} AND (label ==[c] '${q(text)}' OR title ==[c] '${q(text)}')`,
|
|
210
|
+
`elementType IN {9, 10, 42, 75} AND label CONTAINS[c] '${q(text)}'`]) {
|
|
211
|
+
const el = await s.find('-ios predicate string', value, win).catch(() => null);
|
|
212
|
+
if (el) return el;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
async function reach(sc, first) {
|
|
217
|
+
const name = sc.name;
|
|
218
|
+
const win = await s.find('-ios predicate string', 'elementType == 4').catch(() => null);
|
|
219
|
+
if (sc.nav && sc.nav.length) { // the path the code gives (Lekhak TF-027)
|
|
220
|
+
for (const [i, label] of sc.nav.entries()) {
|
|
221
|
+
const el = await control(win, label);
|
|
222
|
+
if (!el) return { how: '', win, why: `the code links "${label}" to it${i ? ` (after ${sc.nav.slice(0, i).map((x) => `"${x}"`).join(', ')})` : ''}, and no such control was on view` };
|
|
223
|
+
await s.click(el); await sleep(SETTLE);
|
|
224
|
+
}
|
|
225
|
+
return { how: `clicked ${sc.nav.map((x) => `"${x}"`).join(' > ')}`, win };
|
|
226
|
+
}
|
|
227
|
+
const el = await s.find('accessibility id', `nav-${slug(name)}`, win).catch(() => null) || await control(win, name);
|
|
228
|
+
if (el) { await s.click(el); await sleep(SETTLE); return { how: `clicked the control for "${name}"`, win }; }
|
|
229
|
+
// only the start screen may be the one on view without a click; any other was never shown
|
|
230
|
+
if (first && ['', '/'].includes(String(sc.route || '').trim())) return { how: 'the screen the app opens on', win };
|
|
231
|
+
return { how: '', win };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
mkdirSync(SHOTS, { recursive: true });
|
|
235
|
+
const results = []; let width = 0;
|
|
236
|
+
const PID = !FROM_SOURCE.length && APP_PATH ? appPid(APP_PATH) : 0;
|
|
237
|
+
const before = PID ? macWindow(PID) : null; // put back at the end
|
|
238
|
+
let firstDrawn = false;
|
|
239
|
+
for (const [i, sc] of screens.entries()) {
|
|
240
|
+
// another head's screen: the list says a project the booted one does not include serves it
|
|
241
|
+
if (sc.project && heads.size && !heads.has(normalize(sc.project))) {
|
|
242
|
+
skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `it is served by ${sc.project}, not by the booted head (${boot.project})` });
|
|
243
|
+
console.log(`SKIP ${sc.name} (${sc.route}) — another head's screen: served by ${sc.project}`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (/\{/.test(sc.route || '') && !(sc.nav && sc.nav.length)) {
|
|
247
|
+
skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `its route ${sc.route} takes a value and no link in the code reaches it` });
|
|
248
|
+
console.log(`SKIP ${sc.name} (${sc.route}) — its route takes a value and no link in the code reaches it`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const r = { name: sc.name, route: sc.route, mockup: sc.mockup, rows: sc.rows, anchors: anchorsOf(sc.mockup), widths: [] };
|
|
252
|
+
if (NO_NAMES) r.anchors_not_measured = true;
|
|
253
|
+
results.push(r);
|
|
254
|
+
const want = NO_NAMES ? [] : r.anchors || [];
|
|
255
|
+
let nav;
|
|
256
|
+
try {
|
|
257
|
+
// the first look: opening the session starts the app again, and a web view took about eight
|
|
258
|
+
// seconds to draw its first page, menu included (2026-10-07). Wait for it before looking for the menu.
|
|
259
|
+
if (s && !firstDrawn) { const t1 = Date.now(); while (Date.now() - t1 < Math.max(RENDER_WAIT, 20000) && !hasContent(parseSource(await s.source()))) await sleep(300); firstDrawn = true; }
|
|
260
|
+
nav = FROM_SOURCE.length ? { how: `saved source ${FROM_SOURCE[i]}` } : await reach(sc, results.length === 1);
|
|
261
|
+
} catch (e) { nav = { how: '', why: `Appium: ${e.message}` }; }
|
|
262
|
+
if (!nav.how) {
|
|
263
|
+
const detail = nav.why ? `${sc.name} was not reached: ${nav.why}`
|
|
264
|
+
: `no control reaches ${sc.name}: no link to ${sc.route} was found in the code${NO_NAMES ? '' : `; give its tab, flyout item, link or button AutomationId="nav-${slug(sc.name)}"`}, or the label "${sc.name}"`;
|
|
265
|
+
r.widths.push({ width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail }] });
|
|
266
|
+
} else {
|
|
267
|
+
for (const w of PID ? WIDTHS : [0]) {
|
|
268
|
+
const shot = `${SHOTS}/${slug(sc.name)}${w ? `-${w}` : ''}-mac.png`;
|
|
269
|
+
let entry;
|
|
270
|
+
try {
|
|
271
|
+
const size = w ? resizeWindow(PID, w, w < 700 ? 844 : 800) : null;
|
|
272
|
+
if (w) await sleep(SETTLE);
|
|
273
|
+
const t0 = Date.now();
|
|
274
|
+
const src = async () => parseSource(FROM_SOURCE.length ? readFileSync(FROM_SOURCE[i], 'utf8') : await s.source());
|
|
275
|
+
let nodes = await src();
|
|
276
|
+
while (!FROM_SOURCE.length && Date.now() - t0 < RENDER_WAIT && !(want.length ? nodes.some((n) => want.includes(n.id)) : hasContent(nodes))) {
|
|
277
|
+
await sleep(300); nodes = await src();
|
|
278
|
+
}
|
|
279
|
+
const g = grade(nodes, want);
|
|
280
|
+
if (g.window) width = g.window.w;
|
|
281
|
+
entry = { width: g.window ? g.window.w : 0, height: g.window ? g.window.h : 0, url: '', status: 200, reached: nav.how, render_wait_ms: Date.now() - t0,
|
|
282
|
+
screenshot: FROM_SOURCE.length ? '' : shot, render: g.render, visual: g.visual, findings: g.findings, anchors_present: g.anchors_present, console_errors: [] };
|
|
283
|
+
if (w) { entry.requested_width = w; if (!size || Math.abs(size.w - w) > 8) entry.width_note = size ? `the window stayed ${size.w} px wide (the app's minimum)` : 'macOS did not resize the window (Accessibility for the terminal)'; }
|
|
284
|
+
if (NO_NAMES) entry.anchors_not_measured = true;
|
|
285
|
+
if (s) {
|
|
286
|
+
if (!(PID && windowShot(PID, shot))) {
|
|
287
|
+
try { writeFileSync(shot, Buffer.from(await s.shot(nav.win), 'base64')); } catch (e) { entry.screenshot = ''; }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch (e) {
|
|
291
|
+
entry = { width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `Appium: ${e.message}` }] };
|
|
292
|
+
}
|
|
293
|
+
r.widths.push(entry);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const worst = r.widths.find((e) => e.render !== 'OK') || r.widths[0];
|
|
297
|
+
r.render = worst.render; r.visual = r.widths.some((e) => e.visual === 'FAIL') ? 'FAIL' : worst.visual; r.anchors_n = (r.anchors || []).length;
|
|
298
|
+
const notes = [...new Set(r.widths.flatMap((e) => (e.findings || []).map((f) => f.detail + (e.requested_width ? ` @${e.width}` : ''))))].slice(0, 4);
|
|
299
|
+
const sizes = r.widths.filter((e) => e.requested_width).map((e) => `${e.width}${e.width_note ? ` (asked ${e.requested_width})` : ''}`);
|
|
300
|
+
const names = NO_NAMES ? 'names not measured (web view)' : `${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}`;
|
|
301
|
+
console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${names}${sizes.length ? `, @${sizes.join('/')}` : ''}${r.widths[0].reached ? ` — ${r.widths[0].reached}` : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
|
|
302
|
+
}
|
|
303
|
+
if (PID && before) resizeWindow(PID, before.w, before.h);
|
|
304
|
+
if (s) await s.close();
|
|
305
|
+
const out = writeOut(results, {}, width);
|
|
306
|
+
console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} skipped)` : ''}: 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${NO_NAMES ? '; control names not measured (web view)' : ''}; screenshots ${SHOTS}; JSON ${OUT}`);
|
|
307
|
+
process.exit(out.summary.screens && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// so a wide table inside a horizontal scroller is not read as covering its neighbour.
|
|
12
12
|
// Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
|
|
13
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.
|
|
14
18
|
// At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
|
|
15
19
|
// visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
|
|
16
20
|
// zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
|
|
@@ -23,6 +27,9 @@
|
|
|
23
27
|
import { chromium } from 'playwright';
|
|
24
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
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';
|
|
26
33
|
|
|
27
34
|
const argv = process.argv.slice(2);
|
|
28
35
|
const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
|
@@ -54,6 +61,14 @@ const ATTR = arg('--testid-attr', 'data-testid');
|
|
|
54
61
|
// own banner. A project on neither still gets every other render check; it simply cannot be
|
|
55
62
|
// told about a banner nobody named.
|
|
56
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');
|
|
57
72
|
|
|
58
73
|
if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
|
|
59
74
|
|
|
@@ -68,7 +83,7 @@ let listValues = {};
|
|
|
68
83
|
if (LIST) {
|
|
69
84
|
const l = JSON.parse(readFileSync(LIST, 'utf8'));
|
|
70
85
|
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 || {} }));
|
|
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 || '' }));
|
|
72
87
|
}
|
|
73
88
|
for (const s of args('--screen')) {
|
|
74
89
|
const m = s.match(/^([^=]+)=(.+)$/);
|
|
@@ -114,6 +129,14 @@ const anchorsOf = (mockup) => {
|
|
|
114
129
|
return [...out];
|
|
115
130
|
};
|
|
116
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
|
+
|
|
117
140
|
// The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
|
|
118
141
|
// menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
|
|
119
142
|
// TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
|
|
@@ -146,7 +169,8 @@ async function hiddenByWidth(b, mockup, anchors) {
|
|
|
146
169
|
// The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
|
|
147
170
|
// shown only while the database is down, a result shown only after "Test". The mockup says so with
|
|
148
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
|
|
149
|
-
// first view; when it is drawn and visible it is graded like any other.
|
|
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).
|
|
150
174
|
async function stateOnlyOf(b, mockup, anchors) {
|
|
151
175
|
if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
|
|
152
176
|
if (b) {
|
|
@@ -157,12 +181,25 @@ async function stateOnlyOf(b, mockup, anchors) {
|
|
|
157
181
|
await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
|
|
158
182
|
return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
|
|
159
183
|
const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
|
|
160
|
-
return !!el && !!el.closest('[data-tf-state],[data-state-testid]');
|
|
184
|
+
return !!el && !!el.closest('[data-tf-state],[data-state-testid],[data-tf-sample]');
|
|
161
185
|
}), { anchors, attr: ATTR });
|
|
162
186
|
} catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
|
|
163
187
|
}
|
|
164
188
|
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));
|
|
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 };
|
|
166
203
|
}
|
|
167
204
|
|
|
168
205
|
// ---------------------------------------------------------------- browser
|
|
@@ -182,6 +219,7 @@ const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
|
|
|
182
219
|
function writeOut(extra, results = [], login = null) {
|
|
183
220
|
const summary = {
|
|
184
221
|
mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
|
|
222
|
+
themes: THEMES || (darkOffered ? ['light', 'dark'] : ['light']),
|
|
185
223
|
screens: results,
|
|
186
224
|
skipped, // screens not driven because their route needs a value (TF-005)
|
|
187
225
|
summary: {
|
|
@@ -316,8 +354,27 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
316
354
|
if (!el) { out.anchors.push({ id, present: false }); continue; }
|
|
317
355
|
const tag = el.tagName.toLowerCase();
|
|
318
356
|
const r = el.getBoundingClientRect();
|
|
319
|
-
const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
320
|
-
|
|
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
|
+
// A checkbox, switch or radio drawn by a library (TrBlazeUI: <button role="checkbox"
|
|
370
|
+
// aria-checked="false">, no text, no child while unticked) paints its own box, the way a native
|
|
371
|
+
// <input type="checkbox"> does (Lekhak TF-035). Counted filled when it paints; an unpainted one
|
|
372
|
+
// with no size is still empty.
|
|
373
|
+
const toggle = '[role="checkbox"],[role="switch"],[role="radio"],[aria-checked]';
|
|
374
|
+
const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe', 'progress', 'meter'].includes(tag)
|
|
375
|
+
|| el.matches(bar) || el.querySelector(`img,svg,canvas,input,select,textarea,button,a,${bar}`) !== null
|
|
376
|
+
|| (el.matches(toggle) && paints(el)) || [...el.querySelectorAll(toggle)].some(paints)
|
|
377
|
+
|| [...el.children].some(paints);
|
|
321
378
|
const c = clipRect(el);
|
|
322
379
|
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
380
|
cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
|
|
@@ -346,7 +403,7 @@ async function inspect(page, anchors, attr, errorSelectors) {
|
|
|
346
403
|
}).filter((f) => f.w > 0 && f.h > 0);
|
|
347
404
|
if (fs.length > 1) frags = fs;
|
|
348
405
|
}
|
|
349
|
-
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
|
|
406
|
+
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: String(el.innerText || el.value || '').trim().slice(0, 40),
|
|
350
407
|
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 });
|
|
351
408
|
});
|
|
352
409
|
// ancestry so a button inside its card is never an "overlap"
|
|
@@ -412,6 +469,14 @@ function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
|
|
|
412
469
|
const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
|
|
413
470
|
findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
|
|
414
471
|
}
|
|
472
|
+
// Lekhak TF-024: text a reader cannot read, and a white panel left in a dark shell
|
|
473
|
+
const c = info.contrast;
|
|
474
|
+
if (c) {
|
|
475
|
+
for (const x of c.low) findings.push({ check: 'visual', class: 'low-contrast',
|
|
476
|
+
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}` : ''}` });
|
|
477
|
+
for (const x of c.light) findings.push({ check: 'visual', class: 'light-surface',
|
|
478
|
+
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}` : ''}` });
|
|
479
|
+
}
|
|
415
480
|
const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
|
|
416
481
|
: findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
|
|
417
482
|
const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
|
|
@@ -423,6 +488,7 @@ mkdirSync(SHOTS, { recursive: true });
|
|
|
423
488
|
const results = [];
|
|
424
489
|
let loginResult = null;
|
|
425
490
|
let firstUnreachable = false;
|
|
491
|
+
let darkOffered = false; // auto mode: has any screen shown a dark theme yet? (Lekhak TF-024)
|
|
426
492
|
for (const width of WIDTHS) {
|
|
427
493
|
const { page, close } = await contextFor(width);
|
|
428
494
|
const consoleErrors = [];
|
|
@@ -436,6 +502,7 @@ for (const width of WIDTHS) {
|
|
|
436
502
|
r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
|
|
437
503
|
r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
|
|
438
504
|
if (s.route_pattern) r.route_pattern = s.route_pattern;
|
|
505
|
+
if (s.seed) r.seed = runSeed(s.seed);
|
|
439
506
|
}
|
|
440
507
|
const excused = (r.hidden_by_width || {})[width] || [];
|
|
441
508
|
const stateOnly = r.state_only || [];
|
|
@@ -463,30 +530,127 @@ for (const width of WIDTHS) {
|
|
|
463
530
|
entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
|
|
464
531
|
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}` }];
|
|
465
532
|
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
533
|
+
try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
|
|
534
|
+
r.widths.push(entry);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
|
|
538
|
+
// one pass per theme on the same opened screen; each pass puts the page back as it found it, so an
|
|
539
|
+
// attached desktop head is left in the viewer's theme (Lekhak TF-024)
|
|
540
|
+
let lightSurface = null;
|
|
541
|
+
for (const theme of THEMES || ['light', 'dark']) {
|
|
542
|
+
const restore = await useTheme(page, theme);
|
|
543
|
+
try {
|
|
544
|
+
if (theme === 'light') lightSurface = await surface(page);
|
|
545
|
+
if (theme === 'dark' && !THEMES && !darkOffered) {
|
|
546
|
+
// auto: the app has a dark theme when dark changes what it paints. Asked screen by screen until
|
|
547
|
+
// one answers yes; from then on every screen is graded in dark, so a screen that ignores the
|
|
548
|
+
// dark theme altogether is graded in it too, and fails.
|
|
549
|
+
darkOffered = differs(lightSurface, await surface(page));
|
|
550
|
+
if (!darkOffered) { r.dark = 'not offered'; continue; }
|
|
551
|
+
}
|
|
552
|
+
const te = theme === 'light' ? entry : { ...entry, screenshot: `${SHOTS}/${slug(s.name)}-${width}-${theme}.png` };
|
|
553
|
+
te.theme = theme;
|
|
554
|
+
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
555
|
+
info.contrast = await audit(page, theme === 'dark', MIN_CONTRAST);
|
|
556
|
+
const g = grade(info, theme === 'light' ? consoleErrors : [], width, excused, stateOnly);
|
|
557
|
+
// a screen still showing placeholder text its mockup does not draw is not built (Chatur TF-011)
|
|
558
|
+
if (theme === (THEMES || ['light'])[0]) {
|
|
559
|
+
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}"` });
|
|
560
|
+
if (g.render === 'OK' && g.findings.some((f) => f.class === 'placeholder')) g.render = 'EMPTY';
|
|
561
|
+
}
|
|
562
|
+
if (r.seed && !r.seed.ok) {
|
|
563
|
+
g.findings.unshift({ check: 'render', class: 'other', detail: `seed ${r.seed.path} failed (exit ${r.seed.exit ?? '?'}): ${r.seed.output}` });
|
|
564
|
+
if (g.render === 'OK') g.render = 'EMPTY';
|
|
565
|
+
}
|
|
566
|
+
if (theme === 'dark') for (const f of g.findings) { f.theme = 'dark'; f.detail += ' in dark mode'; }
|
|
567
|
+
if (excused.length) te.hidden_in_mockup = excused;
|
|
568
|
+
if (stateOnly.length) te.state_only = stateOnly;
|
|
569
|
+
Object.assign(te, g);
|
|
570
|
+
te.console_errors = theme === 'light' ? consoleErrors.slice(0, 5) : [];
|
|
571
|
+
te.anchors_present = (info.anchors || []).filter((a) => a.present).length;
|
|
572
|
+
try { await page.screenshot({ path: te.screenshot, fullPage: !CDP }); } catch (e) { te.screenshot = ''; }
|
|
573
|
+
r.widths.push(te);
|
|
574
|
+
if (theme === 'dark') r.dark = 'graded';
|
|
575
|
+
} finally { await restore(); }
|
|
475
576
|
}
|
|
476
|
-
try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
|
|
477
|
-
r.widths.push(entry);
|
|
478
577
|
}
|
|
479
578
|
await close();
|
|
480
579
|
}
|
|
580
|
+
|
|
581
|
+
// ---------------------------------------------------------------- menus (Chatur TF-011)
|
|
582
|
+
// Every menu the mockups draw open is opened in the app and every item clicked, once per menu, on the
|
|
583
|
+
// first screen that draws it, at the first width. What a menu lacks or does wrong is a render finding
|
|
584
|
+
// on every screen whose mockup draws that menu. A screen a menu item links to, which no click reached,
|
|
585
|
+
// is UNREACHABLE.
|
|
586
|
+
let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
|
|
587
|
+
if (WALK_MENUS && mockBrowser) {
|
|
588
|
+
const byScreen = {};
|
|
589
|
+
{ const ctx = await mockBrowser.newContext(); const mp = await ctx.newPage();
|
|
590
|
+
for (const s of screens) {
|
|
591
|
+
if (!s.mockup || !existsSync(s.mockup)) continue;
|
|
592
|
+
try { await mp.goto('file://' + resolve(s.mockup), { waitUntil: 'load', timeout: 15000 }); byScreen[s.name] = await mockupMenus(mp, ATTR); } catch (e) { byScreen[s.name] = []; }
|
|
593
|
+
}
|
|
594
|
+
await ctx.close().catch(() => {}); }
|
|
595
|
+
const key = (m) => `${m.trigger}|${m.popup}`;
|
|
596
|
+
const done = new Map(); // menu key -> its findings
|
|
597
|
+
const width = WIDTHS[0];
|
|
598
|
+
const anyMockMenus = Object.values(byScreen).some((l) => l.length);
|
|
599
|
+
for (const s of screens) {
|
|
600
|
+
const r = results.find((x) => x.name === s.name);
|
|
601
|
+
if (!r || r.render === 'UNREACHABLE' || r.widths.every((w) => w.render === 'UNREACHABLE')) continue;
|
|
602
|
+
const todo = (byScreen[s.name] || []).filter((m) => !done.has(key(m)));
|
|
603
|
+
if (anyMockMenus && !todo.length) continue;
|
|
604
|
+
if (!anyMockMenus && done.size) break; // the app's own menus: walked once, on the first screen
|
|
605
|
+
const { page, close } = await contextFor(width);
|
|
606
|
+
try {
|
|
607
|
+
if (!CDP) await login(page);
|
|
608
|
+
const back = async () => { await navigate(page, s.route); await waitForRender(page, (r.anchors || []).slice(0, 20), ATTR); };
|
|
609
|
+
await back();
|
|
610
|
+
const pages = async () => CDP ? browser.contexts().flatMap((c) => c.pages()) : page.context().pages();
|
|
611
|
+
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 });
|
|
612
|
+
menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: s.name })));
|
|
613
|
+
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 })));
|
|
614
|
+
menuWalk._clicks = (menuWalk._clicks || []).concat(w.clicks);
|
|
615
|
+
if (!w.from_mockup) done.set('app|', w.findings);
|
|
616
|
+
for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
|
|
617
|
+
if (!w.from_mockup) { r.menu_findings = w.findings; }
|
|
618
|
+
} catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
|
|
619
|
+
finally { await close(); }
|
|
620
|
+
}
|
|
621
|
+
for (const r of results) {
|
|
622
|
+
const mine = (byScreen[r.name] || []).flatMap((m) => done.get(key(m)) || []).concat(r.menu_findings || []);
|
|
623
|
+
delete r.menu_findings;
|
|
624
|
+
if (!mine.length || !r.widths.length) continue;
|
|
625
|
+
const w0 = r.widths.find((w) => w.render !== 'UNREACHABLE') || r.widths[0];
|
|
626
|
+
w0.findings = [...(w0.findings || []), ...mine.map((f) => ({ check: 'render', class: f.class, detail: f.detail }))];
|
|
627
|
+
if (w0.render === 'OK') w0.render = 'EMPTY';
|
|
628
|
+
}
|
|
629
|
+
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 || []);
|
|
630
|
+
delete menuWalk._clicks;
|
|
631
|
+
menuWalk.reach = reach;
|
|
632
|
+
for (const [name, v] of Object.entries(reach)) {
|
|
633
|
+
const r = results.find((x) => x.name === name);
|
|
634
|
+
if (!r || v.reached) { if (r) r.reached_by = v.by; continue; }
|
|
635
|
+
r.unreachable_by_menu = `no menu click reaches ${r.route}: ${v.tried.join('; ')}`;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
481
639
|
for (const r of results) {
|
|
482
640
|
const rs = r.widths.map((w) => w.render);
|
|
483
641
|
r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
|
|
642
|
+
if (r.unreachable_by_menu) {
|
|
643
|
+
// the verdict reads the first finding of an UNREACHABLE screen as its reason
|
|
644
|
+
r.render = 'UNREACHABLE';
|
|
645
|
+
r.widths[0].findings = [{ check: 'render', class: 'unreachable', detail: r.unreachable_by_menu }, ...(r.widths[0].findings || [])];
|
|
646
|
+
}
|
|
484
647
|
r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
|
|
485
648
|
r.anchors_n = (r.anchors || []).length;
|
|
486
649
|
const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
|
|
487
|
-
|
|
650
|
+
r.themes = [...new Set(r.widths.map((w) => w.theme).filter(Boolean))];
|
|
651
|
+
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('; ') : ''}`);
|
|
488
652
|
}
|
|
489
|
-
const out = writeOut({}, results, loginResult);
|
|
653
|
+
const out = writeOut({ menus: menuWalk }, results, loginResult);
|
|
490
654
|
if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
|
|
491
655
|
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
656
|
if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});
|