@techierathore/techieflow 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- 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-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- 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-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-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-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.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-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -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 +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- 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-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -4,19 +4,33 @@
|
|
|
4
4
|
# bash .tfcore/utils/tf-verify-screens.sh --list tests/.artifacts/verify/list.json --base http://localhost:5099 \
|
|
5
5
|
# [--login-path /login --user ravi --password '…'] [--cookie 'k=v'] [--storage-state file]
|
|
6
6
|
# bash .tfcore/utils/tf-verify-screens.sh --screen home=/ --screen posts=/posts --base URL # a smoke: changed screens only
|
|
7
|
-
# bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 #
|
|
7
|
+
# bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
|
|
8
|
+
# … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
|
|
9
|
+
# # (repeatable; defaults cover one framework's id and [data-error-ui])
|
|
10
|
+
# … --route-value ProviderId=21 # a sample for /x/{ProviderId:long} (repeatable; or "route_values"
|
|
11
|
+
# # in the list); a screen whose route still needs one is SKIPped
|
|
12
|
+
# A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
|
|
13
|
+
# of the screen: it is not owed on the first view.
|
|
8
14
|
#
|
|
9
15
|
# For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
|
|
10
16
|
# something, no header-only table, no blank page, no Blazor or console error (render); nothing
|
|
11
17
|
# overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
|
|
12
|
-
# (visual).
|
|
18
|
+
# (visual). Overlap and off-screen are measured on the rectangle an element paints in, clipped by
|
|
19
|
+
# every scrolling ancestor, and the page is read only after its first render (--render-wait,
|
|
20
|
+
# default 5000 ms). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
|
|
13
21
|
# writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
|
|
14
22
|
# row is written Implemented, and what the verdict script reads. It decides no row's status.
|
|
15
23
|
# Exit 0 every screen OK · 5 a screen failed · 2 the app was unreachable · 4 Playwright missing.
|
|
16
24
|
set -uo pipefail
|
|
17
25
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
-
|
|
26
|
+
# `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
|
|
27
|
+
# form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
|
|
28
|
+
case "${1:-}" in
|
|
29
|
+
-h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
30
|
+
"") sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
|
|
31
|
+
esac
|
|
19
32
|
command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
|
|
20
33
|
node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
|
|
21
34
|
echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
|
|
22
|
-
|
|
35
|
+
source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
|
|
36
|
+
node "$HERE/tf-verify-screens.mjs" "$@"
|
|
@@ -2,37 +2,162 @@
|
|
|
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
10
|
# `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
|
|
10
|
-
# failed (the reason and the screenshot, when the reporter kept one, are recorded),
|
|
11
|
-
# when
|
|
11
|
+
# the row's id (REQ-UI-004 …). A row is PASS when a test carrying its id passed and none failed,
|
|
12
|
+
# FAIL when one failed (the reason and the screenshot, when the reporter kept one, are recorded),
|
|
13
|
+
# NOT-TESTED when every test carrying its id was SKIPPED, and absent when no test carries its id.
|
|
14
|
+
# A skipped clause is a third outcome, never a failure: `test.skip(!SEEDED, …)` says the state does
|
|
15
|
+
# not exist in the data, which is not a defect (TF-022). Writes tests/.artifacts/verify/tests.json.
|
|
16
|
+
# A suite too long for one command runs in parts (AppManager TF-013): `--shard N/M` runs Playwright's
|
|
17
|
+
# shard N of M (browser only; run the unit tests once with --no-browser), `--spec` names files, and
|
|
18
|
+
# each part writes its own playwright-<part>.json and tests-<part>.json. `--merge` then combines the
|
|
19
|
+
# parts' rows and totals into one tests.json; a later run of the same test stands for it (Lekhak TF-007).
|
|
20
|
+
# When tf-verify-list.sh was given a list of ids, the browser run keeps only the tests carrying them
|
|
21
|
+
# (`--grep`), and is skipped when no row has a screen and no browser test names one (Lekhak TF-017).
|
|
22
|
+
# `--list` names another list.json; `--all-specs` runs every browser test whatever the scope.
|
|
12
23
|
# Exit 0 ran (whatever the results) · 2 nothing could run.
|
|
13
24
|
set -uo pipefail
|
|
14
25
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
-
|
|
26
|
+
source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
|
|
27
|
+
BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT=""; SHARD=""; SPECS=(); MERGE=(); LIST="tests/.artifacts/verify/list.json"; ALLSPECS=0
|
|
16
28
|
while [[ $# -gt 0 ]]; do
|
|
17
29
|
case "$1" in
|
|
18
30
|
--base) BASE="${2:-}"; shift 2 ;;
|
|
19
31
|
--target) TARGET="${2:-}"; shift 2 ;;
|
|
20
32
|
--no-browser) BROWSER=0; shift ;;
|
|
21
33
|
--no-unit) UNIT=0; shift ;;
|
|
34
|
+
--shard) SHARD="${2:-}"; UNIT=0; shift 2 ;;
|
|
35
|
+
--spec) SPECS+=("${2:-}"); shift 2 ;;
|
|
36
|
+
--list) LIST="${2:-}"; shift 2 ;;
|
|
37
|
+
--all-specs) ALLSPECS=1; shift ;;
|
|
38
|
+
--merge) shift; while [[ $# -gt 0 && "$1" != --* ]]; do MERGE+=("$1"); shift; done ;;
|
|
22
39
|
--json-out) OUT="${2:-}"; shift 2 ;;
|
|
23
|
-
-h|--help) sed -n '2,
|
|
40
|
+
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
24
41
|
*) echo "tf-verify-tests: unknown argument $1" >&2; exit 2 ;;
|
|
25
42
|
esac
|
|
26
43
|
done
|
|
44
|
+
PART=""
|
|
45
|
+
if [[ -n "$SHARD" ]]; then
|
|
46
|
+
[[ "$SHARD" =~ ^([1-9][0-9]*)/([1-9][0-9]*)$ ]] || { echo "tf-verify-tests: --shard takes N/M, such as 2/4" >&2; exit 2; }
|
|
47
|
+
PART="${BASH_REMATCH[1]}of${BASH_REMATCH[2]}"
|
|
48
|
+
fi
|
|
49
|
+
[[ -n "$OUT" ]] || OUT="tests/.artifacts/verify/tests${PART:+-$PART}.json"
|
|
27
50
|
mkdir -p "$(dirname "$OUT")" tests/.artifacts/verify
|
|
28
|
-
|
|
51
|
+
|
|
52
|
+
# ---- --merge: the parts into one tests.json, no test run ------------------------------------------
|
|
53
|
+
if [[ ${#MERGE[@]} -gt 0 ]]; then
|
|
54
|
+
TF_OUT="$OUT" python3 - "${MERGE[@]}" <<'PY'
|
|
55
|
+
import json, os, sys
|
|
56
|
+
# Lekhak TF-007. The parts are read oldest first (by the time each ran, else in the order given), and
|
|
57
|
+
# a later run of the SAME test stands for it: a test that failed because its service was not started,
|
|
58
|
+
# run again once it was, clears the row. A later skip never replaces a run that happened. A part
|
|
59
|
+
# written before tests carried their own outcome counts each of its tests as the row's result.
|
|
60
|
+
loaded = []
|
|
61
|
+
for i, p in enumerate(sys.argv[1:]):
|
|
62
|
+
try:
|
|
63
|
+
loaded.append((json.load(open(p, encoding="utf-8")), i, p))
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"merge: {p} unreadable ({str(e)[:80]}); skipped")
|
|
66
|
+
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],))
|
|
67
|
+
reqs, browser, unit, parts = {}, {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}, {"ran": False, "line": ""}, []
|
|
68
|
+
outs, sources, superseded = {}, {}, 0
|
|
69
|
+
for d, _i, p in loaded:
|
|
70
|
+
parts.append(p)
|
|
71
|
+
for rid, r in d.get("reqs", {}).items():
|
|
72
|
+
oc = r.get("outcomes")
|
|
73
|
+
if oc is None: # an older part: every test it lists carries the row's result
|
|
74
|
+
res = {"PASS": "pass", "FAIL": "fail"}.get(r.get("result"), "skip")
|
|
75
|
+
oc = {t: {"outcome": res, "reason": r.get("reason", "") if res == "fail" else "", "screenshot": r.get("screenshot", "")} for t in r.get("tests", [])}
|
|
76
|
+
oc.update({t: {"outcome": "skip", "reason": r.get("reason", ""), "screenshot": ""} for t in r.get("skipped", [])})
|
|
77
|
+
o = outs.setdefault(rid, {})
|
|
78
|
+
for name, rec in oc.items():
|
|
79
|
+
old = o.get(name)
|
|
80
|
+
if old and rec["outcome"] == "skip" and old["outcome"] != "skip":
|
|
81
|
+
continue
|
|
82
|
+
if old and old["outcome"] != rec["outcome"]:
|
|
83
|
+
superseded += 1
|
|
84
|
+
o[name] = rec
|
|
85
|
+
src = sources.get(rid)
|
|
86
|
+
sources[rid] = r.get("source", "") if src in (None, r.get("source", "")) else "browser+unit"
|
|
87
|
+
b = d.get("browser", {})
|
|
88
|
+
if b.get("ran"):
|
|
89
|
+
browser["ran"] = True
|
|
90
|
+
for k in ("passed", "failed", "skipped", "tests"):
|
|
91
|
+
browser[k] += b.get(k, 0)
|
|
92
|
+
u = d.get("unit", {})
|
|
93
|
+
if u.get("ran") and not unit["ran"]:
|
|
94
|
+
unit = u
|
|
95
|
+
for rid, o in outs.items():
|
|
96
|
+
fails = [(n, x) for n, x in o.items() if x["outcome"] == "fail"]
|
|
97
|
+
npass = sum(1 for x in o.values() if x["outcome"] == "pass")
|
|
98
|
+
skips = [(n, x) for n, x in o.items() if x["outcome"] == "skip"]
|
|
99
|
+
result = "FAIL" if fails else "PASS" if npass else "NOT-TESTED"
|
|
100
|
+
reason = fails[0][1]["reason"] if fails else (skips[0][1]["reason"] if skips and not npass else "")
|
|
101
|
+
reqs[rid] = {"result": result, "source": sources.get(rid, ""), "tests": [n for n, x in o.items() if x["outcome"] != "skip"],
|
|
102
|
+
"skipped": [n for n, _ in skips], "passed": npass, "failed": len(fails), "reason": reason,
|
|
103
|
+
"screenshot": fails[0][1].get("screenshot", "") if fails else "", "outcomes": o}
|
|
104
|
+
json.dump({"reqs": reqs, "browser": browser, "unit": unit, "merged_from": parts, "superseded": superseded},
|
|
105
|
+
open(os.environ["TF_OUT"], "w"), indent=1)
|
|
106
|
+
p = sum(1 for r in reqs.values() if r["result"] == "PASS"); f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
|
|
107
|
+
ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
|
|
108
|
+
print(f"merged {len(parts)} part(s): rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
|
|
109
|
+
+ (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else "")
|
|
110
|
+
+ (f"; {superseded} test(s) ran again in a later part, and the later run stands" if superseded else ""))
|
|
111
|
+
for rid, r in sorted(reqs.items()):
|
|
112
|
+
if r["result"] == "FAIL":
|
|
113
|
+
print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
|
|
114
|
+
print(f"JSON: {os.environ['TF_OUT']}")
|
|
115
|
+
PY
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
source "$HERE/tf-lock.sh"; tf_take_lock "verify-tests${PART:+ $PART}" # one browser check at a time (TF-048)
|
|
120
|
+
PWJSON="tests/.artifacts/verify/playwright${PART:+-$PART}.json"; PWLOG="tests/.artifacts/verify/playwright${PART:+-$PART}.log"; UNITLOG=""; UNITLINE=""
|
|
29
121
|
ran_any=0
|
|
30
122
|
|
|
31
123
|
if [[ $BROWSER -eq 1 ]]; then
|
|
32
124
|
if ls tests/verify/*.spec.* >/dev/null 2>&1 || ls tests/verify/**/*.spec.* >/dev/null 2>&1; then
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
125
|
+
# --base reaches the tests only as BASE_URL, which Playwright never reads by itself. When neither
|
|
126
|
+
# the config nor a spec reads it, every test opens the config's own address, where an older build
|
|
127
|
+
# may still be running and pass in the new one's name (TfLens TF-031, 2026-09-11): refuse instead.
|
|
128
|
+
if [[ -n "$BASE" ]] && ! grep -qs "BASE_URL" playwright.config.* tests/verify/*.spec.* tests/verify/**/*.spec.*; then
|
|
129
|
+
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"
|
|
130
|
+
rm -f "$PWJSON"
|
|
131
|
+
elif node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
|
|
132
|
+
PWARGS=(--reporter=json); [[ -n "$SHARD" ]] && PWARGS+=("--shard=$SHARD")
|
|
133
|
+
# (the block below ends in SCOPEPY, not PY: tests/regression lifts the PY blocks out verbatim)
|
|
134
|
+
# Lekhak TF-017: a verify of a list of ids runs only the browser tests carrying those ids, and
|
|
135
|
+
# none when no row in scope has a screen and no file under tests/verify/ names one of them. A
|
|
136
|
+
# verify of one no-screen NFR row ran the whole suite and was stopped at 30 minutes. ui,
|
|
137
|
+
# functional and all run everything; so do --spec, --all-specs and a run without list.json.
|
|
138
|
+
SCOPE=""
|
|
139
|
+
[[ $ALLSPECS -eq 0 && ${#SPECS[@]} -eq 0 && -f "$LIST" ]] && SCOPE="$(python3 - "$LIST" <<'SCOPEPY'
|
|
140
|
+
import json, sys
|
|
141
|
+
try:
|
|
142
|
+
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
|
143
|
+
except Exception:
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
if str(d.get("scope", "")).strip().lower() in ("", "ui", "functional", "all"):
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
rows = d.get("rows", [])
|
|
148
|
+
ids = [r["id"] for r in rows if r.get("id")]
|
|
149
|
+
if ids:
|
|
150
|
+
print(("screen " if any(r.get("screen") for r in rows) else "none ") + "|".join(ids))
|
|
151
|
+
SCOPEPY
|
|
152
|
+
)"
|
|
153
|
+
if [[ "$SCOPE" == none\ * ]] && ! grep -rqsE "${SCOPE#none }" tests/verify; then
|
|
154
|
+
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)"
|
|
155
|
+
rm -f "$PWJSON"
|
|
156
|
+
else
|
|
157
|
+
[[ -n "$SCOPE" ]] && PWARGS+=(--grep "${SCOPE#* }")
|
|
158
|
+
BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test "${PWARGS[@]}" ${SPECS[@]+"${SPECS[@]}"} > "$PWLOG" 2>&1
|
|
159
|
+
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
|
|
160
|
+
fi
|
|
36
161
|
else
|
|
37
162
|
echo "browser tests: @playwright/test is not installed here (bash .tfcore/utils/tf-verify-env.sh); skipped"; rm -f "$PWJSON"
|
|
38
163
|
fi
|
|
@@ -42,29 +167,96 @@ if [[ $BROWSER -eq 1 ]]; then
|
|
|
42
167
|
else rm -f "$PWJSON"; fi
|
|
43
168
|
|
|
44
169
|
if [[ $UNIT -eq 1 ]]; then
|
|
45
|
-
|
|
46
|
-
|
|
170
|
+
# Each pattern on its own: `ls *.sln *.slnx` fails when either matches nothing, so a .slnx-only
|
|
171
|
+
# solution read as none; and a test project may sit at any depth under tests/, not one folder down
|
|
172
|
+
# (AppManager TF-007: tests/unit/AppManager.UnitTests/). tf-build.sh finds a root solution itself;
|
|
173
|
+
# without one it is handed the only test project, and several are named rather than guessed between.
|
|
174
|
+
ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
|
|
175
|
+
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)
|
|
176
|
+
[[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
|
|
177
|
+
if [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -gt 1 ]]; then
|
|
178
|
+
echo "unit tests: NOT RUN — no solution here and ${#TESTPROJ[@]} test projects under tests/ (${TESTPROJ[*]}); name one with --target"
|
|
179
|
+
elif [[ -n "$TARGET" || -n "$ROOTSLN" ]]; then
|
|
180
|
+
# the VERDICT line, not the first line: tf-build.sh may print a note before it, and a note read
|
|
181
|
+
# as the verdict left 975 passing TfLens tests recorded as never run (TF-037)
|
|
182
|
+
# A test project on Microsoft.Testing.Platform ignores --logger and prints no line per test, so no
|
|
183
|
+
# unit test ever reached a row (AppManager TF-005, xunit.v3, 286 tests). Such a project is asked for
|
|
184
|
+
# a TRX report, the per-test record both platforms write, through the property `dotnet test` passes
|
|
185
|
+
# to it. One switch serves every project or none is passed: an unknown switch stops a test app.
|
|
186
|
+
MTP="$(python3 - <<'PY'
|
|
187
|
+
import os, re
|
|
188
|
+
flags, mtp = set(), 0
|
|
189
|
+
for root, dirs, files in os.walk("."):
|
|
190
|
+
dirs[:] = [d for d in dirs if d not in ("bin", "obj", "node_modules", ".git") and not (root == "./tests" and d == ".artifacts")]
|
|
191
|
+
for fn in files:
|
|
192
|
+
if not fn.endswith(".csproj"):
|
|
193
|
+
continue
|
|
194
|
+
t = open(os.path.join(root, fn), encoding="utf-8", errors="replace").read()
|
|
195
|
+
if not re.search(r"<(TestingPlatformDotnetTestSupport|UseMicrosoftTestingPlatformRunner|EnableMSTestRunner|EnableNUnitRunner)>\s*true", t, re.I) and 'Sdk="MSTest.Sdk' not in t:
|
|
196
|
+
continue
|
|
197
|
+
mtp += 1
|
|
198
|
+
if re.search(r'Include="xunit\.v3"', t, re.I):
|
|
199
|
+
flags.add("--report-xunit-trx")
|
|
200
|
+
elif re.search(r'Include="Microsoft\.Testing\.Extensions\.TrxReport"|Sdk="MSTest\.Sdk', t, re.I):
|
|
201
|
+
flags.add("--report-trx")
|
|
202
|
+
else:
|
|
203
|
+
flags.add("?")
|
|
204
|
+
print(next(iter(flags)) if mtp and len(flags) == 1 and "?" not in flags else ("mixed" if mtp else ""))
|
|
205
|
+
PY
|
|
206
|
+
)"
|
|
207
|
+
UARGS=(--logger "console;verbosity=normal")
|
|
208
|
+
case "$MTP" in
|
|
209
|
+
--report-*) UARGS+=("-p:TestingPlatformCommandLineArguments=$MTP") ;;
|
|
210
|
+
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" ;;
|
|
211
|
+
esac
|
|
212
|
+
STAMP="$(mktemp)"
|
|
213
|
+
UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- "${UARGS[@]}" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
|
|
47
214
|
UNITLOG="$(sed -n 's/.*log \(tests\/\.artifacts\/build\/[^ ;]*\).*/\1/p' <<<"$UNITLINE" | head -1)"
|
|
215
|
+
TRX="$(find . -name '*.trx' -newer "$STAMP" -not -path '*/node_modules/*' 2>/dev/null)"; rm -f "$STAMP"
|
|
48
216
|
echo "unit tests: $UNITLINE"; [[ "$UNITLINE" != NOT-RUN* ]] && ran_any=1
|
|
49
217
|
else
|
|
50
218
|
echo "unit tests: no solution or test project found; skipped"
|
|
51
219
|
fi
|
|
52
220
|
fi
|
|
53
221
|
|
|
54
|
-
TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_OUT="$OUT" python3 - <<'PY'
|
|
222
|
+
TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_TRX="${TRX:-}" TF_OUT="$OUT" python3 - <<'PY'
|
|
55
223
|
import json, os, re
|
|
224
|
+
import xml.etree.ElementTree as ET
|
|
56
225
|
ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
|
|
57
|
-
reqs
|
|
226
|
+
reqs = {}
|
|
227
|
+
browser = {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}
|
|
228
|
+
unit = {"ran": False, "line": os.environ.get("TF_UNITLINE", "")}
|
|
58
229
|
|
|
59
|
-
def add(rid,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
230
|
+
def add(rid, outcome, name, source, reason="", shot=""):
|
|
231
|
+
"""Record one test against a row. `outcome` is "pass", "fail" or "skip".
|
|
232
|
+
|
|
233
|
+
A SKIPPED clause is the third outcome and neither of the other two. A conditional
|
|
234
|
+
`test.skip(!SEEDED, "needs the seeded dataset")` says the state does not exist in the
|
|
235
|
+
data yet: that is not a pass, and it is not a defect either. Counting it as a failure
|
|
236
|
+
put FAIL on rows no code could clear and sent build-phase back into FIX mode against
|
|
237
|
+
them (TF-022, TfLens 2026-09-09). A row whose clauses were ALL skipped ends
|
|
238
|
+
NOT-TESTED, which the verdict script reads as not measured — never Verified, never a
|
|
239
|
+
defect. tests/regression/run.sh tf_022."""
|
|
240
|
+
r = reqs.setdefault(rid.upper(), {"result": "NOT-TESTED", "source": source, "tests": [],
|
|
241
|
+
"skipped": [], "passed": 0, "failed": 0,
|
|
242
|
+
"reason": "", "screenshot": "", "outcomes": {}})
|
|
243
|
+
# each test's own outcome, so a merge can let a later run of the same test stand (Lekhak TF-007)
|
|
244
|
+
r["outcomes"][name] = {"outcome": outcome, "reason": (reason or "")[:200], "screenshot": shot}
|
|
245
|
+
if outcome == "skip":
|
|
246
|
+
r["skipped"].append(name)
|
|
64
247
|
if not r["reason"]:
|
|
248
|
+
r["reason"] = (reason or "the test was skipped")[:200]
|
|
249
|
+
return
|
|
250
|
+
r["tests"].append(name)
|
|
251
|
+
if outcome == "pass":
|
|
252
|
+
r["passed"] += 1
|
|
253
|
+
else:
|
|
254
|
+
if r["result"] != "FAIL": # the first real failure owns the reason
|
|
65
255
|
r["reason"] = reason[:200]
|
|
256
|
+
r["failed"] += 1
|
|
66
257
|
if shot and not r["screenshot"]:
|
|
67
258
|
r["screenshot"] = shot
|
|
259
|
+
r["result"] = "FAIL" if r["failed"] else "PASS"
|
|
68
260
|
|
|
69
261
|
pw = os.environ.get("TF_PWJSON", "")
|
|
70
262
|
if pw and os.path.isfile(pw):
|
|
@@ -78,11 +270,21 @@ if pw and os.path.isfile(pw):
|
|
|
78
270
|
res = t.get("results", [])
|
|
79
271
|
last = res[-1] if res else {}
|
|
80
272
|
status = last.get("status", t.get("status", "unexpected"))
|
|
81
|
-
|
|
273
|
+
# skipped is read FIRST: it is neither a pass nor a failure (TF-022)
|
|
274
|
+
if status == "skipped" or t.get("status") == "skipped":
|
|
275
|
+
outcome = "skip"
|
|
276
|
+
elif status in ("passed", "expected") or t.get("status") == "expected":
|
|
277
|
+
outcome = "pass"
|
|
278
|
+
else:
|
|
279
|
+
outcome = "fail"
|
|
82
280
|
browser["tests"] += 1
|
|
83
|
-
browser["passed"
|
|
281
|
+
browser[{"pass": "passed", "fail": "failed", "skip": "skipped"}[outcome]] += 1
|
|
84
282
|
err = ""
|
|
85
|
-
if
|
|
283
|
+
if outcome == "skip":
|
|
284
|
+
why = [a.get("description") for a in (t.get("annotations") or [])
|
|
285
|
+
if a.get("type") == "skip" and a.get("description")]
|
|
286
|
+
err = "skipped" + (": " + str(why[0]) if why else "")
|
|
287
|
+
elif last.get("error"):
|
|
86
288
|
err = re.sub(r"\x1b\[[0-9;]*m", "", str(last["error"].get("message", "")).split("\n")[0])
|
|
87
289
|
shot = ""
|
|
88
290
|
for a in last.get("attachments", []):
|
|
@@ -91,7 +293,7 @@ if pw and os.path.isfile(pw):
|
|
|
91
293
|
shot = shot[shot.find("tests/"):] if "tests/" in shot else shot
|
|
92
294
|
break
|
|
93
295
|
for rid in set(ID.findall(title)):
|
|
94
|
-
add(rid,
|
|
296
|
+
add(rid, outcome, title.strip(), "browser", err, shot)
|
|
95
297
|
for s in suite.get("suites", []):
|
|
96
298
|
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)
|
|
97
299
|
for s in data.get("suites", []):
|
|
@@ -99,25 +301,67 @@ if pw and os.path.isfile(pw):
|
|
|
99
301
|
except Exception as e:
|
|
100
302
|
browser["error"] = str(e)
|
|
101
303
|
|
|
304
|
+
seen = set() # one test read from both a TRX report and the console is counted once
|
|
305
|
+
for trx in [p for p in os.environ.get("TF_TRX", "").splitlines() if p.strip()]:
|
|
306
|
+
try:
|
|
307
|
+
tree = ET.parse(trx).getroot()
|
|
308
|
+
except Exception as e:
|
|
309
|
+
unit.setdefault("trx_unreadable", []).append(f"{trx}: {str(e)[:80]}")
|
|
310
|
+
continue
|
|
311
|
+
unit["ran"] = True
|
|
312
|
+
unit.setdefault("trx", []).append(trx)
|
|
313
|
+
for el in tree.iter():
|
|
314
|
+
if not el.tag.endswith("UnitTestResult"):
|
|
315
|
+
continue
|
|
316
|
+
name = (el.get("testName") or "").strip()
|
|
317
|
+
oc = (el.get("outcome") or "").lower() # Passed / Failed / NotExecuted …, any case (TF-005)
|
|
318
|
+
outcome = "pass" if oc == "passed" else "skip" if oc in ("notexecuted", "inconclusive", "pending", "notrunnable", "disconnected") else "fail"
|
|
319
|
+
if (name, outcome) in seen:
|
|
320
|
+
continue
|
|
321
|
+
seen.add((name, outcome))
|
|
322
|
+
msg = next(((m.text or "").strip() for m in el.iter() if m.tag.endswith("Message") and (m.text or "").strip()), "")
|
|
323
|
+
for rid in set(ID.findall(name)):
|
|
324
|
+
add(rid, outcome, name, "unit",
|
|
325
|
+
("unit test failed: " + (msg.splitlines()[0] if msg else name))[:200] if outcome == "fail"
|
|
326
|
+
else ("unit test skipped: " + name[:120]) if outcome == "skip" else "")
|
|
327
|
+
|
|
102
328
|
ul = os.environ.get("TF_UNITLOG", "")
|
|
103
329
|
if ul and os.path.isfile(ul):
|
|
104
330
|
unit["ran"] = True
|
|
105
331
|
for line in open(ul, encoding="utf-8", errors="replace"):
|
|
106
|
-
|
|
107
|
-
|
|
332
|
+
# any case, and a "(133ms)" tail: the Testing Platform's own log writes "failed REQ-NFR-007 … (133ms)" (TF-005)
|
|
333
|
+
m = re.match(r"\s*(passed|failed|skipped)\s+(\S.*?)\s*(?:\[[^\]]*\]|\(\d+(?:\.\d+)?\s*m?s\))?\s*$", line, re.I)
|
|
334
|
+
if not m:
|
|
108
335
|
continue
|
|
336
|
+
# a skipped unit test is recorded as skipped, not dropped: the row then reads
|
|
337
|
+
# NOT-TESTED with the evidence, rather than looking as if no test exists (TF-022)
|
|
338
|
+
outcome = {"passed": "pass", "failed": "fail", "skipped": "skip"}[m.group(1).lower()]
|
|
109
339
|
name = m.group(2).strip()
|
|
340
|
+
if (name, outcome) in seen:
|
|
341
|
+
continue
|
|
342
|
+
seen.add((name, outcome))
|
|
343
|
+
# a pass has no reason: every non-failure was once written "unit test skipped" (Lekhak TF-018)
|
|
110
344
|
for rid in set(ID.findall(name)):
|
|
111
|
-
add(rid,
|
|
345
|
+
add(rid, outcome, name, "unit",
|
|
346
|
+
"unit test failed: " + name[:120] if outcome == "fail"
|
|
347
|
+
else "unit test skipped: " + name[:120] if outcome == "skip" else "")
|
|
112
348
|
unit["passed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "PASS")
|
|
113
349
|
unit["failed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "FAIL")
|
|
350
|
+
unit["not_tested"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "NOT-TESTED")
|
|
114
351
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
352
|
+
import datetime
|
|
353
|
+
ran_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
354
|
+
json.dump({"reqs": reqs, "browser": browser, "unit": unit, "ran_at": ran_at}, open(os.environ["TF_OUT"], "w"), indent=1)
|
|
355
|
+
p = sum(1 for r in reqs.values() if r["result"] == "PASS")
|
|
356
|
+
f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
|
|
357
|
+
ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
|
|
358
|
+
print(f"rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
|
|
359
|
+
+ (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else ""))
|
|
118
360
|
for rid, r in sorted(reqs.items()):
|
|
119
361
|
if r["result"] == "FAIL":
|
|
120
362
|
print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
|
|
363
|
+
elif r["result"] == "NOT-TESTED":
|
|
364
|
+
print(f" NOT-TESTED {rid} — {len(r['skipped'])} test(s) skipped, none ran — {r['reason']}")
|
|
121
365
|
print(f"JSON: {os.environ['TF_OUT']}")
|
|
122
366
|
PY
|
|
123
367
|
[[ $ran_any -eq 1 ]] && exit 0 || exit 2
|
|
@@ -19,9 +19,12 @@ For each row, the seven checks in order, and the first that fails is the verdict
|
|
|
19
19
|
(standards has no script yet and is never listed as run)
|
|
20
20
|
|
|
21
21
|
Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL · VISUAL-FAIL · MOCKUP-FAIL ·
|
|
22
|
-
PERF-FAIL · NOT-TESTED (no test carried the id
|
|
22
|
+
PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
|
|
23
|
+
checks passed or did not run) ·
|
|
23
24
|
NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
|
|
24
|
-
the head could not be booted, or has no driver)
|
|
25
|
+
the head could not be booted, or has no driver) · DEFECT-OPEN (every check passed, but the old Remarks
|
|
26
|
+
carry a defect no verify can see, tf_defect.py: the row goes to Needs re-verify, and so to a fix, with
|
|
27
|
+
the defect kept; AppManager TF-021). A row is Verified only on PASS. NOT-* rows keep their
|
|
25
28
|
status and get a Remark saying so; nothing is ever written as a static-only pass.
|
|
26
29
|
|
|
27
30
|
Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
|
|
@@ -37,6 +40,8 @@ import re
|
|
|
37
40
|
import sys
|
|
38
41
|
|
|
39
42
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
43
|
+
sys.path.insert(0, HERE)
|
|
44
|
+
import tf_defect # noqa: E402
|
|
40
45
|
FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
|
|
41
46
|
"VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
|
|
42
47
|
STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
|
|
@@ -70,6 +75,14 @@ def norm_route(r):
|
|
|
70
75
|
return r or "/"
|
|
71
76
|
|
|
72
77
|
|
|
78
|
+
def quoted(s, n=None):
|
|
79
|
+
"""A test's own words — its title or its failure message — in backticks, so the Remark shows them as
|
|
80
|
+
quoted and tf-doc-check reads them as quoted: a title saying "Found/Not found" failed the checker's
|
|
81
|
+
not-present rule on every verify (Lekhak TF-012)."""
|
|
82
|
+
s = re.sub(r"\s+", " ", (s or "").replace("`", "'").replace("|", "/")).strip()
|
|
83
|
+
return f"`{s[:n] if n else s}`" if s else ""
|
|
84
|
+
|
|
85
|
+
|
|
73
86
|
def words(s, n=60):
|
|
74
87
|
w = s.split()
|
|
75
88
|
return s if len(w) <= n else " ".join(w[:n]) + "…" # the ellipsis rides on the last word: the checker counts words, and a bare "…" was a 61st (miss 29)
|
|
@@ -91,6 +104,8 @@ def main(argv):
|
|
|
91
104
|
tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
|
|
92
105
|
screens = load(os.path.join(d, "screens.json")) or {}
|
|
93
106
|
scr_by_name = {s["name"]: s for s in screens.get("screens", [])}
|
|
107
|
+
# screens tf-verify-screens did not drive because their route needs a sample value (Lekhak TF-005)
|
|
108
|
+
scr_skipped = {s["name"]: s for s in screens.get("skipped", []) or []}
|
|
94
109
|
assets = load(os.path.join(d, "assets.json")) or {}
|
|
95
110
|
pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
|
|
96
111
|
parity = load(os.path.join(d, "parity.json")) or {}
|
|
@@ -114,6 +129,13 @@ def main(argv):
|
|
|
114
129
|
scr = scr_by_name.get(r.get("screen") or "")
|
|
115
130
|
driven = bool(scr) and scr.get("render") != "UNREACHABLE"
|
|
116
131
|
t = tests.get(rid)
|
|
132
|
+
# A row whose every clause was SKIPPED is NOT-TESTED: the acceptance gate did not run on
|
|
133
|
+
# it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
|
|
134
|
+
# all — never Verified, no gate record (TF-022). `t` is kept for the note.
|
|
135
|
+
skipped_only = bool(t) and t.get("result") == "NOT-TESTED"
|
|
136
|
+
if skipped_only:
|
|
137
|
+
notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
|
|
138
|
+
t = None
|
|
117
139
|
# every check on its own: (name, ran, failed, verdict, class, detail, evidence); the first failure wins
|
|
118
140
|
checks = []
|
|
119
141
|
|
|
@@ -121,7 +143,7 @@ def main(argv):
|
|
|
121
143
|
checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
|
|
122
144
|
# 2 acceptance
|
|
123
145
|
if t:
|
|
124
|
-
checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", t.get("reason") or "test failed", t.get("screenshot") or ""))
|
|
146
|
+
checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", quoted(t.get("reason")) or "test failed", t.get("screenshot") or ""))
|
|
125
147
|
# 3 render
|
|
126
148
|
if scr:
|
|
127
149
|
if scr.get("render") == "UNREACHABLE":
|
|
@@ -184,17 +206,21 @@ def main(argv):
|
|
|
184
206
|
detail = "NFR row with no perf-budget and no unit test carrying its id"
|
|
185
207
|
elif not t and r.get("screen") and not driven:
|
|
186
208
|
verdict = "NOT-DRIVEN"
|
|
209
|
+
sk = scr_skipped.get(r["screen"])
|
|
187
210
|
detail = (f"{boot.get('head', 'the app')} not driven: {boot.get('reason')}" if not booted
|
|
211
|
+
else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
|
|
212
|
+
+ ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
|
|
188
213
|
else f"screen {r['screen']} was not driven in this run")
|
|
189
214
|
elif not t:
|
|
190
215
|
verdict = "NOT-TESTED"
|
|
191
|
-
detail = f"
|
|
216
|
+
detail = (f"every test named {rid} was skipped" if skipped_only else f"no test named {rid} ran") \
|
|
217
|
+
+ (f"; screen {r['screen']} renders and looks right" if driven else "")
|
|
192
218
|
elif r.get("screen") and not driven and not booted:
|
|
193
219
|
verdict = "NOT-DRIVEN"
|
|
194
220
|
detail = f"test passed but {boot.get('head', 'the app')} was not driven: {boot.get('reason')}"
|
|
195
221
|
else:
|
|
196
222
|
verdict = "PASS"
|
|
197
|
-
parts = [f"test {t['tests'][0]
|
|
223
|
+
parts = [f"test {quoted(t['tests'][0], 60)}" if t and t.get("tests") else "test passed"]
|
|
198
224
|
if driven:
|
|
199
225
|
parts.append(f"{r['screen']} renders and looks right @{'/'.join(str(w['width']) for w in scr['widths'])}")
|
|
200
226
|
if "mockup-parity" in ran:
|
|
@@ -203,22 +229,29 @@ def main(argv):
|
|
|
203
229
|
parts.append("within its speed budget")
|
|
204
230
|
detail = "; ".join(parts)
|
|
205
231
|
|
|
206
|
-
|
|
232
|
+
# A defect found by the guide, the owner's testing or production is not in the acceptance line, so
|
|
233
|
+
# a pass cannot clear it and a rewrite must not erase it (TF-021). tests/regression/run.sh am_021.
|
|
234
|
+
kept = tf_defect.unseen(r.get("remarks"))
|
|
235
|
+
if kept and verdict == "PASS":
|
|
236
|
+
verdict = "DEFECT-OPEN"
|
|
237
|
+
status = STATUS.get(verdict, "Needs re-verify" if verdict == "DEFECT-OPEN" else prior)
|
|
207
238
|
pct = r.get("pct")
|
|
208
239
|
if verdict == "PASS":
|
|
209
240
|
pct = 100
|
|
210
|
-
elif verdict in ("RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
|
|
241
|
+
elif verdict in ("DEFECT-OPEN", "RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
|
|
211
242
|
pct = min(pct if pct is not None else 75, 75)
|
|
212
243
|
gate = FAILING.get(verdict)
|
|
213
244
|
marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
|
|
214
245
|
"VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
|
|
215
|
-
"NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS"
|
|
246
|
+
"NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS",
|
|
247
|
+
"DEFECT-OPEN": "checks pass, defect open"}
|
|
216
248
|
remark = f"{today} verify: {marks[verdict]} — {detail}"
|
|
217
249
|
if evidence:
|
|
218
250
|
remark += f" ({evidence})"
|
|
219
251
|
if notes:
|
|
220
252
|
remark += "; " + "; ".join(notes[:2])
|
|
221
|
-
|
|
253
|
+
carried = words(" ".join(kept).replace("|", "/"), 30) if kept else ""
|
|
254
|
+
remark = words(remark.replace("|", "/"), 60 - len(carried.split())) + (f" {carried}" if carried else "")
|
|
222
255
|
emit = verdict == "PASS" or gate is not None
|
|
223
256
|
row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
|
|
224
257
|
"prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
|
|
@@ -231,8 +264,23 @@ def main(argv):
|
|
|
231
264
|
for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
|
|
232
265
|
if any(c in x["gates_run"] for x in rows_out):
|
|
233
266
|
checks.append(c)
|
|
267
|
+
# Lekhak TF-007: a targeted verify rewrote the ledger with its own rows only (395 became 1), so every
|
|
268
|
+
# other row lost its last verdict. The ledger is kept row by row: a row this run graded is replaced,
|
|
269
|
+
# with the date and run it was graded in; the rest keep theirs. A scope-all run starts it afresh.
|
|
270
|
+
# The verify guard unlocks a Verified only for a row graded today, and fix-close reads only the rows
|
|
271
|
+
# graded since its fix began, by these dates.
|
|
272
|
+
all_rows, row_dates, row_runs = dict(ledger_rows), {k: today for k in ledger_rows}, {k: started for k in ledger_rows}
|
|
273
|
+
prev = load(os.path.join("docs", ".last-verify.json")) if os.path.isfile(os.path.join("docs", ".last-verify.json")) else None
|
|
274
|
+
if lst.get("scope", "all") != "all" and isinstance(prev, dict) and prev.get("app") in (None, app) \
|
|
275
|
+
and isinstance(prev.get("rows"), dict) and prev.get("mode") != "reconcile":
|
|
276
|
+
for k, v in prev["rows"].items():
|
|
277
|
+
if k not in all_rows:
|
|
278
|
+
all_rows[k] = v
|
|
279
|
+
row_dates[k] = (prev.get("row_dates") or {}).get(k, prev.get("date", ""))
|
|
280
|
+
row_runs[k] = (prev.get("row_runs") or {}).get(k, prev.get("run_id", ""))
|
|
234
281
|
ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "booted": boot_text, "gates": checks,
|
|
235
|
-
"evidence": d, "run_id": started, "rows":
|
|
282
|
+
"evidence": d, "run_id": started, "rows": all_rows, "row_dates": row_dates, "row_runs": row_runs,
|
|
283
|
+
"this_run": sorted(ledger_rows)}
|
|
236
284
|
os.makedirs("docs", exist_ok=True)
|
|
237
285
|
with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
|
|
238
286
|
json.dump(ledger, f, indent=1)
|
|
@@ -274,7 +322,7 @@ def main(argv):
|
|
|
274
322
|
for t in table:
|
|
275
323
|
print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
|
|
276
324
|
print()
|
|
277
|
-
print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows). Verdicts: {os.path.join(d, 'verdicts.json')}."
|
|
325
|
+
print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows graded now, {len(all_rows)} in all). Verdicts: {os.path.join(d, 'verdicts.json')}."
|
|
278
326
|
+ (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
|
|
279
327
|
return 0
|
|
280
328
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""tf_defect.py — the defect marks the framework's own writers put in a checklist row's Remarks.
|
|
2
|
+
|
|
3
|
+
devguide.md writes `⚠ DevGuide`, tf-checklist-edit's demote `⚠ UAT bug` / `⚠ prod bug` / `⚠ miss`,
|
|
4
|
+
tf-verify-verdict one mark per failing check, the smoke policy `⚠ visual`, security findings
|
|
5
|
+
`⚠ SECURITY`. A bare ⚠ is not a defect: hand-written remarks use it for "not verifiable here".
|
|
6
|
+
|
|
7
|
+
Imported by tf-status-facts.py (TF-019), tf-build-list.py (TF-020) and tf-verify-verdict.py (TF-021).
|
|
8
|
+
tests/regression/run.sh am_019, am_020, am_021.
|
|
9
|
+
"""
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# found by a verify check: a new verify grades it again, so its verdict replaces the old mark
|
|
13
|
+
VERIFY = r"acceptance|build|render|assets|visual|mockup-parity|perf"
|
|
14
|
+
# found by another step (guide, owner's testing, production, a miss, a review): no verify sees it
|
|
15
|
+
OTHER = r"DevGuide|UAT bug|prod bug|miss \d{4}-|SECURITY"
|
|
16
|
+
DEFECT = re.compile(rf"⚠\s*(?:{OTHER}|{VERIFY})\b")
|
|
17
|
+
UNSEEN = re.compile(rf"⚠\s*(?:{OTHER})\b")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clauses(remarks, pattern=DEFECT):
|
|
21
|
+
"""The defect clauses of a Remarks cell: each runs from its ⚠ mark to the next ⚠ or the end."""
|
|
22
|
+
return [c.strip().rstrip(";").strip() for c in re.split(r"(?=⚠)", remarks or "") if pattern.match(c)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unseen(remarks):
|
|
26
|
+
"""The clauses a verify cannot see, so a verify must neither erase them nor pass over them."""
|
|
27
|
+
return clauses(remarks, UNSEEN)
|