@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
@@ -2,39 +2,218 @@
2
2
  # tf-verify-tests.sh — run the acceptance tests and map them to rows (Sitting 4c, 2026-09-06).
3
3
  #
4
4
  # bash .tfcore/utils/tf-verify-tests.sh [--base URL] [--target <sln|csproj>] [--no-browser] [--no-unit]
5
+ # [--shard N/M] [--spec <file or glob>]… [--list <list.json>] [--all-specs]
5
6
  # [--json-out tests/.artifacts/verify/tests.json]
7
+ # bash .tfcore/utils/tf-verify-tests.sh --merge <tests-a.json> <tests-b.json>… [--json-out tests/.artifacts/verify/tests.json]
6
8
  #
7
9
  # Browser tests: `npx playwright test` over tests/verify/ (the JSON reporter). Unit tests:
8
- # `tf-build.sh test` with normal console verbosity. A test belongs to a row when its name contains
9
- # the row's id (REQ-UI-004 …). A row is PASS when a test carrying its id passed and none failed,
10
+ # `tf-build.sh test` with normal console verbosity. A test belongs to a row when its name STARTS with
11
+ # the row's id (REQ-UI-004 …; tf-req-ids.py): an id later in the name, or in a theory case's arguments,
12
+ # is what the test is about, not what it proves, and a row only named that way is listed under
13
+ # named_only_by_other_rows_tests (Chatur TF-012). A row is PASS when a test carrying its id passed and none failed,
10
14
  # FAIL when one failed (the reason and the screenshot, when the reporter kept one, are recorded),
11
15
  # NOT-TESTED when every test carrying its id was SKIPPED, and absent when no test carries its id.
12
16
  # A skipped clause is a third outcome, never a failure: `test.skip(!SEEDED, …)` says the state does
13
17
  # not exist in the data, which is not a defect (TF-022). Writes tests/.artifacts/verify/tests.json.
18
+ # A suite too long for one command runs in parts (AppManager TF-013): `--shard N/M` runs Playwright's
19
+ # shard N of M (browser only; run the unit tests once with --no-browser), `--spec` names files, and
20
+ # each part writes its own playwright-<part>.json and tests-<part>.json. `--merge` then combines the
21
+ # parts' rows and totals into one tests.json; a later run of the same test stands for it (Lekhak TF-007).
22
+ # When tf-verify-list.sh was given a list of ids, the browser run keeps only the tests carrying them
23
+ # (`--grep`), and is skipped when no row has a screen and no browser test names one (Lekhak TF-017).
24
+ # `--list` names another list.json; `--all-specs` runs every browser test whatever the scope.
25
+ # A desktop head's debugging address (--base answering /json/version, or boot.json mode=cdp) also
26
+ # reaches the tests as CDP_URL; a suite that reads it nowhere is refused, not run (Lekhak TF-021).
27
+ # A Mac Catalyst head (boot.json mode=appium) reaches them as APPIUM_URL, TF_BUNDLE_ID and TF_APP_PATH.
14
28
  # Exit 0 ran (whatever the results) · 2 nothing could run.
15
29
  set -uo pipefail
16
30
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
- BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT="tests/.artifacts/verify/tests.json"
31
+ source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
32
+ BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT=""; SHARD=""; SPECS=(); MERGE=(); LIST="tests/.artifacts/verify/list.json"; ALLSPECS=0
18
33
  while [[ $# -gt 0 ]]; do
19
34
  case "$1" in
20
35
  --base) BASE="${2:-}"; shift 2 ;;
21
36
  --target) TARGET="${2:-}"; shift 2 ;;
22
37
  --no-browser) BROWSER=0; shift ;;
23
38
  --no-unit) UNIT=0; shift ;;
39
+ --shard) SHARD="${2:-}"; UNIT=0; shift 2 ;;
40
+ --spec) SPECS+=("${2:-}"); shift 2 ;;
41
+ --list) LIST="${2:-}"; shift 2 ;;
42
+ --all-specs) ALLSPECS=1; shift ;;
43
+ --merge) shift; while [[ $# -gt 0 && "$1" != --* ]]; do MERGE+=("$1"); shift; done ;;
24
44
  --json-out) OUT="${2:-}"; shift 2 ;;
25
- -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
45
+ -h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
46
  *) echo "tf-verify-tests: unknown argument $1" >&2; exit 2 ;;
