@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.
- package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- package/.tfcore/utils/tf-goal.sh +12 -12
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -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
|
|
9
|
-
# the row's id (REQ-UI-004
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = {"
|
|
412
|
+
outcome = {"passed": "pass", "failed": "fail", "skipped": "skip"}[m.group(1).lower()]
|
|
145
413
|
name = m.group(2).strip()
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|