@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.
Files changed (106) 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/_smoke-test-policy.md +7 -5
  5. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  6. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  9. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  10. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  11. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  14. package/.opencode/plugin/techieflow.js +15 -7
  15. package/.tfcore/agents/flow-master.md +2 -0
  16. package/.tfcore/agents/verifier.md +2 -2
  17. package/.tfcore/core-config.yaml +2 -1
  18. package/.tfcore/hooks/block-git.sh +25 -2
  19. package/.tfcore/hooks/guard-db.sh +106 -9
  20. package/.tfcore/hooks/guard-status-html.sh +5 -1
  21. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  22. package/.tfcore/hooks/yolo-allow.sh +27 -0
  23. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  24. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  25. package/.tfcore/tasks/_develop-mode.md +34 -0
  26. package/.tfcore/tasks/_smoke-test-policy.md +7 -5
  27. package/.tfcore/tasks/_yolo-mode.md +2 -2
  28. package/.tfcore/tasks/amend-docs.md +1 -1
  29. package/.tfcore/tasks/day1-brownfield.md +2 -2
  30. package/.tfcore/tasks/day1-greenfield.md +4 -4
  31. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  32. package/.tfcore/tasks/fix-issues.md +1 -0
  33. package/.tfcore/tasks/mockups.md +1 -1
  34. package/.tfcore/tasks/triage-and-fix.md +2 -2
  35. package/.tfcore/tasks/verify-phase.md +12 -11
  36. package/.tfcore/telemetry/SCHEMA.md +6 -2
  37. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  38. package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
  39. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  40. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  41. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  42. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  43. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
  44. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  45. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/tf-appium.mjs +179 -0
  52. package/.tfcore/utils/tf-brd-status.py +10 -1
  53. package/.tfcore/utils/tf-build-list.py +16 -1
  54. package/.tfcore/utils/tf-build.sh +75 -0
  55. package/.tfcore/utils/tf-checklist-edit.py +28 -3
  56. package/.tfcore/utils/tf-day1-files.py +55 -28
  57. package/.tfcore/utils/tf-develop.sh +264 -0
  58. package/.tfcore/utils/tf-devguide-list.py +6 -2
  59. package/.tfcore/utils/tf-doc-check.py +124 -21
  60. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  61. package/.tfcore/utils/tf-emit.sh +40 -10
  62. package/.tfcore/utils/tf-fix-close.sh +13 -7
  63. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  64. package/.tfcore/utils/tf-menus.mjs +401 -0
  65. package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
  66. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  67. package/.tfcore/utils/tf-owner-text.py +27 -5
  68. package/.tfcore/utils/tf-phase.sh +24 -4
  69. package/.tfcore/utils/tf-render-html.py +19 -3
  70. package/.tfcore/utils/tf-req-ids.py +36 -0
  71. package/.tfcore/utils/tf-stack-check.sh +3 -0
  72. package/.tfcore/utils/tf-status-facts.py +90 -15
  73. package/.tfcore/utils/tf-theme.mjs +189 -0
  74. package/.tfcore/utils/tf-triage.py +30 -4
  75. package/.tfcore/utils/tf-verify-boot.sh +296 -21
  76. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  77. package/.tfcore/utils/tf-verify-list.py +219 -20
  78. package/.tfcore/utils/tf-verify-list.sh +3 -1
  79. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  80. package/.tfcore/utils/tf-verify-screens.mjs +184 -20
  81. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  82. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  84. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  85. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  86. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  87. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  88. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  89. package/.tfcore/utils/tf-yolo.sh +31 -3
  90. package/.tfcore/utils/tf_feedback.py +86 -18
  91. package/.tfcore/utils/tf_stack_check.py +93 -0
  92. package/README.md +2 -2
  93. package/opencode.jsonc +6 -2
  94. package/package.json +1 -1
  95. package/scripts/install.mjs +14 -3
  96. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # tf-maccatalyst-check.sh — the Mac Catalyst rules a MAUI project must meet before its Mac app can