27
47
  esac
28
48
  done
49
+ PART=""
50
+ if [[ -n "$SHARD" ]]; then
51
+ [[ "$SHARD" =~ ^([1-9][0-9]*)/([1-9][0-9]*)$ ]] || { echo "tf-verify-tests: --shard takes N/M, such as 2/4" >&2; exit 2; }
52
+ PART="${BASH_REMATCH[1]}of${BASH_REMATCH[2]}"
53
+ fi
54
+ [[ -n "$OUT" ]] || OUT="tests/.artifacts/verify/tests${PART:+-$PART}.json"
29
55
  mkdir -p "$(dirname "$OUT")" tests/.artifacts/verify
30
- PWJSON="tests/.artifacts/verify/playwright.json"; PWLOG="tests/.artifacts/verify/playwright.log"; UNITLOG=""; UNITLINE=""
56
+
57
+ # ---- --merge: the parts into one tests.json, no test run ------------------------------------------
58
+ if [[ ${#MERGE[@]} -gt 0 ]]; then
59
+ TF_HERE="$HERE" TF_OUT="$OUT" python3 - "${MERGE[@]}" <<'PY'
60
+ import json, os, re, sys
61
+ import importlib.util
62
+ _spec = importlib.util.spec_from_file_location("tf_req_ids", os.path.join(os.environ.get("TF_HERE", ".tfcore/utils"), "tf-req-ids.py"))
63
+ rq = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(rq)
64
+ # Lekhak TF-007. The parts are read oldest first (by the time each ran, else in the order given), and
65
+ # a later run of the SAME test stands for it: a test that failed because its service was not started,
66
+ # run again once it was, clears the row. A later skip never replaces a run that happened. A part
67
+ # written before tests carried their own outcome counts each of its tests as the row's result.
68
+ loaded = []
69
+ for i, p in enumerate(sys.argv[1:]):
70
+ try:
71
+ loaded.append((json.load(open(p, encoding="utf-8")), i, p))
72
+ except Exception as e:
73
+ print(f"merge: {p} unreadable ({str(e)[:80]}); skipped")
74
+ loaded.sort(key=lambda x: (x[0].get("ran_at") or "", x[1]) if all(y[0].get("ran_at") for y in loaded) else (x[1],))
75
+ reqs, browser, unit, parts = {}, {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}, {"ran": False, "line": ""}, []
76
+ outs, sources, superseded, elsewhere = {}, {}, 0, {}
77
+ for d, _i, p in loaded:
78
+ parts.append(p)
79
+ for rid, r in d.get("reqs", {}).items():
80
+ oc = r.get("outcomes")
81
+ if oc is None: # an older part: every test it lists carries the row's result
82
+ res = {"PASS": "pass", "FAIL": "fail"}.get(r.get("result"), "skip")
83
+ oc = {t: {"outcome": res, "reason": r.get("reason", "") if res == "fail" else "", "screenshot": r.get("screenshot", "")} for t in r.get("tests", [])}
84
+ oc.update({t: {"outcome": "skip", "reason": r.get("reason", ""), "screenshot": ""} for t in r.get("skipped", [])})
85
+ o = outs.setdefault(rid, {})
86
+ for name, rec in oc.items():
87
+ # a part read before Chatur TF-012 counted a test for every id its name carried: a test that
88
+ # does not claim this row is dropped here, and the row noted if that test passed
89
+ # (a browser test's name was stored with its describe titles joined on, so there each title is read)
90
+ segs = re.split(r"\s+(?=REQ-)", name) if rec.get("source", r.get("source")) == "browser" else [name]
91
+ if not any(rid in rq.ids_of(seg) for seg in segs):
92
+ if rec.get("outcome") == "pass":
93
+ elsewhere.setdefault(rid, []).append(name[:160])
94
+ continue
95
+ # a part written before outcomes carried their source: the row's, when it had only one
96
+ if "source" not in rec:
97
+ rec = dict(rec, source=r.get("source") if r.get("source") in ("browser", "unit") else "")
98
+ old = o.get(name)
99
+ if old and rec["outcome"] == "skip" and old["outcome"] != "skip":
100
+ continue
101
+ if old and old["outcome"] != rec["outcome"]:
102
+ superseded += 1
103
+ o[name] = rec
104
+ src = sources.get(rid)
105
+ sources[rid] = r.get("source", "") if src in (None, r.get("source", "")) else "browser+unit"
106
+ b = d.get("browser", {})
107
+ if b.get("ran"):
108
+ browser["ran"] = True
109
+ for k in ("passed", "failed", "skipped", "tests"):
110
+ browser[k] += b.get(k, 0)
111
+ u = d.get("unit", {})
112
+ if u.get("ran") and not unit["ran"]:
113
+ unit = u
114
+ for d, _i, p in loaded:
115
+ for rid, names in (d.get("named_only_by_other_rows_tests") or {}).items():
116
+ elsewhere.setdefault(rid, []).extend(names)
117
+ for rid, o in outs.items():
118
+ if not o:
119
+ continue
120
+ fails = [(n, x) for n, x in o.items() if x["outcome"] == "fail"]
121
+ npass = sum(1 for x in o.values() if x["outcome"] == "pass")
122
+ skips = [(n, x) for n, x in o.items() if x["outcome"] == "skip"]
123
+ result = "FAIL" if fails else "PASS" if npass else "NOT-TESTED"
124
+ reason = fails[0][1]["reason"] if fails else (skips[0][1]["reason"] if skips and not npass else "")
125
+ # Sevak TF-004: a unit pass does not stand in for a row's on-app tests when they were all skipped
126
+ app = [x for x in o.values() if x.get("source") == "browser"]
127
+ if result == "PASS" and app and all(x["outcome"] == "skip" for x in app):
128
+ why = next((x["reason"] for x in app if x["reason"]), "the test was skipped")
129
+ result = "NOT-TESTED"
130
+ reason = ("on-app test skipped (" + why + "); a unit test does not measure on-app acceptance")[:200]
131
+ reqs[rid] = {"result": result, "source": sources.get(rid, ""), "tests": [n for n, x in o.items() if x["outcome"] != "skip"],
132
+ "skipped": [n for n, _ in skips], "passed": npass, "failed": len(fails), "reason": reason,
133
+ "screenshot": fails[0][1].get("screenshot", "") if fails else "", "outcomes": o}
134
+ mentioned = {rid: sorted(set(v))[:3] for rid, v in elsewhere.items() if rid not in reqs}
135
+ json.dump({"reqs": reqs, "browser": browser, "unit": unit, "merged_from": parts, "superseded": superseded,
136
+ "named_only_by_other_rows_tests": mentioned},
137
+ open(os.environ["TF_OUT"], "w"), indent=1)
138
+ p = sum(1 for r in reqs.values() if r["result"] == "PASS"); f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
139
+ ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
140
+ print(f"merged {len(parts)} part(s): rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
141
+ + (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else "")
142
+ + (f"; {superseded} test(s) ran again in a later part, and the later run stands" if superseded else ""))
143
+ for rid, r in sorted(reqs.items()):
144
+ if r["result"] == "FAIL":
145
+ print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
146
+ for rid, names in sorted(mentioned.items()):
147
+ print(f" NO TEST {rid} — only named inside another row's passing test, which does not test it: {names[0]}")
148
+ print(f"JSON: {os.environ['TF_OUT']}")
149
+ PY
150
+ exit 0
151
+ fi
152
+
153
+ source "$HERE/tf-lock.sh"; tf_take_lock "verify-tests${PART:+ $PART}" # one browser check at a time (TF-048)
154
+ PWJSON="tests/.artifacts/verify/playwright${PART:+-$PART}.json"; PWLOG="tests/.artifacts/verify/playwright${PART:+-$PART}.log"; UNITLOG=""; UNITLINE=""
31
155
  ran_any=0
32
156
 
33
157
  if [[ $BROWSER -eq 1 ]]; then
158
+ # A desktop head is reached over its debugging address (tf-verify-boot.sh prints mode=cdp). The tests
159
+ # get it as CDP_URL as well as BASE_URL: Lekhak's helper fell back to its own default port and every
160
+ # desktop test failed after ten minutes (Lekhak TF-021). --base is that address when it answers
161
+ # /json/version; without --base, boot.json's url when its mode is cdp.
162
+ CDPURL=""
163
+ if [[ -n "$BASE" ]]; then
164
+ curl -s -m 3 "${BASE%/}/json/version" 2>/dev/null | grep -q webSocketDebuggerUrl && CDPURL="${BASE%/}"
165
+ elif [[ -f tests/.artifacts/verify/boot.json ]]; then
166
+ CDPURL="$(python3 -c 'import json,sys
167
+ b = json.load(open(sys.argv[1]))
168
+ print(b.get("url", "") if b.get("mode") == "cdp" and not b.get("stopped") else "")' tests/.artifacts/verify/boot.json 2>/dev/null)"
169
+ [[ -n "$CDPURL" ]] && BASE="$CDPURL"
170
+ fi
171
+ # A Mac Catalyst head (boot.json mode=appium) is reached through Appium: its tests get APPIUM_URL
172
+ # TF_BUNDLE_ID and TF_APP_PATH, and open their session with .tfcore/utils/tf-appium.mjs.
173
+ APPIUMURL=""; BUNDLEID=""; APPPATH=""
174
+ [[ -f tests/.artifacts/verify/boot.json ]] && read -r APPIUMURL BUNDLEID APPPATH < <(python3 -c 'import json,sys
175
+ b = json.load(open(sys.argv[1]))
176
+ print(b.get("url", ""), b.get("bundle_id", ""), b.get("app_path", "")) if b.get("mode") == "appium" and not b.get("stopped") else print()' tests/.artifacts/verify/boot.json 2>/dev/null)
34
177
  if ls tests/verify/*.spec.* >/dev/null 2>&1 || ls tests/verify/**/*.spec.* >/dev/null 2>&1; then
35
- if node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
36
- BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test --reporter=json > "$PWLOG" 2>&1
37
- echo "browser tests: ran (log $PWLOG)"; ran_any=1
178
+ # --base reaches the tests only as BASE_URL, which Playwright never reads by itself. When neither
179
+ # the config nor a spec reads it, every test opens the config's own address, where an older build
180
+ # may still be running and pass in the new one's name (TfLens TF-031, 2026-09-11): refuse instead.
181
+ if [[ -n "$BASE" ]] && ! grep -qs "BASE_URL" playwright.config.* tests/verify/*.spec.* tests/verify/**/*.spec.*; then
182
+ echo "browser tests: NOT RUN — nothing reads BASE_URL, so the tests would open another address than --base $BASE; run bash .tfcore/utils/tf-verify-env.sh, which makes playwright.config.ts read it"
183
+ rm -f "$PWJSON"
184
+ elif [[ -n "$CDPURL" ]] && ! grep -qsE "env(\.|\[['\"])CDP_URL" playwright.config.* tests/verify/*.ts tests/verify/**/*.ts; then
185
+ echo "browser tests: NOT RUN — $CDPURL is a desktop app's debugging address and no test reads CDP_URL, so they would attach to another address; make the connectOverCDP helper read process.env.CDP_URL first"
186
+ rm -f "$PWJSON"
187
+ elif node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
188
+ PWARGS=(--reporter=json); [[ -n "$SHARD" ]] && PWARGS+=("--shard=$SHARD")
189
+ # (the block below ends in SCOPEPY, not PY: tests/regression lifts the PY blocks out verbatim)
190
+ # Lekhak TF-017: a verify of a list of ids runs only the browser tests carrying those ids, and
191
+ # none when no row in scope has a screen and no file under tests/verify/ names one of them. A
192
+ # verify of one no-screen NFR row ran the whole suite and was stopped at 30 minutes. ui,
193
+ # functional and all run everything; so do --spec, --all-specs and a run without list.json.
194
+ SCOPE=""
195
+ [[ $ALLSPECS -eq 0 && ${#SPECS[@]} -eq 0 && -f "$LIST" ]] && SCOPE="$(python3 - "$LIST" <<'SCOPEPY'
196
+ import json, sys
197
+ try:
198
+ d = json.load(open(sys.argv[1], encoding="utf-8"))
199
+ except Exception:
200
+ sys.exit(0)
201
+ if str(d.get("scope", "")).strip().lower() in ("", "ui", "functional", "all"):
202
+ sys.exit(0)
203
+ rows = d.get("rows", [])
204
+ ids = [r["id"] for r in rows if r.get("id")]
205
+ if ids:
206
+ print(("screen " if any(r.get("screen") for r in rows) else "none ") + "|".join(ids))
207
+ SCOPEPY
208
+ )"
209
+ if [[ "$SCOPE" == none\ * ]] && ! grep -rqsE "${SCOPE#none }" tests/verify; then
210
+ echo "browser tests: skipped — no row in scope (${SCOPE#none }) has a screen and no file under tests/verify/ names one; its tests are unit tests (--all-specs runs every spec anyway)"
211
+ rm -f "$PWJSON"
212
+ else
213
+ [[ -n "$SCOPE" ]] && PWARGS+=(--grep "${SCOPE#* }")
214
+ APPIUM_URL="$APPIUMURL" TF_BUNDLE_ID="$BUNDLEID" TF_APP_PATH="$APPPATH" CDP_URL="$CDPURL" BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test "${PWARGS[@]}" ${SPECS[@]+"${SPECS[@]}"} > "$PWLOG" 2>&1
215
+ echo "browser tests: ran${SHARD:+ shard $SHARD}${SPECS:+ (${#SPECS[@]} spec argument(s))}${SCOPE:+ only the tests carrying ${SCOPE#* } (the scope in $LIST)} (log $PWLOG)"; ran_any=1
216
+ fi
38
217
  else
39
218
  echo "browser tests: @playwright/test is not installed here (bash .tfcore/utils/tf-verify-env.sh); skipped"; rm -f "$PWJSON"
40
219
  fi
@@ -44,18 +223,77 @@ if [[ $BROWSER -eq 1 ]]; then
44
223
  else rm -f "$PWJSON"; fi
45
224
 
46
225
  if [[ $UNIT -eq 1 ]]; then
47
- if [[ -n "$TARGET" ]] || ls *.sln *.slnx >/dev/null 2>&1 || ls tests/*/*.csproj >/dev/null 2>&1; then
48
- UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- --logger "console;verbosity=normal" 2>&1 | head -1)"
226
+ # Each pattern on its own: `ls *.sln *.slnx` fails when either matches nothing, so a .slnx-only
227
+ # solution read as none; and a test project may sit at any depth under tests/, not one folder down
228
+ # (AppManager TF-007: tests/unit/AppManager.UnitTests/). tf-build.sh finds a root solution itself;
229
+ # without one it is handed the only test project, and several are named rather than guessed between.
230
+ ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
231
+ tf_read_lines TESTPROJ < <(find tests -name '*.csproj' -not -path 'tests/.artifacts/*' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' 2>/dev/null | sort)
232
+ [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
233
+ if [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -gt 1 ]]; then
234
+ echo "unit tests: NOT RUN — no solution here and ${#TESTPROJ[@]} test projects under tests/ (${TESTPROJ[*]}); name one with --target"
235
+ elif [[ -n "$TARGET" || -n "$ROOTSLN" ]]; then
236
+ # the VERDICT line, not the first line: tf-build.sh may print a note before it, and a note read
237
+ # as the verdict left 975 passing TfLens tests recorded as never run (TF-037)
238
+ # A test project on Microsoft.Testing.Platform ignores --logger and prints no line per test, so no
239
+ # unit test ever reached a row (AppManager TF-005, xunit.v3, 286 tests). Such a project is asked for
240
+ # a TRX report, the per-test record both platforms write, through the property `dotnet test` passes
241
+ # to it. One switch serves every project or none is passed: an unknown switch stops a test app.
242
+ MTP="$(python3 - <<'PY'
243
+ import os, re
244
+ flags, mtp = set(), 0
245
+ for root, dirs, files in os.walk("."):
246
+ dirs[:] = [d for d in dirs if d not in ("bin", "obj", "node_modules", ".git") and not (root == "./tests" and d == ".artifacts")]
247
+ for fn in files:
248
+ if not fn.endswith(".csproj"):
249
+ continue
250
+ t = open(os.path.join(root, fn), encoding="utf-8", errors="replace").read()
251
+ if not re.search(r"<(TestingPlatformDotnetTestSupport|UseMicrosoftTestingPlatformRunner|EnableMSTestRunner|EnableNUnitRunner)>\s*true", t, re.I) and 'Sdk="MSTest.Sdk' not in t:
252
+ continue
253
+ mtp += 1
254
+ if re.search(r'Include="xunit\.v3"', t, re.I):
255
+ flags.add("--report-xunit-trx")
256
+ elif re.search(r'Include="Microsoft\.Testing\.Extensions\.TrxReport"|Sdk="MSTest\.Sdk', t, re.I):
257
+ flags.add("--report-trx")
258
+ else:
259
+ flags.add("?")
260
+ print(next(iter(flags)) if mtp and len(flags) == 1 and "?" not in flags else ("mixed" if mtp else ""))
261
+ PY
262
+ )"
263
+ UARGS=(--logger "console;verbosity=normal")
264
+ case "$MTP" in
265
+ --report-*) UARGS+=("-p:TestingPlatformCommandLineArguments=$MTP") ;;
266
+ mixed) echo "unit tests: note the Testing Platform projects here take different report switches, so none was passed; their tests are not mapped to rows" ;;
267
+ esac
268
+ # the stamp sits on the project's own drive: one in /tmp takes its time from WSL's clock and the
269
+ # report on /mnt/c from Windows', which drift apart, so a fresh report could read as older (2026-10-02)
270
+ mkdir -p tests/.artifacts; STAMP="$(mktemp tests/.artifacts/.unit-stamp.XXXXXX)"
271
+ UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- "${UARGS[@]}" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
49
272
  UNITLOG="$(sed -n 's/.*log \(tests\/\.artifacts\/build\/[^ ;]*\).*/\1/p' <<<"$UNITLINE" | head -1)"
273
+ TRX="$(find . -name '*.trx' -newer "$STAMP" -not -path '*/node_modules/*' 2>/dev/null)"; rm -f "$STAMP"
50
274
  echo "unit tests: $UNITLINE"; [[ "$UNITLINE" != NOT-RUN* ]] && ran_any=1
51
275
  else
52
276
  echo "unit tests: no solution or test project found; skipped"
53
277
  fi
54
278
  fi
55
279
 
56
- TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_OUT="$OUT" python3 - <<'PY'
280
+ TF_HERE="$HERE" TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_TRX="${TRX:-}" TF_OUT="$OUT" python3 - <<'PY'
57
281
  import json, os, re
58
- ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
282
+ import xml.etree.ElementTree as ET
283
+ import importlib.util
284
+ _spec = importlib.util.spec_from_file_location("tf_req_ids", os.path.join(os.environ.get("TF_HERE", ".tfcore/utils"), "tf-req-ids.py"))
285
+ rq = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(rq)
286
+ ID, ids_of = rq.ID, rq.ids_of # Chatur TF-012: a test claims the ids its name starts with (tf-req-ids.py)
287
+ elsewhere = {} # row -> names of passing tests that mention it without claiming it
288
+ def claims(names, outcome):
289
+ """the ids a test claims: the leading run of each name given (a browser test's describe titles and
290
+ its own title are separate names); the other ids it mentions are noted against those rows"""
291
+ own = {i for n in names for i in ids_of(n)}
292
+ for n in names:
293
+ for rid in {x.upper() for x in ID.findall(n)} - own:
294
+ if outcome == "pass":
295
+ elsewhere.setdefault(rid, []).append(" ".join(names).strip()[:160])
296
+ return own
59
297
  reqs = {}
60
298
  browser = {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}
61
299
  unit = {"ran": False, "line": os.environ.get("TF_UNITLINE", "")}
@@ -72,7 +310,12 @@ def add(rid, outcome, name, source, reason="", shot=""):
72
310
  defect. tests/regression/run.sh tf_022."""
73
311
  r = reqs.setdefault(rid.upper(), {"result": "NOT-TESTED", "source": source, "tests": [],
74
312
  "skipped": [], "passed": 0, "failed": 0,
75
- "reason": "", "screenshot": ""})
313
+ "reason": "", "screenshot": "", "outcomes": {}})
314
+ # each test's own outcome, so a merge can let a later run of the same test stand (Lekhak TF-007),
315
+ # and where it ran, so a unit pass never stands in for a skipped on-app test (Sevak TF-004)
316
+ r["outcomes"][name] = {"outcome": outcome, "reason": (reason or "")[:200], "screenshot": shot, "source": source}
317
+ if r["source"] != source:
318
+ r["source"] = "browser+unit"
76
319
  if outcome == "skip":
77
320
  r["skipped"].append(name)
78
321
  if not r["reason"]:
@@ -123,7 +366,7 @@ if pw and os.path.isfile(pw):
123
366
  shot = a["path"].replace("\\", "/")
124
367
  shot = shot[shot.find("tests/"):] if "tests/" in shot else shot
125
368
  break
126
- for rid in set(ID.findall(title)):
369
+ for rid in claims(path + [sp.get("title", "")], outcome):
127
370
  add(rid, outcome, title.strip(), "browser", err, shot)
128
371
  for s in suite.get("suites", []):
129
372
  walk(s, path + [s.get("title", "")] if s.get("title") and not s["title"].endswith(".ts") and not s["title"].endswith(".js") and not s["title"].endswith(".mjs") else path)
@@ -132,25 +375,71 @@ if pw and os.path.isfile(pw):
132
375
  except Exception as e:
133
376
  browser["error"] = str(e)
134
377
 
378
+ seen = set() # one test read from both a TRX report and the console is counted once
379
+ for trx in [p for p in os.environ.get("TF_TRX", "").splitlines() if p.strip()]:
380
+ try:
381
+ tree = ET.parse(trx).getroot()
382
+ except Exception as e:
383
+ unit.setdefault("trx_unreadable", []).append(f"{trx}: {str(e)[:80]}")
384
+ continue
385
+ unit["ran"] = True
386
+ unit.setdefault("trx", []).append(trx)
387
+ for el in tree.iter():
388
+ if not el.tag.endswith("UnitTestResult"):
389
+ continue
390
+ name = (el.get("testName") or "").strip()
391
+ oc = (el.get("outcome") or "").lower() # Passed / Failed / NotExecuted …, any case (TF-005)
392
+ outcome = "pass" if oc == "passed" else "skip" if oc in ("notexecuted", "inconclusive", "pending", "notrunnable", "disconnected") else "fail"
393
+ if (name, outcome) in seen:
394
+ continue
395
+ seen.add((name, outcome))
396
+ msg = next(((m.text or "").strip() for m in el.iter() if m.tag.endswith("Message") and (m.text or "").strip()), "")
397
+ for rid in claims([name], outcome):
398
+ add(rid, outcome, name, "unit",
399
+ ("unit test failed: " + (msg.splitlines()[0] if msg else name))[:200] if outcome == "fail"
400
+ else ("unit test skipped: " + name[:120]) if outcome == "skip" else "")
401
+
135
402
  ul = os.environ.get("TF_UNITLOG", "")
136
403
  if ul and os.path.isfile(ul):
137
404
  unit["ran"] = True
138
405
  for line in open(ul, encoding="utf-8", errors="replace"):
139
- m = re.match(r"\s*(Passed|Failed|Skipped)\s+(\S.*?)\s*(\[[^\]]*\])?\s*$", line)
406
+ # any case, and a "(133ms)" tail: the Testing Platform's own log writes "failed REQ-NFR-007 … (133ms)" (TF-005)
407
+ m = re.match(r"\s*(passed|failed|skipped)\s+(\S.*?)\s*(?:\[[^\]]*\]|\(\d+(?:\.\d+)?\s*m?s\))?\s*$", line, re.I)
140
408
  if not m:
141
409
  continue
142
410
  # a skipped unit test is recorded as skipped, not dropped: the row then reads
143
411
  # NOT-TESTED with the evidence, rather than looking as if no test exists (TF-022)
144
- outcome = {"Passed": "pass", "Failed": "fail", "Skipped": "skip"}[m.group(1)]
412
+ outcome = {"passed": "pass", "failed": "fail", "skipped": "skip"}[m.group(1).lower()]
145
413
  name = m.group(2).strip()
146
- for rid in set(ID.findall(name)):
414
+ if (name, outcome) in seen:
415
+ continue
416
+ seen.add((name, outcome))
417
+ # a pass has no reason: every non-failure was once written "unit test skipped" (Lekhak TF-018)
418
+ for rid in claims([name], outcome):
147
419
  add(rid, outcome, name, "unit",
148
- ("unit test failed: " if outcome == "fail" else "unit test skipped: ") + name[:120])
420
+ "unit test failed: " + name[:120] if outcome == "fail"
421
+ else "unit test skipped: " + name[:120] if outcome == "skip" else "")
422
+ # Sevak TF-004: a row that has an on-app test is accepted on the app. When every on-app test of the
423
+ # row was skipped (`test.skip()`: not observable on this host) and none failed, a passing unit test
424
+ # with the same id does not measure it: the row is NOT-TESTED, with the skip's reason. A failure, from
425
+ # either side, still makes it FAIL; a row with no on-app test is graded on its unit tests as before.
426
+ for rid, r in reqs.items():
427
+ app = [x for x in r["outcomes"].values() if x.get("source") == "browser"]
428
+ if app and all(x["outcome"] == "skip" for x in app) and r["result"] == "PASS":
429
+ why = next((x["reason"] for x in app if x["reason"]), "the test was skipped")
430
+ r["result"] = "NOT-TESTED"
431
+ r["reason"] = ("on-app test skipped (" + why + "); a unit test does not measure on-app acceptance")[:200]
432
+ # a row no test claims, whose id a passing test about another row carries, is flagged: it was once
433
+ # counted as tested on that name, and it is not
434
+ mentioned = {rid: sorted(set(v))[:3] for rid, v in elsewhere.items() if rid not in reqs}
149
435
  unit["passed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "PASS")
150
436
  unit["failed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "FAIL")
151
437
  unit["not_tested"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "NOT-TESTED")
152
438
 
153
- json.dump({"reqs": reqs, "browser": browser, "unit": unit}, open(os.environ["TF_OUT"], "w"), indent=1)
439
+ import datetime
440
+ ran_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
441
+ json.dump({"reqs": reqs, "browser": browser, "unit": unit, "ran_at": ran_at, "named_only_by_other_rows_tests": mentioned},
442
+ open(os.environ["TF_OUT"], "w"), indent=1)
154
443
  p = sum(1 for r in reqs.values() if r["result"] == "PASS")
155
444
  f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
156
445
  ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
@@ -161,6 +450,8 @@ for rid, r in sorted(reqs.items()):
161
450
  print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
162
451
  elif r["result"] == "NOT-TESTED":
163
452
  print(f" NOT-TESTED {rid} — {len(r['skipped'])} test(s) skipped, none ran — {r['reason']}")
453
+ for rid, names in sorted(mentioned.items()):
454
+ print(f" NO TEST {rid} — only named inside another row's passing test, which does not test it: {names[0]}")
164
455
  print(f"JSON: {os.environ['TF_OUT']}")
165
456
  PY
166
457
  [[ $ran_any -eq 1 ]] && exit 0 || exit 2