@techierathore/techieflow 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -43,6 +43,7 @@ import { chromium } from 'playwright';
|
|
|
43
43
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
44
44
|
import { dirname, resolve } from 'node:path';
|
|
45
45
|
import { pathToFileURL } from 'node:url';
|
|
46
|
+
import { signIn, reach } from './tf-login.mjs';
|
|
46
47
|
|
|
47
48
|
// ---------------------------------------------------------------- arguments
|
|
48
49
|
const argv = process.argv.slice(2);
|
|
@@ -53,10 +54,19 @@ const arg = (name, dflt = null) => {
|
|
|
53
54
|
const flag = (name) => argv.includes(name);
|
|
54
55
|
|
|
55
56
|
const BASE = (arg('--base') || '').replace(/\/$/, '');
|
|
57
|
+
// An embedded-browser desktop head, attached over its DevTools port as tf-verify-screens does; its
|
|
58
|
+
// screens are opened the way its router does, by pushState (Lekhak TF-006).
|
|
59
|
+
const CDP = arg('--cdp') || '';
|
|
56
60
|
const MOCKUPS = arg('--mockups', 'docs/mockups');
|
|
57
61
|
const WIDTHS = (arg('--widths', '1280,390') || '').split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
|
|
58
62
|
const JSON_OUT = arg('--json-out');
|
|
59
63
|
const COOKIE = arg('--cookie');
|
|
64
|
+
// A sign-in the page keeps for itself sets no cookie, so --cookie could not reach AppManager's screens
|
|
65
|
+
// at all: "app returned HTTP 401" at both widths (AppManager TF-011). The same --login-path, --user,
|
|
66
|
+
// --password and --storage-state tf-verify-screens takes, through the shared recipe in tf-login.mjs.
|
|
67
|
+
const LOGIN_PATH = arg('--login-path'); const USER = arg('--user'); const PASS = arg('--password');
|
|
68
|
+
const STORAGE = arg('--storage-state');
|
|
69
|
+
const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, settle: 1500, renderWait: 5000 };
|
|
60
70
|
const HEADERS = [];
|
|
61
71
|
for (let i = 0; i < argv.length; i++) if (argv[i] === '--header' && argv[i + 1]) HEADERS.push(argv[i + 1]);
|
|
62
72
|
const SCREENS = [];
|
|
@@ -67,10 +77,56 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
67
77
|
}
|
|
68
78
|
}
|
|
69
79
|
const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
|
|
80
|
+
// --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
|
|
81
|
+
const KEEP_APP_THEME = flag('--keep-app-theme');
|
|
82
|
+
// The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
|
|
83
|
+
// scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
|
|
84
|
+
// that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
|
|
85
|
+
// such as Tailwind's `dark` uses (Lekhak TF-013). A utility class such as bg-light does not count.
|
|
86
|
+
// Nothing about any one stack. The class tokens are kept under `<where>|class`, '' when there are none.
|
|
87
|
+
const READ_THEME = () => {
|
|
88
|
+
const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const [where, el] of [['html', document.documentElement], ['body', document.body]]) {
|
|
91
|
+
if (!el) continue;
|
|
92
|
+
for (const a of el.attributes) if (/^data-.*(theme|mode|scheme)/i.test(a.name)) out[`${where}|${a.name}`] = a.value;
|
|
93
|
+
out[`${where}|class`] = [...el.classList].filter((c) => MODE.test(c)).join(' ');
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
// A page declares a theme when it has such an attribute or mode class. A mockup that declares none
|
|
98
|
+
// leaves the app as it is.
|
|
99
|
+
const DECLARES_THEME = (t) => !!t && Object.entries(t).some(([k, v]) => !k.endsWith('|class') || v);
|
|
100
|
+
// Sets those attributes and mode classes on this page; returns what it had, so the same call puts it
|
|
101
|
+
// back. An attribute the page lacked is removed again on the way back (value null). For `|class` the
|
|
102
|
+
// page's own mode tokens are replaced by the given ones; every other class is left alone.
|
|
103
|
+
const APPLY_THEME = (t) => {
|
|
104
|
+
const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
|
|
105
|
+
const before = {}; let changed = false;
|
|
106
|
+
for (const [k, v] of Object.entries(t || {})) {
|
|
107
|
+
const [where, name] = k.split('|');
|
|
108
|
+
const el = where === 'body' ? document.body : document.documentElement;
|
|
109
|
+
if (!el) continue;
|
|
110
|
+
if (name === 'class') {
|
|
111
|
+
const had = [...el.classList].filter((c) => MODE.test(c));
|
|
112
|
+
const want = String(v || '').split(/\s+/).filter(Boolean);
|
|
113
|
+
before[k] = had.join(' ');
|
|
114
|
+
if (had.slice().sort().join(' ') !== want.slice().sort().join(' ')) changed = true;
|
|
115
|
+
for (const c of had) el.classList.remove(c);
|
|
116
|
+
for (const c of want) el.classList.add(c);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const had = el.getAttribute(name);
|
|
120
|
+
before[k] = had;
|
|
121
|
+
if (had !== v) changed = true;
|
|
122
|
+
if (v === null) el.removeAttribute(name); else el.setAttribute(name, v);
|
|
123
|
+
}
|
|
124
|
+
return { before, changed };
|
|
125
|
+
};
|
|
70
126
|
const MAX_FINDINGS_PER_SCREEN = parseInt(arg('--max-findings', '40'), 10);
|
|
71
127
|
|
|
72
|
-
if (!BASE || SCREENS.length === 0) {
|
|
73
|
-
console.error('usage: tf-mockup-parity.sh --base URL --screen name=/route [--screen ...]');
|
|
128
|
+
if ((!BASE && !CDP) || SCREENS.length === 0) {
|
|
129
|
+
console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
|
|
74
130
|
process.exit(3);
|
|
75
131
|
}
|
|
76
132
|
|
|
@@ -78,7 +134,9 @@ if (!BASE || SCREENS.length === 0) {
|
|
|
78
134
|
// Everything below runs INSIDE the browser, on both the mockup and the app, and
|
|
79
135
|
// returns one flat index keyed by a stable path key. It is deliberately one
|
|
80
136
|
// evaluate() call: two passes over the same DOM can disagree after a re-layout.
|
|
81
|
-
|
|
137
|
+
// `wanted` names the texts of the mockup's badges, so the app's side can find each one at whatever
|
|
138
|
+
// depth the app drew it (TF-045); the mockup's own probe is called without it.
|
|
139
|
+
const PROBE = (wanted = []) => {
|
|
82
140
|
const MAX_DEPTH = 4; // descend this far below an anchor
|
|
83
141
|
const MAX_PER_ANCHOR = 60; // and no further; a huge subtree is noise, not signal
|
|
84
142
|
|
|
@@ -100,11 +158,23 @@ const PROBE = () => {
|
|
|
100
158
|
return false;
|
|
101
159
|
};
|
|
102
160
|
|
|
161
|
+
// --- TF-027: the browser converts the colour, never a regex. getComputedStyle returns a
|
|
162
|
+
// colour in the syntax it was written in, and a component library themed in oklch() came
|
|
163
|
+
// back as "oklch(0.971 0.013 17.38)": read as r/g/b those three numbers are near-black, so
|
|
164
|
+
// every tinted tile, pill and icon in the app bucketed as neutral against a mockup written
|
|
165
|
+
// in hex — "semantic colour differs — mockup negative, app neutral" on tiles plainly red.
|
|
166
|
+
// Painting one pixel and reading it back gives sRGB for any syntax the browser accepts.
|
|
167
|
+
const px = document.createElement('canvas');
|
|
168
|
+
px.width = px.height = 1;
|
|
169
|
+
const pctx = px.getContext('2d', { willReadFrequently: true });
|
|
103
170
|
const RGB = (v) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
171
|
+
if (!v || v === 'transparent' || !pctx) return null;
|
|
172
|
+
pctx.clearRect(0, 0, 1, 1);
|
|
173
|
+
pctx.fillStyle = 'rgba(0,0,0,0)';
|
|
174
|
+
pctx.fillStyle = v; // an unparseable value leaves it transparent
|
|
175
|
+
pctx.fillRect(0, 0, 1, 1);
|
|
176
|
+
const [r, g, b, a] = pctx.getImageData(0, 0, 1, 1).data;
|
|
177
|
+
return a / 255 < 0.05 ? null : { r, g, b };
|
|
108
178
|
};
|
|
109
179
|
|
|
110
180
|
// Semantic BUCKET, never the literal colour: two designs may legitimately differ
|
|
@@ -114,15 +184,26 @@ const PROBE = () => {
|
|
|
114
184
|
const { r, g, b } = c;
|
|
115
185
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
|
116
186
|
if (mx - mn < 28) return 'neutral';
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
187
|
+
// By hue, never by one channel. "Red is highest, so green above 120 is a warning" put a deep
|
|
188
|
+
// amber (180, 83, 9) in `negative` beside a light amber in `warning` — one warning tone on two
|
|
189
|
+
// sides of the line, reported as a colour difference on TfLens /misses (TF-045).
|
|
190
|
+
const d = mx - mn;
|
|
191
|
+
const h = ((mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + 360) % 360;
|
|
192
|
+
if (h < 15 || h >= 330) return 'negative';
|
|
193
|
+
if (h < 70) return 'warning';
|
|
194
|
+
if (h < 185) return 'positive';
|
|
195
|
+
return 'accent';
|
|
121
196
|
};
|
|
122
197
|
|
|
198
|
+
// A border nobody draws carries no colour. `border: 1px solid transparent` is how a mockup
|
|
199
|
+
// reserves space, and a page reset that sets border-color everywhere leaves the property set on
|
|
200
|
+
// elements with no border at all: reading it gave a borderless icon the reset's grey and called
|
|
201
|
+
// it a colour difference (TF-038).
|
|
202
|
+
const drawnBorder = (cs) => (parseFloat(cs.borderTopWidth) || 0) > 0 && !!RGB(cs.borderTopColor);
|
|
203
|
+
|
|
123
204
|
const semanticColor = (el) => {
|
|
124
205
|
const cs = getComputedStyle(el);
|
|
125
|
-
return bucket(RGB(cs.backgroundColor)) || bucket(RGB(cs.borderTopColor)) || bucket(RGB(cs.color));
|
|
206
|
+
return bucket(RGB(cs.backgroundColor)) || (drawnBorder(cs) ? bucket(RGB(cs.borderTopColor)) : null) || bucket(RGB(cs.color));
|
|
126
207
|
};
|
|
127
208
|
|
|
128
209
|
// --- TF-009: the seventh class. A mockup says "provisional / estimated /
|
|
@@ -136,12 +217,22 @@ const PROBE = () => {
|
|
|
136
217
|
const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
|
|
137
218
|
: s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
|
|
138
219
|
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:
|
|
220
|
+
const drawn = drawnBorder(cs); // width AND a colour: transparent draws nothing (TF-038)
|
|
221
|
+
const style = drawn ? sides[0] : 'none';
|
|
222
|
+
return { style, uniform: new Set(sides).size === 1, visible: drawn };
|
|
142
223
|
};
|
|
143
224
|
|
|
144
|
-
const
|
|
225
|
+
const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
|
|
226
|
+
const hasIcon = (el) => !!el.querySelector(ICON);
|
|
227
|
+
// how many icons the element carries at any depth: a component library that wraps an icon
|
|
228
|
+
// one element deeper than the mockup moves it to another path key without removing it (TF-027)
|
|
229
|
+
const iconCount = (el) => [...el.querySelectorAll(ICON)].filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
|
|
230
|
+
// Every icon on the page by number, so the node side can tell that an anchor and the wrappers
|
|
231
|
+
// above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
|
|
232
|
+
// and one Select chevron was reported at four levels of TfLens /misses (TF-046).
|
|
233
|
+
const iconNo = new Map([...document.querySelectorAll(ICON)].map((i, n) => [i, n]));
|
|
234
|
+
const iconIds = (el) => [...el.querySelectorAll(ICON)].map((i) => iconNo.get(i));
|
|
235
|
+
const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
|
|
145
236
|
|
|
146
237
|
// "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
|
|
147
238
|
// a rounded edge. The mockup drawing one and the app rendering bare text is the
|
|
@@ -153,10 +244,30 @@ const PROBE = () => {
|
|
|
153
244
|
const cs = getComputedStyle(el);
|
|
154
245
|
const radius = parseFloat(cs.borderTopLeftRadius) || 0;
|
|
155
246
|
const filled = !!RGB(cs.backgroundColor);
|
|
156
|
-
const ringed = (
|
|
247
|
+
const ringed = drawnBorder(cs); // a transparent ring is not a ring (TF-038)
|
|
157
248
|
return { badge: (filled || ringed) && radius >= 6, filled, ringed, radius: Math.round(radius) };
|
|
158
249
|
};
|
|
159
250
|
|
|
251
|
+
// --- TF-047: the treatment one element away. A component library draws the ring and the radius on
|
|
252
|
+
// the box AROUND the control the mockup styles (an InputGroup around a filter input, a nav <li>
|
|
253
|
+
// around the active link), and a mockup does the reverse. Keys pair by position, so the badge and
|
|
254
|
+
// stroke clauses compared the control with the plain box beside the one that carries the treatment:
|
|
255
|
+
// "badge on nav > a[4], app plain" on TfLens's sidebar, on every screen. The element's neighbours
|
|
256
|
+
// one step away are kept here — the parent when it is the same row (same height, its other
|
|
257
|
+
// children carrying no text: an icon, or the box a library wraps one in) and the only child when
|
|
258
|
+
// it is — so a clause can accept the treatment there.
|
|
259
|
+
const nearOf = (el) => {
|
|
260
|
+
const out = [];
|
|
261
|
+
const h = el.getBoundingClientRect().height;
|
|
262
|
+
const sameRow = (x) => Math.abs(x.getBoundingClientRect().height - h) <= 8;
|
|
263
|
+
const p = el.parentElement;
|
|
264
|
+
if (p && p !== document.body && sameRow(p)
|
|
265
|
+
&& [...p.children].every((s) => s === el || isHidden(s) || !(s.textContent || '').trim())) out.push(p);
|
|
266
|
+
const kids = [...el.children].filter((c) => !isHidden(c));
|
|
267
|
+
if (kids.length === 1 && sameRow(kids[0])) out.push(kids[0]);
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
|
|
160
271
|
// Only text that is genuinely a single inline run can be graded for wrapping or
|
|
161
272
|
// token fit. A container with block children has no meaningful line count, and
|
|
162
273
|
// pretending otherwise is how a card-shaped anchor buys nothing (TF-011).
|
|
@@ -173,8 +284,11 @@ const PROBE = () => {
|
|
|
173
284
|
const cs = getComputedStyle(el);
|
|
174
285
|
let lh = parseFloat(cs.lineHeight);
|
|
175
286
|
if (!lh || Number.isNaN(lh)) lh = (parseFloat(cs.fontSize) || 16) * 1.2;
|
|
176
|
-
|
|
177
|
-
|
|
287
|
+
// the text's height only: padding and borders are not rows. A badge with 4px padding and
|
|
288
|
+
// `line-height: 1` read 18 / 9.756 = 1.85, rounded to 2 rows (AppManager TF-015).
|
|
289
|
+
const h = el.getBoundingClientRect().height
|
|
290
|
+
- ['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth'].reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0);
|
|
291
|
+
if (!(h > 0) || !lh) return null;
|
|
178
292
|
return Math.max(1, Math.round(h / lh));
|
|
179
293
|
};
|
|
180
294
|
|
|
@@ -206,33 +320,81 @@ const PROBE = () => {
|
|
|
206
320
|
// --- TF-012 again, on the measuring side. scrollWidth includes the phantom
|
|
207
321
|
// extent of an sr-only descendant, so the ancestor is measured from the right
|
|
208
322
|
// edge of its VISIBLE descendants instead whenever a hidden one is present.
|
|
323
|
+
// A descendant inside a scroller is painted only as far as that scroller's edge, so its own box
|
|
324
|
+
// must be cut by every ancestor that clips before it can count as the card overflowing. Without
|
|
325
|
+
// that, a table scrolling inside its wrapper — the layout the BRD asks for — read as a card cut
|
|
326
|
+
// off at 390px (TF-039). The same rectangle tf-verify-screens measures since TF-021.
|
|
327
|
+
// The cut stops BELOW `stop`: the element being measured must not cut its own content, or a card
|
|
328
|
+
// that really clips would read as holding everything (it did, from TF-039 until TF-042).
|
|
329
|
+
const paintedRight = (c, stop) => {
|
|
330
|
+
const r = c.getBoundingClientRect();
|
|
331
|
+
let x1 = r.left, x2 = r.right;
|
|
332
|
+
for (let a = c.parentElement; a && a !== stop; a = a.parentElement) {
|
|
333
|
+
if (getComputedStyle(a).overflowX !== 'visible') {
|
|
334
|
+
const ar = a.getBoundingClientRect();
|
|
335
|
+
x1 = Math.max(x1, ar.left); x2 = Math.min(x2, ar.right);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return x2 > x1 ? x2 : null;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// --- TF-042: overflow is not clipping. An element whose overflow-x is `visible` draws what spills
|
|
342
|
+
// past its edge in full, so it is cut off only where an ancestor that clips ends first. Reading
|
|
343
|
+
// scrollWidth alone called a sidebar "cut off" because its rail handle straddles its edge by 8px,
|
|
344
|
+
// on every TfLens screen, with nothing clipped anywhere up to <html> (2026-09-12).
|
|
345
|
+
const cutAbove = (el, right) => {
|
|
346
|
+
for (let a = el.parentElement; a; a = a.parentElement) {
|
|
347
|
+
if (getComputedStyle(a).overflowX === 'visible') continue;
|
|
348
|
+
if (right > a.getBoundingClientRect().left + a.clientLeft + a.clientWidth + 2) return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
};
|
|
352
|
+
|
|
209
353
|
const clipOf = (el) => {
|
|
210
354
|
const hiddenKids = [...el.querySelectorAll('*')].filter(isHidden);
|
|
211
|
-
|
|
355
|
+
const box = el.getBoundingClientRect();
|
|
356
|
+
let overX, right;
|
|
212
357
|
if (hiddenKids.length === 0) {
|
|
213
358
|
overX = el.scrollWidth - el.clientWidth;
|
|
359
|
+
right = box.left + el.clientLeft + el.scrollWidth;
|
|
214
360
|
} else {
|
|
215
|
-
|
|
216
|
-
let right = box.left;
|
|
361
|
+
right = box.left;
|
|
217
362
|
for (const c of el.querySelectorAll('*')) {
|
|
218
363
|
if (isHidden(c)) continue;
|
|
219
364
|
const r = c.getBoundingClientRect();
|
|
220
|
-
if (r.width > 0)
|
|
365
|
+
if (r.width > 0) {
|
|
366
|
+
const painted = paintedRight(c, el);
|
|
367
|
+
if (painted !== null) right = Math.max(right, painted);
|
|
368
|
+
}
|
|
221
369
|
}
|
|
222
370
|
overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
|
|
223
371
|
}
|
|
372
|
+
let x = overX > 2;
|
|
373
|
+
if (x && getComputedStyle(el).overflowX === 'visible') x = cutAbove(el, right);
|
|
224
374
|
const overY = el.scrollHeight - el.clientHeight;
|
|
225
|
-
return { x
|
|
375
|
+
return { x, y: overY > 2, sr_excluded: hiddenKids.length };
|
|
226
376
|
};
|
|
227
377
|
|
|
228
378
|
const sigOf = (el) => {
|
|
229
379
|
const r = el.getBoundingClientRect();
|
|
230
380
|
const chrome = chromeOn(el);
|
|
381
|
+
const near = nearOf(el);
|
|
231
382
|
return {
|
|
383
|
+
// the badge and the visible border one element away, for the clauses to accept (TF-047)
|
|
384
|
+
near: {
|
|
385
|
+
badge: near.some((n) => chromeOn(n)?.badge === true),
|
|
386
|
+
stroke: near.map(strokeOf).find((s) => s.visible) || null,
|
|
387
|
+
},
|
|
232
388
|
tag: el.tagName.toLowerCase(),
|
|
233
389
|
text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
|
|
234
390
|
badge: chrome ? chrome.badge : null,
|
|
391
|
+
full: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 400),
|
|
235
392
|
icon: hasIcon(el),
|
|
393
|
+
icons: iconCount(el),
|
|
394
|
+
icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
|
|
395
|
+
depth: depthOf(el),
|
|
396
|
+
// badges drawn anywhere inside, so a badge one wrapper deeper still counts under its parent (TF-045)
|
|
397
|
+
badges: [...el.querySelectorAll('*')].filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
|
|
236
398
|
color: semanticColor(el),
|
|
237
399
|
stroke: strokeOf(el),
|
|
238
400
|
wrap: lineCount(el),
|
|
@@ -252,16 +414,32 @@ const PROBE = () => {
|
|
|
252
414
|
// table already was. Keys pair only when both sides produce them, so extra depth
|
|
253
415
|
// costs precision nothing — it only ever adds comparisons that were impossible
|
|
254
416
|
// before.
|
|
417
|
+
// A box the mockup marks as another state of the screen (data-tf-state="<state>", or
|
|
418
|
+
// data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
|
|
419
|
+
// siblings are numbered: a database-down alert first in a card moved every row below it one place,
|
|
420
|
+
// and the Host/Port row was compared with the alert (Lekhak TF-011).
|
|
421
|
+
const stateOnly = (el) => el.hasAttribute('data-tf-state') || el.hasAttribute('data-state-testid');
|
|
255
422
|
const keyOf = (el, parentKey) => {
|
|
256
423
|
const tag = el.tagName.toLowerCase();
|
|
257
424
|
let n = 0;
|
|
258
425
|
for (const s of el.parentElement ? el.parentElement.children : []) {
|
|
259
426
|
if (s === el) break;
|
|
260
|
-
if (s.tagName === el.tagName) n++;
|
|
427
|
+
if (s.tagName === el.tagName && !stateOnly(s)) n++;
|
|
261
428
|
}
|
|
262
429
|
return `${parentKey} > ${tag}[${n}]`;
|
|
263
430
|
};
|
|
264
431
|
|
|
432
|
+
// --- TF-045: the same badge, one wrapper deeper. Keys are positional, so a component library that
|
|
433
|
+
// wraps a card header's contents in one more <div> moves every badge in it to a key the mockup
|
|
434
|
+
// never has: 32 "missing" findings on three TfLens screens, each drawn on the page with the right
|
|
435
|
+
// text. For every text a mockup badge carries, the elements under the same anchor that carry it
|
|
436
|
+
// are kept here at any depth, so the node side can find the badge where the app put it. Digits
|
|
437
|
+
// are folded, because live data is not the mockup's sample: "87 of 91" is "39 of 41". Case is
|
|
438
|
+
// kept: a card titled "Draft" is not its badge "draft".
|
|
439
|
+
const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
|
|
440
|
+
const want = new Set((wanted || []).map(norm).filter(Boolean));
|
|
441
|
+
const pool = {};
|
|
442
|
+
|
|
265
443
|
const index = {};
|
|
266
444
|
const anchors = [...document.querySelectorAll('[data-testid]')];
|
|
267
445
|
const allTestIds = anchors.map((a) => a.getAttribute('data-testid'));
|
|
@@ -269,7 +447,7 @@ const PROBE = () => {
|
|
|
269
447
|
for (const a of anchors) {
|
|
270
448
|
const id = a.getAttribute('data-testid');
|
|
271
449
|
if (!id || index[id]) continue;
|
|
272
|
-
if (isHidden(a)) continue;
|
|
450
|
+
if (isHidden(a) || a.closest('[data-tf-state],[data-state-testid]')) continue; // TF-011
|
|
273
451
|
index[id] = sigOf(a);
|
|
274
452
|
let budget = MAX_PER_ANCHOR;
|
|
275
453
|
const walk = (el, key, depth) => {
|
|
@@ -278,6 +456,7 @@ const PROBE = () => {
|
|
|
278
456
|
if (budget <= 0) return;
|
|
279
457
|
if (c.hasAttribute('data-testid')) continue; // it gets its own top-level entry
|
|
280
458
|
if (isHidden(c)) continue; // TF-012
|
|
459
|
+
if (stateOnly(c)) continue; // another state of the screen (TF-011)
|
|
281
460
|
const k = keyOf(c, key);
|
|
282
461
|
index[k] = sigOf(c);
|
|
283
462
|
budget--;
|
|
@@ -285,11 +464,40 @@ const PROBE = () => {
|
|
|
285
464
|
}
|
|
286
465
|
};
|
|
287
466
|
walk(a, id, 1);
|
|
467
|
+
if (want.size) {
|
|
468
|
+
const found = [];
|
|
469
|
+
const queue = [[a, id, 0]];
|
|
470
|
+
let seen = 0;
|
|
471
|
+
while (queue.length && seen < 800 && found.length < 30) {
|
|
472
|
+
const [el, key, depth] = queue.shift();
|
|
473
|
+
if (depth >= 12) continue;
|
|
474
|
+
for (const c of el.children) {
|
|
475
|
+
if (c.hasAttribute('data-testid') || isHidden(c) || stateOnly(c)) continue;
|
|
476
|
+
seen++;
|
|
477
|
+
const k = keyOf(c, key);
|
|
478
|
+
if (want.has(norm((c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)))) found.push({ key: k, ...sigOf(c) });
|
|
479
|
+
queue.push([c, k, depth + 1]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
pool[id] = found;
|
|
483
|
+
}
|
|
288
484
|
}
|
|
289
485
|
|
|
290
486
|
const de = document.documentElement;
|
|
487
|
+
// the shell's content scroll container, when it has one: a box that scrolls inside itself, no
|
|
488
|
+
// taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
|
|
489
|
+
// its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
|
|
490
|
+
// off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
|
|
491
|
+
const scroller = [...document.querySelectorAll('body *')].find((el) => {
|
|
492
|
+
const o = getComputedStyle(el).overflowY;
|
|
493
|
+
if (o !== 'auto' && o !== 'scroll') return false;
|
|
494
|
+
const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
|
|
495
|
+
return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
|
|
496
|
+
&& r.left <= cx && r.right >= cx;
|
|
497
|
+
});
|
|
291
498
|
return {
|
|
292
499
|
index,
|
|
500
|
+
pool,
|
|
293
501
|
anchors: anchors.length,
|
|
294
502
|
testids: allTestIds,
|
|
295
503
|
doc: {
|
|
@@ -297,6 +505,7 @@ const PROBE = () => {
|
|
|
297
505
|
clientHeight: de.clientHeight,
|
|
298
506
|
scrollWidth: de.scrollWidth,
|
|
299
507
|
clientWidth: de.clientWidth,
|
|
508
|
+
scroller: scroller ? `${scroller.tagName.toLowerCase()}${scroller.id ? '#' + scroller.id : ''}${scroller.className ? '.' + String(scroller.className).trim().split(/\s+/)[0] : ''}` : null,
|
|
300
509
|
},
|
|
301
510
|
};
|
|
302
511
|
};
|
|
@@ -305,11 +514,18 @@ const PROBE = () => {
|
|
|
305
514
|
// A clause returns a finding, `null` (not applicable — do NOT count it as graded),
|
|
306
515
|
// or `false` (applicable and agreeing). The three are kept distinct because
|
|
307
516
|
// conflating "agreed" with "not asked" is precisely how TF-011 happened.
|
|
517
|
+
// The same folding the page-side probe uses: spacing and digits (TF-045).
|
|
518
|
+
const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
|
|
519
|
+
|
|
308
520
|
const CLAUSES = {
|
|
309
|
-
badge: (m, a) =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
521
|
+
badge: (m, a) => {
|
|
522
|
+
if (m.badge === null || a.badge === null) return null;
|
|
523
|
+
if (m.badge === a.badge) return false;
|
|
524
|
+
// TF-047: the side without the treatment carries it one element away — the same control, drawn
|
|
525
|
+
// by a library on the box around it or the box inside it. That is agreement, not drift.
|
|
526
|
+
if ((m.badge ? a : m).near?.badge) return false;
|
|
527
|
+
return `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`;
|
|
528
|
+
},
|
|
313
529
|
icon: (m, a) => (m.icon === a.icon ? false
|
|
314
530
|
: m.icon && !a.icon ? 'mockup carries an icon here; the app does not'
|
|
315
531
|
: 'app carries an icon the mockup does not'),
|
|
@@ -318,16 +534,26 @@ const CLAUSES = {
|
|
|
318
534
|
// TF-009
|
|
319
535
|
stroke: (m, a) => {
|
|
320
536
|
if (!m.stroke || !a.stroke) return null;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
537
|
+
let ms = m.stroke, as = a.stroke;
|
|
538
|
+
// TF-047: a side that draws no border of its own is read at the border one element away, and
|
|
539
|
+
// only when the sides disagree — two plain elements inside a ringed box still agree as before.
|
|
540
|
+
if (ms.style !== as.style || ms.visible !== as.visible) {
|
|
541
|
+
if (!ms.visible && m.near?.stroke) ms = m.near.stroke;
|
|
542
|
+
if (!as.visible && a.near?.stroke) as = a.near.stroke;
|
|
543
|
+
}
|
|
544
|
+
if (ms.style !== as.style) {
|
|
545
|
+
return `border style differs — mockup ${ms.style}, app ${as.style}`
|
|
546
|
+
+ (ms.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
|
|
324
547
|
}
|
|
325
|
-
if (
|
|
326
|
-
return `border presence differs — mockup ${
|
|
548
|
+
if (ms.visible !== as.visible) {
|
|
549
|
+
return `border presence differs — mockup ${ms.visible ? 'has a visible rule' : 'has none'}, app ${as.visible ? 'has one' : 'has none'}`;
|
|
327
550
|
}
|
|
328
551
|
return false;
|
|
329
552
|
},
|
|
330
|
-
|
|
553
|
+
// TF-047: a row count says something only about the same text. Live data that reads differently
|
|
554
|
+
// from the mockup's sample wraps differently for that reason alone, so the clause is not applicable
|
|
555
|
+
// (null, never counted as graded) unless the digit-folded texts match.
|
|
556
|
+
wrap: (m, a) => (m.wrap === null || a.wrap === null || norm(m.full) !== norm(a.full) ? null
|
|
331
557
|
: a.wrap > m.wrap ? `wraps to ${a.wrap} rows where the mockup keeps it on ${m.wrap}` : false),
|
|
332
558
|
clip: (m, a) => (!m.clip || !a.clip ? null
|
|
333
559
|
: (a.clip.x && !m.clip.x) ? 'content is cut off horizontally; the mockup is not clipped'
|
|
@@ -348,11 +574,49 @@ function diff(mock, app, screen, width) {
|
|
|
348
574
|
const findings = [];
|
|
349
575
|
const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
|
|
350
576
|
clauseCoverage.missing = 0;
|
|
351
|
-
let compared = 0, contentGraded = 0;
|
|
577
|
+
let compared = 0, contentGraded = 0, relocated = 0;
|
|
578
|
+
const iconsShort = {}; // parent key -> icons the app still owes under it (TF-027)
|
|
579
|
+
const badgesShort = {}; // parent key -> badges the app still owes under it (TF-045)
|
|
580
|
+
const usedApp = new Set();
|
|
581
|
+
const iconSeen = []; // icon findings with the side that holds the icons (TF-046)
|
|
582
|
+
|
|
583
|
+
// --- TF-047: the same icon in another child. Pairing is by position, so a card header that a
|
|
584
|
+
// library wraps in one more <div> pairs the mockup's description with the app's toolbar, and the
|
|
585
|
+
// toolbar's icon reads as one "the mockup does not" carry — while the header holds exactly one
|
|
586
|
+
// icon on both sides. The rule the `missing` clause has followed since TF-027, one wrapper further:
|
|
587
|
+
// an icon finding is dropped when the parent or the grandparent is paired and carries the same
|
|
588
|
+
// number of icons on both sides. Bounded to a few icons, so a coincidence across a whole page
|
|
589
|
+
// (one lost, one added) stays reported.
|
|
590
|
+
const iconMoved = (key) => {
|
|
591
|
+
let k = key;
|
|
592
|
+
for (let up = 0; up < 2; up++) {
|
|
593
|
+
if (!k.includes(' > ')) return false;
|
|
594
|
+
k = k.slice(0, k.lastIndexOf(' > '));
|
|
595
|
+
const pm = mock.index[k], pa = app.index[k];
|
|
596
|
+
if (!pm || !pa) return false;
|
|
597
|
+
const n = pm.icons ?? 0;
|
|
598
|
+
if (n > 0 && n <= 4 && n === (pa.icons ?? 0)) return true;
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
};
|
|
352
602
|
|
|
353
603
|
for (const key of Object.keys(mock.index)) {
|
|
354
604
|
const m = mock.index[key];
|
|
355
|
-
|
|
605
|
+
let a = app.index[key];
|
|
606
|
+
let appKey = key;
|
|
607
|
+
|
|
608
|
+
// --- TF-045: a badge the app draws at another depth is the same badge. Found by its text under
|
|
609
|
+
// the same anchor, it is compared like any paired element, so a badge whose colour or ring really
|
|
610
|
+
// differs is still reported, and so is one the app turned into plain text.
|
|
611
|
+
if (!a && m.badge === true && m.text) {
|
|
612
|
+
const root = key.split(' > ')[0];
|
|
613
|
+
const cands = ((app.pool || {})[root] || []).filter((c) => !usedApp.has(c.key) && norm(c.text) === norm(m.text));
|
|
614
|
+
// a badge first, then the same kind of element, then one no mockup key already pairs with;
|
|
615
|
+
// among equals the shallowest, which is the order the probe found them in
|
|
616
|
+
const score = (c) => (c.badge === true ? 4 : 0) + (c.tag === m.tag ? 2 : 0) + (c.key in mock.index ? 0 : 1);
|
|
617
|
+
const pick = cands.reduce((best, c) => (!best || score(c) > score(best) ? c : best), null);
|
|
618
|
+
if (pick) { a = pick; appKey = pick.key; usedApp.add(pick.key); relocated++; }
|
|
619
|
+
}
|
|
356
620
|
|
|
357
621
|
// --- the `missing` clause. Key-pairing alone cannot see an element that is
|
|
358
622
|
// NOT THERE, and "a control the mockup draws as a badge rendered as plain text"
|
|
@@ -367,14 +631,37 @@ function diff(mock, app, screen, width) {
|
|
|
367
631
|
if (!a) {
|
|
368
632
|
const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
|
|
369
633
|
const parentPaired = parentKey ? !!app.index[parentKey] : false;
|
|
370
|
-
|
|
634
|
+
// an icon is missing only when the paired parent carries fewer icons than the mockup's
|
|
635
|
+
// does. Pairing is by position, so an icon the app draws one wrapper deeper sits under
|
|
636
|
+
// another key and was reported missing on every sidebar group and tile (TF-027).
|
|
637
|
+
// and no more reports under one parent than it is icons short
|
|
638
|
+
if (m.icon === true && parentPaired && mock.index[parentKey] && !(parentKey in iconsShort)) {
|
|
639
|
+
iconsShort[parentKey] = Math.max(0, (mock.index[parentKey].icons ?? 0) - (app.index[parentKey].icons ?? 0));
|
|
640
|
+
}
|
|
641
|
+
const iconGone = m.icon === true && parentPaired && (iconsShort[parentKey] || 0) > 0;
|
|
642
|
+
if (iconGone && m.badge !== true) iconsShort[parentKey]--;
|
|
643
|
+
// A badge not found by its text (live data reads otherwise) is missing only while the paired
|
|
644
|
+
// parent draws fewer badges than the mockup's does, the rule icons follow since TF-027 (TF-045).
|
|
645
|
+
if (m.badge === true && parentPaired && mock.index[parentKey] && !(parentKey in badgesShort)) {
|
|
646
|
+
badgesShort[parentKey] = Math.max(0, (mock.index[parentKey].badges ?? 0) - (app.index[parentKey].badges ?? 0));
|
|
647
|
+
}
|
|
648
|
+
const badgeGone = m.badge === true && parentPaired && (badgesShort[parentKey] || 0) > 0;
|
|
649
|
+
if (badgeGone) badgesShort[parentKey]--;
|
|
650
|
+
if (parentPaired && (m.badge === true || iconGone)) {
|
|
371
651
|
clauseCoverage.missing++;
|
|
372
652
|
contentGraded++;
|
|
653
|
+
}
|
|
654
|
+
if (parentPaired && (m.badge === true ? badgeGone : iconGone)) {
|
|
655
|
+
// Say what was seen. "Flattened into plain text" is true only when the app shows the text;
|
|
656
|
+
// said of a badge the tool had merely failed to find, it sent a reader after a wrong cause.
|
|
657
|
+
const shown = m.text && norm(app.index[parentKey].full).includes(norm(m.text));
|
|
373
658
|
findings.push({
|
|
374
659
|
screen, width, class: 'missing', key,
|
|
375
|
-
detail: m.badge
|
|
376
|
-
? `the mockup
|
|
377
|
-
:
|
|
660
|
+
detail: m.badge !== true
|
|
661
|
+
? `the mockup carries an icon here and the app renders no such element`
|
|
662
|
+
: shown
|
|
663
|
+
? `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`
|
|
664
|
+
: `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
665
|
mockup_text: m.text, app_text: null,
|
|
379
666
|
});
|
|
380
667
|
}
|
|
@@ -386,10 +673,28 @@ function diff(mock, app, screen, width) {
|
|
|
386
673
|
if (r === null) continue;
|
|
387
674
|
clauseCoverage[name]++;
|
|
388
675
|
if (CONTENT_CLAUSES.has(name)) contentGraded++;
|
|
389
|
-
if (r)
|
|
676
|
+
if (r) {
|
|
677
|
+
if (name === 'icon' && iconMoved(key)) continue; // the same icon, another child (TF-047)
|
|
678
|
+
const f = { screen, width, class: name, key, ...(appKey !== key ? { app_key: appKey } : {}), detail: r, mockup_text: m.text, app_text: a.text };
|
|
679
|
+
findings.push(f);
|
|
680
|
+
if (name === 'icon') iconSeen.push({ f, side: m.icon ? m : a });
|
|
681
|
+
}
|
|
390
682
|
}
|
|
391
683
|
}
|
|
392
|
-
|
|
684
|
+
|
|
685
|
+
// --- TF-046: one icon, one finding. "Is there an icon inside?" is true of the element that carries
|
|
686
|
+
// the icon and of every element above it, so each container repeated the finding. A finding is
|
|
687
|
+
// dropped when every icon it holds is already reported on a deeper element, on the side that has
|
|
688
|
+
// the icons; a container holding an extra icon of its own is still reported.
|
|
689
|
+
const repeat = new Set();
|
|
690
|
+
for (const x of iconSeen) {
|
|
691
|
+
const ids = x.side.icon_ids || [];
|
|
692
|
+
const below = new Set(iconSeen
|
|
693
|
+
.filter((y) => y !== x && y.f.detail === x.f.detail && (y.side.depth ?? 0) > (x.side.depth ?? 0))
|
|
694
|
+
.flatMap((y) => y.side.icon_ids || []));
|
|
695
|
+
if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
|
|
696
|
+
}
|
|
697
|
+
return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated };
|
|
393
698
|
}
|
|
394
699
|
|
|
395
700
|
// ---------------------------------------------------------------------- main
|
|
@@ -397,8 +702,30 @@ const results = [];
|
|
|
397
702
|
let hardFail = false;
|
|
398
703
|
|
|
399
704
|
const browser = await chromium.launch();
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
705
|
+
const ctxOpts = { ignoreHTTPSErrors: true };
|
|
706
|
+
if (STORAGE && existsSync(STORAGE)) ctxOpts.storageState = STORAGE;
|
|
707
|
+
const ctx = await browser.newContext(ctxOpts);
|
|
708
|
+
let cdpBrowser = null, cdpPage = null;
|
|
709
|
+
if (CDP) {
|
|
710
|
+
try {
|
|
711
|
+
cdpBrowser = await chromium.connectOverCDP(CDP, { timeout: 15000 });
|
|
712
|
+
const c = cdpBrowser.contexts()[0];
|
|
713
|
+
cdpPage = c && c.pages()[0];
|
|
714
|
+
} catch (e) { console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); }
|
|
715
|
+
if (!cdpPage) {
|
|
716
|
+
console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: no page in the attached app`);
|
|
717
|
+
await browser.close(); process.exit(2);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// the attached app's router, not a document load: a desktop head has no server to answer a goto
|
|
721
|
+
async function reachCdp(page, route) {
|
|
722
|
+
try {
|
|
723
|
+
await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
|
|
724
|
+
await page.waitForTimeout(LOGIN_OPTS.settle);
|
|
725
|
+
return { status: 200, url: page.url() };
|
|
726
|
+
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
727
|
+
}
|
|
728
|
+
if (COOKIE && !CDP) {
|
|
402
729
|
const u = new URL(BASE);
|
|
403
730
|
for (const pair of COOKIE.split(';')) {
|
|
404
731
|
const [name, ...v] = pair.trim().split('=');
|
|
@@ -411,6 +738,20 @@ if (HEADERS.length) {
|
|
|
411
738
|
await ctx.setExtraHTTPHeaders(h);
|
|
412
739
|
}
|
|
413
740
|
|
|
741
|
+
// One app tab per width, signed in once and kept on the app, and one tab for the mockups. A sign-in
|
|
742
|
+
// the page keeps for itself lives in that tab: a fresh tab per screen would be signed out every time
|
|
743
|
+
// (AppManager TF-011). The mockup tab never touches the app, so the app tab never leaves it.
|
|
744
|
+
const tabs = {};
|
|
745
|
+
let loginResult = null;
|
|
746
|
+
for (const width of WIDTHS) {
|
|
747
|
+
const size = { width, height: width < 700 ? 844 : 800 };
|
|
748
|
+
const app = CDP ? cdpPage : await ctx.newPage(); if (!CDP) await app.setViewportSize(size);
|
|
749
|
+
const mock = await ctx.newPage(); await mock.setViewportSize(size);
|
|
750
|
+
if (!CDP && LOGIN_PATH && USER) { const l = await signIn(app, LOGIN_OPTS); if (loginResult === null) loginResult = l; }
|
|
751
|
+
tabs[width] = { app, mock };
|
|
752
|
+
}
|
|
753
|
+
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'}`);
|
|
754
|
+
|
|
414
755
|
for (const s of SCREENS) {
|
|
415
756
|
const mockPath = resolve(MOCKUPS, `${s.name}.html`);
|
|
416
757
|
// TF-008 §3: a screen with no mockup is reported, never silently passed — the
|
|
@@ -423,41 +764,59 @@ for (const s of SCREENS) {
|
|
|
423
764
|
|
|
424
765
|
const perWidth = [];
|
|
425
766
|
for (const width of WIDTHS) {
|
|
426
|
-
const page =
|
|
427
|
-
|
|
428
|
-
let mock, app, docFindings = [];
|
|
767
|
+
const { app: page, mock: mockPage } = tabs[width];
|
|
768
|
+
let mock, app, docFindings = [], reached = '', theme = null;
|
|
429
769
|
try {
|
|
430
|
-
await
|
|
431
|
-
mock = await
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
770
|
+
await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
|
|
771
|
+
mock = await mockPage.evaluate(PROBE);
|
|
772
|
+
// The document may answer 401 and still draw the screen signed in (AppManager TF-006/TF-011):
|
|
773
|
+
// reach() judges by what the page draws, signs in again from inside the page when it must, and
|
|
774
|
+
// returns 200 only when the screen was really drawn signed in.
|
|
775
|
+
if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
|
|
776
|
+
const nav = CDP ? await reachCdp(page, s.route) : await reach(page, LOGIN_OPTS, s.route);
|
|
777
|
+
if (nav.status === 0 || nav.status >= 400 || nav.signedOut) {
|
|
778
|
+
perWidth.push({ width, error: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}`
|
|
779
|
+
: 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'})`
|
|
780
|
+
: `app returned HTTP ${nav.status}` });
|
|
436
781
|
continue;
|
|
437
782
|
}
|
|
438
|
-
|
|
783
|
+
reached = nav.reached || '';
|
|
784
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
785
|
+
const wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
|
|
786
|
+
// Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
|
|
787
|
+
// viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
|
|
788
|
+
// app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
|
|
789
|
+
// and the app's own are put back afterwards, so an attached app is left as the viewer had it.
|
|
790
|
+
// Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
|
|
791
|
+
// dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
|
|
792
|
+
const mockRead = KEEP_APP_THEME ? null : await mockPage.evaluate(READ_THEME);
|
|
793
|
+
const mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
|
|
794
|
+
const saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
|
|
795
|
+
if (saved && saved.changed) { theme = { mockup: mockTheme, app_had: saved.before }; await page.waitForTimeout(400); }
|
|
796
|
+
try { app = await page.evaluate(PROBE, wanted); }
|
|
797
|
+
finally { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); }
|
|
439
798
|
|
|
440
799
|
// TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
|
|
441
800
|
// have caught the /routing void on its own: 2607px of document against a
|
|
442
801
|
// 900px viewport, ~1700px of it blank, with the app shell repainted at the
|
|
443
802
|
// bottom because the page had escaped the shell's scroll container. No gate
|
|
444
803
|
// looked at document height, so that passed too.
|
|
445
|
-
|
|
804
|
+
// Only when the shell has a content scroll container: an app whose document is its only
|
|
805
|
+
// scroller (a long report page) has escaped nothing (AppManager TF-014).
|
|
806
|
+
if (app.doc.scroller && app.doc.scrollHeight > app.doc.clientHeight + 2) {
|
|
446
807
|
docFindings.push({
|
|
447
808
|
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`,
|
|
809
|
+
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
810
|
});
|
|
450
811
|
}
|
|
451
812
|
} catch (e) {
|
|
452
813
|
perWidth.push({ width, error: String(e).slice(0, 200) });
|
|
453
|
-
await page.close();
|
|
454
814
|
continue;
|
|
455
815
|
}
|
|
456
816
|
const d = diff(mock, app, s.name, width);
|
|
457
817
|
d.findings.push(...docFindings);
|
|
458
818
|
perWidth.push({ width, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
|
|
459
|
-
appTestIds: app.testids, mockTestIds: mock.testids });
|
|
460
|
-
await page.close();
|
|
819
|
+
appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
|
|
461
820
|
}
|
|
462
821
|
|
|
463
822
|
const ok = perWidth.filter((w) => !w.error);
|
|
@@ -501,6 +860,8 @@ for (const s of SCREENS) {
|
|
|
501
860
|
screen: s.name, route: s.route, verdict,
|
|
502
861
|
coverage: {
|
|
503
862
|
compared, content_graded: contentGraded, ungradeable,
|
|
863
|
+
// badges found by their text at another depth than the mockup's (TF-045)
|
|
864
|
+
relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
|
|
504
865
|
app_controls: appAnchors, mockup_anchors: mockAnchors,
|
|
505
866
|
ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
|
|
506
867
|
thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
|
|
@@ -517,15 +878,18 @@ for (const s of SCREENS) {
|
|
|
517
878
|
add_data_testid_to_mockup: unanchored.slice(0, 30),
|
|
518
879
|
},
|
|
519
880
|
widths: perWidth.map((w) => ({ width: w.width, error: w.error || null,
|
|
520
|
-
compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length
|
|
881
|
+
compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
|
|
882
|
+
...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
|
|
883
|
+
...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
|
|
521
884
|
});
|
|
522
885
|
}
|
|
523
886
|
|
|
524
887
|
await browser.close();
|
|
888
|
+
if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
|
|
525
889
|
|
|
526
890
|
const summary = {
|
|
527
891
|
status: 'measured',
|
|
528
|
-
base: BASE,
|
|
892
|
+
base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
|
|
529
893
|
widths: WIDTHS,
|
|
530
894
|
screens_n: results.length,
|
|
531
895
|
pass: results.filter((r) => r.verdict === 'PASS').length,
|