@techierathore/techieflow 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -0,0 +1,307 @@
1
+ // tf-verify-native.mjs — the render and visual checks on a MAUI Mac Catalyst head, over Appium mac2.
2
+ // Run through tf-verify-screens.sh --appium <url>, not directly.
3
+ //
4
+ // The native twin of tf-verify-screens.mjs: the same list.json in, the same screens.json out (mode
5
+ // "appium"), so tf-verify-verdict.sh grades a Mac screen exactly as a web one. The mockup's
6
+ // data-testid anchors are looked for as AutomationId (the accessibility identifier the Mac shows).
7
+ // render every anchored control is present and shows something (text, a value, or content
8
+ // inside it); an anchored list has rows; the window is not blank; no error bar.
9
+ // visual no two visible controls overlap; no anchored control has zero size or sits outside
10
+ // the window. (A native window has no stylesheet and no sideways page scroll to check.)
11
+ // A web view head (Blazor Hybrid; boot.json webview, or --no-names): on a Mac neither data-testid
12
+ // nor an HTML id reaches mac2, so controls are not matched by name. Everything else runs: blank
13
+ // window, error bar (--error-text, repeatable; the defaults are the stock Blazor messages), overlap,
14
+ // and the screenshot. Each screen records anchors_not_measured, and the verdict says the name check
15
+ // was not measured (owner decision A, 2026-10-07).
16
+ // A screen is reached by clicking, inside the app, the control whose AutomationId is
17
+ // nav-<screen name> or, failing that, the tab, button, link or list item labelled with the screen's
18
+ // name, or by clicking in turn the labels the list read from the code for it (list.json `nav`, from
19
+ // tf-verify-list.sh: "Images", or "Users" then "+ Add New User"). Only a screen whose route is "/" may be
20
+ // the one the app opens on; any other screen nothing reached is UNREACHABLE and is never graded on
21
+ // whatever was on view (Lekhak TF-027: Search results passed on BlogAdmin's Dashboard). A screen the
22
+ // list says another project serves (a website page, when the Mac head is the desktop app) is skipped
23
+ // as another head's. The session opens the app by its .app path (boot.json app_path, or --app-path)
24
+ // when known, else by bundle id. Each screen is measured at every --widths (default 1280,390): the
25
+ // window is resized through macOS, the width it really took is recorded (an app's minimum size holds),
26
+ // and the screenshot is that window alone, taken by macOS by its window number.
27
+ // --from-source a.xml,b.xml grades saved page sources instead of a running app (one screen per
28
+ // file, no screenshot): how the self-tests check the grading where there is no Mac.
29
+
30
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
31
+ import { basename, dirname, join, normalize, resolve } from 'node:path';
32
+ import { openSession, parseSource, descendants, appPid, resizeWindow, windowShot, macWindow } from './tf-appium.mjs';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
36
+ const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
37
+ const APPIUM = (arg('--appium') || '').replace(/\/$/, '');
38
+ const BOOT = arg('--boot', 'tests/.artifacts/verify/boot.json');
39
+ const boot = existsSync(BOOT) ? JSON.parse(readFileSync(BOOT, 'utf8')) : {};
40
+ const BUNDLE = arg('--bundle') || boot.bundle_id || '';
41
+ const APP_PATH = arg('--app-path') || boot.app_path || '';
42
+ const LIST = arg('--list');
43
+ const MOCKUPS = arg('--mockups', 'docs/mockups');
44
+ const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
45
+ const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
46
+ const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
47
+ const SETTLE = parseInt(arg('--wait', '1500'), 10); // after a click, before the first look
48
+ const ATTR = arg('--testid-attr', 'data-testid');
49
+ const FROM_SOURCE = (arg('--from-source') || '').split(',').filter(Boolean);
50
+ const NO_NAMES = argv.includes('--no-names') || boot.webview === true;
51
+ const ERROR_TEXTS = [...args('--error-text'), 'An unhandled error has occurred', 'An error has occurred. This application may no longer respond']
52
+ .map((t) => t.toLowerCase());
53
+ if (!FROM_SOURCE.length && (!APPIUM || !(BUNDLE || APP_PATH))) { console.error('tf-verify-native: --appium URL and the app (boot.json from tf-verify-boot.sh, --bundle or --app-path) are required'); process.exit(3); }
54
+
55
+ let screens = [];
56
+ if (LIST && !existsSync(LIST)) { console.error(`tf-verify-native: ${LIST} does not exist; make it first with bash .tfcore/utils/tf-verify-list.sh <App> ui`); process.exit(3); }
57
+ if (LIST) screens = (JSON.parse(readFileSync(LIST, 'utf8')).screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [],
58
+ nav: s.nav || null, project: s.project || '' }));
59
+ for (const s of args('--screen')) {
60
+ const m = s.match(/^([^=]+)=(.*)$/);
61
+ if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
62
+ }
63
+ if (FROM_SOURCE.length && !screens.length) screens = FROM_SOURCE.map((f) => ({ name: basename(f).replace(/\.xml$/, ''), route: '', mockup: '', rows: [] }));
64
+ if (!screens.length) { console.error('tf-verify-native: no screens (--list <json> or --screen name=route)'); process.exit(3); }
65
+
66
+ const WIDTHS = String(arg('--widths', '1280,390')).split(',').map((x) => parseInt(x, 10)).filter((x) => x > 0);
67
+ // The booted project and every project it references: a screen another project serves is another head's.
68
+ const heads = (() => {
69
+ const out = new Set(); const todo = boot.project ? [dirname(boot.project)] : [];
70
+ while (todo.length) {
71
+ const d = todo.pop().replace(/\/$/, '');
72
+ if (out.has(d)) continue; out.add(d);
73
+ try {
74
+ const proj = readdirSync(d).find((f) => f.endsWith('.csproj'));
75
+ for (const m of readFileSync(`${d}/${proj}`, 'utf8').matchAll(/<ProjectReference\s+Include="([^"]+)"/g))
76
+ todo.push(normalize(join(d, dirname(m[1].replace(/\\/g, '/')))));
77
+ } catch { /* not a project folder */ }
78
+ }
79
+ return out;
80
+ })();
81
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
82
+ const anchorsOf = (mockup) => { // as tf-verify-screens.mjs: an attribute on an element, not in a style or script
83
+ if (!mockup || !existsSync(mockup)) return null;
84
+ const html = readFileSync(mockup, 'utf8').replace(/<!--[\s\S]*?-->/g, '').replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
85
+ const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
86
+ const out = new Set(); let m;
87
+ while ((m = re.exec(html))) out.add(m[1]);
88
+ return [...out];
89
+ };
90
+
91
+ const LISTS = new Set(['CollectionView', 'Table', 'Outline']);
92
+ const CHROME = new Set(['TabBar', 'Toolbar', 'NavigationBar', 'MenuBar', 'Menu']);
93
+ const LEAF = new Set(['Button', 'StaticText', 'TextField', 'SecureTextField', 'TextView', 'Image', 'CheckBox', 'Switch', 'Slider',
94
+ 'PopUpButton', 'ComboBox', 'Link', 'RadioButton', 'SegmentedControl', 'Stepper', 'DatePicker', 'ProgressIndicator', 'Cell']);
95
+ const WEB_CONTROLS = new Set(['Button', 'Link', 'TextField', 'SecureTextField', 'TextView', 'CheckBox', 'RadioButton', 'PopUpButton', 'ComboBox', 'Heading']);
96
+ const shows = (n) => [n.label, n.value, n.title].some((t) => (t || '').trim().length > 0);
97
+
98
+ function windowOf(nodes) { return nodes.find((n) => n.type === 'Window'); }
99
+ // chrome: bars, menus, and what sits directly on the window (its title, its three buttons)
100
+ function inChrome(nodes, n) {
101
+ if (n.parent >= 0 && nodes[n.parent].type === 'Window') return true;
102
+ for (let p = n.parent; p >= 0; p = nodes[p].parent) if (CHROME.has(nodes[p].type)) return true;
103
+ return CHROME.has(n.type);
104
+ }
105
+
106
+ // the app has drawn something: text or a control in its content, the window's own title not counted
107
+ function hasContent(nodes) {
108
+ const win = windowOf(nodes);
109
+ return !!win && descendants(nodes, win.i).some((n) => !inChrome(nodes, n) && ((n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText')));
110
+ }
111
+
112
+ function grade(nodes, anchors) {
113
+ const findings = [];
114
+ const win = windowOf(nodes);
115
+ const inside = win ? descendants(nodes, win.i) : [];
116
+ const content = inside.filter((n) => !inChrome(nodes, n));
117
+ const out = { anchors: [] };
118
+ for (const id of NO_NAMES ? [] : anchors || []) {
119
+ const n = inside.find((x) => x.id === id);
120
+ if (!n) { out.anchors.push({ id, present: false }); findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" is not on the screen (no AutomationId="${id}")` }); continue; }
121
+ const sub = descendants(nodes, n.i);
122
+ const lists = [n, ...sub].filter((x) => LISTS.has(x.type));
123
+ const cells = sub.filter((x) => x.type === 'Cell').length;
124
+ const filled = shows(n) || sub.some((x) => shows(x) || x.type === 'Cell' || x.type === 'Image' || x.type === 'TextField');
125
+ out.anchors.push({ id, present: true, type: n.type, filled, x: n.x, y: n.y, w: n.w, h: n.h, rows: lists.length ? cells : undefined });
126
+ if (lists.length && cells === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `list "${id}" (${lists[0].type}) has no rows` });
127
+ else if (!filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" (${n.type}) is empty` });
128
+ if (n.w === 0 || n.h === 0) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${id}" has zero ${n.w === 0 ? 'width' : 'height'}` });
129
+ else if (win && (n.x < win.x - 2 || n.y < win.y - 2 || n.x + n.w > win.x + win.w + 2 || n.y + n.h > win.y + win.h + 2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${id}" sits outside the window` });
130
+ }
131
+ // the error bar: shown text naming the app's own crash (a hidden bar is not in the tree at all)
132
+ const bar = content.find((n) => n.w > 0 && n.h > 0 && ERROR_TEXTS.some((t) => [n.label, n.value].some((v) => (v || '').toLowerCase().includes(t))));
133
+ if (bar) findings.push({ check: 'render', class: 'exception', detail: `the application's error bar is showing ("${(bar.label || bar.value).slice(0, 80)}")` });
134
+ if (!win) findings.push({ check: 'render', class: 'blank-data', detail: 'the app has no window open' });
135
+ else if (!content.some((n) => (n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText'))) findings.push({ check: 'render', class: 'blank-data', detail: 'the window is blank (no text, no control)' });
136
+ // overlap: visible leaf controls, never a control and one inside it; the same thresholds as the web
137
+ // check, on the rectangle each one PAINTS in. The tree gives a text its full size even where its box
138
+ // cuts it off: a table cell's comment, drawn as "Seeded approved…" in a 50 pt row, reported 170 pt
139
+ // and "overlapped" the button under the table (Lekhak Comments, 2026-10-07). So each rectangle is
140
+ // cut to every container's; the tree says nothing of overflow, so text that truly spills out of its
141
+ // box is not seen here.
142
+ const clip = (n) => {
143
+ let x1 = n.x, y1 = n.y, x2 = n.x + n.w, y2 = n.y + n.h;
144
+ for (let p = n.parent; p >= 0 && nodes[p].type !== 'Window'; p = nodes[p].parent) {
145
+ const a = nodes[p]; if (a.w <= 0 || a.h <= 0) continue;
146
+ x1 = Math.max(x1, a.x); y1 = Math.max(y1, a.y); x2 = Math.min(x2, a.x + a.w); y2 = Math.min(y2, a.y + a.h);
147
+ }
148
+ return { ...n, x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
149
+ };
150
+ // In a web view plain text is not compared, as the web check compares controls only (links,
151
+ // buttons, fields, headings): the tree does not say a box scrolls, so the lines of a scrolled log
152
+ // report places under the next card (Lekhak AI Story Studio, 2026-10-07).
153
+ const kinds = NO_NAMES ? WEB_CONTROLS : LEAF;
154
+ const boxes = content.filter((n) => kinds.has(n.type) && n.w > 0 && n.h > 0).map(clip).filter((n) => n.w > 0 && n.h > 0);
155
+ const anc = (a, b) => { for (let p = b.parent; p >= 0; p = nodes[p].parent) if (p === a.i) return true; return false; };
156
+ let overlaps = 0;
157
+ for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
158
+ const a = boxes[i], b = boxes[j];
159
+ if (anc(a, b) || anc(b, a)) continue;
160
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x), iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
161
+ if (ix <= 4 || iy <= 4) continue;
162
+ const r = (ix * iy) / Math.min(a.w * a.h, b.w * b.h);
163
+ if (r > 0.9 || r < 0.25) continue;
164
+ overlaps++;
165
+ const nm = (e) => e.id || `${e.type} "${(e.label || e.value || '').slice(0, 40)}"`;
166
+ findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)}` });
167
+ }
168
+ const render = findings.some((f) => f.class === 'exception') ? 'ERROR' : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
169
+ const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
170
+ return { render, visual, findings, anchors_present: out.anchors.filter((a) => a.present).length, window: win ? { w: win.w, h: win.h } : null };
171
+ }
172
+
173
+ const skipped = [];
174
+ function writeOut(results, extra = {}, width = 0) {
175
+ const summary = {
176
+ mode: 'appium', base: APPIUM, bundle_id: BUNDLE, widths: width ? [width] : [], login: null, screens: results, skipped,
177
+ anchors_not_measured: NO_NAMES,
178
+ summary: {
179
+ screens: results.length, skipped: skipped.length,
180
+ render_ok: results.filter((r) => r.render === 'OK').length,
181
+ render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
182
+ unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
183
+ visual_ok: results.filter((r) => r.visual === 'OK').length,
184
+ visual_fail: results.filter((r) => r.visual === 'FAIL').length,
185
+ },
186
+ ...extra,
187
+ };
188
+ mkdirSync(dirname(resolve(OUT)), { recursive: true });
189
+ writeFileSync(OUT, JSON.stringify(summary, null, 2));
190
+ return summary;
191
+ }
192
+
193
+ let s = null;
194
+ if (!FROM_SOURCE.length) {
195
+ try { s = await openSession(APPIUM, { bundleId: BUNDLE, appPath: APP_PATH }); }
196
+ catch (e) {
197
+ console.log(`UNREACHABLE appium ${APPIUM} (${APP_PATH || BUNDLE}): ${e.message}`);
198
+ console.log(' a mac2 session that cannot start is usually a permission: System Settings > Privacy & Security > Accessibility (the terminal, and WebDriverAgentRunner-Runner)');
199
+ writeOut(screens.map((x) => ({ ...x, anchors: anchorsOf(x.mockup), render: 'UNREACHABLE', visual: 'n/a', widths: [{ width: 0, render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `no Appium session: ${e.message}` }] }] })), { unreachable: true, error: e.message });
200
+ process.exit(2);
201
+ }
202
+ }
203
+
204
+ const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
205
+ const q = (t) => t.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
206
+ // Button 9, RadioButton 10, Link 42 (a web view's menu), Cell 75: the control labelled `text`, else
207
+ // the one whose label holds it (a menu link reads "📷 Images" for the code's "Images")
208
+ async function control(win, text) {
209
+ for (const value of [`elementType IN {9, 10, 42, 75} AND (label ==[c] '${q(text)}' OR title ==[c] '${q(text)}')`,
210
+ `elementType IN {9, 10, 42, 75} AND label CONTAINS[c] '${q(text)}'`]) {
211
+ const el = await s.find('-ios predicate string', value, win).catch(() => null);
212
+ if (el) return el;
213
+ }
214
+ return null;
215
+ }
216
+ async function reach(sc, first) {
217
+ const name = sc.name;
218
+ const win = await s.find('-ios predicate string', 'elementType == 4').catch(() => null);
219
+ if (sc.nav && sc.nav.length) { // the path the code gives (Lekhak TF-027)
220
+ for (const [i, label] of sc.nav.entries()) {
221
+ const el = await control(win, label);
222
+ if (!el) return { how: '', win, why: `the code links "${label}" to it${i ? ` (after ${sc.nav.slice(0, i).map((x) => `"${x}"`).join(', ')})` : ''}, and no such control was on view` };
223
+ await s.click(el); await sleep(SETTLE);
224
+ }
225
+ return { how: `clicked ${sc.nav.map((x) => `"${x}"`).join(' > ')}`, win };
226
+ }
227
+ const el = await s.find('accessibility id', `nav-${slug(name)}`, win).catch(() => null) || await control(win, name);
228
+ if (el) { await s.click(el); await sleep(SETTLE); return { how: `clicked the control for "${name}"`, win }; }
229
+ // only the start screen may be the one on view without a click; any other was never shown
230
+ if (first && ['', '/'].includes(String(sc.route || '').trim())) return { how: 'the screen the app opens on', win };
231
+ return { how: '', win };
232
+ }
233
+
234
+ mkdirSync(SHOTS, { recursive: true });
235
+ const results = []; let width = 0;
236
+ const PID = !FROM_SOURCE.length && APP_PATH ? appPid(APP_PATH) : 0;
237
+ const before = PID ? macWindow(PID) : null; // put back at the end
238
+ let firstDrawn = false;
239
+ for (const [i, sc] of screens.entries()) {
240
+ // another head's screen: the list says a project the booted one does not include serves it
241
+ if (sc.project && heads.size && !heads.has(normalize(sc.project))) {
242
+ skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `it is served by ${sc.project}, not by the booted head (${boot.project})` });
243
+ console.log(`SKIP ${sc.name} (${sc.route}) — another head's screen: served by ${sc.project}`);
244
+ continue;
245
+ }
246
+ if (/\{/.test(sc.route || '') && !(sc.nav && sc.nav.length)) {
247
+ skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `its route ${sc.route} takes a value and no link in the code reaches it` });
248
+ console.log(`SKIP ${sc.name} (${sc.route}) — its route takes a value and no link in the code reaches it`);
249
+ continue;
250
+ }
251
+ const r = { name: sc.name, route: sc.route, mockup: sc.mockup, rows: sc.rows, anchors: anchorsOf(sc.mockup), widths: [] };
252
+ if (NO_NAMES) r.anchors_not_measured = true;
253
+ results.push(r);
254
+ const want = NO_NAMES ? [] : r.anchors || [];
255
+ let nav;
256
+ try {
257
+ // the first look: opening the session starts the app again, and a web view took about eight
258
+ // seconds to draw its first page, menu included (2026-10-07). Wait for it before looking for the menu.
259
+ if (s && !firstDrawn) { const t1 = Date.now(); while (Date.now() - t1 < Math.max(RENDER_WAIT, 20000) && !hasContent(parseSource(await s.source()))) await sleep(300); firstDrawn = true; }
260
+ nav = FROM_SOURCE.length ? { how: `saved source ${FROM_SOURCE[i]}` } : await reach(sc, results.length === 1);
261
+ } catch (e) { nav = { how: '', why: `Appium: ${e.message}` }; }
262
+ if (!nav.how) {
263
+ const detail = nav.why ? `${sc.name} was not reached: ${nav.why}`
264
+ : `no control reaches ${sc.name}: no link to ${sc.route} was found in the code${NO_NAMES ? '' : `; give its tab, flyout item, link or button AutomationId="nav-${slug(sc.name)}"`}, or the label "${sc.name}"`;
265
+ r.widths.push({ width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail }] });
266
+ } else {
267
+ for (const w of PID ? WIDTHS : [0]) {
268
+ const shot = `${SHOTS}/${slug(sc.name)}${w ? `-${w}` : ''}-mac.png`;
269
+ let entry;
270
+ try {
271
+ const size = w ? resizeWindow(PID, w, w < 700 ? 844 : 800) : null;
272
+ if (w) await sleep(SETTLE);
273
+ const t0 = Date.now();
274
+ const src = async () => parseSource(FROM_SOURCE.length ? readFileSync(FROM_SOURCE[i], 'utf8') : await s.source());
275
+ let nodes = await src();
276
+ while (!FROM_SOURCE.length && Date.now() - t0 < RENDER_WAIT && !(want.length ? nodes.some((n) => want.includes(n.id)) : hasContent(nodes))) {
277
+ await sleep(300); nodes = await src();
278
+ }
279
+ const g = grade(nodes, want);
280
+ if (g.window) width = g.window.w;
281
+ entry = { width: g.window ? g.window.w : 0, height: g.window ? g.window.h : 0, url: '', status: 200, reached: nav.how, render_wait_ms: Date.now() - t0,
282
+ screenshot: FROM_SOURCE.length ? '' : shot, render: g.render, visual: g.visual, findings: g.findings, anchors_present: g.anchors_present, console_errors: [] };
283
+ if (w) { entry.requested_width = w; if (!size || Math.abs(size.w - w) > 8) entry.width_note = size ? `the window stayed ${size.w} px wide (the app's minimum)` : 'macOS did not resize the window (Accessibility for the terminal)'; }
284
+ if (NO_NAMES) entry.anchors_not_measured = true;
285
+ if (s) {
286
+ if (!(PID && windowShot(PID, shot))) {
287
+ try { writeFileSync(shot, Buffer.from(await s.shot(nav.win), 'base64')); } catch (e) { entry.screenshot = ''; }
288
+ }
289
+ }
290
+ } catch (e) {
291
+ entry = { width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `Appium: ${e.message}` }] };
292
+ }
293
+ r.widths.push(entry);
294
+ }
295
+ }
296
+ const worst = r.widths.find((e) => e.render !== 'OK') || r.widths[0];
297
+ r.render = worst.render; r.visual = r.widths.some((e) => e.visual === 'FAIL') ? 'FAIL' : worst.visual; r.anchors_n = (r.anchors || []).length;
298
+ const notes = [...new Set(r.widths.flatMap((e) => (e.findings || []).map((f) => f.detail + (e.requested_width ? ` @${e.width}` : ''))))].slice(0, 4);
299
+ const sizes = r.widths.filter((e) => e.requested_width).map((e) => `${e.width}${e.width_note ? ` (asked ${e.requested_width})` : ''}`);
300
+ const names = NO_NAMES ? 'names not measured (web view)' : `${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}`;
301
+ console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${names}${sizes.length ? `, @${sizes.join('/')}` : ''}${r.widths[0].reached ? ` — ${r.widths[0].reached}` : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
302
+ }
303
+ if (PID && before) resizeWindow(PID, before.w, before.h);
304
+ if (s) await s.close();
305
+ const out = writeOut(results, {}, width);
306
+ console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} skipped)` : ''}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed${NO_NAMES ? '; control names not measured (web view)' : ''}; screenshots ${SHOTS}; JSON ${OUT}`);
307
+ process.exit(out.summary.screens && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);