3
+ # start (coding-standards-dotnet.md §9; 2026-10-07). Reads files only: runs the same on Windows (WSL,
4
+ # Git Bash), Linux and a Mac, so a team without a Mac finds these before the Mac build does.
5
+ #
6
+ # bash .tfcore/utils/tf-maccatalyst-check.sh [<csproj> ...] (default: every project with a
7
+ # net*-maccatalyst target)
8
+ #
9
+ # FAIL the scene manifest: Platforms/MacCatalyst/Info.plist has no UIApplicationSceneManifest, or
10
+ # names a scene delegate no class in the project registers as a MauiUISceneDelegate. On
11
+ # macOS 27 such an app quits at its first window (the stock `dotnet new maui` template has none).
12
+ # WARN the keychain: an entitlements file asks for keychain-access-groups and the project names no
13
+ # provisioning profile and no Debug entitlements file without it. macOS refuses to start a
14
+ # locally signed (ad hoc) build that asks for it ("Launch failed", POSIX 163).
15
+ # WARN zoom (§9b): a project drawing its screens in a BlazorWebView, Mac or Windows head, whose code has
16
+ # no zoom wiring for that head: Mac, WKWebView.PageZoom and a "Zoom In" menu command; Windows,
17
+ # WebView2's IsZoomControlEnabled set by the app; either, the level kept through Preferences. Copy
18
+ # .tfcore/standards/blazor-zoom.cs.txt. A WARN, so tf-verify-boot.sh still boots such a head.
19
+ # Prints one line per finding and an OK line per clean project. A Windows-only MAUI project is read
20
+ # for the zoom rule alone.
21
+ # Exit 0 no FAIL (WARNs allowed) · 1 a FAIL · 3 usage.
22
+ set -u
23
+ case "${1:-}" in -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
24
+
25
+ PROJECTS=("$@")
26
+ if [[ ${#PROJECTS[@]} -eq 0 ]]; then
27
+ while IFS= read -r p; do PROJECTS+=("$p"); done < <(
28
+ find . -maxdepth 5 -name '*.csproj' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' \
29
+ -not -path './tests/.artifacts/*' 2>/dev/null | sed 's#^\./##' | sort)
30
+ fi
31
+ fails=0; seen=0
32
+ for P in ${PROJECTS[@]+"${PROJECTS[@]}"}; do
33
+ [[ -f "$P" ]] || { echo "tf-maccatalyst-check: $P does not exist" >&2; exit 3; }
34
+ grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$P" || continue
35
+ mac=0; win=0
36
+ grep -qE 'net[0-9.]+-maccatalyst' "$P" && mac=1
37
+ grep -qE 'net[0-9.]+-windows' "$P" && win=1
38
+ [[ $mac -eq 1 || $win -eq 1 ]] || continue
39
+ seen=$((seen+1)); D="$(dirname "$P")"; M="$D/Platforms/MacCatalyst"; found=0
40
+ # --- zoom in a Blazor Hybrid head (§9b): read before the Mac-only rules, which a Windows-only project skips
41
+ CODE=(-rqs --include='*.cs' --exclude-dir=bin --exclude-dir=obj)
42
+ if grep "${CODE[@]}" --include='*.xaml' -E '<BlazorWebView|new BlazorWebView' "$D" \
43
+ || grep -qs 'Microsoft.AspNetCore.Components.WebView.Maui' "$P"; then
44
+ heads=()
45
+ if [[ $mac -eq 1 ]] && ! { grep "${CODE[@]}" 'PageZoom' "$D" && grep "${CODE[@]}" '"Zoom In"' "$D"; }; then heads+=("Mac (no WKWebView.PageZoom and View › Zoom In)"); fi
46
+ if [[ $win -eq 1 ]] && ! grep "${CODE[@]}" 'IsZoomControlEnabled' "$D"; then heads+=("Windows (WebView2's zoom not handled)"); fi
47
+ if [[ ${#heads[@]} -eq 0 ]] && ! grep -rlis --include='*.cs' --exclude-dir=bin --exclude-dir=obj 'zoom' "$D" | xargs grep -ls 'Preferences' >/dev/null 2>&1; then heads+=("the level is not kept in Preferences"); fi
48
+ if [[ ${#heads[@]} -gt 0 ]]; then
49
+ echo "WARN $P: a Blazor Hybrid head without OS zoom: $(IFS=';'; echo "${heads[*]}" | sed 's/;/; /g'). Cmd/Ctrl+=, -, 0 must zoom 50% to 300% and keep the level; copy .tfcore/standards/blazor-zoom.cs.txt (coding-standards-dotnet.md §9b)"
50
+ found=1
51
+ fi
52
+ fi
53
+ if [[ $mac -eq 0 ]]; then
54
+ [[ $found -eq 0 ]] && echo "OK $P: zoom wired (no Mac Catalyst target)"
55
+ continue
56
+ fi
57
+ # --- the scene manifest and its delegate
58
+ PL="$M/Info.plist"
59
+ if [[ ! -f "$PL" ]]; then
60
+ echo "FAIL $P: $PL is missing"; fails=$((fails+1)); found=1
61
+ elif ! grep -q 'UIApplicationSceneManifest' "$PL"; then
62
+ echo "FAIL $P: $PL has no UIApplicationSceneManifest; on macOS 27 the app quits at its first window. Add the manifest and a SceneDelegate : MauiUISceneDelegate (coding-standards-dotnet.md §9)"
63
+ fails=$((fails+1)); found=1
64
+ else
65
+ # the delegate the manifest names: the <string> after UISceneDelegateClassName
66
+ DEL="$(tr -d '\n\r' < "$PL" | grep -oE '<key>UISceneDelegateClassName</key>[[:space:]]*<string>[^<]+' | head -1 | sed 's/.*<string>//')"
67
+ if [[ -n "$DEL" ]] && ! grep -rqsE "Register\(\"$DEL\"\)" "$D" --include='*.cs'; then
68
+ echo "FAIL $P: Info.plist names the scene delegate $DEL, and no class in the project carries [Register(\"$DEL\")] (a class deriving from MauiUISceneDelegate)"
69
+ fails=$((fails+1)); found=1
70
+ elif [[ -n "$DEL" ]] && ! grep -rqs 'MauiUISceneDelegate' "$D" --include='*.cs'; then
71
+ echo "FAIL $P: [Register(\"$DEL\")] is there, but no class derives from MauiUISceneDelegate"
72
+ fails=$((fails+1)); found=1
73
+ fi
74
+ fi
75
+ # --- the keychain permission on a locally signed build
76
+ ENTS=()
77
+ while IFS= read -r e; do [[ -n "$e" ]] && ENTS+=("$D/$(tr '\\' '/' <<<"$e")"); done < <(
78
+ grep -oE '<CodesignEntitlements[^>]*>[^<]+' "$P" | sed 's/<CodesignEntitlements[^>]*>//') # a Condition= one too
79
+ [[ ${#ENTS[@]} -eq 0 && -f "$M/Entitlements.plist" ]] && ENTS=("$M/Entitlements.plist")
80
+ with=0; without=0
81
+ for e in ${ENTS[@]+"${ENTS[@]}"}; do
82
+ [[ -f "$e" ]] || continue
83
+ if grep -q 'keychain-access-groups' "$e"; then with=1; else without=1; fi
84
+ done
85
+ if [[ $with -eq 1 && $without -eq 0 ]] && ! grep -q '<CodesignProvisioningProfile>' "$P"; then
86
+ echo "WARN $P: the entitlements ask for keychain-access-groups and the project names no provisioning profile; macOS will not start a locally signed build that asks for it. Sign with a development certificate and profile, or give Debug builds an entitlements file without it (coding-standards-dotnet.md §9)"
87
+ found=1
88
+ fi
89
+ [[ $found -eq 0 ]] && echo "OK $P: scene manifest and delegate in place; no keychain permission a local build cannot have; zoom wired or not a Blazor Hybrid head"
90
+ done
91
+ [[ $seen -eq 0 ]] && echo "NONE no MAUI project with a Mac Catalyst or Windows target"
92
+ [[ $fails -eq 0 ]]
@@ -0,0 +1,401 @@
1
+ // tf-menus.mjs — open every menu, click every item, and say which screens a click reaches (Chatur TF-011).
2
+ // Imported by tf-verify-screens.mjs and tf-mockup-parity.mjs; not run on its own.
3
+ //
4
+ // Both gates used to load each route directly and compare the closed page with its mockup. A closed
5
+ // menu bar matches its mockup, so File, Edit and View holding only "Nothing to do yet" passed, and
6
+ // Settings, Prerequisites and Repository passed though no click in the desktop app opened them.
7
+ //
8
+ // The mockup says what the menus are: a box marked data-tf-state="popup-open" is a menu or drop-down
9
+ // drawn open, and the nearest box around it with a data-testid is what opens it. For each one the app
10
+ // draws, the walk opens it and grades it against the mockup:
11
+ // - an item the mockup draws and the app lacks;
12
+ // - a menu whose every item is disabled where the mockup draws one enabled (a single disabled item
13
+ // is the state the app is in, no file open, and is only noted in the JSON);
14
+ // - placeholder text ("Nothing to do yet", "Coming soon", "Not built yet", "TODO", and any
15
+ // --placeholder-text a project adds) the mockup does not itself draw;
16
+ // - an enabled item that does nothing when clicked: no address, window, dialog, zoom, text or theme
17
+ // changed. A choice that is already the state (Light, in a light app) is clicked again after a
18
+ // sibling has moved the state, and the theme the walk changed is put back (Sevak TF-006).
19
+ // An item the mockup marks data-tf-sample is sample data and is not owed by its label; a pick-list (a
20
+ // select's options) is checked but not clicked; a trigger disabled in this state is passed over. A menu inside
21
+ // a box marked as another state (data-tf-state="mockup-only" …) is not owed at all. An item whose
22
+ // label ends the session or the work (Exit, Quit, Sign out, Log out, Close …, Delete, Remove, and any
23
+ // --no-click pattern) is checked but never clicked. A mockup that marks no menu still has the app's own menus opened: every
24
+ // [aria-haspopup="menu"|"true"] and every trigger in a [role=menubar], graded on placeholder text and on
25
+ // items that do nothing. A popup that is not a menu (a popover, role="group") is the box the trigger's
26
+ // aria-controls names.
27
+ // A menu item the mockup links to another mockup (<a href="prerequisites.html">) is the way into that
28
+ // screen. reachability() says which screens had such a way in and were reached by none of those clicks.
29
+
30
+ export const PLACEHOLDERS = ['Nothing to do yet', 'Coming soon', 'Not built yet', 'Not implemented yet', 'TODO'];
31
+ export const NO_CLICK = ['exit', 'quit', 'sign out', 'log out', 'logout', 'close', 'delete', 'remove', 'uninstall'];
32
+
33
+ // "Nothing to do yet" in any case; "TODO" only in capitals, so a board's "To do" column is not one
34
+ export const placeholderRe = (extra = []) => [...PLACEHOLDERS, ...extra].map((p) =>
35
+ new RegExp(`(^|[^A-Za-z])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^A-Za-z])`, p === p.toUpperCase() ? '' : 'i'));
36
+
37
+ export const norm = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
38
+ // the same item: one label is the other plus at most two short words (a shortcut: "Settings… Ctrl+,"),
39
+ // so "Run" is not "Run queue…"
40
+ const sameLabel = (a, b) => {
41
+ const [x, y] = [norm(a), norm(b)].sort((p, q) => p.length - q.length);
42
+ if (x.length < 2) return false;
43
+ if (x === y) return true;
44
+ if (!y.startsWith(x + ' ')) return false;
45
+ const extra = y.slice(x.length + 1).split(' ');
46
+ return extra.length <= 2 && extra.every((w) => w.length <= 4);
47
+ };
48
+
49
+ // --------------------------------------------------------------- the mockup's menus (in the page)
50
+ const READ_MOCK = (attr) => {
51
+ const ITEM = 'button, a[href], [role^="menuitem"], [role="option"]';
52
+ const label = (el) => {
53
+ const c = el.cloneNode(true);
54
+ c.querySelectorAll('.sub, kbd, .tick, [class*="shortcut"], svg, [data-shortcut]').forEach((x) => x.remove());
55
+ return (c.textContent || '').replace(/\s+/g, ' ').trim();
56
+ };
57
+ const out = [];
58
+ for (const p of document.querySelectorAll('[data-tf-state="popup-open"]')) {
59
+ if (p.parentElement && p.parentElement.closest('[data-tf-state="popup-open"]')) continue; // a submenu
60
+ if (p.parentElement && p.parentElement.closest('[data-tf-state]:not([data-tf-state="popup-open"]):not([data-tf-state="sample-data"]),[data-state-testid]')) continue;
61
+ const wrap = p.parentElement && p.parentElement.closest(`[${attr}]`);
62
+ const btn = wrap && [...wrap.querySelectorAll('button, [role="button"], [role="menuitem"]')].find((b) => !p.contains(b));
63
+ const items = [...p.querySelectorAll(ITEM)]
64
+ .filter((el) => !el.parentElement.closest(ITEM) || el.parentElement.closest(ITEM) === el)
65
+ .filter((el) => { const sub = el.closest('[data-tf-state="popup-open"]'); return sub === p; })
66
+ .map((el) => ({ label: label(el), disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true',
67
+ href: el.tagName === 'A' ? (el.getAttribute('href') || '') : '',
68
+ sample: !!el.closest('[data-tf-sample],[data-tf-state="sample-data"]') }))
69
+ .filter((x) => x.label);
70
+ out.push({ popup: p.getAttribute(attr) || '', trigger: wrap ? wrap.getAttribute(attr) : '', trigger_text: btn ? label(btn) : '',
71
+ text: (p.textContent || '').replace(/\s+/g, ' ').trim(), items });
72
+ }
73
+ return out;
74
+ };
75
+
76
+ export async function mockupMenus(page, attr = 'data-testid') {
77
+ try { return await page.evaluate(READ_MOCK, attr); } catch (e) { return []; }
78
+ }
79
+
80
+ // --------------------------------------------------------------- the app's side (in the page)
81
+ const MENU_SEL = '[role="menu"], [role="listbox"], [role="menubar"] [data-state="open"], [data-tf-menu-open]';
82
+ const APP_ITEMS = ({ popupId, attr, controls }) => {
83
+ const ITEM = '[role^="menuitem"], [role="option"], button, a[href]';
84
+ const vis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
85
+ // the popup by its name when the app carries it; any open menu only when it does not (the walk closes
86
+ // every menu before it opens one, so an open one is the one just opened)
87
+ let box = popupId ? document.querySelector(`[${attr}="${CSS.escape(popupId)}"]`) : null;
88
+ // a popover opened by an aria-haspopup trigger is no menu: it is the box the trigger's aria-controls names
89
+ if ((!box || !vis(box)) && controls) { const c = document.getElementById(controls); if (c && vis(c)) { box = c; c.setAttribute('data-tf-walk-popup', ''); } }
90
+ if (!box) box = [...document.querySelectorAll('[role="menu"], [role="listbox"]')].filter(vis).pop() || null;
91
+ if (!box || !vis(box)) return null;
92
+ document.querySelectorAll('[data-tf-walk]').forEach((el) => el.removeAttribute('data-tf-walk')); // an earlier menu's marks
93
+ const text = (el) => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
94
+ const items = [...box.querySelectorAll(ITEM)].filter(vis)
95
+ .filter((el) => { const up = el.parentElement && el.parentElement.closest(ITEM); return !up || !box.contains(up) || up === box; })
96
+ .map((el, i) => {
97
+ el.setAttribute('data-tf-walk', String(i));
98
+ // the value already chosen does nothing when chosen again, so it is never "an item that does nothing"
99
+ const checked = el.getAttribute('aria-checked') === 'true' || el.getAttribute('aria-selected') === 'true' || el.getAttribute('data-state') === 'checked'
100
+ || !!el.querySelector('svg[class*="check"], [data-icon="check"], [data-lucide="check"]') || /✓|✔/.test(el.textContent || '');
101
+ // disabled by attribute, or by taking no pointer events at all (TrBlazeUI's menu items set only
102
+ // pointer-events:none and half opacity, with no aria-disabled)
103
+ return { i, label: text(el), disabled: el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('data-disabled')
104
+ || getComputedStyle(el).pointerEvents === 'none',
105
+ checkbox: /checkbox/.test(el.getAttribute('role') || '') || (el.hasAttribute('aria-checked') && !/radio/.test(el.getAttribute('role') || '')), checked };
106
+ });
107
+ const choice = box.getAttribute('role') === 'listbox' || (items.length > 0 && [...box.querySelectorAll('[data-tf-walk]')].every((e) => e.getAttribute('role') === 'option'));
108
+ return { found_by: box.getAttribute(attr) === popupId ? 'testid' : 'role', text: text(box), items, choice };
109
+ };
110
+
111
+ // What a click can change, menus left out: the address, the text a reader sees, open dialogs, the zoom,
112
+ // the theme (light/dark classes and data-*theme attributes on <html> and <body>, and what they paint).
113
+ const SIGNATURE = (menuSel) => {
114
+ const inMenu = (n) => { const e = n.nodeType === 1 ? n : n.parentElement; return !!(e && e.closest(menuSel + ', [data-tf-walk-popup]')); };
115
+ const parts = [];
116
+ const w = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
117
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
118
+ const t = n.textContent.trim();
119
+ if (!t || inMenu(n)) continue;
120
+ const e = n.parentElement;
121
+ if (e && e.checkVisibility && !e.checkVisibility({ visibilityProperty: true, opacityProperty: true })) continue;
122
+ parts.push(t);
123
+ }
124
+ let h = 0; const s = parts.join('\u0001');
125
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
126
+ const dialogs = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open]')]
127
+ .filter((d) => { const r = d.getBoundingClientRect(); return r.width > 0 && r.height > 0; }).length;
128
+ const ids = [...document.querySelectorAll('[data-testid]')].filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && !inMenu(e); })
129
+ .map((e) => e.getAttribute('data-testid')).slice(0, 400);
130
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
131
+ const look = [document.documentElement, document.body].filter(Boolean).map((el) => {
132
+ const cs = getComputedStyle(el);
133
+ return [[...el.classList].filter((c) => MODE.test(c)).sort().join(' '),
134
+ [...el.attributes].filter((a) => /^data-.*(theme|mode|scheme)/i.test(a.name)).map((a) => `${a.name}=${a.value}`).sort().join(' '),
135
+ cs.backgroundColor, cs.colorScheme].join('|');
136
+ }).join('||');
137
+ return { url: location.href, text: h, len: s.length, dialogs, zoom: `${window.devicePixelRatio}|${getComputedStyle(document.documentElement).zoom || ''}|${document.body ? document.body.style.zoom : ''}`, look, ids };
138
+ };
139
+ const changed = (a, b) => a.url !== b.url || a.text !== b.text || a.dialogs !== b.dialogs || a.zoom !== b.zoom || a.look !== b.look;
140
+ const effectOf = (before, after) => !before || !after ? 'unknown' : before.url !== after.url ? 'navigated' : before.dialogs !== after.dialogs ? 'opened a dialog'
141
+ : before.zoom !== after.zoom ? 'zoomed' : before.look !== after.look ? 'changed the theme' : before.text !== after.text ? 'changed the page' : 'nothing';
142
+
143
+ // A point on plain text, outside every menu, list, dialog and control, to click a menu shut.
144
+ const NEUTRAL = () => {
145
+ const CTRL = 'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="tab"], [role="link"], [role="option"], [role="combobox"], [role="menu"], [role="listbox"], [role="dialog"], [onclick], [tabindex]';
146
+ const W = window.innerWidth, H = window.innerHeight;
147
+ for (let y = 0.15; y < 1; y += 0.1) for (let x = 0.05; x < 1; x += 0.1) {
148
+ const e = document.elementFromPoint(W * x, H * y);
149
+ if (!e || e === document.documentElement || e.closest(CTRL)) continue;
150
+ return { x: Math.round(W * x), y: Math.round(H * y) };
151
+ }
152
+ return null;
153
+ };
154
+
155
+ // --------------------------------------------------------------- the walk
156
+ // page: the app tab on a screen that draws these menus. o.back(): reopens that screen as it was.
157
+ // o.pages(): every app tab/window open now (a desktop head's new window, a web app's new tab).
158
+ // Returns { menus: [...what each menu held and did...], findings: [{detail, class, popup}], clicks: [...] }
159
+ export async function walkMenus(page, mockMenus, o = {}) {
160
+ const attr = o.attr || 'data-testid';
161
+ const settle = o.settle ?? 1200;
162
+ const pr = placeholderRe(o.placeholders || []);
163
+ const noClick = [...NO_CLICK, ...(o.noClick || [])].map((x) => norm(x));
164
+ const pagesNow = o.pages || (async () => page.context().pages());
165
+ const findings = [], clicks = [], menus = [];
166
+ // o.mockText: the mockup's own text, so a placeholder the design itself draws is not held against the app
167
+ const placeholderIn = (t) => pr.find((re) => re.test(t) && !re.test(o.mockText || ''));
168
+ const says = (m) => m.trigger_text || m.trigger || m.popup || 'a menu';
169
+
170
+ let list = (mockMenus || []).filter((m) => m.items.length);
171
+ const fromMockup = list.length > 0;
172
+ if (!fromMockup) {
173
+ // the app's own menus, when the mockup marks none
174
+ const found = await page.evaluate(({ attr }) => {
175
+ const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
176
+ const t = [...document.querySelectorAll('[aria-haspopup="menu"], [aria-haspopup="true"], [role="menubar"] [role="menuitem"], [role="menubar"] button')].filter(vis);
177
+ return t.map((el, i) => { el.setAttribute('data-tf-walk-trigger', String(i)); return { trigger: '', trigger_walk: String(i), trigger_text: (el.innerText || el.getAttribute('aria-label') || '').trim().slice(0, 40), popup: '', items: [] }; });
178
+ }, { attr }).catch(() => []);
179
+ list = found;
180
+ }
181
+
182
+ // Opens one menu. Returns its items, or why not: { missing } (the render check reports a missing
183
+ // control; a screen in another state, with no project open, draws no such trigger), { disabled } (a
184
+ // trigger disabled in this state: not a finding), { opened: false }. When the trigger is gone because the
185
+ // last click moved the screen on, the screen is reopened once.
186
+ const findTrigger = async (m) => {
187
+ let trig = m.trigger_walk ? page.locator(`[data-tf-walk-trigger="${m.trigger_walk}"]`) : m.trigger ? page.locator(`[${attr}="${m.trigger}"]`) : null;
188
+ if (!m.trigger && !m.trigger_walk && m.trigger_text) trig = page.getByRole('menuitem', { name: m.trigger_text, exact: true }).or(page.getByRole('button', { name: m.trigger_text, exact: true }));
189
+ return trig && (await trig.count()) ? trig.first() : null;
190
+ };
191
+ // Escape does not close every menu bar (TrBlazeUI's stays open, and so does it on a click in an empty
192
+ // corner), so: Escape, then a click on plain text outside any menu or control, then the trigger
193
+ // again, and last the screen reopened, until no menu or list is open
194
+ const anyOpen = () => page.evaluate(() => [...document.querySelectorAll('[role="menu"], [role="listbox"], [data-tf-walk-popup]')]
195
+ .some((e) => { const r = e.getBoundingClientRect(); const c = getComputedStyle(e); return r.width > 0 && r.height > 0 && c.visibility !== 'hidden'; })).catch(() => false);
196
+ const shut = async (m) => {
197
+ for (let k = 0; k < 3 && await anyOpen(); k++) {
198
+ if (k === 0) await page.keyboard.press('Escape').catch(() => {});
199
+ else if (k === 1) { const pt = await page.evaluate(NEUTRAL).catch(() => null); if (pt) await page.mouse.click(pt.x, pt.y).catch(() => {}); }
200
+ else if (m) { const t = await findTrigger(m); if (t) await t.click({ timeout: 2000 }).catch(() => {}); }
201
+ await page.waitForTimeout(200);
202
+ }
203
+ // one that will not close (a TrBlazeUI menu after an item that did nothing) goes with the screen
204
+ if (o.back && await anyOpen()) await o.back();
205
+ };
206
+ const open = async (m, retry = true) => {
207
+ await shut(m);
208
+ let trig = await findTrigger(m);
209
+ if (!trig && retry && o.back) { await o.back(); trig = await findTrigger(m); }
210
+ if (!trig) return { missing: true };
211
+ const inner = trig.locator('button, [role="button"], [role="menuitem"], [role="combobox"]').first();
212
+ const tries = [trig];
213
+ if (await inner.count()) tries.push(inner);
214
+ // a trigger that is itself disabled, or whose only button is, cannot be opened in this state
215
+ const off = async (l) => l.evaluate((e) => e.matches(':disabled') || e.getAttribute('aria-disabled') === 'true').catch(() => false);
216
+ if (await off(trig) || (tries.length > 1 && await off(inner))) return { disabled: true };
217
+ // a trigger clicked before the page is interactive, or toggled open and shut by one click, is
218
+ // clicked again: three times at most
219
+ for (const t of [...tries, tries[0], tries[0]]) {
220
+ await t.click({ timeout: 3000 }).catch(() => {});
221
+ await page.waitForTimeout(250);
222
+ m.controls = (await t.getAttribute('aria-controls').catch(() => null)) || (await trig.getAttribute('aria-controls').catch(() => null)) || m.controls || '';
223
+ for (let k = 0; k < 8; k++) {
224
+ const items = await page.evaluate(APP_ITEMS, { popupId: m.popup, attr, controls: m.controls }).catch(() => null);
225
+ if (items && items.items.length) return items;
226
+ await page.waitForTimeout(150);
227
+ }
228
+ }
229
+ return { opened: false };
230
+ };
231
+ // the item with that label in the menu as it is drawn now: a menu that redraws itself when it opens
232
+ // (Blazor re-rendering on the trigger's pointerdown) has dropped the marks the first reading put on it
233
+ const current = async (m, label) => {
234
+ await page.waitForTimeout(200);
235
+ const now = await page.evaluate(APP_ITEMS, { popupId: m.popup, attr, controls: m.controls }).catch(() => null);
236
+ return now && now.items.find((x) => x.label === label);
237
+ };
238
+ // one item clicked from a freshly opened menu, for the second look and the putting back below
239
+ const press = async (m, label) => {
240
+ const again = await open(m);
241
+ const fresh = again.items && await current(m, label);
242
+ if (!fresh || fresh.disabled) { await shut(m); return 'menu did not reopen'; }
243
+ const before = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
244
+ await page.locator(`[data-tf-walk="${fresh.i}"]`).first().click({ timeout: 3000 }).catch(() => {});
245
+ let after = null;
246
+ for (let t = 0; t < settle; t += 150) {
247
+ await page.waitForTimeout(150);
248
+ after = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
249
+ if (before && after && changed(before, after)) break;
250
+ }
251
+ await page.keyboard.press('Escape').catch(() => {});
252
+ await shut(m);
253
+ return effectOf(before, after);
254
+ };
255
+
256
+ for (const m of list) {
257
+ const name = says(m);
258
+ const got = await open(m);
259
+ const rec = { popup: m.popup, trigger: m.trigger || m.trigger_text, mockup_items: m.items.length, items: [] };
260
+ menus.push(rec);
261
+ if (got.missing) { rec.result = 'no trigger on the page (the render check reports a missing control)'; continue; }
262
+ if (got.disabled) { rec.result = 'trigger disabled in this state'; continue; }
263
+ if (got.opened === false) {
264
+ findings.push({ class: 'menu', popup: m.popup, detail: `the menu "${name}" opens nothing when clicked` });
265
+ rec.result = 'did not open'; continue;
266
+ }
267
+ rec.items = got.items.map((x) => ({ label: x.label, disabled: x.disabled }));
268
+ const ph = placeholderIn(got.text);
269
+ if (ph) findings.push({ class: 'placeholder', popup: m.popup, detail: `the menu "${name}" shows placeholder text "${got.text.match(ph)[0].trim()}"` });
270
+ // what the mockup owes. One item disabled is the state the app is in (no file open, nothing to
271
+ // redo), so it is noted; a menu whose every item is disabled where the mockup draws one enabled is
272
+ // a menu that does nothing, which is a finding.
273
+ const offVsMock = [];
274
+ for (const want of m.items) {
275
+ if (want.sample) continue;
276
+ const have = got.items.find((x) => sameLabel(x.label, want.label));
277
+ // drawn as a heading in the menu rather than an item ("Recent projects" over the list) is drawn
278
+ if (!have && norm(want.label).length >= 6 && ` ${norm(got.text)} `.includes(` ${norm(want.label)} `)) continue;
279
+ if (!have) findings.push({ class: 'menu', popup: m.popup, detail: `the menu "${name}" has no "${want.label}" item, which the mockup draws` });
280
+ else if (have.disabled && !want.disabled) offVsMock.push(have.label);
281
+ }
282
+ if (offVsMock.length) rec.disabled_where_mockup_enabled = offVsMock;
283
+ if (got.items.every((x) => x.disabled) && m.items.some((x) => !x.disabled))
284
+ findings.push({ class: 'menu', popup: m.popup, detail: `every item of the menu "${name}" is disabled; the mockup draws ${m.items.filter((x) => !x.disabled).length} of them enabled` });
285
+ // a pick-list (a select's options) chooses a value; its items are the data, not commands to click
286
+ if (got.choice) { rec.result = 'pick-list: items checked, not clicked'; await shut(m); continue; }
287
+ const startLook = ((await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null)) || {}).look;
288
+ const mine = [];
289
+ // click every enabled item, one at a time, from a freshly opened menu
290
+ for (const item of got.items) {
291
+ const want = m.items.find((x) => sameLabel(item.label, x.label));
292
+ const c = { popup: m.popup, menu: name, label: item.label, href: want ? want.href : '', disabled: item.disabled };
293
+ clicks.push(c); mine.push({ c, item });
294
+ if (item.disabled) { c.effect = 'disabled'; continue; }
295
+ if (noClick.some((n) => norm(item.label) === n || norm(item.label).startsWith(n + ' '))) { c.effect = 'not clicked (ends the session or deletes)'; continue; }
296
+ const again = await open(m);
297
+ const fresh = again.items && await current(m, item.label);
298
+ if (!fresh) { c.effect = 'menu did not reopen'; continue; }
299
+ if (fresh.disabled) { c.effect = 'disabled'; await shut(m); continue; }
300
+ const before = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
301
+ const pagesBefore = new Set(await pagesNow());
302
+ const events = [];
303
+ const onPage = () => events.push('window'); const onDl = () => events.push('download'); const onFc = () => events.push('file chooser'); const onDg = (d) => { events.push('dialog'); d.dismiss().catch(() => {}); };
304
+ page.context().on('page', onPage); page.on('download', onDl); page.on('filechooser', onFc); page.on('dialog', onDg);
305
+ await page.locator(`[data-tf-walk="${fresh.i}"]`).first().click({ timeout: 3000 }).catch((e) => { c.error = e.message.split('\n')[0]; });
306
+ let after = null, newPages = [];
307
+ for (let t = 0; t < settle; t += 150) {
308
+ await page.waitForTimeout(150);
309
+ newPages = (await pagesNow()).filter((p) => !pagesBefore.has(p));
310
+ after = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
311
+ if (newPages.length || events.length || (before && after && changed(before, after))) break;
312
+ }
313
+ page.context().off('page', onPage); page.off('download', onDl); page.off('filechooser', onFc); page.off('dialog', onDg);
314
+ // where it landed: a new window or tab, or this one
315
+ let landed = null;
316
+ for (const np of newPages) {
317
+ await np.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => {});
318
+ await np.waitForTimeout(settle).catch(() => {});
319
+ landed = { url: np.url(), ids: (await np.evaluate(SIGNATURE, MENU_SEL).catch(() => ({ ids: [] }))).ids, window: true };
320
+ }
321
+ if (!landed && after) landed = { url: after.url, ids: after.ids, window: false };
322
+ c.landed = landed ? { url: landed.url, window: landed.window, ids: landed.ids } : null;
323
+ c.ids_before = before ? before.ids : [];
324
+ c.effect = c.error ? 'could not be clicked' : newPages.length ? 'opened a window' : events.length ? events.join(', ') : effectOf(before, after);
325
+ if (c.error) findings.push({ class: 'menu', popup: m.popup, detail: `"${name} › ${item.label}" could not be clicked (${c.error.slice(0, 80)})` });
326
+ // put the app back: a new window closed (a web tab only; a desktop head's window is the owner's),
327
+ // a dialog dismissed, a toggled item toggled back, the screen reopened when the address moved
328
+ if (!o.keepWindows) for (const np of newPages) await np.close().catch(() => {});
329
+ await page.keyboard.press('Escape').catch(() => {}); // a dialog the item opened
330
+ await shut(m);
331
+ if (item.checkbox && c.effect !== 'nothing') {
332
+ const back = await open(m);
333
+ const b = back.items && await current(m, item.label);
334
+ if (b) { await page.locator(`[data-tf-walk="${b.i}"]`).first().click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(300); }
335
+ await shut(m);
336
+ }
337
+ const now = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
338
+ if (o.back && (!now || !before || now.url !== before.url || now.dialogs > before.dialogs)) await o.back();
339
+ }
340
+ // a choice that was already the state (Light in a light app) shows nothing: when a sibling moved the
341
+ // theme or the page in place, that sibling is clicked first and the choice looked at again
342
+ const donor = mine.find((x) => !x.item.checkbox && /^changed the (theme|page)$/.test(x.c.effect || ''));
343
+ for (const { c, item } of mine) {
344
+ if (c.effect !== 'nothing' || item.checked) continue;
345
+ if (donor && donor.item.label !== item.label && !/^menu did not reopen/.test(await press(m, donor.item.label))) {
346
+ const second = await press(m, item.label);
347
+ if (second !== 'nothing' && !/^menu did not reopen|^unknown/.test(second)) { c.effect = `${second} (once "${donor.item.label}" had moved the state)`; continue; }
348
+ }
349
+ findings.push({ class: 'menu', popup: m.popup, detail: `"${name} › ${item.label}" does nothing when clicked` });
350
+ }
351
+ // the theme put back as the walk found it: an attached desktop head is the viewer's app
352
+ if (startLook) {
353
+ const themed = mine.filter((x) => /^changed the theme/.test(x.c.effect || '')).map((x) => x.item);
354
+ const order = [...themed.filter((x) => x.checked), ...themed.filter((x) => !x.checked)];
355
+ for (const it of order) {
356
+ if (((await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null)) || {}).look === startLook) break;
357
+ await press(m, it.label);
358
+ }
359
+ }
360
+ await shut(m);
361
+ }
362
+ return { from_mockup: fromMockup, menus, findings, clicks };
363
+ }
364
+
365
+ // Text a placeholder screen shows: the deny list, less what the mockup itself draws.
366
+ export async function placeholdersOnPage(page, mockText = '', extra = []) {
367
+ const text = await page.evaluate(() => (document.body ? document.body.innerText : '') || '').catch(() => '');
368
+ return placeholderRe(extra).filter((re) => re.test(text) && !re.test(mockText)).map((re) => text.match(re)[0].replace(/^[^A-Za-z]|[^A-Za-z]$/g, '').trim());
369
+ }
370
+
371
+ // --------------------------------------------------------------- the way into each screen
372
+ // screens: [{ name, route, mockup, anchors }]. clicks: from every walk. A screen has a way in when a
373
+ // menu item the mockup draws links to its mockup file. It is reached when one of those clicks landed on
374
+ // its route, or on a page showing at least half of its anchors that were not showing before the click.
375
+ // A screen whose every way in was left unclicked (Sign out) is not judged.
376
+ export function reachability(screens, clicks) {
377
+ const base = (p) => String(p || '').split(/[?#]/)[0].split('/').pop();
378
+ const routeRe = (r) => new RegExp('^' + String(r || '').split('?')[0].replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\?\{[^}]*\}/g, '[^/]+').replace(/\/$/, '') + '/?$', 'i');
379
+ const out = {};
380
+ for (const s of screens) {
381
+ if (!s.mockup) continue;
382
+ const ways = clicks.filter((c) => c.href && base(c.href) === base(s.mockup));
383
+ if (!ways.length) continue;
384
+ const tried = ways.filter((c) => c.effect && !/^not clicked|^menu did not reopen/.test(c.effect));
385
+ if (!tried.length) continue;
386
+ const re = routeRe(s.route_pattern || s.route);
387
+ const want = (s.anchors || []).filter((a) => !(s.state_only || []).includes(a));
388
+ const hit = tried.find((c) => {
389
+ if (!c.landed) return false;
390
+ let path = '';
391
+ try { path = new URL(c.landed.url).pathname; } catch (e) { path = ''; }
392
+ if (path && re.test(path) && c.effect !== 'nothing') return true;
393
+ if (!want.length) return false;
394
+ const fresh = want.filter((a) => (c.landed.ids || []).includes(a) && !(c.landed.window ? [] : c.ids_before || []).includes(a));
395
+ return fresh.length >= Math.ceil(want.length / 2);
396
+ });
397
+ out[s.name] = hit ? { reached: true, by: `${hit.menu} › ${hit.label}` }
398
+ : { reached: false, tried: tried.map((c) => `${c.menu} › ${c.label} (${c.effect}${c.landed ? `, at ${(() => { try { return new URL(c.landed.url).pathname; } catch (e) { return c.landed.url; } })()}` : ''})`) };
399
+ }
400
+ return out;
401
+ }