robotframework-falsegreen 0.2.0__py3-none-any.whl
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.
- falsegreen_robot/__init__.py +3 -0
- falsegreen_robot/__main__.py +6 -0
- falsegreen_robot/scanner.py +704 -0
- robotframework_falsegreen-0.2.0.dist-info/METADATA +177 -0
- robotframework_falsegreen-0.2.0.dist-info/RECORD +8 -0
- robotframework_falsegreen-0.2.0.dist-info/WHEEL +4 -0
- robotframework_falsegreen-0.2.0.dist-info/entry_points.txt +2 -0
- robotframework_falsegreen-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
robotframework-falsegreen: deterministic false-positive scanner for Robot Framework tests.
|
|
5
|
+
|
|
6
|
+
Parses .robot files with the official Robot Framework parser (robot.api.get_model)
|
|
7
|
+
- no execution - and flags test cases that pass green without protecting anything:
|
|
8
|
+
a test with no verification keyword, a swallowed Run Keyword And Ignore Error, an
|
|
9
|
+
always-true Should Be True ${TRUE}, a self-compare, Sleep used as a wait, a skipped
|
|
10
|
+
test. Sibling of falsegreen (Python/pytest) and falsegreen-js (JS/TS).
|
|
11
|
+
|
|
12
|
+
Output: readable text (default) or JSON (--json).
|
|
13
|
+
Exit: 0 clean, 10 low-confidence only, 20 high-confidence present.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
TOOL_URI = "https://github.com/vinicq/robotframework-falsegreen"
|
|
23
|
+
|
|
24
|
+
# --- case catalog. code -> (title, confidence, judgment J1-J6) -------------
|
|
25
|
+
JUDGMENTS = {
|
|
26
|
+
"J1": "does the verification run?",
|
|
27
|
+
"J2": "is the oracle independent of the code?",
|
|
28
|
+
"J4": "does it check enough, and the right thing?",
|
|
29
|
+
"J5": "is it coupled / hard to maintain?",
|
|
30
|
+
}
|
|
31
|
+
CASES = {
|
|
32
|
+
"C2": ("empty test case, task, or keyword (no keywords run)", "high", "J1"),
|
|
33
|
+
"C2b": ("runs keywords but no verification keyword (no oracle)", "low", "J1"),
|
|
34
|
+
"C3": ("Run Keyword And Ignore Error/Return Status swallows the failure and the status is never asserted", "high", "J1"),
|
|
35
|
+
"C5": ("always-true check (Should Be True ${TRUE} / Should Be Equal with equal literals)", "high", "J2"),
|
|
36
|
+
"C6": ("weak check — Should Be True on a bare variable (truthiness only, not a comparison)", "low", "J4"),
|
|
37
|
+
"C7": ("self-compare (Should Be Equal ${x} ${x})", "high", "J2"),
|
|
38
|
+
"C16": ("Sleep used as synchronization (result depends on timing)", "low", "J1"),
|
|
39
|
+
"C23": ("hard-coded IP-address URL in test data (environment coupling / mystery guest)", "low", "J6"),
|
|
40
|
+
"C21": ("verification only runs conditionally (inside IF / Run Keyword If) — it may never execute", "low", "J1"),
|
|
41
|
+
"C32": ("skipped test (robot:skip / Skip) never runs", "low", "J1"),
|
|
42
|
+
"R1": ("Pass Execution forces the test to pass regardless of any check (forced green)", "high", "J1"),
|
|
43
|
+
"R2": ("user keyword named like a verifier (Verify/Assert/Should...) but its body contains no verification — a hollow oracle", "low", "J1"),
|
|
44
|
+
"R3": ("*** Test Cases *** section inside a .resource file — invalid; the cases never run", "high", "J1"),
|
|
45
|
+
"R4": ("No Operation is the only step — the test/task/keyword does nothing", "high", "J1"),
|
|
46
|
+
"R5": ("[Template] with no data rows — the templated test is generated with zero cases", "high", "J1"),
|
|
47
|
+
# --- diagnostic group (maintainability; default off, opt-in via --diagnostics) ---
|
|
48
|
+
"D2": ("control flow (IF/FOR/WHILE/TRY) at the test/task level — the guide advises against it", "off", "J4"),
|
|
49
|
+
# --- coupling group (structure; default off, opt-in) ----------------------
|
|
50
|
+
"M2": ("test/task has too many steps (the guide suggests max ~10)", "off", "J5"),
|
|
51
|
+
# --- project layer (config-audit only; emitted by --config-audit, never by
|
|
52
|
+
# the per-file scan). The suite reports green by run configuration. ---------
|
|
53
|
+
"PL9": ("skip-on-failure / noncritical in the run config turns a failing test into a non-fatal pass (legacy, removed in RF 4+)", "low", "J1"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Default thresholds for the opt-in groups (overridable later via config).
|
|
57
|
+
DIAGNOSTIC_THRESHOLDS = {"long_test_steps": 10}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def group_of(code):
|
|
61
|
+
"""false-positive (C*/R*) / diagnostic (D*) / coupling (M*) / project (PL*) — mirrors the siblings."""
|
|
62
|
+
if code.startswith("PL"):
|
|
63
|
+
return "project"
|
|
64
|
+
if code.startswith("D"):
|
|
65
|
+
return "diagnostic"
|
|
66
|
+
if code.startswith("M"):
|
|
67
|
+
return "coupling"
|
|
68
|
+
return "false-positive"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# One-line remediation per case: what to change so the test verifies something.
|
|
72
|
+
# Short, imperative, no trailing period. Surfaced in the status report (text +
|
|
73
|
+
# JSON `fix` field). A code missing here renders no fix line, never crashes.
|
|
74
|
+
FIX_HINTS = {
|
|
75
|
+
"C2": "add keywords that exercise and verify the behaviour",
|
|
76
|
+
"C2b": "add a verification keyword (Should..., a library assertion)",
|
|
77
|
+
"C3": "assert the returned status, or let the failure propagate",
|
|
78
|
+
"C5": "compare against an independent expected value, not a constant",
|
|
79
|
+
"C6": "compare the value (Should Be Equal), not just its truthiness",
|
|
80
|
+
"C7": "compare against an independent expected value, not the same variable",
|
|
81
|
+
"C16": "wait for the condition (Wait Until...) instead of Sleep",
|
|
82
|
+
"C21": "move the verification out of the IF/Run Keyword If so it always runs",
|
|
83
|
+
"C23": "read the URL from a variable/resource, not a hard-coded IP",
|
|
84
|
+
"C32": "remove the skip, or document why with a reason",
|
|
85
|
+
"R1": "remove Pass Execution; let the checks decide the result",
|
|
86
|
+
"R2": "make the verifier keyword actually assert, or rename it",
|
|
87
|
+
"R3": "move the test cases to a .robot suite; .resource holds keywords only",
|
|
88
|
+
"R4": "replace No Operation with real steps and a verification",
|
|
89
|
+
"R5": "add data rows to the [Template], or remove the template",
|
|
90
|
+
"D2": "move control flow into a keyword; keep the test case flat",
|
|
91
|
+
"M2": "split the long test into focused cases or extract keywords",
|
|
92
|
+
"PL9": "remove --skiponfailure/--noncritical so a failing test fails the run",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Test-pyramid level by imported library. A browser/mobile driver is E2E; an
|
|
96
|
+
# HTTP client or a database library crosses an I/O boundary, so it is
|
|
97
|
+
# integration; neither leaves the suite at unit level.
|
|
98
|
+
E2E_LIBRARIES = {"seleniumlibrary", "selenium2library", "browser", "appiumlibrary"}
|
|
99
|
+
INTEGRATION_LIBRARIES = {
|
|
100
|
+
"requestslibrary", "restinstance", "rest", "databaselibrary", "rpa.http",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _collect_libraries(model):
|
|
105
|
+
"""Library names imported in the suite's *** Settings *** section."""
|
|
106
|
+
libs = []
|
|
107
|
+
for section in getattr(model, "sections", None) or []:
|
|
108
|
+
for item in getattr(section, "body", None) or []:
|
|
109
|
+
if type(item).__name__ == "LibraryImport":
|
|
110
|
+
nm = getattr(item, "name", "") or ""
|
|
111
|
+
if nm:
|
|
112
|
+
libs.append(nm)
|
|
113
|
+
return libs
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def detect_pyramid_level(model):
|
|
117
|
+
"""Map the suite to a pyramid level from its imported libraries: 'e2e' (browser
|
|
118
|
+
or mobile driver), 'integration' (HTTP client or database library), or 'unit'
|
|
119
|
+
(neither). Broadest wins. A real API/DB library in a test the author treats as
|
|
120
|
+
unit is itself the smell, surfaced by the level mismatch."""
|
|
121
|
+
norm = {_norm(name) for name in _collect_libraries(model)}
|
|
122
|
+
if norm & E2E_LIBRARIES:
|
|
123
|
+
return "e2e"
|
|
124
|
+
if norm & INTEGRATION_LIBRARIES:
|
|
125
|
+
return "integration"
|
|
126
|
+
return "unit"
|
|
127
|
+
|
|
128
|
+
# --- verification vocabulary (the oracle), across Robot libraries ----------
|
|
129
|
+
# Dominant convention: the word "Should". Plus library-specific forms.
|
|
130
|
+
REST_SCHEMA = {"Integer", "Number", "String", "Boolean", "Object", "Array", "Null", "Missing"}
|
|
131
|
+
BROWSER_OPS = {"==", "!=", "contains", "not contains", "validate", "matches",
|
|
132
|
+
">", "<", ">=", "<=", "*=", "^=", "$=", "then"}
|
|
133
|
+
VERIFY_PREFIXES = ("verify", "assert", "validate", "check ")
|
|
134
|
+
SWALLOW_KEYWORDS = {"run keyword and ignore error", "run keyword and return status"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _norm(name):
|
|
138
|
+
return (name or "").strip().lower()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_verification(keyword, args):
|
|
142
|
+
"""True if this keyword call verifies an expected result (is an oracle)."""
|
|
143
|
+
if keyword is None:
|
|
144
|
+
return False
|
|
145
|
+
n = _norm(keyword)
|
|
146
|
+
if "should" in n:
|
|
147
|
+
return True # BuiltIn/Collections/String/Selenium/...
|
|
148
|
+
if keyword in REST_SCHEMA:
|
|
149
|
+
return True # RESTinstance schema assertions
|
|
150
|
+
if n.startswith(VERIFY_PREFIXES):
|
|
151
|
+
return True # custom Verify*/Assert*/Validate*/Check *
|
|
152
|
+
if n.startswith("wait until") and any(w in n for w in ("contain", "visible", "present")):
|
|
153
|
+
return True # Selenium/Appium waits that fail on timeout
|
|
154
|
+
if n.startswith("get ") and any(a in BROWSER_OPS for a in (args or ())):
|
|
155
|
+
return True # Browser assertion engine: Get ... == expected
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_swallow(keyword):
|
|
160
|
+
return _norm(keyword) in SWALLOW_KEYWORDS
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _looks_constant_true(arg):
|
|
164
|
+
return _norm(arg) in ("${true}", "true", "1", "${1}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_BARE_VAR_RE = re.compile(r"^[\$@&]\{[^{}]+\}$")
|
|
168
|
+
# A URL whose host is a literal IP address: strong signal of environment coupling
|
|
169
|
+
# (the test points at a fixed machine). A hostname URL is too common in E2E to flag.
|
|
170
|
+
_IP_URL_RE = re.compile(r"https?://\d{1,3}(?:\.\d{1,3}){3}\b")
|
|
171
|
+
# Body item types that actually do something (vs. settings like [Documentation]).
|
|
172
|
+
_EXECUTABLE_TYPES = {"KeywordCall", "If", "For", "While", "Try", "Var",
|
|
173
|
+
"ReturnStatement", "Return", "TemplateArguments"}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_bare_variable(arg):
|
|
177
|
+
"""True for a lone variable like ${x} (no comparison/expression) — a weak oracle
|
|
178
|
+
when passed to Should Be True (truthiness only)."""
|
|
179
|
+
return bool(_BARE_VAR_RE.match((arg or "").strip()))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _body_has_executable(node):
|
|
183
|
+
"""True if the body has any item that runs (a keyword call, a control block, a
|
|
184
|
+
variable assignment, a return, or template data) — not only settings."""
|
|
185
|
+
return any(type(i).__name__ in _EXECUTABLE_TYPES
|
|
186
|
+
for i in (getattr(node, "body", None) or []))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _only_no_operation(calls):
|
|
190
|
+
"""True if there is at least one call and every one is `No Operation`."""
|
|
191
|
+
return bool(calls) and all(_norm(c.keyword) == "no operation" for c in calls)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --- AST walk over the Robot model -----------------------------------------
|
|
195
|
+
def _keyword_calls(node):
|
|
196
|
+
"""Yield every KeywordCall in a block, descending into IF/FOR/TRY/WHILE."""
|
|
197
|
+
for item in getattr(node, "body", None) or []:
|
|
198
|
+
cls = type(item).__name__
|
|
199
|
+
if cls == "KeywordCall":
|
|
200
|
+
yield item
|
|
201
|
+
if hasattr(item, "body"):
|
|
202
|
+
yield from _keyword_calls(item)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _top_level_keyword_calls(testcase):
|
|
206
|
+
"""KeywordCalls directly in the test body (not nested in IF/FOR/TRY/WHILE)."""
|
|
207
|
+
return [i for i in (getattr(testcase, "body", None) or [])
|
|
208
|
+
if type(i).__name__ == "KeywordCall"]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _has_control_block(testcase):
|
|
212
|
+
return any(type(i).__name__ in ("If", "For", "While", "Try")
|
|
213
|
+
for i in (getattr(testcase, "body", None) or []))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _rkif_verifies(call):
|
|
217
|
+
"""A `Run Keyword If`/`Unless` whose arguments name a verification keyword -
|
|
218
|
+
that is a conditional verification (may not run)."""
|
|
219
|
+
if _norm(getattr(call, "keyword", None)) in ("run keyword if", "run keyword unless"):
|
|
220
|
+
return any("should" in _norm(a) for a in (getattr(call, "args", None) or ()))
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _try_blocks(node):
|
|
225
|
+
"""Yield native TRY blocks (RF 5+) anywhere in the test body."""
|
|
226
|
+
for item in getattr(node, "body", None) or []:
|
|
227
|
+
if type(item).__name__ == "Try" and _norm(getattr(item, "type", "")) == "try":
|
|
228
|
+
yield item
|
|
229
|
+
if hasattr(item, "body"):
|
|
230
|
+
yield from _try_blocks(item)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _except_swallows(try_node):
|
|
234
|
+
"""True if any EXCEPT branch of this TRY swallows the failure: its body has no
|
|
235
|
+
Fail, no verification keyword, and no re-raise - only logging / no-op / empty."""
|
|
236
|
+
branch = getattr(try_node, "next", None)
|
|
237
|
+
while branch is not None:
|
|
238
|
+
if _norm(getattr(branch, "type", "")) == "except":
|
|
239
|
+
harmless = True
|
|
240
|
+
for st in getattr(branch, "body", None) or []:
|
|
241
|
+
if type(st).__name__ != "KeywordCall":
|
|
242
|
+
continue
|
|
243
|
+
kw = _norm(st.keyword)
|
|
244
|
+
if kw in ("fail", "fatal error") or "should" in kw or kw.startswith(VERIFY_PREFIXES):
|
|
245
|
+
harmless = False
|
|
246
|
+
break
|
|
247
|
+
if harmless:
|
|
248
|
+
return True
|
|
249
|
+
branch = getattr(branch, "next", None)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _try_body_has_keyword(try_node):
|
|
254
|
+
return any(type(st).__name__ == "KeywordCall" for st in (getattr(try_node, "body", None) or []))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _tags(testcase):
|
|
258
|
+
tags = []
|
|
259
|
+
for item in getattr(testcase, "body", None) or []:
|
|
260
|
+
if type(item).__name__ == "Tags":
|
|
261
|
+
tags += [str(v) for v in getattr(item, "values", []) or []]
|
|
262
|
+
return tags
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Finding:
|
|
266
|
+
__slots__ = ("file", "line", "test", "code", "detail", "level")
|
|
267
|
+
|
|
268
|
+
def __init__(self, file, line, test, code, detail=""):
|
|
269
|
+
self.file = file
|
|
270
|
+
self.line = line
|
|
271
|
+
self.test = test
|
|
272
|
+
self.code = code
|
|
273
|
+
self.detail = detail
|
|
274
|
+
self.level = "unit" # unit | integration | e2e; set per file in analyze_file
|
|
275
|
+
|
|
276
|
+
def dict(self):
|
|
277
|
+
title, conf, judg = CASES[self.code]
|
|
278
|
+
return {"file": self.file, "line": self.line, "test": self.test,
|
|
279
|
+
"code": self.code, "confidence": conf, "judgment": judg,
|
|
280
|
+
"title": title, "detail": self.detail, "level": self.level,
|
|
281
|
+
"fix": FIX_HINTS.get(self.code, "")}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _name_implies_verification(name):
|
|
285
|
+
"""A user-keyword name that promises to verify. 'Check' is excluded - it often
|
|
286
|
+
names a getter that returns a status for the caller to assert."""
|
|
287
|
+
n = _norm(name)
|
|
288
|
+
return "should" in n or n.startswith(("verify", "assert", "validate"))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _call_level_smells(file, owner, calls, findings):
|
|
292
|
+
"""Per-call false-green checks shared by test cases, tasks, and user keywords:
|
|
293
|
+
C5 (always-true), C7 (self-compare), C16 (Sleep). Returns whether any keyword
|
|
294
|
+
call verifies something."""
|
|
295
|
+
has_verification = False
|
|
296
|
+
for c in calls:
|
|
297
|
+
kw, args = c.keyword, list(getattr(c, "args", []) or [])
|
|
298
|
+
ln = getattr(c, "lineno", 0) or 0
|
|
299
|
+
# C23 runs first: a hard-coded IP URL can sit in the arguments of a
|
|
300
|
+
# verification keyword (Should Be Equal ${url} http://10.0.0.5:8080),
|
|
301
|
+
# and the assertion branches below `continue` before reaching it.
|
|
302
|
+
for a in args:
|
|
303
|
+
if _IP_URL_RE.search(a or ""):
|
|
304
|
+
findings.append(Finding(file, ln, owner, "C23", "hard-coded IP-address URL"))
|
|
305
|
+
break
|
|
306
|
+
if _norm(kw) == "should be true" and args and _looks_constant_true(args[0]):
|
|
307
|
+
findings.append(Finding(file, ln, owner, "C5", "Should Be True on a constant"))
|
|
308
|
+
has_verification = True
|
|
309
|
+
continue
|
|
310
|
+
if _norm(kw) == "should be true" and len(args) == 1 and _is_bare_variable(args[0]):
|
|
311
|
+
findings.append(Finding(file, ln, owner, "C6", "Should Be True on a bare variable (truthiness only)"))
|
|
312
|
+
has_verification = True
|
|
313
|
+
continue
|
|
314
|
+
if _norm(kw) == "should be equal" and len(args) >= 2:
|
|
315
|
+
if args[0] == args[1]:
|
|
316
|
+
code = "C7" if args[0].startswith("${") else "C5"
|
|
317
|
+
findings.append(Finding(file, ln, owner, code, "both sides are identical"))
|
|
318
|
+
has_verification = True
|
|
319
|
+
continue
|
|
320
|
+
if _norm(kw) == "sleep":
|
|
321
|
+
findings.append(Finding(file, ln, owner, "C16"))
|
|
322
|
+
if is_verification(kw, args) or _rkif_verifies(c):
|
|
323
|
+
has_verification = True
|
|
324
|
+
return has_verification
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def analyze_keyword(file, kw, findings):
|
|
328
|
+
"""Analyze a User Keyword definition (.robot Keywords section or .resource).
|
|
329
|
+
Flags call-level smells inside the body, and R2 when the keyword is named like a
|
|
330
|
+
verifier but verifies nothing (a hollow oracle used by tests)."""
|
|
331
|
+
name = getattr(kw, "name", "") or ""
|
|
332
|
+
line = getattr(kw, "lineno", 0) or 0
|
|
333
|
+
calls = list(_keyword_calls(kw))
|
|
334
|
+
|
|
335
|
+
# C2: empty keyword (only settings, no steps). A do-nothing keyword called
|
|
336
|
+
# where verification should happen leaves the test green for free.
|
|
337
|
+
if not _body_has_executable(kw):
|
|
338
|
+
findings.append(Finding(file, line, name, "C2", "empty keyword"))
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# R4: the only step(s) are No Operation — the keyword does nothing.
|
|
342
|
+
if _only_no_operation(calls):
|
|
343
|
+
findings.append(Finding(file, line, name, "R4"))
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
has_verification = _call_level_smells(file, name, calls, findings)
|
|
347
|
+
if _name_implies_verification(name) and not has_verification:
|
|
348
|
+
findings.append(Finding(file, line, name, "R2"))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def analyze_testcase(file, tc, findings):
|
|
352
|
+
name = getattr(tc, "name", "") or ""
|
|
353
|
+
line = getattr(tc, "lineno", 0) or 0
|
|
354
|
+
calls = list(_keyword_calls(tc))
|
|
355
|
+
tags = [_norm(t) for t in _tags(tc)]
|
|
356
|
+
|
|
357
|
+
# C32: skipped
|
|
358
|
+
if any("robot:skip" in t for t in tags) or any(_norm(c.keyword) in ("skip",) for c in calls):
|
|
359
|
+
findings.append(Finding(file, line, name, "C32"))
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# R5: a templated test ([Template] keyword) is driven by data rows. With no
|
|
363
|
+
# data rows it generates zero cases and never runs. Templated tests carry
|
|
364
|
+
# TemplateArguments, not keyword calls, so this is checked before the
|
|
365
|
+
# call-based logic below.
|
|
366
|
+
body_items = getattr(tc, "body", None) or []
|
|
367
|
+
template = next((i for i in body_items if type(i).__name__ == "Template"), None)
|
|
368
|
+
if template is not None:
|
|
369
|
+
if not any(type(i).__name__ == "TemplateArguments" for i in body_items):
|
|
370
|
+
findings.append(Finding(file, line, name, "R5"))
|
|
371
|
+
return
|
|
372
|
+
# Populated template: the [Template] keyword is the oracle for every data
|
|
373
|
+
# row. When it is a known non-verifying builtin (e.g. [Template] Log), each
|
|
374
|
+
# generated case runs without verifying anything - false-green (C2b).
|
|
375
|
+
tmpl_kw = _norm(getattr(template, "value", None) or getattr(template, "name", None) or "")
|
|
376
|
+
non_verifying = {"log", "log to console", "no operation", "sleep", "comment",
|
|
377
|
+
"set variable", "set test variable", "set suite variable",
|
|
378
|
+
"set global variable"}
|
|
379
|
+
if tmpl_kw in non_verifying:
|
|
380
|
+
findings.append(Finding(file, line, name, "C2b",
|
|
381
|
+
"templated test's keyword does not verify anything"))
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# R1: Pass Execution at the top level forces the test green regardless of checks
|
|
385
|
+
if any(_norm(c.keyword) == "pass execution" for c in _top_level_keyword_calls(tc)):
|
|
386
|
+
findings.append(Finding(file, line, name, "R1"))
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# C2: empty (no keyword calls at all)
|
|
390
|
+
if not calls:
|
|
391
|
+
findings.append(Finding(file, line, name, "C2"))
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
# R4: the only step(s) are No Operation — the test runs but does nothing.
|
|
395
|
+
if _only_no_operation(calls):
|
|
396
|
+
findings.append(Finding(file, line, name, "R4"))
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
has_verification = _call_level_smells(file, name, calls, findings)
|
|
400
|
+
|
|
401
|
+
# diagnostic/coupling group (off by default; emitted always, filtered in scan)
|
|
402
|
+
if _has_control_block(tc):
|
|
403
|
+
findings.append(Finding(file, line, name, "D2"))
|
|
404
|
+
if len(calls) > DIAGNOSTIC_THRESHOLDS["long_test_steps"]:
|
|
405
|
+
findings.append(Finding(file, line, name, "M2", "%d steps" % len(calls)))
|
|
406
|
+
|
|
407
|
+
# C3: native TRY/EXCEPT whose EXCEPT swallows the failure
|
|
408
|
+
for tb in _try_blocks(tc):
|
|
409
|
+
if _try_body_has_keyword(tb) and _except_swallows(tb):
|
|
410
|
+
findings.append(Finding(file, getattr(tb, "lineno", line), name, "C3",
|
|
411
|
+
"TRY failure is swallowed by EXCEPT"))
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
# C3: swallow without an asserted status
|
|
415
|
+
if not has_verification and any(is_swallow(c.keyword) for c in calls):
|
|
416
|
+
findings.append(Finding(file, line, name, "C3"))
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# C2b: keywords ran but nothing verified
|
|
420
|
+
if not has_verification:
|
|
421
|
+
findings.append(Finding(file, line, name, "C2b"))
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
# C21: a verification exists, but none runs unconditionally — the only
|
|
425
|
+
# verification lives inside an IF/FOR block or a Run Keyword If. The guide is
|
|
426
|
+
# explicit: no if/else/for at the test-case level.
|
|
427
|
+
top = _top_level_keyword_calls(tc)
|
|
428
|
+
has_unconditional = any(
|
|
429
|
+
is_verification(c.keyword, list(getattr(c, "args", []) or []))
|
|
430
|
+
for c in top
|
|
431
|
+
if _norm(c.keyword) not in ("run keyword if", "run keyword unless")
|
|
432
|
+
)
|
|
433
|
+
if not has_unconditional and (_has_control_block(tc) or any(_rkif_verifies(c) for c in calls)):
|
|
434
|
+
findings.append(Finding(file, line, name, "C21"))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def analyze_file(path):
|
|
438
|
+
findings = []
|
|
439
|
+
try:
|
|
440
|
+
from robot.api import get_model
|
|
441
|
+
from robot.parsing import ModelVisitor
|
|
442
|
+
except Exception as exc: # pragma: no cover
|
|
443
|
+
sys.stderr.write("rffalsegreen: robotframework is required (%s)\n" % exc)
|
|
444
|
+
return findings
|
|
445
|
+
try:
|
|
446
|
+
model = get_model(path)
|
|
447
|
+
except Exception:
|
|
448
|
+
return findings
|
|
449
|
+
self_findings = findings
|
|
450
|
+
is_resource = path.endswith(".resource")
|
|
451
|
+
|
|
452
|
+
# R3: a .resource file holds keywords/variables for reuse; a *** Test Cases ***
|
|
453
|
+
# section there is invalid and its cases never run. Flag the section once and
|
|
454
|
+
# skip per-test analysis for the file (the keywords are still analyzed).
|
|
455
|
+
if is_resource:
|
|
456
|
+
for section in getattr(model, "sections", None) or []:
|
|
457
|
+
if type(section).__name__ == "TestCaseSection":
|
|
458
|
+
ln = getattr(getattr(section, "header", None), "lineno", 0) \
|
|
459
|
+
or getattr(section, "lineno", 0) or 1
|
|
460
|
+
self_findings.append(Finding(path, ln, "", "R3",
|
|
461
|
+
"Test Cases section is not allowed in a .resource file"))
|
|
462
|
+
break
|
|
463
|
+
|
|
464
|
+
class _V(ModelVisitor):
|
|
465
|
+
def visit_TestCase(self, node):
|
|
466
|
+
if not is_resource:
|
|
467
|
+
analyze_testcase(path, node, self_findings)
|
|
468
|
+
|
|
469
|
+
def visit_Task(self, node): # RPA suites use *** Tasks ***, not *** Test Cases ***
|
|
470
|
+
if not is_resource:
|
|
471
|
+
analyze_testcase(path, node, self_findings)
|
|
472
|
+
|
|
473
|
+
def visit_Keyword(self, node): # user keyword defs in .robot Keywords + .resource
|
|
474
|
+
analyze_keyword(path, node, self_findings)
|
|
475
|
+
|
|
476
|
+
_V().visit(model)
|
|
477
|
+
level = detect_pyramid_level(model)
|
|
478
|
+
for f in findings:
|
|
479
|
+
f.level = level
|
|
480
|
+
return findings
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# --- discovery + CLI -------------------------------------------------------
|
|
484
|
+
IGNORED_DIRS = {".git", ".tox", "venv", ".venv", "node_modules", "results", "output"}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def is_robot_file(path):
|
|
488
|
+
return path.endswith((".robot", ".resource"))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def discover(paths):
|
|
492
|
+
files = []
|
|
493
|
+
for root in paths:
|
|
494
|
+
if os.path.isfile(root):
|
|
495
|
+
if is_robot_file(root):
|
|
496
|
+
files.append(root)
|
|
497
|
+
continue
|
|
498
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
499
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
|
|
500
|
+
for f in filenames:
|
|
501
|
+
if is_robot_file(f):
|
|
502
|
+
files.append(os.path.join(dirpath, f))
|
|
503
|
+
return sorted(set(files))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _eff_conf(code):
|
|
507
|
+
"""Effective confidence: an off-by-default (D*/M*) code shows as 'low' when enabled."""
|
|
508
|
+
c = CASES[code][1]
|
|
509
|
+
return "low" if c == "off" else c
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def scan(paths, disable=None, diagnostics=False):
|
|
513
|
+
disable = disable or set()
|
|
514
|
+
out = []
|
|
515
|
+
for f in discover(paths):
|
|
516
|
+
for finding in analyze_file(f):
|
|
517
|
+
conf = CASES[finding.code][1]
|
|
518
|
+
if finding.code in disable:
|
|
519
|
+
continue
|
|
520
|
+
if conf == "off" and not diagnostics:
|
|
521
|
+
continue
|
|
522
|
+
out.append(finding)
|
|
523
|
+
return out
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _render_text(findings):
|
|
527
|
+
if not findings:
|
|
528
|
+
return "rffalsegreen: no false-positive patterns found."
|
|
529
|
+
lines, high, low = [], 0, 0
|
|
530
|
+
by_file = {}
|
|
531
|
+
for f in findings:
|
|
532
|
+
by_file.setdefault(f.file, []).append(f)
|
|
533
|
+
for file, fs in by_file.items():
|
|
534
|
+
lines.append("\n" + file)
|
|
535
|
+
for f in sorted(fs, key=lambda x: x.line):
|
|
536
|
+
title = CASES[f.code][0]
|
|
537
|
+
conf = _eff_conf(f.code)
|
|
538
|
+
tag = "HIGH" if conf == "high" else "low "
|
|
539
|
+
high += conf == "high"
|
|
540
|
+
low += conf == "low"
|
|
541
|
+
lines.append(" %s %-4s L%-4d %s %s" % (tag, f.code, f.line, f.test, title))
|
|
542
|
+
if f.detail:
|
|
543
|
+
lines.append(" " + f.detail)
|
|
544
|
+
hint = FIX_HINTS.get(f.code, "")
|
|
545
|
+
lines.append(" level: %s%s" % (
|
|
546
|
+
f.level, (" fix: " + hint) if hint else ""))
|
|
547
|
+
lines.append("\n%d high, %d low. %s" % (high, low, TOOL_URI))
|
|
548
|
+
|
|
549
|
+
# Test-pyramid breakdown + the most common fixes, over every finding shown.
|
|
550
|
+
by_level, by_code = {}, {}
|
|
551
|
+
for f in findings:
|
|
552
|
+
by_level[f.level] = by_level.get(f.level, 0) + 1
|
|
553
|
+
by_code[f.code] = by_code.get(f.code, 0) + 1
|
|
554
|
+
order = ["unit", "integration", "e2e"]
|
|
555
|
+
levels = [lv for lv in order if lv in by_level] + \
|
|
556
|
+
[lv for lv in sorted(by_level) if lv not in order]
|
|
557
|
+
lines.append("By level: " + ", ".join("%s:%d" % (lv, by_level[lv]) for lv in levels))
|
|
558
|
+
top = sorted(by_code.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
|
|
559
|
+
lines.append("Top fixes:")
|
|
560
|
+
for code, n in top:
|
|
561
|
+
lines.append(" %s (%d): %s" % (code, n, FIX_HINTS.get(code, CASES[code][0])))
|
|
562
|
+
return "\n".join(lines)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
_OUTPUT_EXT = {"text": "txt", "json": "json"}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def resolve_output_path(path, fmt):
|
|
569
|
+
"""Turn --output into a concrete file path. A directory (existing dir, a
|
|
570
|
+
trailing separator, or an extension-less name like '.falsegreen') receives
|
|
571
|
+
'report.<ext>' for the chosen format; anything else is treated as a file.
|
|
572
|
+
Missing parent directories are created either way."""
|
|
573
|
+
ext = _OUTPUT_EXT.get(fmt, "txt")
|
|
574
|
+
base = os.path.basename(path.rstrip("/\\"))
|
|
575
|
+
is_dir = (path.endswith(("/", "\\")) or os.path.isdir(path)
|
|
576
|
+
or os.path.splitext(base)[1] == "")
|
|
577
|
+
if is_dir:
|
|
578
|
+
os.makedirs(path, exist_ok=True)
|
|
579
|
+
return os.path.join(path, "report." + ext)
|
|
580
|
+
parent = os.path.dirname(path)
|
|
581
|
+
if parent:
|
|
582
|
+
os.makedirs(parent, exist_ok=True)
|
|
583
|
+
return path
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# Project-layer audit (--config-audit): the suite reports green by run config,
|
|
588
|
+
# not by a smell inside any one suite file. Reads the Robot run config.
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
def _read_toml_file(path):
|
|
591
|
+
try:
|
|
592
|
+
import tomllib as _t
|
|
593
|
+
except Exception:
|
|
594
|
+
try:
|
|
595
|
+
import tomli as _t
|
|
596
|
+
except Exception:
|
|
597
|
+
return None
|
|
598
|
+
try:
|
|
599
|
+
with open(path, "rb") as fh:
|
|
600
|
+
return _t.load(fh)
|
|
601
|
+
except Exception:
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def audit_config(start=None):
|
|
606
|
+
"""Project-layer audit: read the Robot run config (robot.toml, pyproject
|
|
607
|
+
[tool.robot]/[tool.robotframework], *.args argument files) and report PL9 -
|
|
608
|
+
a skip-on-failure / noncritical option that turns a failing test into a
|
|
609
|
+
non-fatal pass. Findings carry the config file and level 'project'.
|
|
610
|
+
Returns [] when no such config is found."""
|
|
611
|
+
import glob
|
|
612
|
+
base = start or os.getcwd()
|
|
613
|
+
findings = []
|
|
614
|
+
|
|
615
|
+
def _flag(path, detail=""):
|
|
616
|
+
f = Finding(path, 1, "", "PL9", detail)
|
|
617
|
+
f.level = "project"
|
|
618
|
+
findings.append(f)
|
|
619
|
+
|
|
620
|
+
toml_sources = (
|
|
621
|
+
("robot.toml", lambda d: d),
|
|
622
|
+
("pyproject.toml", lambda d: (d.get("tool", {}).get("robot")
|
|
623
|
+
or d.get("tool", {}).get("robotframework") or {})),
|
|
624
|
+
)
|
|
625
|
+
for name, getter in toml_sources:
|
|
626
|
+
path = os.path.join(base, name)
|
|
627
|
+
if not os.path.isfile(path):
|
|
628
|
+
continue
|
|
629
|
+
data = _read_toml_file(path)
|
|
630
|
+
if data is None:
|
|
631
|
+
continue
|
|
632
|
+
section = getter(data) or {}
|
|
633
|
+
keys = {str(k).replace("-", "").replace("_", "").lower() for k in section}
|
|
634
|
+
if {"skiponfailure", "noncritical"} & keys:
|
|
635
|
+
_flag(path, "skip-on-failure/noncritical in %s" % name)
|
|
636
|
+
return findings
|
|
637
|
+
|
|
638
|
+
for argfile in sorted(glob.glob(os.path.join(base, "*.args"))):
|
|
639
|
+
try:
|
|
640
|
+
with open(argfile, "r", encoding="utf-8") as fh:
|
|
641
|
+
text = fh.read()
|
|
642
|
+
except Exception:
|
|
643
|
+
continue
|
|
644
|
+
if re.search(r"--(skiponfailure|noncritical)\b", text):
|
|
645
|
+
_flag(argfile, "skip-on-failure/noncritical in argument file")
|
|
646
|
+
return findings
|
|
647
|
+
return findings
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def main(argv=None):
|
|
651
|
+
p = argparse.ArgumentParser(prog="rffalsegreen",
|
|
652
|
+
description="Find false-positive Robot Framework tests (static).")
|
|
653
|
+
p.add_argument("paths", nargs="*", default=["."], help="files or directories (default: cwd)")
|
|
654
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
655
|
+
p.add_argument("--output", default=None, metavar="PATH",
|
|
656
|
+
help="write the output to PATH instead of stdout; "
|
|
657
|
+
"a directory (e.g. .falsegreen/) gets report.<ext>")
|
|
658
|
+
p.add_argument("--config-audit", action="store_true",
|
|
659
|
+
help="audit the Robot run config (robot.toml / argument files) for "
|
|
660
|
+
"project-layer false-green (PL codes) instead of scanning suites")
|
|
661
|
+
p.add_argument("--disable", default="", help="comma-separated codes to turn off")
|
|
662
|
+
p.add_argument("--diagnostics", action="store_true",
|
|
663
|
+
help="also report the opt-in maintainability group (D*/M*)")
|
|
664
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
665
|
+
args = p.parse_args(argv)
|
|
666
|
+
disable = {c.strip() for c in args.disable.split(",") if c.strip()}
|
|
667
|
+
if args.config_audit:
|
|
668
|
+
base = next((d for d in (args.paths or ["."]) if os.path.isdir(d)), os.getcwd())
|
|
669
|
+
findings = audit_config(base)
|
|
670
|
+
if args.json:
|
|
671
|
+
rendered = json.dumps({"tool": "robotframework-falsegreen", "version": __version__,
|
|
672
|
+
"judgments": JUDGMENTS,
|
|
673
|
+
"findings": [f.dict() for f in findings]}, indent=2)
|
|
674
|
+
else:
|
|
675
|
+
rendered = _render_text(findings)
|
|
676
|
+
if args.output:
|
|
677
|
+
dest = resolve_output_path(args.output, "json" if args.json else "text")
|
|
678
|
+
with open(dest, "w", encoding="utf-8") as fh:
|
|
679
|
+
fh.write(rendered + "\n")
|
|
680
|
+
else:
|
|
681
|
+
print(rendered)
|
|
682
|
+
return 10 if findings else 0
|
|
683
|
+
findings = scan(args.paths or ["."], disable=disable, diagnostics=args.diagnostics)
|
|
684
|
+
if args.json:
|
|
685
|
+
rendered = json.dumps({"tool": "robotframework-falsegreen", "version": __version__,
|
|
686
|
+
"judgments": JUDGMENTS,
|
|
687
|
+
"findings": [f.dict() for f in findings]}, indent=2)
|
|
688
|
+
else:
|
|
689
|
+
rendered = _render_text(findings)
|
|
690
|
+
if args.output:
|
|
691
|
+
dest = resolve_output_path(args.output, "json" if args.json else "text")
|
|
692
|
+
with open(dest, "w", encoding="utf-8") as fh:
|
|
693
|
+
fh.write(rendered + "\n")
|
|
694
|
+
else:
|
|
695
|
+
print(rendered)
|
|
696
|
+
if any(_eff_conf(f.code) == "high" for f in findings):
|
|
697
|
+
return 20
|
|
698
|
+
if findings:
|
|
699
|
+
return 10
|
|
700
|
+
return 0
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
if __name__ == "__main__":
|
|
704
|
+
sys.exit(main())
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-falsegreen
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Find Robot Framework tests that pass green without protecting anything: tests with no verification keyword, swallowed failures, always-true checks. Deterministic static scan, sibling of falsegreen (Python) and falsegreen-js (JS/TS).
|
|
5
|
+
Project-URL: Homepage, https://github.com/vinicq/robotframework-falsegreen
|
|
6
|
+
Project-URL: Issues, https://github.com/vinicq/robotframework-falsegreen/issues
|
|
7
|
+
Author: Vinicius Queiroz
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: code-quality,false-positive,robot-framework,robotframework,static-analysis,test-smells,testing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Robot Framework
|
|
13
|
+
Classifier: Framework :: Robot Framework :: Tool
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: robotframework>=4.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# robotframework-falsegreen
|
|
26
|
+
|
|
27
|
+
**One problem, one tool: the false positive.** robotframework-falsegreen finds Robot Framework
|
|
28
|
+
tests that pass green without protecting anything - tests that let broken behavior
|
|
29
|
+
through because no keyword verifies anything, the failure is swallowed, the check is
|
|
30
|
+
always true, or the test is skipped.
|
|
31
|
+
|
|
32
|
+
Deterministic static scan over the official Robot Framework parser
|
|
33
|
+
(`robot.api.get_model`) - no execution. Sibling of
|
|
34
|
+
[falsegreen](https://github.com/vinicq/falsegreen) (Python/pytest) and
|
|
35
|
+
[falsegreen-js](https://github.com/vinicq/falsegreen-js) (JS/TS). The semantic,
|
|
36
|
+
intent-based pass lives in [falsegreen-skill](https://github.com/vinicq/falsegreen-skill).
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
A green Robot suite is not proof of correctness. A test case can run keywords and never
|
|
41
|
+
call a verification keyword; a `Run Keyword And Ignore Error` can absorb the failure; a
|
|
42
|
+
`Should Be True ${TRUE}` can never fail. This tool flags the patterns a parser can
|
|
43
|
+
prove, before they reach review.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install robotframework-falsegreen
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
rffalsegreen # scan cwd
|
|
55
|
+
rffalsegreen tests/ # scan a path
|
|
56
|
+
rffalsegreen --json # machine-readable output
|
|
57
|
+
rffalsegreen --output report.json # write to a file
|
|
58
|
+
rffalsegreen --output .falsegreen/ # write report.<ext> into a directory
|
|
59
|
+
rffalsegreen --config-audit # audit the Robot run config (project-layer PL codes)
|
|
60
|
+
rffalsegreen --disable C16 # turn off specific codes
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each finding is reported with its pyramid level (unit / integration / e2e, read from the suite's imported libraries) and a one-line fix hint, and the summary breaks the findings down by level and lists the most common fixes. `--output` takes a file or a directory: an extension-less or trailing-slash path (e.g. `.falsegreen/`) receives `report.<ext>` for the chosen format. Reports are run artifacts; keep the output directory gitignored.
|
|
64
|
+
|
|
65
|
+
`--config-audit` is a separate mode: instead of scanning suites, it reads the Robot run config (`robot.toml`, `pyproject.toml` `[tool.robot]`, `*.args` argument files) and reports `PL9` - a `--skiponfailure` / `--noncritical` option that turns a failing test into a non-fatal pass (legacy, removed in RF 4+). The per-file scan cannot see run config.
|
|
66
|
+
|
|
67
|
+
For the layer no static scan reaches (does a green test fail when the code is wrong?), Robot has no standard mutation tester, so that check is manual review; the semantic [falsegreen-skill](https://github.com/vinicq/falsegreen-skill) covers the intent-level cases.
|
|
68
|
+
|
|
69
|
+
Exit code: `0` clean, `10` low-confidence only, `20` high-confidence present. Wire exit
|
|
70
|
+
`20` into CI to block the merge.
|
|
71
|
+
|
|
72
|
+
## What it detects
|
|
73
|
+
|
|
74
|
+
The oracle in Robot is the **verification keyword**. The scanner recognizes them across
|
|
75
|
+
libraries (the `Should` convention plus library-specific forms: SeleniumLibrary
|
|
76
|
+
`Element Should Be Visible`, Browser's assertion engine `Get Text sel == expected`,
|
|
77
|
+
RESTinstance schema keywords, DatabaseLibrary `Row Count Should Be Equal`, custom
|
|
78
|
+
`Verify*`/`Assert*` keywords). A test with none of them verifies nothing.
|
|
79
|
+
|
|
80
|
+
| Code | Confidence | What it flags |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| C2 | high | empty test case, task, or keyword (no keywords run) |
|
|
83
|
+
| C2b | low | runs keywords but no verification keyword (no oracle) |
|
|
84
|
+
| C3 | high | `Run Keyword And Ignore Error`/`Return Status` swallows the failure, status never asserted |
|
|
85
|
+
| C5 | high | always-true (`Should Be True ${TRUE}`, `Should Be Equal` with equal literals) |
|
|
86
|
+
| C6 | low | weak check — `Should Be True` on a bare variable (truthiness only, not a comparison) |
|
|
87
|
+
| C7 | high | self-compare (`Should Be Equal ${x} ${x}`) |
|
|
88
|
+
| C16 | low | `Sleep` used as synchronization (timing dependence) |
|
|
89
|
+
| C21 | low | verification only runs conditionally (inside `IF` / `Run Keyword If`) — it may never execute |
|
|
90
|
+
| C23 | low | hard-coded IP-address URL in test data (`http://10.0.0.5:8080`) — environment coupling |
|
|
91
|
+
| C32 | low | skipped test (`robot:skip` / `Skip`) |
|
|
92
|
+
| R1 | high | `Pass Execution` forces the test green regardless of any check |
|
|
93
|
+
| R2 | low | user keyword named like a verifier (`Verify`/`Assert`/`Should`...) but its body verifies nothing — a hollow oracle |
|
|
94
|
+
| R3 | high | `*** Test Cases ***` inside a `.resource` file — invalid; the cases never run |
|
|
95
|
+
| R4 | high | `No Operation` is the only step — the test/task/keyword does nothing |
|
|
96
|
+
| R5 | high | `[Template]` with no data rows — the templated test runs zero cases |
|
|
97
|
+
|
|
98
|
+
Scans `*** Test Cases ***`, `*** Tasks ***` (RPA), and `*** Keywords ***` definitions in
|
|
99
|
+
both `.robot` and `.resource` files. R2 catches the root cause of a missed C2b: a test
|
|
100
|
+
calls `Verify Login` and looks protected, but that keyword never asserts anything.
|
|
101
|
+
|
|
102
|
+
### Opt-in: maintainability group (default off)
|
|
103
|
+
|
|
104
|
+
Not false-green - the test still verifies - so off by default. Enable with `--diagnostics`.
|
|
105
|
+
Three groups, mirroring `falsegreen` and `falsegreen-js`: `false-positive` (C*/R*, on),
|
|
106
|
+
`diagnostic` (D*, opt-in), `coupling` (M*, opt-in).
|
|
107
|
+
|
|
108
|
+
| Code | Group | What it flags |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| D2 | diagnostic | control flow (`IF`/`FOR`/`WHILE`/`TRY`) at the test/task level (the guide advises against it) |
|
|
111
|
+
| M2 | coupling | test/task with too many steps (guide suggests max ~10) |
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
rffalsegreen --diagnostics # include D*/M* as warnings
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Codes share ids with the sibling scanners where the concept matches (C2/C2b/C3/C5/C7/C16/C21/C32).
|
|
118
|
+
A Browser `Get` keyword with no assertion operator is a plain getter, so a test whose only
|
|
119
|
+
step is `Get Text h1` surfaces as no-verification (C2b).
|
|
120
|
+
|
|
121
|
+
## Test levels (the pyramid)
|
|
122
|
+
|
|
123
|
+
rffalsegreen scans Robot suites at every level of the pyramid. Discovery is
|
|
124
|
+
level-agnostic - it reads any `.robot`/`.resource` - but a few codes are read in light of
|
|
125
|
+
the level, so a valid pattern at one level is not flagged at another.
|
|
126
|
+
|
|
127
|
+
- **Unit:** keyword logic with the boundaries doubled. The oracle is a `Should` keyword.
|
|
128
|
+
- **Integration (API and database):** API tests through RequestsLibrary and RESTinstance
|
|
129
|
+
(the schema keywords count as the oracle), database tests through DatabaseLibrary
|
|
130
|
+
(`Row Count Should Be Equal`, `Check If Exists In Database`). These hit a real endpoint or
|
|
131
|
+
datastore on purpose, so the request or row IS the verification at that level.
|
|
132
|
+
- **E2E:** the Browser library and SeleniumLibrary/Appium. The page assertion
|
|
133
|
+
(`Page Should Contain`, `Get Text ... == ...`) is the oracle; the presence of a rendered
|
|
134
|
+
element is a real check at this level, not a weak one.
|
|
135
|
+
|
|
136
|
+
A real API or database hit inside a test that claims to be a unit test is itself the smell
|
|
137
|
+
(environment coupling, mystery guest), not the level of the test. C23 flags the strongest
|
|
138
|
+
form: a hard-coded IP-address endpoint.
|
|
139
|
+
|
|
140
|
+
## Scope and honesty
|
|
141
|
+
|
|
142
|
+
Static scan: it owns what the keyword structure proves. It does not run the suite, so it
|
|
143
|
+
cannot see runtime-only smells (Test Run War, order dependence across suites). Whether the
|
|
144
|
+
expected value contradicts the intended behavior is semantic and belongs to
|
|
145
|
+
`falsegreen-skill`. Precision over recall: `C2b` is low-confidence because a custom keyword
|
|
146
|
+
may assert internally without `Should` in its name.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT, Vinicius Queiroz.
|
|
151
|
+
|
|
152
|
+
## Contributors ✨
|
|
153
|
+
|
|
154
|
+
Thanks to the people who keep false-green tests out of real suites ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
|
155
|
+
|
|
156
|
+
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
|
157
|
+
[](#contributors-)
|
|
158
|
+
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
|
159
|
+
|
|
160
|
+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
161
|
+
<!-- prettier-ignore-start -->
|
|
162
|
+
<!-- markdownlint-disable -->
|
|
163
|
+
<table>
|
|
164
|
+
<tbody>
|
|
165
|
+
<tr>
|
|
166
|
+
<td align="center" valign="top" width="14.28%"><a href="https://vinicq.github.io/md-bridge/"><img src="https://avatars.githubusercontent.com/u/78210890?v=4?s=100" width="100px;" alt="Vinicius Queiroz"/><br /><sub><b>Vinicius Queiroz</b></sub></a><br /><a href="https://github.com/vinicq/robotframework-falsegreen/commits?author=vinicq" title="Code">💻</a> <a href="https://github.com/vinicq/robotframework-falsegreen/commits?author=vinicq" title="Documentation">📖</a> <a href="#ideas-vinicq" title="Ideas, Planning, & Feedback">🤔</a> <a href="#maintenance-vinicq" title="Maintenance">🚧</a> <a href="#infra-vinicq" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/vinicq/robotframework-falsegreen/commits?author=vinicq" title="Tests">⚠️</a> <a href="#research-vinicq" title="Research">🔬</a></td>
|
|
167
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/homesellerq-coder"><img src="https://avatars.githubusercontent.com/u/294912019?v=4?s=100" width="100px;" alt="Home Seller"/><br /><sub><b>Home Seller</b></sub></a><br /><a href="https://github.com/vinicq/robotframework-falsegreen/commits?author=homesellerq-coder" title="Code">💻</a></td>
|
|
168
|
+
</tr>
|
|
169
|
+
</tbody>
|
|
170
|
+
</table>
|
|
171
|
+
|
|
172
|
+
<!-- markdownlint-restore -->
|
|
173
|
+
<!-- prettier-ignore-end -->
|
|
174
|
+
|
|
175
|
+
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
176
|
+
|
|
177
|
+
New contributors are added automatically; the table also recognizes non-code work (docs, ideas, infrastructure, tests, research) via the [all-contributors](https://allcontributors.org) spec.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
falsegreen_robot/__init__.py,sha256=SjrmpczQ08XrcIMSAgUVMqG8uHdq6rLqjaSilsC_1sA,134
|
|
2
|
+
falsegreen_robot/__main__.py,sha256=n_pUpMzyNzzyqoBaKbHVgLI1lbDPImYhIENNulBOZPY,87
|
|
3
|
+
falsegreen_robot/scanner.py,sha256=1j5ovm9ftTiKpMgaM0PpWzl0MZAGCKcjRczC5_bAapo,30850
|
|
4
|
+
robotframework_falsegreen-0.2.0.dist-info/METADATA,sha256=z1TCRlUfO_icMxd9XDSY5RXA36RXXlapFbKAhbC3o88,10482
|
|
5
|
+
robotframework_falsegreen-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
robotframework_falsegreen-0.2.0.dist-info/entry_points.txt,sha256=7ecGGPcSGmcf58nT95mNWGvqtUUE6tUTGkatB16R0jI,63
|
|
7
|
+
robotframework_falsegreen-0.2.0.dist-info/licenses/LICENSE,sha256=u347iJB1k4mASuUpZnxYdINA-jKLOkzyaVHOOzQf70U,1073
|
|
8
|
+
robotframework_falsegreen-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Queiroz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|