@techierathore/techieflow 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +15 -7
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +25 -2
- package/.tfcore/hooks/guard-db.sh +106 -9
- package/.tfcore/hooks/guard-status-html.sh +5 -1
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_smoke-test-policy.md +7 -5
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +2 -2
- package/.tfcore/tasks/day1-greenfield.md +4 -4
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +1 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +6 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -0
- package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
- package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -0
- package/.tfcore/utils/tf-brd-status.py +10 -1
- package/.tfcore/utils/tf-build-list.py +16 -1
- package/.tfcore/utils/tf-build.sh +75 -0
- package/.tfcore/utils/tf-checklist-edit.py +28 -3
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +6 -2
- package/.tfcore/utils/tf-doc-check.py +124 -21
- package/.tfcore/utils/tf-doc-tests.sh +40 -3
- package/.tfcore/utils/tf-emit.sh +40 -10
- package/.tfcore/utils/tf-fix-close.sh +13 -7
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
- package/.tfcore/utils/tf-mockup-parity.sh +14 -3
- package/.tfcore/utils/tf-owner-text.py +27 -5
- package/.tfcore/utils/tf-phase.sh +24 -4
- package/.tfcore/utils/tf-render-html.py +19 -3
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-facts.py +90 -15
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +30 -4
- package/.tfcore/utils/tf-verify-boot.sh +296 -21
- package/.tfcore/utils/tf-verify-emit.sh +37 -17
- package/.tfcore/utils/tf-verify-list.py +219 -20
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +184 -20
- package/.tfcore/utils/tf-verify-screens.sh +17 -4
- package/.tfcore/utils/tf-verify-tests.sh +107 -17
- package/.tfcore/utils/tf-verify-verdict.py +118 -15
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_feedback.py +86 -18
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +2 -2
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +14 -3
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -40,10 +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
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';
|
|
47
49
|
|
|
48
50
|
// ---------------------------------------------------------------- arguments
|
|
49
51
|
const argv = process.argv.slice(2);
|
|
@@ -76,9 +78,63 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
76
78
|
SCREENS.push({ name: name.trim(), route: rest.join('=').trim() || '/' + name.trim() });
|
|
77
79
|
}
|
|
78
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
|
+
};
|
|
79
126
|
const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
|
|
80
127
|
// --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
|
|
81
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?
|
|
82
138
|
// The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
|
|
83
139
|
// scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
|
|
84
140
|
// that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
|
|
@@ -124,6 +180,13 @@ const APPLY_THEME = (t) => {
|
|
|
124
180
|
return { before, changed };
|
|
125
181
|
};
|
|
126
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');
|
|
127
190
|
|
|
128
191
|
if ((!BASE && !CDP) || SCREENS.length === 0) {
|
|
129
192
|
console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
|
|
@@ -212,7 +275,12 @@ const PROBE = (wanted = []) => {
|
|
|
212
275
|
// reads every other border property but not this one keeps missing that whole
|
|
213
276
|
// vocabulary. Quantised to the three values that carry meaning rather than the
|
|
214
277
|
// full CSS keyword set, so the clause stays as noise-free as the colour buckets.
|
|
278
|
+
// A checkbox, switch or radio is not compared by its border (Lekhak TF-035): a native
|
|
279
|
+
// <input type="checkbox"> computes border-style none because the browser draws its box, while a
|
|
280
|
+
// library's role="checkbox" draws the same box as a CSS border. Same control, so not applicable.
|
|
281
|
+
const TOGGLE = 'input[type="checkbox"],input[type="radio"],[role="checkbox"],[role="switch"],[role="radio"]';
|
|
215
282
|
const strokeOf = (el) => {
|
|
283
|
+
if (el.matches(TOGGLE)) return null;
|
|
216
284
|
const cs = getComputedStyle(el);
|
|
217
285
|
const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
|
|
218
286
|
: s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
|
|
@@ -223,15 +291,32 @@ const PROBE = (wanted = []) => {
|
|
|
223
291
|
};
|
|
224
292
|
|
|
225
293
|
const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
|
|
226
|
-
|
|
294
|
+
// A box the mockup marks as another state of the screen is left out of the comparison (Lekhak
|
|
295
|
+
// TF-011), and so is everything inside it when a box around it is counted: the download icon in a
|
|
296
|
+
// "newer build" banner was reported on the card holding the banner (Chatur TF-001).
|
|
297
|
+
// data-tf-state="sample-data" is not another state: it is sample data, the data-tf-sample rule
|
|
298
|
+
// (Chatur TF-005). Left out here, a list the app fills with real rows read "app carries an icon the
|
|
299
|
+
// mockup does not" on every row.
|
|
300
|
+
const STATE = '[data-tf-state]:not([data-tf-state="sample-data"]),[data-state-testid]';
|
|
301
|
+
const SAMPLE = '[data-tf-sample],[data-tf-state="sample-data"]';
|
|
302
|
+
// A chart's <svg> is not an icon (TF-054): an svg larger than 48px on either side is left out.
|
|
303
|
+
const glyph = (x) => x.tagName.toLowerCase() !== 'svg' || (() => { const r = x.getBoundingClientRect(); return r.width <= 48 && r.height <= 48; })();
|
|
304
|
+
const own = (el, sel) => [...el.querySelectorAll(sel)].filter((x) => (!x.closest(STATE) || el.closest(STATE)) && (sel !== ICON || glyph(x)));
|
|
305
|
+
const ownText = (el) => {
|
|
306
|
+
if (!el.querySelector(STATE) || el.closest(STATE)) return el.textContent || '';
|
|
307
|
+
const c = el.cloneNode(true);
|
|
308
|
+
for (const x of c.querySelectorAll(STATE)) x.remove();
|
|
309
|
+
return c.textContent || '';
|
|
310
|
+
};
|
|
311
|
+
const hasIcon = (el) => own(el, ICON).length > 0;
|
|
227
312
|
// how many icons the element carries at any depth: a component library that wraps an icon
|
|
228
313
|
// one element deeper than the mockup moves it to another path key without removing it (TF-027)
|
|
229
|
-
const iconCount = (el) =>
|
|
314
|
+
const iconCount = (el) => own(el, ICON).filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
|
|
230
315
|
// Every icon on the page by number, so the node side can tell that an anchor and the wrappers
|
|
231
316
|
// above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
|
|
232
317
|
// 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) =>
|
|
318
|
+
const iconNo = new Map([...document.querySelectorAll(ICON)].filter(glyph).map((i, n) => [i, n]));
|
|
319
|
+
const iconIds = (el) => own(el, ICON).map((i) => iconNo.get(i));
|
|
235
320
|
const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
|
|
236
321
|
|
|
237
322
|
// "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
|
|
@@ -369,10 +454,12 @@ const PROBE = (wanted = []) => {
|
|
|
369
454
|
}
|
|
370
455
|
overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
|
|
371
456
|
}
|
|
372
|
-
|
|
373
|
-
|
|
457
|
+
const cs = getComputedStyle(el);
|
|
458
|
+
const scrolls = (v) => v === 'auto' || v === 'scroll'; // a scroller keeps its own content in reach (TF-053)
|
|
459
|
+
let x = overX > 2 && !scrolls(cs.overflowX);
|
|
460
|
+
if (x && cs.overflowX === 'visible') x = cutAbove(el, right);
|
|
374
461
|
const overY = el.scrollHeight - el.clientHeight;
|
|
375
|
-
return { x, y: overY > 2, sr_excluded: hiddenKids.length };
|
|
462
|
+
return { x, y: overY > 2 && !scrolls(cs.overflowY), sr_excluded: hiddenKids.length };
|
|
376
463
|
};
|
|
377
464
|
|
|
378
465
|
const sigOf = (el) => {
|
|
@@ -383,18 +470,25 @@ const PROBE = (wanted = []) => {
|
|
|
383
470
|
// the badge and the visible border one element away, for the clauses to accept (TF-047)
|
|
384
471
|
near: {
|
|
385
472
|
badge: near.some((n) => chromeOn(n)?.badge === true),
|
|
386
|
-
stroke: near.map(strokeOf).find((s) => s.visible) || null,
|
|
473
|
+
stroke: near.map(strokeOf).find((s) => s && s.visible) || null,
|
|
387
474
|
},
|
|
388
475
|
tag: el.tagName.toLowerCase(),
|
|
389
|
-
text: (el
|
|
476
|
+
text: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 60),
|
|
390
477
|
badge: chrome ? chrome.badge : null,
|
|
391
|
-
full: (el
|
|
478
|
+
full: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 400),
|
|
479
|
+
// a box the mockup marks as sample data, drawn only when the app has data there (Chatur TF-002)
|
|
480
|
+
sample: el.matches(SAMPLE),
|
|
392
481
|
icon: hasIcon(el),
|
|
393
482
|
icons: iconCount(el),
|
|
394
483
|
icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
|
|
484
|
+
// the anchors inside and the icons each holds, so a row graded on its own can be taken off
|
|
485
|
+
// the box around it (Chatur TF-006)
|
|
486
|
+
nested: own(el, '[data-testid]').filter((x) => !isHidden(x))
|
|
487
|
+
.map((x) => ({ id: x.getAttribute('data-testid'), ids: iconIds(x),
|
|
488
|
+
top: x.parentElement?.closest('[data-testid]') === el })).slice(0, 200),
|
|
395
489
|
depth: depthOf(el),
|
|
396
490
|
// badges drawn anywhere inside, so a badge one wrapper deeper still counts under its parent (TF-045)
|
|
397
|
-
badges:
|
|
491
|
+
badges: own(el, '*').filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
|
|
398
492
|
color: semanticColor(el),
|
|
399
493
|
stroke: strokeOf(el),
|
|
400
494
|
wrap: lineCount(el),
|
|
@@ -418,7 +512,7 @@ const PROBE = (wanted = []) => {
|
|
|
418
512
|
// data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
|
|
419
513
|
// siblings are numbered: a database-down alert first in a card moved every row below it one place,
|
|
420
514
|
// and the Host/Port row was compared with the alert (Lekhak TF-011).
|
|
421
|
-
const stateOnly = (el) => el.
|
|
515
|
+
const stateOnly = (el) => el.matches(STATE);
|
|
422
516
|
const keyOf = (el, parentKey) => {
|
|
423
517
|
const tag = el.tagName.toLowerCase();
|
|
424
518
|
let n = 0;
|
|
@@ -447,7 +541,7 @@ const PROBE = (wanted = []) => {
|
|
|
447
541
|
for (const a of anchors) {
|
|
448
542
|
const id = a.getAttribute('data-testid');
|
|
449
543
|
if (!id || index[id]) continue;
|
|
450
|
-
if (isHidden(a) || a.closest(
|
|
544
|
+
if (isHidden(a) || a.closest(STATE)) continue; // TF-011
|
|
451
545
|
index[id] = sigOf(a);
|
|
452
546
|
let budget = MAX_PER_ANCHOR;
|
|
453
547
|
const walk = (el, key, depth) => {
|
|
@@ -488,9 +582,15 @@ const PROBE = (wanted = []) => {
|
|
|
488
582
|
// taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
|
|
489
583
|
// its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
|
|
490
584
|
// off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
|
|
585
|
+
// A box that only scrolls sideways is not one either: `overflow-x: auto` alone makes the browser
|
|
586
|
+
// compute overflow-y as auto too, so Bootstrap's .table-responsive looked like a shell at 390px
|
|
587
|
+
// (AppManager TF-028). Skipped: a box whose only child is a table, and a box wider inside than
|
|
588
|
+
// out whose height fits its content.
|
|
491
589
|
const scroller = [...document.querySelectorAll('body *')].find((el) => {
|
|
492
590
|
const o = getComputedStyle(el).overflowY;
|
|
493
591
|
if (o !== 'auto' && o !== 'scroll') return false;
|
|
592
|
+
if (el.children.length === 1 && el.children[0].tagName === 'TABLE') return false;
|
|
593
|
+
if (el.scrollWidth > el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) return false;
|
|
494
594
|
const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
|
|
495
595
|
return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
|
|
496
596
|
&& r.left <= cx && r.right >= cx;
|
|
@@ -570,7 +670,107 @@ const CLAUSES = {
|
|
|
570
670
|
// as coverage is what let a screen the gate never really looked at read as clean.
|
|
571
671
|
const CONTENT_CLAUSES = new Set(['badge', 'icon', 'wrap', 'token']);
|
|
572
672
|
|
|
573
|
-
function diff(mock, app, screen, width) {
|
|
673
|
+
function diff(mock, app, screen, width, skip = null) {
|
|
674
|
+
// --- Chatur TF-002: sample data the app is not showing. A mockup draws a list full of sample rows
|
|
675
|
+
// and a fresh app has none, so every icon on a sample row read as missing. A box the mockup marks
|
|
676
|
+
// data-tf-sample is compared when the app draws something at its place; when it draws nothing
|
|
677
|
+
// there, the box and everything in it are "not measured", and its icons and badges are taken off
|
|
678
|
+
// the boxes around it. A seed script (tests/verify/seed/) puts the app in the drawn state instead.
|
|
679
|
+
const notMeasured = [];
|
|
680
|
+
const mockAll = mock.index; // before anything is dropped (Chatur TF-007)
|
|
681
|
+
// the mockup's sample rows that carry their own anchor (commit-row-N), read before any is dropped
|
|
682
|
+
const fold = (id) => id.replace(/\d+/g, '#');
|
|
683
|
+
const sampleRows = new Set(Object.keys(mock.index).filter((k) => !k.includes(' > ') && mock.index[k].sample));
|
|
684
|
+
const sampleShapes = new Set([...sampleRows].map(fold));
|
|
685
|
+
const idx = { ...mock.index };
|
|
686
|
+
for (const key of Object.keys(mock.index)) {
|
|
687
|
+
const m = mock.index[key];
|
|
688
|
+
if (!m.sample || app.index[key] || notMeasured.some((k) => key.startsWith(k + ' > '))) continue;
|
|
689
|
+
notMeasured.push(key);
|
|
690
|
+
for (const k of Object.keys(idx)) if (k === key || k.startsWith(key + ' > ')) delete idx[k];
|
|
691
|
+
const gone = new Set(m.icon_ids || []);
|
|
692
|
+
for (let up = key; up.includes(' > ');) {
|
|
693
|
+
up = up.slice(0, up.lastIndexOf(' > '));
|
|
694
|
+
const a = idx[up];
|
|
695
|
+
if (!a) continue;
|
|
696
|
+
const icons = Math.max(0, (a.icons ?? 0) - (m.icons ?? 0));
|
|
697
|
+
idx[up] = { ...a, icons, icon: a.icon && icons > 0, icon_ids: (a.icon_ids || []).filter((i) => !gone.has(i)),
|
|
698
|
+
badges: Math.max(0, (a.badges ?? 0) - (m.badges ?? 0) - (m.badge === true ? 1 : 0)) };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
mock = { ...mock, index: idx };
|
|
702
|
+
// --- Chatur TF-006: a sample row graded on its own is not the box's icon too. The mockup's table
|
|
703
|
+
// lost its sample rows' icons when their tbody was not measured, while the app's table still counted
|
|
704
|
+
// every row's: "app carries an icon the mockup does not" on a box whose rows were each compared. A
|
|
705
|
+
// row anchored as a mockup sample row, or as more of the same (commit-row-7 beside commit-row-1), is
|
|
706
|
+
// left out of every box around it on both sides; an icon of the box's own is still counted.
|
|
707
|
+
const strip = (index, goneOf) => Object.fromEntries(Object.entries(index).map(([k, s]) => {
|
|
708
|
+
const gone = goneOf(k, s);
|
|
709
|
+
const ids = (s.icon_ids || []).filter((i) => !gone.has(i));
|
|
710
|
+
const cut = (s.icon_ids || []).length - ids.length;
|
|
711
|
+
if (!cut) return [k, s];
|
|
712
|
+
const icons = Math.max(0, (s.icons ?? 0) - cut);
|
|
713
|
+
return [k, { ...s, icons, icon: icons > 0, icon_ids: ids }];
|
|
714
|
+
}));
|
|
715
|
+
const dropRows = (index, isRow) => strip(index, (k, s) => new Set((s.nested || []).filter((n) => isRow(n.id)).flatMap((n) => n.ids)));
|
|
716
|
+
if (sampleRows.size) {
|
|
717
|
+
mock = { ...mock, index: dropRows(mock.index, (id) => sampleRows.has(id)) };
|
|
718
|
+
app = { ...app, index: dropRows(app.index, (id) => sampleRows.has(id) || (!(id in mock.index) && sampleShapes.has(fold(id)))) };
|
|
719
|
+
}
|
|
720
|
+
// --- Chatur TF-007: the app's rows in the place of sample rows not measured. The mockup's list box
|
|
721
|
+
// lost the icons of sample rows the app does not draw (recent-tflens, tools-table > tbody[0]), while
|
|
722
|
+
// the app's box kept the icons of the real rows it draws there instead (recent-chatur, a wrapped
|
|
723
|
+
// tbody): "app carries an icon the mockup does not" on Start, Prerequisites, Providers, Corrections and
|
|
724
|
+
// the file tree. The app's rows in that place — the children and anchors of the box holding the sample
|
|
725
|
+
// rows that the mockup does not have — are left out of every box around them, as the sample rows are
|
|
726
|
+
// on the mockup's side, there taken off every box too, anchors around the list included.
|
|
727
|
+
// A row's icons come off the boxes AROUND it only: an element above it that holds all of them. A
|
|
728
|
+
// button inside the row keeps its own icon, or it reads as one the mockup does not carry.
|
|
729
|
+
const appAll = app.index;
|
|
730
|
+
const rowsGone = (rows, all) => (k, s) => {
|
|
731
|
+
const had = new Set(all[k]?.icon_ids || s.icon_ids || []);
|
|
732
|
+
return new Set(rows.filter((r) => (s.depth ?? 0) < r.depth && r.ids.every((i) => had.has(i))).flatMap((r) => r.ids));
|
|
733
|
+
};
|
|
734
|
+
const inPlace = [];
|
|
735
|
+
// The app's rows in that place are what the mockup does not have under the same box and of the
|
|
736
|
+
// same kind as the sample row (a tbody for a tbody, an <a> for an <a>), the outermost of them; only
|
|
737
|
+
// when there is none of that kind, every child of the box the mockup does not have. A heading or a
|
|
738
|
+
// toolbar of the box's own is not a row.
|
|
739
|
+
const takeRows = (box, tag) => {
|
|
740
|
+
const a = appAll[box];
|
|
741
|
+
if (!a) return;
|
|
742
|
+
const tagOf = (k) => k.slice(k.lastIndexOf(' > ') + 3).replace(/\[\d+\]$/, '');
|
|
743
|
+
const same = Object.keys(appAll).filter((k) => k.startsWith(box + ' > ') && !(k in mockAll) && tagOf(k) === tag);
|
|
744
|
+
const rows = same.filter((k) => !same.some((o) => o !== k && k.startsWith(o + ' > ')))
|
|
745
|
+
.map((k) => ({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 }));
|
|
746
|
+
for (const n of a.nested || []) {
|
|
747
|
+
if (!(n.id in mockAll) && appAll[n.id]?.tag === tag) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
|
|
748
|
+
}
|
|
749
|
+
if (!rows.length) {
|
|
750
|
+
for (const k of Object.keys(appAll)) {
|
|
751
|
+
if (k.startsWith(box + ' > ') && !k.slice(box.length + 3).includes(' > ') && !(k in mockAll)) {
|
|
752
|
+
rows.push({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// and the box's own anchors the mockup does not have: a scroll area holding the rows
|
|
756
|
+
for (const n of a.nested || []) {
|
|
757
|
+
if (n.top && !(n.id in mockAll) && appAll[n.id]) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
inPlace.push(...rows);
|
|
761
|
+
};
|
|
762
|
+
const sampleGone = [];
|
|
763
|
+
for (const k of notMeasured) {
|
|
764
|
+
sampleGone.push({ ids: mockAll[k].icon_ids || [], depth: mockAll[k].depth ?? 0 });
|
|
765
|
+
if (k.includes(' > ')) { takeRows(k.slice(0, k.lastIndexOf(' > ')), mockAll[k].tag); continue; }
|
|
766
|
+
// an anchored sample row: the nearest mockup box holding it
|
|
767
|
+
const box = Object.keys(mockAll).filter((b) => (mockAll[b].nested || []).some((n) => n.id === k))
|
|
768
|
+
.sort((x, y) => (mockAll[y].depth ?? 0) - (mockAll[x].depth ?? 0))[0];
|
|
769
|
+
if (box) takeRows(box, mockAll[k].tag);
|
|
770
|
+
}
|
|
771
|
+
const some = (rows) => rows.filter((r) => r.ids.length);
|
|
772
|
+
if (some(sampleGone).length) mock = { ...mock, index: strip(mock.index, rowsGone(some(sampleGone), mockAll)) };
|
|
773
|
+
if (some(inPlace).length) app = { ...app, index: strip(app.index, rowsGone(some(inPlace), appAll)) };
|
|
574
774
|
const findings = [];
|
|
575
775
|
const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
|
|
576
776
|
clauseCoverage.missing = 0;
|
|
@@ -669,6 +869,7 @@ function diff(mock, app, screen, width) {
|
|
|
669
869
|
}
|
|
670
870
|
compared++;
|
|
671
871
|
for (const [name, fn] of Object.entries(CLAUSES)) {
|
|
872
|
+
if (skip && skip.has(name)) continue; // a clause the mockup says nothing about in this theme (TF-024)
|
|
672
873
|
const r = fn(m, a);
|
|
673
874
|
if (r === null) continue;
|
|
674
875
|
clauseCoverage[name]++;
|
|
@@ -694,7 +895,7 @@ function diff(mock, app, screen, width) {
|
|
|
694
895
|
.flatMap((y) => y.side.icon_ids || []));
|
|
695
896
|
if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
|
|
696
897
|
}
|
|
697
|
-
return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated };
|
|
898
|
+
return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated, notMeasured };
|
|
698
899
|
}
|
|
699
900
|
|
|
700
901
|
// ---------------------------------------------------------------------- main
|
|
@@ -753,19 +954,25 @@ for (const width of WIDTHS) {
|
|
|
753
954
|
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
955
|
|
|
755
956
|
for (const s of SCREENS) {
|
|
756
|
-
const mockPath =
|
|
957
|
+
const mockPath = findMockup(s.name);
|
|
757
958
|
// TF-008 §3: a screen with no mockup is reported, never silently passed — the
|
|
758
959
|
// same discipline `⚠ STATIC-ONLY` already uses. Silence is not evidence.
|
|
759
960
|
if (!existsSync(mockPath)) {
|
|
760
961
|
results.push({ screen: s.name, route: s.route, verdict: 'NO-MOCKUP', mockup: mockPath,
|
|
761
|
-
note:
|
|
962
|
+
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.` });
|
|
762
963
|
continue;
|
|
763
964
|
}
|
|
764
965
|
|
|
765
966
|
const perWidth = [];
|
|
967
|
+
const notOffered = new Set();
|
|
766
968
|
for (const width of WIDTHS) {
|
|
767
969
|
const { app: page, mock: mockPage } = tabs[width];
|
|
768
|
-
let mock, app, docFindings = [], reached = '', theme = null;
|
|
970
|
+
let mock, app, docFindings = [], reached = '', theme = null, own = 'light', ownSurface = null, mockSurface = null, mockTheme = null, wanted = [];
|
|
971
|
+
// Lekhak TF-025: the mockup's site theme stays on the app through the other-theme pass too, and is
|
|
972
|
+
// taken off only after it. It used to come off after the first pass, so the dark pass compared the
|
|
973
|
+
// app in the viewer's site theme (minimal, grey primary) with the mockup in its own (blue primary).
|
|
974
|
+
let saved = null;
|
|
975
|
+
const unSite = async () => { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); saved = null; };
|
|
769
976
|
try {
|
|
770
977
|
await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
|
|
771
978
|
mock = await mockPage.evaluate(PROBE);
|
|
@@ -782,7 +989,7 @@ for (const s of SCREENS) {
|
|
|
782
989
|
}
|
|
783
990
|
reached = nav.reached || '';
|
|
784
991
|
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
785
|
-
|
|
992
|
+
wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
|
|
786
993
|
// Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
|
|
787
994
|
// viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
|
|
788
995
|
// app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
|
|
@@ -790,11 +997,19 @@ for (const s of SCREENS) {
|
|
|
790
997
|
// Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
|
|
791
998
|
// dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
|
|
792
999
|
const mockRead = KEEP_APP_THEME ? null : await mockPage.evaluate(READ_THEME);
|
|
793
|
-
|
|
794
|
-
|
|
1000
|
+
mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
|
|
1001
|
+
// The mockup's own theme: what it declares, or, declaring none, what it paints (TF-024). A mockup
|
|
1002
|
+
// that declares none now has the app put in that theme too (its colour scheme as well as its
|
|
1003
|
+
// attributes), so a viewer's saved dark theme no longer decides what a light mockup is compared with.
|
|
1004
|
+
mockSurface = await surface(mockPage);
|
|
1005
|
+
own = mockTheme ? (Object.values(mockTheme).some((v) => /dark/i.test(String(v || ''))) ? 'dark' : 'light')
|
|
1006
|
+
: looksDark(mockSurface) ? 'dark' : 'light';
|
|
1007
|
+
saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
|
|
795
1008
|
if (saved && saved.changed) { theme = { mockup: mockTheme, app_had: saved.before }; await page.waitForTimeout(400); }
|
|
796
|
-
|
|
797
|
-
|
|
1009
|
+
const putBack = KEEP_APP_THEME ? null : await useTheme(page, own);
|
|
1010
|
+
// the mockup's site theme is put back after the other-theme pass below (TF-025)
|
|
1011
|
+
try { app = await page.evaluate(PROBE, wanted); ownSurface = await surface(page); }
|
|
1012
|
+
finally { if (putBack) await putBack(); }
|
|
798
1013
|
|
|
799
1014
|
// TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
|
|
800
1015
|
// have caught the /routing void on its own: 2607px of document against a
|
|
@@ -811,12 +1026,50 @@ for (const s of SCREENS) {
|
|
|
811
1026
|
}
|
|
812
1027
|
} catch (e) {
|
|
813
1028
|
perWidth.push({ width, error: String(e).slice(0, 200) });
|
|
1029
|
+
await unSite();
|
|
814
1030
|
continue;
|
|
815
1031
|
}
|
|
816
1032
|
const d = diff(mock, app, s.name, width);
|
|
817
1033
|
d.findings.push(...docFindings);
|
|
818
|
-
|
|
819
|
-
|
|
1034
|
+
if (wantTheme(own)) {
|
|
1035
|
+
perWidth.push({ width, mode: own, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
|
|
1036
|
+
appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// --- Lekhak TF-024: the other theme. Both pages are drawn in it and put back after, so an
|
|
1040
|
+
// attached app is left in the viewer's theme. Every finding here says which theme it is in.
|
|
1041
|
+
const other = own === 'dark' ? 'light' : 'dark';
|
|
1042
|
+
if (KEEP_APP_THEME || !wantTheme(other)) { await unSite(); continue; }
|
|
1043
|
+
let backApp = null, backMock = null;
|
|
1044
|
+
try {
|
|
1045
|
+
// The app keeps the mockup's site theme, but gets its own light/dark classes back, so that the
|
|
1046
|
+
// switch below flips them as the app's toggle would (a class-driven dark mode, TF-013).
|
|
1047
|
+
if (saved && saved.changed) {
|
|
1048
|
+
const cls = Object.fromEntries(Object.entries(saved.before).filter(([k]) => k.endsWith('|class')));
|
|
1049
|
+
if (Object.keys(cls).length) await page.evaluate(APPLY_THEME, cls).catch(() => {});
|
|
1050
|
+
}
|
|
1051
|
+
backApp = await useTheme(page, other);
|
|
1052
|
+
backMock = await useTheme(mockPage, other, 0);
|
|
1053
|
+
if (!THEMES && !otherSeen && !differs(ownSurface, await surface(page))) { notOffered.add(other); continue; }
|
|
1054
|
+
otherSeen = true;
|
|
1055
|
+
// A mockup that does not change under the other theme does not draw it: forcing it there only
|
|
1056
|
+
// strips the styles it keys on its own theme (a badge losing its fill). The app in the other theme
|
|
1057
|
+
// is then compared with the mockup as drawn, colour left out.
|
|
1058
|
+
const mockDraws = differs(mockSurface, await surface(mockPage));
|
|
1059
|
+
if (!mockDraws) { await backMock(); backMock = null; }
|
|
1060
|
+
const mockO = mockDraws ? await mockPage.evaluate(PROBE) : mock;
|
|
1061
|
+
const appO = await page.evaluate(PROBE, wanted);
|
|
1062
|
+
const d2 = diff(mockO, appO, s.name, width, mockDraws ? null : new Set(['color']));
|
|
1063
|
+
for (const f of d2.findings) f.theme = other;
|
|
1064
|
+
perWidth.push({ width, mode: other, mockup_draws_theme: mockDraws, ...d2, mockAnchors: mockO.anchors, appAnchors: appO.anchors,
|
|
1065
|
+
appTestIds: appO.testids, mockTestIds: mockO.testids, ...(reached ? { reached } : {}) });
|
|
1066
|
+
} catch (e) {
|
|
1067
|
+
perWidth.push({ width, mode: other, error: String(e).slice(0, 200) });
|
|
1068
|
+
} finally {
|
|
1069
|
+
if (backMock) await backMock();
|
|
1070
|
+
if (backApp) await backApp();
|
|
1071
|
+
await unSite();
|
|
1072
|
+
}
|
|
820
1073
|
}
|
|
821
1074
|
|
|
822
1075
|
const ok = perWidth.filter((w) => !w.error);
|
|
@@ -843,9 +1096,13 @@ for (const s of SCREENS) {
|
|
|
843
1096
|
// not read as a thorough screen with one problem — `coverage.ungradeable` stays
|
|
844
1097
|
// true either way, so the report can say both things at once.
|
|
845
1098
|
const ungradeable = contentGraded === 0;
|
|
1099
|
+
// A width or theme pass that threw was not compared, so it cannot be part of a PASS: a crash in the
|
|
1100
|
+
// dark pass gave PASS on light alone, with nothing saying dark was missing (MISS-TechieFlow-20261009-04).
|
|
1101
|
+
const failedPasses = perWidth.filter((w) => w.error);
|
|
846
1102
|
let verdict;
|
|
847
1103
|
if (ok.length === 0) verdict = 'ERROR';
|
|
848
1104
|
else if (findings.length) verdict = 'FAIL';
|
|
1105
|
+
else if (failedPasses.length) verdict = 'ERROR';
|
|
849
1106
|
else if (ungradeable) verdict = 'UNGRADEABLE';
|
|
850
1107
|
else verdict = 'PASS';
|
|
851
1108
|
|
|
@@ -862,6 +1119,8 @@ for (const s of SCREENS) {
|
|
|
862
1119
|
compared, content_graded: contentGraded, ungradeable,
|
|
863
1120
|
// badges found by their text at another depth than the mockup's (TF-045)
|
|
864
1121
|
relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
|
|
1122
|
+
// sample boxes the app drew nothing at, so not compared (Chatur TF-002)
|
|
1123
|
+
not_measured: [...new Set(ok.flatMap((w) => w.notMeasured || []))],
|
|
865
1124
|
app_controls: appAnchors, mockup_anchors: mockAnchors,
|
|
866
1125
|
ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
|
|
867
1126
|
thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
|
|
@@ -877,18 +1136,92 @@ for (const s of SCREENS) {
|
|
|
877
1136
|
// Named so the fix is a mechanical edit to the mockup, not a research task.
|
|
878
1137
|
add_data_testid_to_mockup: unanchored.slice(0, 30),
|
|
879
1138
|
},
|
|
880
|
-
|
|
1139
|
+
// the themes it was compared in, and one the app showed no sign of having (TF-024)
|
|
1140
|
+
themes: [...new Set(ok.map((w) => w.mode).filter(Boolean))],
|
|
1141
|
+
...(notOffered.size ? { themes_not_offered: [...notOffered] } : {}),
|
|
1142
|
+
...(failedPasses.length ? { not_compared: failedPasses.map((w) => `${w.width}${w.mode ? ' ' + w.mode : ''}: ${w.error}`) } : {}),
|
|
1143
|
+
widths: perWidth.map((w) => ({ width: w.width, ...(w.mode ? { mode: w.mode } : {}),
|
|
1144
|
+
...(w.mockup_draws_theme === false ? { colour_not_compared: 'the mockup draws no ' + w.mode + ' theme' } : {}), error: w.error || null,
|
|
881
1145
|
compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
|
|
1146
|
+
not_measured: (w.notMeasured || []).length,
|
|
882
1147
|
...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
|
|
883
1148
|
...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
|
|
884
1149
|
});
|
|
885
1150
|
}
|
|
886
1151
|
|
|
1152
|
+
// ---------------------------------------------------------------- menus (Chatur TF-011)
|
|
1153
|
+
// Each menu the mockups draw open is opened in the app and every item clicked, once, on the first screen
|
|
1154
|
+
// that draws it, at the first width. Its findings go on every screen whose mockup draws it; a screen a
|
|
1155
|
+
// menu item links to and no click reached gets an `unreachable` finding.
|
|
1156
|
+
let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
|
|
1157
|
+
if (WALK_MENUS && results.length) {
|
|
1158
|
+
const width = WIDTHS[0];
|
|
1159
|
+
const { app: page, mock: mockPage } = tabs[width];
|
|
1160
|
+
const byScreen = {}, mockText = {}, mockIds = {};
|
|
1161
|
+
for (const r of results) {
|
|
1162
|
+
if (r.verdict === 'NO-MOCKUP' || r.verdict === 'ERROR') continue;
|
|
1163
|
+
try {
|
|
1164
|
+
await mockPage.goto(pathToFileURL(findMockup(r.screen)).href, { waitUntil: 'load' });
|
|
1165
|
+
byScreen[r.screen] = await mockupMenus(mockPage, 'data-testid');
|
|
1166
|
+
mockText[r.screen] = await mockPage.evaluate(() => document.body.innerText || '');
|
|
1167
|
+
// the controls that say a click landed on this screen: anchors outside any other state or open menu
|
|
1168
|
+
mockIds[r.screen] = await mockPage.evaluate(() => [...document.querySelectorAll('[data-testid]')]
|
|
1169
|
+
.filter((e) => !e.closest('[data-tf-state],[data-state-testid],[data-tf-sample]')).map((e) => e.getAttribute('data-testid')));
|
|
1170
|
+
} catch (e) { byScreen[r.screen] = []; }
|
|
1171
|
+
}
|
|
1172
|
+
const key = (m) => `${m.trigger}|${m.popup}`;
|
|
1173
|
+
const done = new Map();
|
|
1174
|
+
const anyMock = Object.values(byScreen).some((l) => l.length);
|
|
1175
|
+
const allClicks = [];
|
|
1176
|
+
for (const r of results) {
|
|
1177
|
+
if (!(r.screen in byScreen)) continue;
|
|
1178
|
+
const todo = byScreen[r.screen].filter((m) => !done.has(key(m)));
|
|
1179
|
+
if (anyMock && !todo.length) continue;
|
|
1180
|
+
if (!anyMock && done.size) break;
|
|
1181
|
+
const back = async () => { if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route); };
|
|
1182
|
+
try {
|
|
1183
|
+
if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
|
|
1184
|
+
await back();
|
|
1185
|
+
const pages = async () => CDP ? cdpBrowser.contexts().flatMap((c) => c.pages()) : ctx.pages().filter((p) => p !== mockPage && !Object.values(tabs).some((t) => t.mock === p));
|
|
1186
|
+
const w = await walkMenus(page, todo, { attr: 'data-testid', settle: LOGIN_OPTS.settle, back, pages, keepWindows: !!CDP,
|
|
1187
|
+
mockText: mockText[r.screen], placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
|
|
1188
|
+
menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: r.screen })));
|
|
1189
|
+
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 })));
|
|
1190
|
+
allClicks.push(...w.clicks);
|
|
1191
|
+
if (!w.from_mockup) { done.set('app|', w.findings); r._menu = w.findings; }
|
|
1192
|
+
for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
|
|
1193
|
+
} catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
|
|
1194
|
+
}
|
|
1195
|
+
// what each screen owes: its menus' findings, placeholder text on the page, and a way in that works
|
|
1196
|
+
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);
|
|
1197
|
+
menuWalk.reach = ways;
|
|
1198
|
+
for (const r of results) {
|
|
1199
|
+
if (!(r.screen in byScreen)) continue;
|
|
1200
|
+
const add = (byScreen[r.screen] || []).flatMap((m) => done.get(key(m)) || []).concat(r._menu || [])
|
|
1201
|
+
.map((f) => ({ screen: r.screen, width, class: f.class, key: f.popup ? `menu ${f.popup}` : 'menu', detail: f.detail }));
|
|
1202
|
+
try {
|
|
1203
|
+
if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route);
|
|
1204
|
+
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}"` });
|
|
1205
|
+
} catch (e) { /* the screen was compared above; a second opening that fails adds nothing */ }
|
|
1206
|
+
const rv = ways[r.screen];
|
|
1207
|
+
if (rv && !rv.reached) add.unshift({ screen: r.screen, width, class: 'unreachable', key: 'document', detail: `no menu click reaches ${r.route}: ${rv.tried.join('; ')}` });
|
|
1208
|
+
if (rv && rv.reached) r.reached_by = rv.by;
|
|
1209
|
+
delete r._menu;
|
|
1210
|
+
if (!add.length) continue;
|
|
1211
|
+
r.findings = [...add, ...r.findings].slice(0, MAX_FINDINGS_PER_SCREEN);
|
|
1212
|
+
r.findings_n = r.findings.length;
|
|
1213
|
+
if (r.verdict === 'PASS' || r.verdict === 'UNGRADEABLE') r.verdict = 'FAIL';
|
|
1214
|
+
hardFail = true;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const r of results) delete r._menu;
|
|
1218
|
+
|
|
887
1219
|
await browser.close();
|
|
888
1220
|
if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
|
|
889
1221
|
|
|
890
1222
|
const summary = {
|
|
891
1223
|
status: 'measured',
|
|
1224
|
+
menus: menuWalk,
|
|
892
1225
|
base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
|
|
893
1226
|
widths: WIDTHS,
|
|
894
1227
|
screens_n: results.length,
|
|
@@ -9,13 +9,24 @@
|
|
|
9
9
|
# --login-path /login --user <u> --password <p> (or --storage-state <file>)
|
|
10
10
|
# and each screen is opened in a tab that signed in through the form, the way
|
|
11
11
|
# tf-verify-screens.sh does; `widths[].reached` in the JSON says how a 401 screen was reached.
|
|
12
|
+
# --list tests/.artifacts/verify/list.json takes each screen's mockup path from the list
|
|
13
|
+
# tf-verify-list.sh wrote, so a mockup in a subfolder is found (Lekhak TF-022); with no --screen
|
|
14
|
+
# it drives every listed screen. Without a list, a mockup in a subfolder is used when it is the only one.
|
|
12
15
|
# A desktop head (Lekhak TF-006): --cdp http://<host>:9223 in place of --base attaches to the
|
|
13
16
|
# running app, as tf-verify-screens.sh --cdp does, and opens each route by pushState.
|
|
14
17
|
# The app is compared in the mockup's theme: the mockup's data-*theme/mode/scheme attributes on
|
|
15
18
|
# <html> and <body>, and a light/dark mode class there (class="dark", Lekhak TF-013), are set on the
|
|
16
19
|
# app page for the comparison and put back after (Lekhak TF-010);
|
|
17
|
-
#
|
|
18
|
-
#
|
|
20
|
+
# Then the screen is compared again in the other theme (Lekhak TF-024), the mockup drawn in it too:
|
|
21
|
+
# --themes light,dark runs both, light or dark alone only that one, and auto (the default) runs the
|
|
22
|
+
# other once the app shows it has it. Colour is not compared in a theme the mockup does not draw.
|
|
23
|
+
# Each finding of that pass carries "theme"; the result lists "themes" it was compared in.
|
|
24
|
+
# --keep-app-theme compares the theme the app shows, and only that one. A mockup box marked data-tf-state or
|
|
25
|
+
# data-state-testid (another state of the screen) is left out of the comparison (Lekhak TF-011),
|
|
26
|
+
# and so are its icons, badges and text when a box around it is compared (Chatur TF-001).
|
|
27
|
+
# A box marked data-tf-sample, or data-tf-state="sample-data" (Chatur TF-005), is sample data: compared
|
|
28
|
+
# when the app draws something in its place,
|
|
29
|
+
# otherwise listed in coverage.not_measured and taken off the boxes around it (Chatur TF-002).
|
|
19
30
|
#
|
|
20
31
|
# WHAT IT ASKS: "does the built screen carry the structure its approved mockup
|
|
21
32
|
# draws?" — at the same viewports, comparing STRUCTURE, never pixels. Pixel diffing
|
|
@@ -58,7 +69,7 @@ set -uo pipefail
|
|
|
58
69
|
|
|
59
70
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
60
71
|
|
|
61
|
-
case "${1:-}" in -h|--help) sed -n '2,
|
|
72
|
+
case "${1:-}" in -h|--help) sed -n '2,56p' "$0"; exit 0 ;; esac
|
|
62
73
|
|
|
63
74
|
command -v node >/dev/null 2>&1 || {
|
|
64
75
|
echo "tf-mockup-parity: node is required (verify-phase §1 installs the verify environment)." >&2
|