@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
|
@@ -40,9 +40,12 @@
|
|
|
40
40
|
// means for a REQ.
|
|
41
41
|
|
|
42
42
|
import { chromium } from 'playwright';
|
|
43
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
44
|
-
import { dirname, resolve } from 'node:path';
|
|
43
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
44
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
45
45
|
import { pathToFileURL } from 'node:url';
|
|
46
|
+
import { signIn, reach } from './tf-login.mjs';
|
|
47
|
+
import { parseThemes, useTheme, surface, differs, looksDark } from './tf-theme.mjs';
|
|
48
|
+
import { mockupMenus, walkMenus, placeholdersOnPage, reachability } from './tf-menus.mjs';
|
|
46
49
|
|
|
47
50
|
// ---------------------------------------------------------------- arguments
|
|
48
51
|
const argv = process.argv.slice(2);
|
|
@@ -53,10 +56,19 @@ const arg = (name, dflt = null) => {
|
|
|
53
56
|
const flag = (name) => argv.includes(name);
|
|
54
57
|
|
|
55
58
|
const BASE = (arg('--base') || '').replace(/\/$/, '');
|
|
59
|
+
// An embedded-browser desktop head, attached over its DevTools port as tf-verify-screens does; its
|
|
60
|
+
// screens are opened the way its router does, by pushState (Lekhak TF-006).
|
|
61
|
+
const CDP = arg('--cdp') || '';
|
|
56
62
|
const MOCKUPS = arg('--mockups', 'docs/mockups');
|
|
57
63
|
const WIDTHS = (arg('--widths', '1280,390') || '').split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
|
|
58
64
|
const JSON_OUT = arg('--json-out');
|
|
59
65
|
const COOKIE = arg('--cookie');
|
|
66
|
+
// A sign-in the page keeps for itself sets no cookie, so --cookie could not reach AppManager's screens
|
|
67
|
+
// at all: "app returned HTTP 401" at both widths (AppManager TF-011). The same --login-path, --user,
|
|
68
|
+
// --password and --storage-state tf-verify-screens takes, through the shared recipe in tf-login.mjs.
|
|
69
|
+
const LOGIN_PATH = arg('--login-path'); const USER = arg('--user'); const PASS = arg('--password');
|
|
70
|
+
const STORAGE = arg('--storage-state');
|
|
71
|
+
const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, settle: 1500, renderWait: 5000 };
|
|
60
72
|
const HEADERS = [];
|
|
61
73
|
for (let i = 0; i < argv.length; i++) if (argv[i] === '--header' && argv[i + 1]) HEADERS.push(argv[i + 1]);
|
|
62
74
|
const SCREENS = [];
|
|
@@ -66,11 +78,118 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
66
78
|
SCREENS.push({ name: name.trim(), route: rest.join('=').trim() || '/' + name.trim() });
|
|
67
79
|
}
|
|
68
80
|
}
|
|
81
|
+
// Where each screen's mockup is (Lekhak TF-022). --list names the list.json tf-verify-list.sh wrote,
|
|
82
|
+
// which holds the path it resolved per screen (docs/mockups/admin/prompt-manager.html); a --screen is
|
|
83
|
+
// matched to a listed screen by its mockup's stem or its name, and with no --screen every listed screen
|
|
84
|
+
// that has a mockup and a route without a {value} is driven. Without a list, a mockup missing at the top
|
|
85
|
+
// of --mockups is looked for in its subfolders, and used when exactly one file has that name.
|
|
86
|
+
const MOCK_OF = {};
|
|
87
|
+
const LIST = arg('--list');
|
|
88
|
+
if (LIST) {
|
|
89
|
+
const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
90
|
+
let listed = [];
|
|
91
|
+
try { listed = JSON.parse(readFileSync(LIST, 'utf8')).screens || []; }
|
|
92
|
+
catch (e) { console.error(`tf-mockup-parity: --list ${LIST} unreadable (${String(e.message).slice(0, 80)})`); }
|
|
93
|
+
const byKey = {};
|
|
94
|
+
for (const ls of listed) {
|
|
95
|
+
if (!ls.mockup) continue;
|
|
96
|
+
byKey[basename(ls.mockup).replace(/\.html$/, '')] ??= ls;
|
|
97
|
+
byKey[slug(ls.name)] ??= ls;
|
|
98
|
+
}
|
|
99
|
+
if (!SCREENS.length) {
|
|
100
|
+
for (const ls of listed) {
|
|
101
|
+
if (ls.mockup && ls.route && !ls.route.includes('{')) SCREENS.push({ name: basename(ls.mockup).replace(/\.html$/, ''), route: ls.route });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const s of SCREENS) {
|
|
105
|
+
const ls = byKey[s.name] || byKey[slug(s.name)];
|
|
106
|
+
if (ls) MOCK_OF[s.name] = ls.mockup;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const findMockup = (name) => {
|
|
110
|
+
if (MOCK_OF[name]) return resolve(MOCK_OF[name]);
|
|
111
|
+
const top = resolve(MOCKUPS, `${name}.html`);
|
|
112
|
+
if (existsSync(top)) return top;
|
|
113
|
+
const hits = [];
|
|
114
|
+
const walk = (dir, depth) => {
|
|
115
|
+
if (depth > 4) return;
|
|
116
|
+
let ents = [];
|
|
117
|
+
try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
118
|
+
for (const e of ents) {
|
|
119
|
+
if (e.isDirectory() && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1);
|
|
120
|
+
else if (e.isFile() && e.name === `${name}.html` && dir !== resolve(MOCKUPS)) hits.push(join(dir, e.name));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(resolve(MOCKUPS), 0);
|
|
124
|
+
return hits.length === 1 ? hits[0] : top;
|
|
125
|
+
};
|
|
69
126
|
const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
|
|
127
|
+
// --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
|
|
128
|
+
const KEEP_APP_THEME = flag('--keep-app-theme');
|
|
129
|
+
// Lekhak TF-024: a screen is compared in the mockup's own theme and then in the other one. --themes
|
|
130
|
+
// light,dark runs both; light or dark alone runs only that one; auto (the default) runs the other one
|
|
131
|
+
// once the app shows it has it (what it paints changes when that theme is put on it). In the other
|
|
132
|
+
// theme the mockup is drawn in it too; when the mockup does not change under it (no dark styles in the
|
|
133
|
+
// mockup), the app is compared with the mockup as drawn and colour is left out, since the mockup says
|
|
134
|
+
// nothing about it; every other class is compared.
|
|
135
|
+
const THEMES = parseThemes(arg('--themes', 'auto'));
|
|
136
|
+
const wantTheme = (t) => !THEMES || THEMES.includes(t);
|
|
137
|
+
let otherSeen = false; // auto: has any screen shown the other theme yet?
|
|
138
|
+
// The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
|
|
139
|
+
// scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
|
|
140
|
+
// that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
|
|
141
|
+
// such as Tailwind's `dark` uses (Lekhak TF-013). A utility class such as bg-light does not count.
|
|
142
|
+
// Nothing about any one stack. The class tokens are kept under `<where>|class`, '' when there are none.
|
|
143
|
+
const READ_THEME = () => {
|
|
144
|
+
const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [where, el] of [['html', document.documentElement], ['body', document.body]]) {
|
|
147
|
+
if (!el) continue;
|
|
148
|
+
for (const a of el.attributes) if (/^data-.*(theme|mode|scheme)/i.test(a.name)) out[`${where}|${a.name}`] = a.value;
|
|
149
|
+
out[`${where}|class`] = [...el.classList].filter((c) => MODE.test(c)).join(' ');
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
// A page declares a theme when it has such an attribute or mode class. A mockup that declares none
|
|
154
|
+
// leaves the app as it is.
|
|
155
|
+
const DECLARES_THEME = (t) => !!t && Object.entries(t).some(([k, v]) => !k.endsWith('|class') || v);
|
|
156
|
+
// Sets those attributes and mode classes on this page; returns what it had, so the same call puts it
|
|
157
|
+
// back. An attribute the page lacked is removed again on the way back (value null). For `|class` the
|
|
158
|
+
// page's own mode tokens are replaced by the given ones; every other class is left alone.
|
|
159
|
+
const APPLY_THEME = (t) => {
|
|
160
|
+
const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
|
|
161
|
+
const before = {}; let changed = false;
|
|
162
|
+
for (const [k, v] of Object.entries(t || {})) {
|
|
163
|
+
const [where, name] = k.split('|');
|
|
164
|
+
const el = where === 'body' ? document.body : document.documentElement;
|
|
165
|
+
if (!el) continue;
|
|
166
|
+
if (name === 'class') {
|
|
167
|
+
const had = [...el.classList].filter((c) => MODE.test(c));
|
|
168
|
+
const want = String(v || '').split(/\s+/).filter(Boolean);
|
|
169
|
+
before[k] = had.join(' ');
|
|
170
|
+
if (had.slice().sort().join(' ') !== want.slice().sort().join(' ')) changed = true;
|
|
171
|
+
for (const c of had) el.classList.remove(c);
|
|
172
|
+
for (const c of want) el.classList.add(c);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const had = el.getAttribute(name);
|
|
176
|
+
before[k] = had;
|
|
177
|
+
if (had !== v) changed = true;
|
|
178
|
+
if (v === null) el.removeAttribute(name); else el.setAttribute(name, v);
|
|
179
|
+
}
|
|
180
|
+
return { before, changed };
|
|
181
|
+
};
|
|
70
182
|
const MAX_FINDINGS_PER_SCREEN = parseInt(arg('--max-findings', '40'), 10);
|
|
183
|
+
// Chatur TF-011: a closed menu bar matches its mockup, so menus drawn open (data-tf-state="popup-open") are
|
|
184
|
+
// opened and every item clicked (tf-menus.mjs). --placeholder-text and --no-click are repeatable;
|
|
185
|
+
// --no-menus leaves the menus closed.
|
|
186
|
+
const many = (name) => argv.flatMap((a, i) => (a === name && i + 1 < argv.length ? [argv[i + 1]] : []));
|
|
187
|
+
const PLACEHOLDER_EXTRA = many('--placeholder-text');
|
|
188
|
+
const NO_CLICK_EXTRA = many('--no-click');
|
|
189
|
+
const WALK_MENUS = !flag('--no-menus');
|
|
71
190
|
|
|
72
|
-
if (!BASE || SCREENS.length === 0) {
|
|
73
|
-
console.error('usage: tf-mockup-parity.sh --base URL --screen name=/route [--screen ...]');
|
|
191
|
+
if ((!BASE && !CDP) || SCREENS.length === 0) {
|
|
192
|
+
console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
|
|
74
193
|
process.exit(3);
|
|
75
194
|
}
|
|
76
195
|
|
|
@@ -78,7 +197,9 @@ if (!BASE || SCREENS.length === 0) {
|
|
|
78
197
|
// Everything below runs INSIDE the browser, on both the mockup and the app, and
|
|
79
198
|
// returns one flat index keyed by a stable path key. It is deliberately one
|
|
80
199
|
// evaluate() call: two passes over the same DOM can disagree after a re-layout.
|
|
81
|
-
|
|
200
|
+
// `wanted` names the texts of the mockup's badges, so the app's side can find each one at whatever
|
|
201
|
+
// depth the app drew it (TF-045); the mockup's own probe is called without it.
|
|
202
|
+
const PROBE = (wanted = []) => {
|
|
82
203
|
const MAX_DEPTH = 4; // descend this far below an anchor
|
|
83
204
|
const MAX_PER_ANCHOR = 60; // and no further; a huge subtree is noise, not signal
|
|
84
205
|
|
|
@@ -100,11 +221,23 @@ const PROBE = () => {
|
|
|
100
221
|
return false;
|
|
101
222
|
};
|
|
102
223
|
|
|
224
|
+
// --- TF-027: the browser converts the colour, never a regex. getComputedStyle returns a
|
|
225
|
+
// colour in the syntax it was written in, and a component library themed in oklch() came
|
|
226
|
+
// back as "oklch(0.971 0.013 17.38)": read as r/g/b those three numbers are near-black, so
|
|
227
|
+
// every tinted tile, pill and icon in the app bucketed as neutral against a mockup written
|
|
228
|
+
// in hex — "semantic colour differs — mockup negative, app neutral" on tiles plainly red.
|
|
229
|
+
// Painting one pixel and reading it back gives sRGB for any syntax the browser accepts.
|
|
230
|
+
const px = document.createElement('canvas');
|
|
231
|
+
px.width = px.height = 1;
|
|
232
|
+
const pctx = px.getContext('2d', { willReadFrequently: true });
|
|
103
233
|
const RGB = (v) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
234
|
+
if (!v || v === 'transparent' || !pctx) return null;
|
|
235
|
+
pctx.clearRect(0, 0, 1, 1);
|
|
236
|
+
pctx.fillStyle = 'rgba(0,0,0,0)';
|
|
237
|
+
pctx.fillStyle = v; // an unparseable value leaves it transparent
|
|
238
|
+
pctx.fillRect(0, 0, 1, 1);
|
|
239
|
+
const [r, g, b, a] = pctx.getImageData(0, 0, 1, 1).data;
|
|
240
|
+
return a / 255 < 0.05 ? null : { r, g, b };
|
|
108
241
|
};
|
|
109
242
|
|
|
110
243
|
// Semantic BUCKET, never the literal colour: two designs may legitimately differ
|
|
@@ -114,15 +247,26 @@ const PROBE = () => {
|
|
|
114
247
|
const { r, g, b } = c;
|
|
115
248
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
|
116
249
|
if (mx - mn < 28) return 'neutral';
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
250
|
+
// By hue, never by one channel. "Red is highest, so green above 120 is a warning" put a deep
|
|
251
|
+
// amber (180, 83, 9) in `negative` beside a light amber in `warning` — one warning tone on two
|
|
252
|
+
// sides of the line, reported as a colour difference on TfLens /misses (TF-045).
|
|
253
|
+
const d = mx - mn;
|
|
254
|
+
const h = ((mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + 360) % 360;
|
|
255
|
+
if (h < 15 || h >= 330) return 'negative';
|
|
256
|
+
if (h < 70) return 'warning';
|
|
257
|
+
if (h < 185) return 'positive';
|
|
258
|
+
return 'accent';
|
|
121
259
|
};
|
|
122
260
|
|
|
261
|
+
// A border nobody draws carries no colour. `border: 1px solid transparent` is how a mockup
|
|
262
|
+
// reserves space, and a page reset that sets border-color everywhere leaves the property set on
|
|
263
|
+
// elements with no border at all: reading it gave a borderless icon the reset's grey and called
|
|
264
|
+
// it a colour difference (TF-038).
|
|
265
|
+
const drawnBorder = (cs) => (parseFloat(cs.borderTopWidth) || 0) > 0 && !!RGB(cs.borderTopColor);
|
|
266
|
+
|
|
123
267
|
const semanticColor = (el) => {
|
|
124
268
|
const cs = getComputedStyle(el);
|
|
125
|
-
return bucket(RGB(cs.backgroundColor)) || bucket(RGB(cs.borderTopColor)) || bucket(RGB(cs.color));
|
|
269
|
+
return bucket(RGB(cs.backgroundColor)) || (drawnBorder(cs) ? bucket(RGB(cs.borderTopColor)) : null) || bucket(RGB(cs.color));
|
|
126
270
|
};
|
|
127
271
|
|
|
128
272
|
// --- TF-009: the seventh class. A mockup says "provisional / estimated /
|
|
@@ -136,12 +280,39 @@ const PROBE = () => {
|
|
|
136
280
|
const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
|
|
137
281
|
: s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
|
|
138
282
|
const sides = ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'].map((k) => q(cs[k]));
|
|
139
|
-
const
|
|
140
|
-
const style =
|
|
141
|
-
return { style, uniform: new Set(sides).size === 1, visible:
|
|
283
|
+
const drawn = drawnBorder(cs); // width AND a colour: transparent draws nothing (TF-038)
|
|
284
|
+
const style = drawn ? sides[0] : 'none';
|
|
285
|
+
return { style, uniform: new Set(sides).size === 1, visible: drawn };
|
|
142
286
|
};
|
|
143
287
|
|
|
144
|
-
const
|
|
288
|
+
const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
|
|
289
|
+
// A box the mockup marks as another state of the screen is left out of the comparison (Lekhak
|
|
290
|
+
// TF-011), and so is everything inside it when a box around it is counted: the download icon in a
|
|
291
|
+
// "newer build" banner was reported on the card holding the banner (Chatur TF-001).
|
|
292
|
+
// data-tf-state="sample-data" is not another state: it is sample data, the data-tf-sample rule
|
|
293
|
+
// (Chatur TF-005). Left out here, a list the app fills with real rows read "app carries an icon the
|
|
294
|
+
// mockup does not" on every row.
|
|
295
|
+
const STATE = '[data-tf-state]:not([data-tf-state="sample-data"]),[data-state-testid]';
|
|
296
|
+
const SAMPLE = '[data-tf-sample],[data-tf-state="sample-data"]';
|
|
297
|
+
// A chart's <svg> is not an icon (TF-054): an svg larger than 48px on either side is left out.
|
|
298
|
+
const glyph = (x) => x.tagName.toLowerCase() !== 'svg' || (() => { const r = x.getBoundingClientRect(); return r.width <= 48 && r.height <= 48; })();
|
|
299
|
+
const own = (el, sel) => [...el.querySelectorAll(sel)].filter((x) => (!x.closest(STATE) || el.closest(STATE)) && (sel !== ICON || glyph(x)));
|
|
300
|
+
const ownText = (el) => {
|
|
301
|
+
if (!el.querySelector(STATE) || el.closest(STATE)) return el.textContent || '';
|
|
302
|
+
const c = el.cloneNode(true);
|
|
303
|
+
for (const x of c.querySelectorAll(STATE)) x.remove();
|
|
304
|
+
return c.textContent || '';
|
|
305
|
+
};
|
|
306
|
+
const hasIcon = (el) => own(el, ICON).length > 0;
|
|
307
|
+
// how many icons the element carries at any depth: a component library that wraps an icon
|
|
308
|
+
// one element deeper than the mockup moves it to another path key without removing it (TF-027)
|
|
309
|
+
const iconCount = (el) => own(el, ICON).filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
|
|
310
|
+
// Every icon on the page by number, so the node side can tell that an anchor and the wrappers
|
|
311
|
+
// above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
|
|
312
|
+
// and one Select chevron was reported at four levels of TfLens /misses (TF-046).
|
|
313
|
+
const iconNo = new Map([...document.querySelectorAll(ICON)].filter(glyph).map((i, n) => [i, n]));
|
|
314
|
+
const iconIds = (el) => own(el, ICON).map((i) => iconNo.get(i));
|
|
315
|
+
const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
|
|
145
316
|
|
|
146
317
|
// "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
|
|
147
318
|
// a rounded edge. The mockup drawing one and the app rendering bare text is the
|
|
@@ -153,10 +324,30 @@ const PROBE = () => {
|
|
|
153
324
|
const cs = getComputedStyle(el);
|
|
154
325
|
const radius = parseFloat(cs.borderTopLeftRadius) || 0;
|
|
155
326
|
const filled = !!RGB(cs.backgroundColor);
|
|
156
|
-
const ringed = (
|
|
327
|
+
const ringed = drawnBorder(cs); // a transparent ring is not a ring (TF-038)
|
|
157
328
|
return { badge: (filled || ringed) && radius >= 6, filled, ringed, radius: Math.round(radius) };
|
|
158
329
|
};
|
|
159
330
|
|
|
331
|
+
// --- TF-047: the treatment one element away. A component library draws the ring and the radius on
|
|
332
|
+
// the box AROUND the control the mockup styles (an InputGroup around a filter input, a nav <li>
|
|
333
|
+
// around the active link), and a mockup does the reverse. Keys pair by position, so the badge and
|
|
334
|
+
// stroke clauses compared the control with the plain box beside the one that carries the treatment:
|
|
335
|
+
// "badge on nav > a[4], app plain" on TfLens's sidebar, on every screen. The element's neighbours
|
|
336
|
+
// one step away are kept here — the parent when it is the same row (same height, its other
|
|
337
|
+
// children carrying no text: an icon, or the box a library wraps one in) and the only child when
|
|
338
|
+
// it is — so a clause can accept the treatment there.
|
|
339
|
+
const nearOf = (el) => {
|
|
340
|
+
const out = [];
|
|
341
|
+
const h = el.getBoundingClientRect().height;
|
|
342
|
+
const sameRow = (x) => Math.abs(x.getBoundingClientRect().height - h) <= 8;
|
|
343
|
+
const p = el.parentElement;
|
|
344
|
+
if (p && p !== document.body && sameRow(p)
|
|
345
|
+
&& [...p.children].every((s) => s === el || isHidden(s) || !(s.textContent || '').trim())) out.push(p);
|
|
346
|
+
const kids = [...el.children].filter((c) => !isHidden(c));
|
|
347
|
+
if (kids.length === 1 && sameRow(kids[0])) out.push(kids[0]);
|
|
348
|
+
return out;
|
|
349
|
+
};
|
|
350
|
+
|
|
160
351
|
// Only text that is genuinely a single inline run can be graded for wrapping or
|
|
161
352
|
// token fit. A container with block children has no meaningful line count, and
|
|
162
353
|
// pretending otherwise is how a card-shaped anchor buys nothing (TF-011).
|
|
@@ -173,8 +364,11 @@ const PROBE = () => {
|
|
|
173
364
|
const cs = getComputedStyle(el);
|
|
174
365
|
let lh = parseFloat(cs.lineHeight);
|
|
175
366
|
if (!lh || Number.isNaN(lh)) lh = (parseFloat(cs.fontSize) || 16) * 1.2;
|
|
176
|
-
|
|
177
|
-
|
|
367
|
+
// the text's height only: padding and borders are not rows. A badge with 4px padding and
|
|
368
|
+
// `line-height: 1` read 18 / 9.756 = 1.85, rounded to 2 rows (AppManager TF-015).
|
|
369
|
+
const h = el.getBoundingClientRect().height
|
|
370
|
+
- ['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth'].reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0);
|
|
371
|
+
if (!(h > 0) || !lh) return null;
|
|
178
372
|
return Math.max(1, Math.round(h / lh));
|
|
179
373
|
};
|
|
180
374
|
|
|
@@ -206,33 +400,90 @@ const PROBE = () => {
|
|
|
206
400
|
// --- TF-012 again, on the measuring side. scrollWidth includes the phantom
|
|
207
401
|
// extent of an sr-only descendant, so the ancestor is measured from the right
|
|
208
402
|
// edge of its VISIBLE descendants instead whenever a hidden one is present.
|
|
403
|
+
// A descendant inside a scroller is painted only as far as that scroller's edge, so its own box
|
|
404
|
+
// must be cut by every ancestor that clips before it can count as the card overflowing. Without
|
|
405
|
+
// that, a table scrolling inside its wrapper — the layout the BRD asks for — read as a card cut
|
|
406
|
+
// off at 390px (TF-039). The same rectangle tf-verify-screens measures since TF-021.
|
|
407
|
+
// The cut stops BELOW `stop`: the element being measured must not cut its own content, or a card
|
|
408
|
+
// that really clips would read as holding everything (it did, from TF-039 until TF-042).
|
|
409
|
+
const paintedRight = (c, stop) => {
|
|
410
|
+
const r = c.getBoundingClientRect();
|
|
411
|
+
let x1 = r.left, x2 = r.right;
|
|
412
|
+
for (let a = c.parentElement; a && a !== stop; a = a.parentElement) {
|
|
413
|
+
if (getComputedStyle(a).overflowX !== 'visible') {
|
|
414
|
+
const ar = a.getBoundingClientRect();
|
|
415
|
+
x1 = Math.max(x1, ar.left); x2 = Math.min(x2, ar.right);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return x2 > x1 ? x2 : null;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// --- TF-042: overflow is not clipping. An element whose overflow-x is `visible` draws what spills
|
|
422
|
+
// past its edge in full, so it is cut off only where an ancestor that clips ends first. Reading
|
|
423
|
+
// scrollWidth alone called a sidebar "cut off" because its rail handle straddles its edge by 8px,
|
|
424
|
+
// on every TfLens screen, with nothing clipped anywhere up to <html> (2026-09-12).
|
|
425
|
+
const cutAbove = (el, right) => {
|
|
426
|
+
for (let a = el.parentElement; a; a = a.parentElement) {
|
|
427
|
+
if (getComputedStyle(a).overflowX === 'visible') continue;
|
|
428
|
+
if (right > a.getBoundingClientRect().left + a.clientLeft + a.clientWidth + 2) return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
};
|
|
432
|
+
|
|
209
433
|
const clipOf = (el) => {
|
|
210
434
|
const hiddenKids = [...el.querySelectorAll('*')].filter(isHidden);
|
|
211
|
-
|
|
435
|
+
const box = el.getBoundingClientRect();
|
|
436
|
+
let overX, right;
|
|
212
437
|
if (hiddenKids.length === 0) {
|
|
213
438
|
overX = el.scrollWidth - el.clientWidth;
|
|
439
|
+
right = box.left + el.clientLeft + el.scrollWidth;
|
|
214
440
|
} else {
|
|
215
|
-
|
|
216
|
-
let right = box.left;
|
|
441
|
+
right = box.left;
|
|
217
442
|
for (const c of el.querySelectorAll('*')) {
|
|
218
443
|
if (isHidden(c)) continue;
|
|
219
444
|
const r = c.getBoundingClientRect();
|
|
220
|
-
if (r.width > 0)
|
|
445
|
+
if (r.width > 0) {
|
|
446
|
+
const painted = paintedRight(c, el);
|
|
447
|
+
if (painted !== null) right = Math.max(right, painted);
|
|
448
|
+
}
|
|
221
449
|
}
|
|
222
450
|
overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
|
|
223
451
|
}
|
|
452
|
+
const cs = getComputedStyle(el);
|
|
453
|
+
const scrolls = (v) => v === 'auto' || v === 'scroll'; // a scroller keeps its own content in reach (TF-053)
|
|
454
|
+
let x = overX > 2 && !scrolls(cs.overflowX);
|
|
455
|
+
if (x && cs.overflowX === 'visible') x = cutAbove(el, right);
|
|
224
456
|
const overY = el.scrollHeight - el.clientHeight;
|
|
225
|
-
return { x
|
|
457
|
+
return { x, y: overY > 2 && !scrolls(cs.overflowY), sr_excluded: hiddenKids.length };
|
|
226
458
|
};
|
|
227
459
|
|
|
228
460
|
const sigOf = (el) => {
|
|
229
461
|
const r = el.getBoundingClientRect();
|
|
230
462
|
const chrome = chromeOn(el);
|
|
463
|
+
const near = nearOf(el);
|
|
231
464
|
return {
|
|
465
|
+
// the badge and the visible border one element away, for the clauses to accept (TF-047)
|
|
466
|
+
near: {
|
|
467
|
+
badge: near.some((n) => chromeOn(n)?.badge === true),
|
|
468
|
+
stroke: near.map(strokeOf).find((s) => s.visible) || null,
|
|
469
|
+
},
|
|
232
470
|
tag: el.tagName.toLowerCase(),
|
|
233
|
-
text: (el
|
|
471
|
+
text: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 60),
|
|
234
472
|
badge: chrome ? chrome.badge : null,
|
|
473
|
+
full: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 400),
|
|
474
|
+
// a box the mockup marks as sample data, drawn only when the app has data there (Chatur TF-002)
|
|
475
|
+
sample: el.matches(SAMPLE),
|
|
235
476
|
icon: hasIcon(el),
|
|
477
|
+
icons: iconCount(el),
|
|
478
|
+
icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
|
|
479
|
+
// the anchors inside and the icons each holds, so a row graded on its own can be taken off
|
|
480
|
+
// the box around it (Chatur TF-006)
|
|
481
|
+
nested: own(el, '[data-testid]').filter((x) => !isHidden(x))
|
|
482
|
+
.map((x) => ({ id: x.getAttribute('data-testid'), ids: iconIds(x),
|
|
483
|
+
top: x.parentElement?.closest('[data-testid]') === el })).slice(0, 200),
|
|
484
|
+
depth: depthOf(el),
|
|
485
|
+
// badges drawn anywhere inside, so a badge one wrapper deeper still counts under its parent (TF-045)
|
|
486
|
+
badges: own(el, '*').filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
|
|
236
487
|
color: semanticColor(el),
|
|
237
488
|
stroke: strokeOf(el),
|
|
238
489
|
wrap: lineCount(el),
|
|
@@ -252,16 +503,32 @@ const PROBE = () => {
|
|
|
252
503
|
// table already was. Keys pair only when both sides produce them, so extra depth
|
|
253
504
|
// costs precision nothing — it only ever adds comparisons that were impossible
|
|
254
505
|
// before.
|
|
506
|
+
// A box the mockup marks as another state of the screen (data-tf-state="<state>", or
|
|
507
|
+
// data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
|
|
508
|
+
// siblings are numbered: a database-down alert first in a card moved every row below it one place,
|
|
509
|
+
// and the Host/Port row was compared with the alert (Lekhak TF-011).
|
|
510
|
+
const stateOnly = (el) => el.matches(STATE);
|
|
255
511
|
const keyOf = (el, parentKey) => {
|
|
256
512
|
const tag = el.tagName.toLowerCase();
|
|
257
513
|
let n = 0;
|
|
258
514
|
for (const s of el.parentElement ? el.parentElement.children : []) {
|
|
259
515
|
if (s === el) break;
|
|
260
|
-
if (s.tagName === el.tagName) n++;
|
|
516
|
+
if (s.tagName === el.tagName && !stateOnly(s)) n++;
|
|
261
517
|
}
|
|
262
518
|
return `${parentKey} > ${tag}[${n}]`;
|
|
263
519
|
};
|
|
264
520
|
|
|
521
|
+
// --- TF-045: the same badge, one wrapper deeper. Keys are positional, so a component library that
|
|
522
|
+
// wraps a card header's contents in one more <div> moves every badge in it to a key the mockup
|
|
523
|
+
// never has: 32 "missing" findings on three TfLens screens, each drawn on the page with the right
|
|
524
|
+
// text. For every text a mockup badge carries, the elements under the same anchor that carry it
|
|
525
|
+
// are kept here at any depth, so the node side can find the badge where the app put it. Digits
|
|
526
|
+
// are folded, because live data is not the mockup's sample: "87 of 91" is "39 of 41". Case is
|
|
527
|
+
// kept: a card titled "Draft" is not its badge "draft".
|
|
528
|
+
const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
|
|
529
|
+
const want = new Set((wanted || []).map(norm).filter(Boolean));
|
|
530
|
+
const pool = {};
|
|
531
|
+
|
|
265
532
|
const index = {};
|
|
266
533
|
const anchors = [...document.querySelectorAll('[data-testid]')];
|
|
267
534
|
const allTestIds = anchors.map((a) => a.getAttribute('data-testid'));
|
|
@@ -269,7 +536,7 @@ const PROBE = () => {
|
|
|
269
536
|
for (const a of anchors) {
|
|
270
537
|
const id = a.getAttribute('data-testid');
|
|
271
538
|
if (!id || index[id]) continue;
|
|
272
|
-
if (isHidden(a)) continue;
|
|
539
|
+
if (isHidden(a) || a.closest(STATE)) continue; // TF-011
|
|
273
540
|
index[id] = sigOf(a);
|
|
274
541
|
let budget = MAX_PER_ANCHOR;
|
|
275
542
|
const walk = (el, key, depth) => {
|
|
@@ -278,6 +545,7 @@ const PROBE = () => {
|
|
|
278
545
|
if (budget <= 0) return;
|
|
279
546
|
if (c.hasAttribute('data-testid')) continue; // it gets its own top-level entry
|
|
280
547
|
if (isHidden(c)) continue; // TF-012
|
|
548
|
+
if (stateOnly(c)) continue; // another state of the screen (TF-011)
|
|
281
549
|
const k = keyOf(c, key);
|
|
282
550
|
index[k] = sigOf(c);
|
|
283
551
|
budget--;
|
|
@@ -285,11 +553,46 @@ const PROBE = () => {
|
|
|
285
553
|
}
|
|
286
554
|
};
|
|
287
555
|
walk(a, id, 1);
|
|
556
|
+
if (want.size) {
|
|
557
|
+
const found = [];
|
|
558
|
+
const queue = [[a, id, 0]];
|
|
559
|
+
let seen = 0;
|
|
560
|
+
while (queue.length && seen < 800 && found.length < 30) {
|
|
561
|
+
const [el, key, depth] = queue.shift();
|
|
562
|
+
if (depth >= 12) continue;
|
|
563
|
+
for (const c of el.children) {
|
|
564
|
+
if (c.hasAttribute('data-testid') || isHidden(c) || stateOnly(c)) continue;
|
|
565
|
+
seen++;
|
|
566
|
+
const k = keyOf(c, key);
|
|
567
|
+
if (want.has(norm((c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)))) found.push({ key: k, ...sigOf(c) });
|
|
568
|
+
queue.push([c, k, depth + 1]);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
pool[id] = found;
|
|
572
|
+
}
|
|
288
573
|
}
|
|
289
574
|
|
|
290
575
|
const de = document.documentElement;
|
|
576
|
+
// the shell's content scroll container, when it has one: a box that scrolls inside itself, no
|
|
577
|
+
// taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
|
|
578
|
+
// its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
|
|
579
|
+
// off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
|
|
580
|
+
// A box that only scrolls sideways is not one either: `overflow-x: auto` alone makes the browser
|
|
581
|
+
// compute overflow-y as auto too, so Bootstrap's .table-responsive looked like a shell at 390px
|
|
582
|
+
// (AppManager TF-028). Skipped: a box whose only child is a table, and a box wider inside than
|
|
583
|
+
// out whose height fits its content.
|
|
584
|
+
const scroller = [...document.querySelectorAll('body *')].find((el) => {
|
|
585
|
+
const o = getComputedStyle(el).overflowY;
|
|
586
|
+
if (o !== 'auto' && o !== 'scroll') return false;
|
|
587
|
+
if (el.children.length === 1 && el.children[0].tagName === 'TABLE') return false;
|
|
588
|
+
if (el.scrollWidth > el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) return false;
|
|
589
|
+
const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
|
|
590
|
+
return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
|
|
591
|
+
&& r.left <= cx && r.right >= cx;
|
|
592
|
+
});
|
|
291
593
|
return {
|
|
292
594
|
index,
|
|
595
|
+
pool,
|
|
293
596
|
anchors: anchors.length,
|
|
294
597
|
testids: allTestIds,
|
|
295
598
|
doc: {
|
|
@@ -297,6 +600,7 @@ const PROBE = () => {
|
|
|
297
600
|
clientHeight: de.clientHeight,
|
|
298
601
|
scrollWidth: de.scrollWidth,
|
|
299
602
|
clientWidth: de.clientWidth,
|
|
603
|
+
scroller: scroller ? `${scroller.tagName.toLowerCase()}${scroller.id ? '#' + scroller.id : ''}${scroller.className ? '.' + String(scroller.className).trim().split(/\s+/)[0] : ''}` : null,
|
|
300
604
|
},
|
|
301
605
|
};
|
|
302
606
|
};
|
|
@@ -305,11 +609,18 @@ const PROBE = () => {
|
|
|
305
609
|
// A clause returns a finding, `null` (not applicable — do NOT count it as graded),
|
|
306
610
|
// or `false` (applicable and agreeing). The three are kept distinct because
|
|
307
611
|
// conflating "agreed" with "not asked" is precisely how TF-011 happened.
|
|
612
|
+
// The same folding the page-side probe uses: spacing and digits (TF-045).
|
|
613
|
+
const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
|
|
614
|
+
|
|
308
615
|
const CLAUSES = {
|
|
309
|
-
badge: (m, a) =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
616
|
+
badge: (m, a) => {
|
|
617
|
+
if (m.badge === null || a.badge === null) return null;
|
|
618
|
+
if (m.badge === a.badge) return false;
|
|
619
|
+
// TF-047: the side without the treatment carries it one element away — the same control, drawn
|
|
620
|
+
// by a library on the box around it or the box inside it. That is agreement, not drift.
|
|
621
|
+
if ((m.badge ? a : m).near?.badge) return false;
|
|
622
|
+
return `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`;
|
|
623
|
+
},
|
|
313
624
|
icon: (m, a) => (m.icon === a.icon ? false
|
|
314
625
|
: m.icon && !a.icon ? 'mockup carries an icon here; the app does not'
|
|
315
626
|
: 'app carries an icon the mockup does not'),
|
|
@@ -318,16 +629,26 @@ const CLAUSES = {
|
|
|
318
629
|
// TF-009
|
|
319
630
|
stroke: (m, a) => {
|
|
320
631
|
if (!m.stroke || !a.stroke) return null;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
632
|
+
let ms = m.stroke, as = a.stroke;
|
|
633
|
+
// TF-047: a side that draws no border of its own is read at the border one element away, and
|
|
634
|
+
// only when the sides disagree — two plain elements inside a ringed box still agree as before.
|
|
635
|
+
if (ms.style !== as.style || ms.visible !== as.visible) {
|
|
636
|
+
if (!ms.visible && m.near?.stroke) ms = m.near.stroke;
|
|
637
|
+
if (!as.visible && a.near?.stroke) as = a.near.stroke;
|
|
638
|
+
}
|
|
639
|
+
if (ms.style !== as.style) {
|
|
640
|
+
return `border style differs — mockup ${ms.style}, app ${as.style}`
|
|
641
|
+
+ (ms.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
|
|
324
642
|
}
|
|
325
|
-
if (
|
|
326
|
-
return `border presence differs — mockup ${
|
|
643
|
+
if (ms.visible !== as.visible) {
|
|
644
|
+
return `border presence differs — mockup ${ms.visible ? 'has a visible rule' : 'has none'}, app ${as.visible ? 'has one' : 'has none'}`;
|
|
327
645
|
}
|
|
328
646
|
return false;
|
|
329
647
|
},
|
|
330
|
-
|
|
648
|
+
// TF-047: a row count says something only about the same text. Live data that reads differently
|
|
649
|
+
// from the mockup's sample wraps differently for that reason alone, so the clause is not applicable
|
|
650
|
+
// (null, never counted as graded) unless the digit-folded texts match.
|
|
651
|
+
wrap: (m, a) => (m.wrap === null || a.wrap === null || norm(m.full) !== norm(a.full) ? null
|
|
331
652
|
: a.wrap > m.wrap ? `wraps to ${a.wrap} rows where the mockup keeps it on ${m.wrap}` : false),
|
|
332
653
|
clip: (m, a) => (!m.clip || !a.clip ? null
|
|
333
654
|
: (a.clip.x && !m.clip.x) ? 'content is cut off horizontally; the mockup is not clipped'
|
|
@@ -344,15 +665,153 @@ const CLAUSES = {
|
|
|
344
665
|
// as coverage is what let a screen the gate never really looked at read as clean.
|
|
345
666
|
const CONTENT_CLAUSES = new Set(['badge', 'icon', 'wrap', 'token']);
|
|
346
667
|
|
|
347
|
-
function diff(mock, app, screen, width) {
|
|
668
|
+
function diff(mock, app, screen, width, skip = null) {
|
|
669
|
+
// --- Chatur TF-002: sample data the app is not showing. A mockup draws a list full of sample rows
|
|
670
|
+
// and a fresh app has none, so every icon on a sample row read as missing. A box the mockup marks
|
|
671
|
+
// data-tf-sample is compared when the app draws something at its place; when it draws nothing
|
|
672
|
+
// there, the box and everything in it are "not measured", and its icons and badges are taken off
|
|
673
|
+
// the boxes around it. A seed script (tests/verify/seed/) puts the app in the drawn state instead.
|
|
674
|
+
const notMeasured = [];
|
|
675
|
+
const mockAll = mock.index; // before anything is dropped (Chatur TF-007)
|
|
676
|
+
// the mockup's sample rows that carry their own anchor (commit-row-N), read before any is dropped
|
|
677
|
+
const fold = (id) => id.replace(/\d+/g, '#');
|
|
678
|
+
const sampleRows = new Set(Object.keys(mock.index).filter((k) => !k.includes(' > ') && mock.index[k].sample));
|
|
679
|
+
const sampleShapes = new Set([...sampleRows].map(fold));
|
|
680
|
+
const idx = { ...mock.index };
|
|
681
|
+
for (const key of Object.keys(mock.index)) {
|
|
682
|
+
const m = mock.index[key];
|
|
683
|
+
if (!m.sample || app.index[key] || notMeasured.some((k) => key.startsWith(k + ' > '))) continue;
|
|
684
|
+
notMeasured.push(key);
|
|
685
|
+
for (const k of Object.keys(idx)) if (k === key || k.startsWith(key + ' > ')) delete idx[k];
|
|
686
|
+
const gone = new Set(m.icon_ids || []);
|
|
687
|
+
for (let up = key; up.includes(' > ');) {
|
|
688
|
+
up = up.slice(0, up.lastIndexOf(' > '));
|
|
689
|
+
const a = idx[up];
|
|
690
|
+
if (!a) continue;
|
|
691
|
+
const icons = Math.max(0, (a.icons ?? 0) - (m.icons ?? 0));
|
|
692
|
+
idx[up] = { ...a, icons, icon: a.icon && icons > 0, icon_ids: (a.icon_ids || []).filter((i) => !gone.has(i)),
|
|
693
|
+
badges: Math.max(0, (a.badges ?? 0) - (m.badges ?? 0) - (m.badge === true ? 1 : 0)) };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
mock = { ...mock, index: idx };
|
|
697
|
+
// --- Chatur TF-006: a sample row graded on its own is not the box's icon too. The mockup's table
|
|
698
|
+
// lost its sample rows' icons when their tbody was not measured, while the app's table still counted
|
|
699
|
+
// every row's: "app carries an icon the mockup does not" on a box whose rows were each compared. A
|
|
700
|
+
// row anchored as a mockup sample row, or as more of the same (commit-row-7 beside commit-row-1), is
|
|
701
|
+
// left out of every box around it on both sides; an icon of the box's own is still counted.
|
|
702
|
+
const strip = (index, goneOf) => Object.fromEntries(Object.entries(index).map(([k, s]) => {
|
|
703
|
+
const gone = goneOf(k, s);
|
|
704
|
+
const ids = (s.icon_ids || []).filter((i) => !gone.has(i));
|
|
705
|
+
const cut = (s.icon_ids || []).length - ids.length;
|
|
706
|
+
if (!cut) return [k, s];
|
|
707
|
+
const icons = Math.max(0, (s.icons ?? 0) - cut);
|
|
708
|
+
return [k, { ...s, icons, icon: icons > 0, icon_ids: ids }];
|
|
709
|
+
}));
|
|
710
|
+
const dropRows = (index, isRow) => strip(index, (k, s) => new Set((s.nested || []).filter((n) => isRow(n.id)).flatMap((n) => n.ids)));
|
|
711
|
+
if (sampleRows.size) {
|
|
712
|
+
mock = { ...mock, index: dropRows(mock.index, (id) => sampleRows.has(id)) };
|
|
713
|
+
app = { ...app, index: dropRows(app.index, (id) => sampleRows.has(id) || (!(id in mock.index) && sampleShapes.has(fold(id)))) };
|
|
714
|
+
}
|
|
715
|
+
// --- Chatur TF-007: the app's rows in the place of sample rows not measured. The mockup's list box
|
|
716
|
+
// lost the icons of sample rows the app does not draw (recent-tflens, tools-table > tbody[0]), while
|
|
717
|
+
// the app's box kept the icons of the real rows it draws there instead (recent-chatur, a wrapped
|
|
718
|
+
// tbody): "app carries an icon the mockup does not" on Start, Prerequisites, Providers, Corrections and
|
|
719
|
+
// the file tree. The app's rows in that place — the children and anchors of the box holding the sample
|
|
720
|
+
// rows that the mockup does not have — are left out of every box around them, as the sample rows are
|
|
721
|
+
// on the mockup's side, there taken off every box too, anchors around the list included.
|
|
722
|
+
// A row's icons come off the boxes AROUND it only: an element above it that holds all of them. A
|
|
723
|
+
// button inside the row keeps its own icon, or it reads as one the mockup does not carry.
|
|
724
|
+
const appAll = app.index;
|
|
725
|
+
const rowsGone = (rows, all) => (k, s) => {
|
|
726
|
+
const had = new Set(all[k]?.icon_ids || s.icon_ids || []);
|
|
727
|
+
return new Set(rows.filter((r) => (s.depth ?? 0) < r.depth && r.ids.every((i) => had.has(i))).flatMap((r) => r.ids));
|
|
728
|
+
};
|
|
729
|
+
const inPlace = [];
|
|
730
|
+
// The app's rows in that place are what the mockup does not have under the same box and of the
|
|
731
|
+
// same kind as the sample row (a tbody for a tbody, an <a> for an <a>), the outermost of them; only
|
|
732
|
+
// when there is none of that kind, every child of the box the mockup does not have. A heading or a
|
|
733
|
+
// toolbar of the box's own is not a row.
|
|
734
|
+
const takeRows = (box, tag) => {
|
|
735
|
+
const a = appAll[box];
|
|
736
|
+
if (!a) return;
|
|
737
|
+
const tagOf = (k) => k.slice(k.lastIndexOf(' > ') + 3).replace(/\[\d+\]$/, '');
|
|
738
|
+
const same = Object.keys(appAll).filter((k) => k.startsWith(box + ' > ') && !(k in mockAll) && tagOf(k) === tag);
|
|
739
|
+
const rows = same.filter((k) => !same.some((o) => o !== k && k.startsWith(o + ' > ')))
|
|
740
|
+
.map((k) => ({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 }));
|
|
741
|
+
for (const n of a.nested || []) {
|
|
742
|
+
if (!(n.id in mockAll) && appAll[n.id]?.tag === tag) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
|
|
743
|
+
}
|
|
744
|
+
if (!rows.length) {
|
|
745
|
+
for (const k of Object.keys(appAll)) {
|
|
746
|
+
if (k.startsWith(box + ' > ') && !k.slice(box.length + 3).includes(' > ') && !(k in mockAll)) {
|
|
747
|
+
rows.push({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// and the box's own anchors the mockup does not have: a scroll area holding the rows
|
|
751
|
+
for (const n of a.nested || []) {
|
|
752
|
+
if (n.top && !(n.id in mockAll) && appAll[n.id]) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
inPlace.push(...rows);
|
|
756
|
+
};
|
|
757
|
+
const sampleGone = [];
|
|
758
|
+
for (const k of notMeasured) {
|
|
759
|
+
sampleGone.push({ ids: mockAll[k].icon_ids || [], depth: mockAll[k].depth ?? 0 });
|
|
760
|
+
if (k.includes(' > ')) { takeRows(k.slice(0, k.lastIndexOf(' > ')), mockAll[k].tag); continue; }
|
|
761
|
+
// an anchored sample row: the nearest mockup box holding it
|
|
762
|
+
const box = Object.keys(mockAll).filter((b) => (mockAll[b].nested || []).some((n) => n.id === k))
|
|
763
|
+
.sort((x, y) => (mockAll[y].depth ?? 0) - (mockAll[x].depth ?? 0))[0];
|
|
764
|
+
if (box) takeRows(box, mockAll[k].tag);
|
|
765
|
+
}
|
|
766
|
+
const some = (rows) => rows.filter((r) => r.ids.length);
|
|
767
|
+
if (some(sampleGone).length) mock = { ...mock, index: strip(mock.index, rowsGone(some(sampleGone), mockAll)) };
|
|
768
|
+
if (some(inPlace).length) app = { ...app, index: strip(app.index, rowsGone(some(inPlace), appAll)) };
|
|
348
769
|
const findings = [];
|
|
349
770
|
const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
|
|
350
771
|
clauseCoverage.missing = 0;
|
|
351
|
-
let compared = 0, contentGraded = 0;
|
|
772
|
+
let compared = 0, contentGraded = 0, relocated = 0;
|
|
773
|
+
const iconsShort = {}; // parent key -> icons the app still owes under it (TF-027)
|
|
774
|
+
const badgesShort = {}; // parent key -> badges the app still owes under it (TF-045)
|
|
775
|
+
const usedApp = new Set();
|
|
776
|
+
const iconSeen = []; // icon findings with the side that holds the icons (TF-046)
|
|
777
|
+
|
|
778
|
+
// --- TF-047: the same icon in another child. Pairing is by position, so a card header that a
|
|
779
|
+
// library wraps in one more <div> pairs the mockup's description with the app's toolbar, and the
|
|
780
|
+
// toolbar's icon reads as one "the mockup does not" carry — while the header holds exactly one
|
|
781
|
+
// icon on both sides. The rule the `missing` clause has followed since TF-027, one wrapper further:
|
|
782
|
+
// an icon finding is dropped when the parent or the grandparent is paired and carries the same
|
|
783
|
+
// number of icons on both sides. Bounded to a few icons, so a coincidence across a whole page
|
|
784
|
+
// (one lost, one added) stays reported.
|
|
785
|
+
const iconMoved = (key) => {
|
|
786
|
+
let k = key;
|
|
787
|
+
for (let up = 0; up < 2; up++) {
|
|
788
|
+
if (!k.includes(' > ')) return false;
|
|
789
|
+
k = k.slice(0, k.lastIndexOf(' > '));
|
|
790
|
+
const pm = mock.index[k], pa = app.index[k];
|
|
791
|
+
if (!pm || !pa) return false;
|
|
792
|
+
const n = pm.icons ?? 0;
|
|
793
|
+
if (n > 0 && n <= 4 && n === (pa.icons ?? 0)) return true;
|
|
794
|
+
}
|
|
795
|
+
return false;
|
|
796
|
+
};
|
|
352
797
|
|
|
353
798
|
for (const key of Object.keys(mock.index)) {
|
|
354
799
|
const m = mock.index[key];
|
|
355
|
-
|
|
800
|
+
let a = app.index[key];
|
|
801
|
+
let appKey = key;
|
|
802
|
+
|
|
803
|
+
// --- TF-045: a badge the app draws at another depth is the same badge. Found by its text under
|
|
804
|
+
// the same anchor, it is compared like any paired element, so a badge whose colour or ring really
|
|
805
|
+
// differs is still reported, and so is one the app turned into plain text.
|
|
806
|
+
if (!a && m.badge === true && m.text) {
|
|
807
|
+
const root = key.split(' > ')[0];
|
|
808
|
+
const cands = ((app.pool || {})[root] || []).filter((c) => !usedApp.has(c.key) && norm(c.text) === norm(m.text));
|
|
809
|
+
// a badge first, then the same kind of element, then one no mockup key already pairs with;
|
|
810
|
+
// among equals the shallowest, which is the order the probe found them in
|
|
811
|
+
const score = (c) => (c.badge === true ? 4 : 0) + (c.tag === m.tag ? 2 : 0) + (c.key in mock.index ? 0 : 1);
|
|
812
|
+
const pick = cands.reduce((best, c) => (!best || score(c) > score(best) ? c : best), null);
|
|
813
|
+
if (pick) { a = pick; appKey = pick.key; usedApp.add(pick.key); relocated++; }
|
|
814
|
+
}
|
|
356
815
|
|
|
357
816
|
// --- the `missing` clause. Key-pairing alone cannot see an element that is
|
|
358
817
|
// NOT THERE, and "a control the mockup draws as a badge rendered as plain text"
|
|
@@ -367,14 +826,37 @@ function diff(mock, app, screen, width) {
|
|
|
367
826
|
if (!a) {
|
|
368
827
|
const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
|
|
369
828
|
const parentPaired = parentKey ? !!app.index[parentKey] : false;
|
|
370
|
-
|
|
829
|
+
// an icon is missing only when the paired parent carries fewer icons than the mockup's
|
|
830
|
+
// does. Pairing is by position, so an icon the app draws one wrapper deeper sits under
|
|
831
|
+
// another key and was reported missing on every sidebar group and tile (TF-027).
|
|
832
|
+
// and no more reports under one parent than it is icons short
|
|
833
|
+
if (m.icon === true && parentPaired && mock.index[parentKey] && !(parentKey in iconsShort)) {
|
|
834
|
+
iconsShort[parentKey] = Math.max(0, (mock.index[parentKey].icons ?? 0) - (app.index[parentKey].icons ?? 0));
|
|
835
|
+
}
|
|
836
|
+
const iconGone = m.icon === true && parentPaired && (iconsShort[parentKey] || 0) > 0;
|
|
837
|
+
if (iconGone && m.badge !== true) iconsShort[parentKey]--;
|
|
838
|
+
// A badge not found by its text (live data reads otherwise) is missing only while the paired
|
|
839
|
+
// parent draws fewer badges than the mockup's does, the rule icons follow since TF-027 (TF-045).
|
|
840
|
+
if (m.badge === true && parentPaired && mock.index[parentKey] && !(parentKey in badgesShort)) {
|
|
841
|
+
badgesShort[parentKey] = Math.max(0, (mock.index[parentKey].badges ?? 0) - (app.index[parentKey].badges ?? 0));
|
|
842
|
+
}
|
|
843
|
+
const badgeGone = m.badge === true && parentPaired && (badgesShort[parentKey] || 0) > 0;
|
|
844
|
+
if (badgeGone) badgesShort[parentKey]--;
|
|
845
|
+
if (parentPaired && (m.badge === true || iconGone)) {
|
|
371
846
|
clauseCoverage.missing++;
|
|
372
847
|
contentGraded++;
|
|
848
|
+
}
|
|
849
|
+
if (parentPaired && (m.badge === true ? badgeGone : iconGone)) {
|
|
850
|
+
// Say what was seen. "Flattened into plain text" is true only when the app shows the text;
|
|
851
|
+
// said of a badge the tool had merely failed to find, it sent a reader after a wrong cause.
|
|
852
|
+
const shown = m.text && norm(app.index[parentKey].full).includes(norm(m.text));
|
|
373
853
|
findings.push({
|
|
374
854
|
screen, width, class: 'missing', key,
|
|
375
|
-
detail: m.badge
|
|
376
|
-
? `the mockup
|
|
377
|
-
:
|
|
855
|
+
detail: m.badge !== true
|
|
856
|
+
? `the mockup carries an icon here and the app renders no such element`
|
|
857
|
+
: shown
|
|
858
|
+
? `the mockup draws a badge/pill here ("${m.text}"); the app shows that text with no badge/pill around it — the value is flattened into plain text`
|
|
859
|
+
: `the mockup draws a badge/pill here ("${m.text}"); it could not be located in the app — no element under this parent carries that text, and the app draws ${app.index[parentKey].badges ?? 0} badge(s) there where the mockup draws ${mock.index[parentKey].badges ?? 0}`,
|
|
378
860
|
mockup_text: m.text, app_text: null,
|
|
379
861
|
});
|
|
380
862
|
}
|
|
@@ -382,14 +864,33 @@ function diff(mock, app, screen, width) {
|
|
|
382
864
|
}
|
|
383
865
|
compared++;
|
|
384
866
|
for (const [name, fn] of Object.entries(CLAUSES)) {
|
|
867
|
+
if (skip && skip.has(name)) continue; // a clause the mockup says nothing about in this theme (TF-024)
|
|
385
868
|
const r = fn(m, a);
|
|
386
869
|
if (r === null) continue;
|
|
387
870
|
clauseCoverage[name]++;
|
|
388
871
|
if (CONTENT_CLAUSES.has(name)) contentGraded++;
|
|
389
|
-
if (r)
|
|
872
|
+
if (r) {
|
|
873
|
+
if (name === 'icon' && iconMoved(key)) continue; // the same icon, another child (TF-047)
|
|
874
|
+
const f = { screen, width, class: name, key, ...(appKey !== key ? { app_key: appKey } : {}), detail: r, mockup_text: m.text, app_text: a.text };
|
|
875
|
+
findings.push(f);
|
|
876
|
+
if (name === 'icon') iconSeen.push({ f, side: m.icon ? m : a });
|
|
877
|
+
}
|
|
390
878
|
}
|
|
391
879
|
}
|
|
392
|
-
|
|
880
|
+
|
|
881
|
+
// --- TF-046: one icon, one finding. "Is there an icon inside?" is true of the element that carries
|
|
882
|
+
// the icon and of every element above it, so each container repeated the finding. A finding is
|
|
883
|
+
// dropped when every icon it holds is already reported on a deeper element, on the side that has
|
|
884
|
+
// the icons; a container holding an extra icon of its own is still reported.
|
|
885
|
+
const repeat = new Set();
|
|
886
|
+
for (const x of iconSeen) {
|
|
887
|
+
const ids = x.side.icon_ids || [];
|
|
888
|
+
const below = new Set(iconSeen
|
|
889
|
+
.filter((y) => y !== x && y.f.detail === x.f.detail && (y.side.depth ?? 0) > (x.side.depth ?? 0))
|
|
890
|
+
.flatMap((y) => y.side.icon_ids || []));
|
|
891
|
+
if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
|
|
892
|
+
}
|
|
893
|
+
return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated, notMeasured };
|
|
393
894
|
}
|
|
394
895
|
|
|
395
896
|
// ---------------------------------------------------------------------- main
|
|
@@ -397,8 +898,30 @@ const results = [];
|
|
|
397
898
|
let hardFail = false;
|
|
398
899
|
|
|
399
900
|
const browser = await chromium.launch();
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
901
|
+
const ctxOpts = { ignoreHTTPSErrors: true };
|
|
902
|
+
if (STORAGE && existsSync(STORAGE)) ctxOpts.storageState = STORAGE;
|
|
903
|
+
const ctx = await browser.newContext(ctxOpts);
|
|
904
|
+
let cdpBrowser = null, cdpPage = null;
|
|
905
|
+
if (CDP) {
|
|
906
|
+
try {
|
|
907
|
+
cdpBrowser = await chromium.connectOverCDP(CDP, { timeout: 15000 });
|
|
908
|
+
const c = cdpBrowser.contexts()[0];
|
|
909
|
+
cdpPage = c && c.pages()[0];
|
|
910
|
+
} catch (e) { console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); }
|
|
911
|
+
if (!cdpPage) {
|
|
912
|
+
console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: no page in the attached app`);
|
|
913
|
+
await browser.close(); process.exit(2);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// the attached app's router, not a document load: a desktop head has no server to answer a goto
|
|
917
|
+
async function reachCdp(page, route) {
|
|
918
|
+
try {
|
|
919
|
+
await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
|
|
920
|
+
await page.waitForTimeout(LOGIN_OPTS.settle);
|
|
921
|
+
return { status: 200, url: page.url() };
|
|
922
|
+
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
923
|
+
}
|
|
924
|
+
if (COOKIE && !CDP) {
|
|
402
925
|
const u = new URL(BASE);
|
|
403
926
|
for (const pair of COOKIE.split(';')) {
|
|
404
927
|
const [name, ...v] = pair.trim().split('=');
|
|
@@ -411,53 +934,137 @@ if (HEADERS.length) {
|
|
|
411
934
|
await ctx.setExtraHTTPHeaders(h);
|
|
412
935
|
}
|
|
413
936
|
|
|
937
|
+
// One app tab per width, signed in once and kept on the app, and one tab for the mockups. A sign-in
|
|
938
|
+
// the page keeps for itself lives in that tab: a fresh tab per screen would be signed out every time
|
|
939
|
+
// (AppManager TF-011). The mockup tab never touches the app, so the app tab never leaves it.
|
|
940
|
+
const tabs = {};
|
|
941
|
+
let loginResult = null;
|
|
942
|
+
for (const width of WIDTHS) {
|
|
943
|
+
const size = { width, height: width < 700 ? 844 : 800 };
|
|
944
|
+
const app = CDP ? cdpPage : await ctx.newPage(); if (!CDP) await app.setViewportSize(size);
|
|
945
|
+
const mock = await ctx.newPage(); await mock.setViewportSize(size);
|
|
946
|
+
if (!CDP && LOGIN_PATH && USER) { const l = await signIn(app, LOGIN_OPTS); if (loginResult === null) loginResult = l; }
|
|
947
|
+
tabs[width] = { app, mock };
|
|
948
|
+
}
|
|
949
|
+
if (loginResult && loginResult.attempted && !loginResult.ok) console.error(`tf-mockup-parity: LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
|
|
950
|
+
|
|
414
951
|
for (const s of SCREENS) {
|
|
415
|
-
const mockPath =
|
|
952
|
+
const mockPath = findMockup(s.name);
|
|
416
953
|
// TF-008 §3: a screen with no mockup is reported, never silently passed — the
|
|
417
954
|
// same discipline `⚠ STATIC-ONLY` already uses. Silence is not evidence.
|
|
418
955
|
if (!existsSync(mockPath)) {
|
|
419
956
|
results.push({ screen: s.name, route: s.route, verdict: 'NO-MOCKUP', mockup: mockPath,
|
|
420
|
-
note:
|
|
957
|
+
note: `no ${s.name}.html under ${MOCKUPS} (or several in its subfolders)${LIST ? ` and none named for it in ${LIST}` : '; pass --list tests/.artifacts/verify/list.json'} — this screen was NOT graded against a design. It must not license a Verified on design grounds.` });
|
|
421
958
|
continue;
|
|
422
959
|
}
|
|
423
960
|
|
|
424
961
|
const perWidth = [];
|
|
962
|
+
const notOffered = new Set();
|
|
425
963
|
for (const width of WIDTHS) {
|
|
426
|
-
const page =
|
|
427
|
-
|
|
428
|
-
|
|
964
|
+
const { app: page, mock: mockPage } = tabs[width];
|
|
965
|
+
let mock, app, docFindings = [], reached = '', theme = null, own = 'light', ownSurface = null, mockSurface = null, mockTheme = null, wanted = [];
|
|
966
|
+
// Lekhak TF-025: the mockup's site theme stays on the app through the other-theme pass too, and is
|
|
967
|
+
// taken off only after it. It used to come off after the first pass, so the dark pass compared the
|
|
968
|
+
// app in the viewer's site theme (minimal, grey primary) with the mockup in its own (blue primary).
|
|
969
|
+
let saved = null;
|
|
970
|
+
const unSite = async () => { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); saved = null; };
|
|
429
971
|
try {
|
|
430
|
-
await
|
|
431
|
-
mock = await
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
972
|
+
await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
|
|
973
|
+
mock = await mockPage.evaluate(PROBE);
|
|
974
|
+
// The document may answer 401 and still draw the screen signed in (AppManager TF-006/TF-011):
|
|
975
|
+
// reach() judges by what the page draws, signs in again from inside the page when it must, and
|
|
976
|
+
// returns 200 only when the screen was really drawn signed in.
|
|
977
|
+
if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
|
|
978
|
+
const nav = CDP ? await reachCdp(page, s.route) : await reach(page, LOGIN_OPTS, s.route);
|
|
979
|
+
if (nav.status === 0 || nav.status >= 400 || nav.signedOut) {
|
|
980
|
+
perWidth.push({ width, error: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}`
|
|
981
|
+
: nav.signedOut ? `app returned HTTP ${nav.document_status ?? nav.status} and stayed signed out (sign-in ${LOGIN_PATH && USER ? 'did not hold' : 'not given: --login-path/--user/--password or --cookie'})`
|
|
982
|
+
: `app returned HTTP ${nav.status}` });
|
|
436
983
|
continue;
|
|
437
984
|
}
|
|
438
|
-
|
|
985
|
+
reached = nav.reached || '';
|
|
986
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
987
|
+
wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
|
|
988
|
+
// Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
|
|
989
|
+
// viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
|
|
990
|
+
// app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
|
|
991
|
+
// and the app's own are put back afterwards, so an attached app is left as the viewer had it.
|
|
992
|
+
// Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
|
|
993
|
+
// dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
|
|
994
|
+
const mockRead = KEEP_APP_THEME ? null : await mockPage.evaluate(READ_THEME);
|
|
995
|
+
mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
|
|
996
|
+
// The mockup's own theme: what it declares, or, declaring none, what it paints (TF-024). A mockup
|
|
997
|
+
// that declares none now has the app put in that theme too (its colour scheme as well as its
|
|
998
|
+
// attributes), so a viewer's saved dark theme no longer decides what a light mockup is compared with.
|
|
999
|
+
mockSurface = await surface(mockPage);
|
|
1000
|
+
own = mockTheme ? (Object.values(mockTheme).some((v) => /dark/i.test(String(v || ''))) ? 'dark' : 'light')
|
|
1001
|
+
: looksDark(mockSurface) ? 'dark' : 'light';
|
|
1002
|
+
saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
|
|
1003
|
+
if (saved && saved.changed) { theme = { mockup: mockTheme, app_had: saved.before }; await page.waitForTimeout(400); }
|
|
1004
|
+
const putBack = KEEP_APP_THEME ? null : await useTheme(page, own);
|
|
1005
|
+
// the mockup's site theme is put back after the other-theme pass below (TF-025)
|
|
1006
|
+
try { app = await page.evaluate(PROBE, wanted); ownSurface = await surface(page); }
|
|
1007
|
+
finally { if (putBack) await putBack(); }
|
|
439
1008
|
|
|
440
1009
|
// TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
|
|
441
1010
|
// have caught the /routing void on its own: 2607px of document against a
|
|
442
1011
|
// 900px viewport, ~1700px of it blank, with the app shell repainted at the
|
|
443
1012
|
// bottom because the page had escaped the shell's scroll container. No gate
|
|
444
1013
|
// looked at document height, so that passed too.
|
|
445
|
-
|
|
1014
|
+
// Only when the shell has a content scroll container: an app whose document is its only
|
|
1015
|
+
// scroller (a long report page) has escaped nothing (AppManager TF-014).
|
|
1016
|
+
if (app.doc.scroller && app.doc.scrollHeight > app.doc.clientHeight + 2) {
|
|
446
1017
|
docFindings.push({
|
|
447
1018
|
screen: s.name, width, class: 'document-scroll', key: 'document',
|
|
448
|
-
detail: `document.scrollHeight ${app.doc.scrollHeight} exceeds clientHeight ${app.doc.clientHeight} — the page has escaped the app shell's scroll container`,
|
|
1019
|
+
detail: `document.scrollHeight ${app.doc.scrollHeight} exceeds clientHeight ${app.doc.clientHeight} — the page has escaped the app shell's scroll container (${app.doc.scroller})`,
|
|
449
1020
|
});
|
|
450
1021
|
}
|
|
451
1022
|
} catch (e) {
|
|
452
1023
|
perWidth.push({ width, error: String(e).slice(0, 200) });
|
|
453
|
-
await
|
|
1024
|
+
await unSite();
|
|
454
1025
|
continue;
|
|
455
1026
|
}
|
|
456
1027
|
const d = diff(mock, app, s.name, width);
|
|
457
1028
|
d.findings.push(...docFindings);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1029
|
+
if (wantTheme(own)) {
|
|
1030
|
+
perWidth.push({ width, mode: own, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
|
|
1031
|
+
appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// --- Lekhak TF-024: the other theme. Both pages are drawn in it and put back after, so an
|
|
1035
|
+
// attached app is left in the viewer's theme. Every finding here says which theme it is in.
|
|
1036
|
+
const other = own === 'dark' ? 'light' : 'dark';
|
|
1037
|
+
if (KEEP_APP_THEME || !wantTheme(other)) { await unSite(); continue; }
|
|
1038
|
+
let backApp = null, backMock = null;
|
|
1039
|
+
try {
|
|
1040
|
+
// The app keeps the mockup's site theme, but gets its own light/dark classes back, so that the
|
|
1041
|
+
// switch below flips them as the app's toggle would (a class-driven dark mode, TF-013).
|
|
1042
|
+
if (saved && saved.changed) {
|
|
1043
|
+
const cls = Object.fromEntries(Object.entries(saved.before).filter(([k]) => k.endsWith('|class')));
|
|
1044
|
+
if (Object.keys(cls).length) await page.evaluate(APPLY_THEME, cls).catch(() => {});
|
|
1045
|
+
}
|
|
1046
|
+
backApp = await useTheme(page, other);
|
|
1047
|
+
backMock = await useTheme(mockPage, other, 0);
|
|
1048
|
+
if (!THEMES && !otherSeen && !differs(ownSurface, await surface(page))) { notOffered.add(other); continue; }
|
|
1049
|
+
otherSeen = true;
|
|
1050
|
+
// A mockup that does not change under the other theme does not draw it: forcing it there only
|
|
1051
|
+
// strips the styles it keys on its own theme (a badge losing its fill). The app in the other theme
|
|
1052
|
+
// is then compared with the mockup as drawn, colour left out.
|
|
1053
|
+
const mockDraws = differs(mockSurface, await surface(mockPage));
|
|
1054
|
+
if (!mockDraws) { await backMock(); backMock = null; }
|
|
1055
|
+
const mockO = mockDraws ? await mockPage.evaluate(PROBE) : mock;
|
|
1056
|
+
const appO = await page.evaluate(PROBE, wanted);
|
|
1057
|
+
const d2 = diff(mockO, appO, s.name, width, mockDraws ? null : new Set(['color']));
|
|
1058
|
+
for (const f of d2.findings) f.theme = other;
|
|
1059
|
+
perWidth.push({ width, mode: other, mockup_draws_theme: mockDraws, ...d2, mockAnchors: mockO.anchors, appAnchors: appO.anchors,
|
|
1060
|
+
appTestIds: appO.testids, mockTestIds: mockO.testids, ...(reached ? { reached } : {}) });
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
perWidth.push({ width, mode: other, error: String(e).slice(0, 200) });
|
|
1063
|
+
} finally {
|
|
1064
|
+
if (backMock) await backMock();
|
|
1065
|
+
if (backApp) await backApp();
|
|
1066
|
+
await unSite();
|
|
1067
|
+
}
|
|
461
1068
|
}
|
|
462
1069
|
|
|
463
1070
|
const ok = perWidth.filter((w) => !w.error);
|
|
@@ -484,9 +1091,13 @@ for (const s of SCREENS) {
|
|
|
484
1091
|
// not read as a thorough screen with one problem — `coverage.ungradeable` stays
|
|
485
1092
|
// true either way, so the report can say both things at once.
|
|
486
1093
|
const ungradeable = contentGraded === 0;
|
|
1094
|
+
// A width or theme pass that threw was not compared, so it cannot be part of a PASS: a crash in the
|
|
1095
|
+
// dark pass gave PASS on light alone, with nothing saying dark was missing (MISS-TechieFlow-20261009-04).
|
|
1096
|
+
const failedPasses = perWidth.filter((w) => w.error);
|
|
487
1097
|
let verdict;
|
|
488
1098
|
if (ok.length === 0) verdict = 'ERROR';
|
|
489
1099
|
else if (findings.length) verdict = 'FAIL';
|
|
1100
|
+
else if (failedPasses.length) verdict = 'ERROR';
|
|
490
1101
|
else if (ungradeable) verdict = 'UNGRADEABLE';
|
|
491
1102
|
else verdict = 'PASS';
|
|
492
1103
|
|
|
@@ -501,6 +1112,10 @@ for (const s of SCREENS) {
|
|
|
501
1112
|
screen: s.name, route: s.route, verdict,
|
|
502
1113
|
coverage: {
|
|
503
1114
|
compared, content_graded: contentGraded, ungradeable,
|
|
1115
|
+
// badges found by their text at another depth than the mockup's (TF-045)
|
|
1116
|
+
relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
|
|
1117
|
+
// sample boxes the app drew nothing at, so not compared (Chatur TF-002)
|
|
1118
|
+
not_measured: [...new Set(ok.flatMap((w) => w.notMeasured || []))],
|
|
504
1119
|
app_controls: appAnchors, mockup_anchors: mockAnchors,
|
|
505
1120
|
ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
|
|
506
1121
|
thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
|
|
@@ -516,16 +1131,93 @@ for (const s of SCREENS) {
|
|
|
516
1131
|
// Named so the fix is a mechanical edit to the mockup, not a research task.
|
|
517
1132
|
add_data_testid_to_mockup: unanchored.slice(0, 30),
|
|
518
1133
|
},
|
|
519
|
-
|
|
520
|
-
|
|
1134
|
+
// the themes it was compared in, and one the app showed no sign of having (TF-024)
|
|
1135
|
+
themes: [...new Set(ok.map((w) => w.mode).filter(Boolean))],
|
|
1136
|
+
...(notOffered.size ? { themes_not_offered: [...notOffered] } : {}),
|
|
1137
|
+
...(failedPasses.length ? { not_compared: failedPasses.map((w) => `${w.width}${w.mode ? ' ' + w.mode : ''}: ${w.error}`) } : {}),
|
|
1138
|
+
widths: perWidth.map((w) => ({ width: w.width, ...(w.mode ? { mode: w.mode } : {}),
|
|
1139
|
+
...(w.mockup_draws_theme === false ? { colour_not_compared: 'the mockup draws no ' + w.mode + ' theme' } : {}), error: w.error || null,
|
|
1140
|
+
compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
|
|
1141
|
+
not_measured: (w.notMeasured || []).length,
|
|
1142
|
+
...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
|
|
1143
|
+
...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
|
|
521
1144
|
});
|
|
522
1145
|
}
|
|
523
1146
|
|
|
1147
|
+
// ---------------------------------------------------------------- menus (Chatur TF-011)
|
|
1148
|
+
// Each menu the mockups draw open is opened in the app and every item clicked, once, on the first screen
|
|
1149
|
+
// that draws it, at the first width. Its findings go on every screen whose mockup draws it; a screen a
|
|
1150
|
+
// menu item links to and no click reached gets an `unreachable` finding.
|
|
1151
|
+
let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
|
|
1152
|
+
if (WALK_MENUS && results.length) {
|
|
1153
|
+
const width = WIDTHS[0];
|
|
1154
|
+
const { app: page, mock: mockPage } = tabs[width];
|
|
1155
|
+
const byScreen = {}, mockText = {}, mockIds = {};
|
|
1156
|
+
for (const r of results) {
|
|
1157
|
+
if (r.verdict === 'NO-MOCKUP' || r.verdict === 'ERROR') continue;
|
|
1158
|
+
try {
|
|
1159
|
+
await mockPage.goto(pathToFileURL(findMockup(r.screen)).href, { waitUntil: 'load' });
|
|
1160
|
+
byScreen[r.screen] = await mockupMenus(mockPage, 'data-testid');
|
|
1161
|
+
mockText[r.screen] = await mockPage.evaluate(() => document.body.innerText || '');
|
|
1162
|
+
// the controls that say a click landed on this screen: anchors outside any other state or open menu
|
|
1163
|
+
mockIds[r.screen] = await mockPage.evaluate(() => [...document.querySelectorAll('[data-testid]')]
|
|
1164
|
+
.filter((e) => !e.closest('[data-tf-state],[data-state-testid],[data-tf-sample]')).map((e) => e.getAttribute('data-testid')));
|
|
1165
|
+
} catch (e) { byScreen[r.screen] = []; }
|
|
1166
|
+
}
|
|
1167
|
+
const key = (m) => `${m.trigger}|${m.popup}`;
|
|
1168
|
+
const done = new Map();
|
|
1169
|
+
const anyMock = Object.values(byScreen).some((l) => l.length);
|
|
1170
|
+
const allClicks = [];
|
|
1171
|
+
for (const r of results) {
|
|
1172
|
+
if (!(r.screen in byScreen)) continue;
|
|
1173
|
+
const todo = byScreen[r.screen].filter((m) => !done.has(key(m)));
|
|
1174
|
+
if (anyMock && !todo.length) continue;
|
|
1175
|
+
if (!anyMock && done.size) break;
|
|
1176
|
+
const back = async () => { if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route); };
|
|
1177
|
+
try {
|
|
1178
|
+
if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
|
|
1179
|
+
await back();
|
|
1180
|
+
const pages = async () => CDP ? cdpBrowser.contexts().flatMap((c) => c.pages()) : ctx.pages().filter((p) => p !== mockPage && !Object.values(tabs).some((t) => t.mock === p));
|
|
1181
|
+
const w = await walkMenus(page, todo, { attr: 'data-testid', settle: LOGIN_OPTS.settle, back, pages, keepWindows: !!CDP,
|
|
1182
|
+
mockText: mockText[r.screen], placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
|
|
1183
|
+
menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: r.screen })));
|
|
1184
|
+
menuWalk.clicks.push(...w.clicks.map(({ ids_before, ...c }) => ({ ...c, landed: c.landed ? { url: c.landed.url, window: c.landed.window } : null, screen: r.screen })));
|
|
1185
|
+
allClicks.push(...w.clicks);
|
|
1186
|
+
if (!w.from_mockup) { done.set('app|', w.findings); r._menu = w.findings; }
|
|
1187
|
+
for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
|
|
1188
|
+
} catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
|
|
1189
|
+
}
|
|
1190
|
+
// what each screen owes: its menus' findings, placeholder text on the page, and a way in that works
|
|
1191
|
+
const ways = reachability(results.filter((r) => r.screen in byScreen).map((r) => ({ name: r.screen, route: r.route, mockup: findMockup(r.screen), anchors: mockIds[r.screen] || [] })), allClicks);
|
|
1192
|
+
menuWalk.reach = ways;
|
|
1193
|
+
for (const r of results) {
|
|
1194
|
+
if (!(r.screen in byScreen)) continue;
|
|
1195
|
+
const add = (byScreen[r.screen] || []).flatMap((m) => done.get(key(m)) || []).concat(r._menu || [])
|
|
1196
|
+
.map((f) => ({ screen: r.screen, width, class: f.class, key: f.popup ? `menu ${f.popup}` : 'menu', detail: f.detail }));
|
|
1197
|
+
try {
|
|
1198
|
+
if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route);
|
|
1199
|
+
for (const p of await placeholdersOnPage(page, mockText[r.screen], PLACEHOLDER_EXTRA)) add.push({ screen: r.screen, width, class: 'placeholder', key: 'document', detail: `the screen shows placeholder text "${p}"` });
|
|
1200
|
+
} catch (e) { /* the screen was compared above; a second opening that fails adds nothing */ }
|
|
1201
|
+
const rv = ways[r.screen];
|
|
1202
|
+
if (rv && !rv.reached) add.unshift({ screen: r.screen, width, class: 'unreachable', key: 'document', detail: `no menu click reaches ${r.route}: ${rv.tried.join('; ')}` });
|
|
1203
|
+
if (rv && rv.reached) r.reached_by = rv.by;
|
|
1204
|
+
delete r._menu;
|
|
1205
|
+
if (!add.length) continue;
|
|
1206
|
+
r.findings = [...add, ...r.findings].slice(0, MAX_FINDINGS_PER_SCREEN);
|
|
1207
|
+
r.findings_n = r.findings.length;
|
|
1208
|
+
if (r.verdict === 'PASS' || r.verdict === 'UNGRADEABLE') r.verdict = 'FAIL';
|
|
1209
|
+
hardFail = true;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
for (const r of results) delete r._menu;
|
|
1213
|
+
|
|
524
1214
|
await browser.close();
|
|
1215
|
+
if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
|
|
525
1216
|
|
|
526
1217
|
const summary = {
|
|
527
1218
|
status: 'measured',
|
|
528
|
-
|
|
1219
|
+
menus: menuWalk,
|
|
1220
|
+
base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
|
|
529
1221
|
widths: WIDTHS,
|
|
530
1222
|
screens_n: results.length,
|
|
531
1223
|
pass: results.filter((r) => r.verdict === 'PASS').length,
|