@arjunkhera/atlas 0.3.41 → 0.3.43
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-plugin/plugin.json +1 -1
- package/door/cli.mjs +10 -4
- package/door/kit-releases.json +4 -0
- package/door/lib/changed-tests.mjs +420 -0
- package/door/lib/tests.mjs +3 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/tests/SKILL.md +25 -0
- package/tests/bridge.mjs +91 -0
- package/tests/contract.py +893 -0
- package/tests/drivers/index.mjs +3 -0
- package/tests/link-check.mjs +221 -9
- package/tests/tests-yaml.mjs +13 -4
- package/tests/verdict.mjs +8 -4
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"""The contract library for Python (design section 8.1). It is the Python form of
|
|
2
|
+
contract.mjs, on the standard unittest runner, so a repo needs no new package.
|
|
3
|
+
|
|
4
|
+
# in test/scenarios/routing_score_test.py, with the kit copy in test/atlas/
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "atlas"))
|
|
8
|
+
from contract import scenario
|
|
9
|
+
|
|
10
|
+
@scenario("../../scenarios/routing-score.md", adapter=adapter)
|
|
11
|
+
def RoutingScore(run, way):
|
|
12
|
+
owner = run.persona("owner", way)
|
|
13
|
+
# ... steps, through the actions of the area ...
|
|
14
|
+
with run.gate("routing-score/e1#e46b3c"):
|
|
15
|
+
assert first.exit == 0, first.stderr
|
|
16
|
+
with run.check("routing-score/e2#7d78e1") as proof:
|
|
17
|
+
assert "held-out: 3/3 routed correctly" in right.stdout
|
|
18
|
+
proof.value = right.stdout.strip()
|
|
19
|
+
|
|
20
|
+
What `scenario(path, adapter=...)` does
|
|
21
|
+
1. It reads the scenario and atlas/tests.yaml through the bridge
|
|
22
|
+
(bridge.mjs), so the words, the fingerprints, the tests.yaml check and
|
|
23
|
+
the guard parts are read by the same code as the link check. It needs
|
|
24
|
+
node, as the link check does. It refuses to run when either file has a
|
|
25
|
+
fault: the import fails.
|
|
26
|
+
2. It makes one unittest.TestCase class, with one test for each way in of
|
|
27
|
+
`through:`. Each test runs the body with (run, way).
|
|
28
|
+
3. It writes the evidence file, schema `evidence: 1`, the same file as
|
|
29
|
+
evidence.mjs writes: at the start and after each assertion. So the run
|
|
30
|
+
half of the link check reads a Python run as it reads a JavaScript run.
|
|
31
|
+
4. A test fails when any assertion is not `pass`. That holds for `fail`,
|
|
32
|
+
`not checked`, `not exercised`, `blocked` and `unsure`.
|
|
33
|
+
5. When the scenario's `runs-in` excludes the environment, each result is
|
|
34
|
+
`not here` and the test passes.
|
|
35
|
+
|
|
36
|
+
A check is a `with` block. The block must hold an assert statement (the
|
|
37
|
+
Python lint of the link check refuses one that does not). A failed assert,
|
|
38
|
+
or any other error in the block, is a `fail`, and the run goes on. A failed
|
|
39
|
+
gate stops the run; later assertions read `not checked`. A block that is not
|
|
40
|
+
entered checks nothing, so its assertion stays `not checked`.
|
|
41
|
+
|
|
42
|
+
What this library does not do yet. Each one ends `blocked`, with the reason,
|
|
43
|
+
and never a pass: an environment that starts a product or a stand-in, run
|
|
44
|
+
secrets, a persona with a secret, `make:` or `sign-in:` that the adapter does
|
|
45
|
+
not give, `repeat`, judged assertions, model calls, and the drivers of the
|
|
46
|
+
kit. A way in gets its driver from the adapter; `child_env` here is the guard
|
|
47
|
+
that a driver gives its child process.
|
|
48
|
+
|
|
49
|
+
The adapter binds the library to one product. Every part is optional:
|
|
50
|
+
drivers {way or driver name: (ctx) -> driver}; ctx holds way, spec, record,
|
|
51
|
+
redactor, root, run_id and tests. driver.call(request, **opts) and
|
|
52
|
+
driver.close()
|
|
53
|
+
warm_up (env) -> None, once before the first test; raise Blocked when the
|
|
54
|
+
product cannot run on this machine
|
|
55
|
+
actions an object, or (run) -> object: the verbs of the area, on run.actions
|
|
56
|
+
data {rule: (run_id, count, rule) -> value}, a data rule of the repo
|
|
57
|
+
makers, sign_ins {name: (ctx) -> {credential, headers, cleanup}}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import datetime as _dt
|
|
63
|
+
import hashlib
|
|
64
|
+
import inspect
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import re
|
|
68
|
+
import secrets as _secrets
|
|
69
|
+
import signal
|
|
70
|
+
import subprocess
|
|
71
|
+
import sys
|
|
72
|
+
import threading
|
|
73
|
+
import time
|
|
74
|
+
import types
|
|
75
|
+
import unittest
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"scenario", "define_adapter", "wait_until", "parse_limit", "child_env",
|
|
80
|
+
"Blocked", "GateFailed", "WaitFailed", "WaitTimeout", "Redactor", "RUN_ID",
|
|
81
|
+
"EVIDENCE_SCHEMA", "RESULTS",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
KIT = Path(__file__).resolve().parent
|
|
85
|
+
BRIDGE = KIT / "bridge.mjs"
|
|
86
|
+
FULL_ID = re.compile(r"^(.+)/(e\d+)#([0-9a-f]{6})$")
|
|
87
|
+
EVIDENCE_SCHEMA = 1
|
|
88
|
+
RESULTS = ("pass", "fail", "not checked", "not exercised", "blocked", "not here", "flaky", "unsure")
|
|
89
|
+
MAX_BODY = 8000
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- errors (errors.mjs)
|
|
93
|
+
|
|
94
|
+
class Blocked(Exception):
|
|
95
|
+
"""A setup failed, a guard refused, or a precondition does not hold. The result is `blocked`."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GateFailed(Exception):
|
|
99
|
+
"""A failed gate. It stops the body of the test."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class WaitFailed(Exception):
|
|
103
|
+
"""A wait met its fail state. This is a product result, so it is a `fail`."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, reason, last=None):
|
|
106
|
+
super().__init__(reason)
|
|
107
|
+
self.last = last
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WaitTimeout(Exception):
|
|
111
|
+
"""A wait ran out of time. This is a product result, so it is a `fail`."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, reason, last=None):
|
|
114
|
+
super().__init__(reason)
|
|
115
|
+
self.last = last
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _OutOfTime(BaseException):
|
|
119
|
+
"""Not an Exception, so neither a check block nor an `except Exception` of a test can swallow it."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def define_adapter(**parts):
|
|
123
|
+
return types.SimpleNamespace(**parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------- the run id and fresh values (fresh.mjs)
|
|
127
|
+
|
|
128
|
+
def make_run_id(env=os.environ):
|
|
129
|
+
if env.get("ATLAS_RUN_ID"):
|
|
130
|
+
return env["ATLAS_RUN_ID"]
|
|
131
|
+
if env.get("GITHUB_RUN_ID"):
|
|
132
|
+
return f"ci{env['GITHUB_RUN_ID']}-{env.get('GITHUB_RUN_ATTEMPT', 1)}"
|
|
133
|
+
return f"run{_secrets.token_hex(3)}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
RUN_ID = make_run_id()
|
|
137
|
+
|
|
138
|
+
_JAN1 = _dt.date(2035, 1, 1)
|
|
139
|
+
_FIRST_MONDAY = _JAN1 + _dt.timedelta(days=((8 - (_JAN1.isoweekday() % 7)) % 7) or 7)
|
|
140
|
+
_SPREAD_WEEKS = 520
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def monday_for(run_id, count):
|
|
144
|
+
digest = hashlib.sha256(f"{run_id}:{count}".encode()).digest()
|
|
145
|
+
weeks = int.from_bytes(digest[:4], "big") % _SPREAD_WEEKS
|
|
146
|
+
return (_FIRST_MONDAY + _dt.timedelta(weeks=weeks)).isoformat()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def make_fresh(run_id, rules, adapter_rules=None):
|
|
150
|
+
rules = rules or {}
|
|
151
|
+
adapter_rules = adapter_rules or {}
|
|
152
|
+
counts = {}
|
|
153
|
+
made = []
|
|
154
|
+
|
|
155
|
+
def fresh(rule):
|
|
156
|
+
if rule not in rules and rule not in adapter_rules:
|
|
157
|
+
raise ValueError(f'there is no data rule "{rule}" in tests.yaml')
|
|
158
|
+
count = counts.get(rule, 0) + 1
|
|
159
|
+
counts[rule] = count
|
|
160
|
+
if rule in adapter_rules:
|
|
161
|
+
value = adapter_rules[rule](run_id, count, rule)
|
|
162
|
+
elif re.search("monday", rule, re.I):
|
|
163
|
+
value = monday_for(run_id, f"{rule}:{count}")
|
|
164
|
+
else:
|
|
165
|
+
value = f"{re.sub('^fresh-', '', rule)}-{run_id}-{count}"
|
|
166
|
+
made.append({"rule": rule, "value": value})
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
fresh.made = made
|
|
170
|
+
return fresh
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- redaction (redact.mjs)
|
|
174
|
+
|
|
175
|
+
MIN_SECRET_LENGTH = 4
|
|
176
|
+
_SHAPES = [
|
|
177
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), "[redacted:jwt]"),
|
|
178
|
+
(re.compile(r"\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}", re.I), r"\1 [redacted]"),
|
|
179
|
+
(re.compile(r"([?&](?:X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token|Signature|sig|access_token)=)[^&\s\"']+", re.I), r"\1[redacted]"),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Redactor:
|
|
184
|
+
"""Redaction by value. No value in the set reaches an evidence file or a log."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, values=()):
|
|
187
|
+
self.values = set()
|
|
188
|
+
self.add(values)
|
|
189
|
+
|
|
190
|
+
def add(self, value):
|
|
191
|
+
for one in value if isinstance(value, (list, tuple, set)) else [value]:
|
|
192
|
+
if isinstance(one, str) and len(one) >= MIN_SECRET_LENGTH:
|
|
193
|
+
self.values.add(one)
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def text(self, value):
|
|
197
|
+
if not isinstance(value, str):
|
|
198
|
+
return value
|
|
199
|
+
out = value
|
|
200
|
+
for secret in sorted(self.values, key=len, reverse=True):
|
|
201
|
+
out = out.replace(secret, "[redacted]")
|
|
202
|
+
for shape, replacement in _SHAPES:
|
|
203
|
+
out = shape.sub(replacement, out)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
def deep(self, value):
|
|
207
|
+
if isinstance(value, str):
|
|
208
|
+
return self.text(value)
|
|
209
|
+
if isinstance(value, (list, tuple)):
|
|
210
|
+
return [self.deep(one) for one in value]
|
|
211
|
+
if isinstance(value, BaseException):
|
|
212
|
+
return self.text(str(value))
|
|
213
|
+
if isinstance(value, dict):
|
|
214
|
+
return {self.text(str(k)): self.deep(v) for k, v in value.items()}
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def clip(value, redactor=None):
|
|
219
|
+
"""Redact first, then cut: a cut before the redaction could split a secret."""
|
|
220
|
+
safe = redactor.deep(value) if redactor else value
|
|
221
|
+
text = safe if isinstance(safe, str) else json.dumps(safe, separators=(",", ":"), default=str)
|
|
222
|
+
if len(text) <= MAX_BODY:
|
|
223
|
+
return safe
|
|
224
|
+
return f"{text[:MAX_BODY]}... [{len(text) - MAX_BODY} more characters]"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------- the child environment (guards.mjs)
|
|
228
|
+
|
|
229
|
+
_ALLOWED = re.compile(r"^(?:PATH|HOME|LANG|LC_[A-Z_]+|TMPDIR|TERM)$")
|
|
230
|
+
_CREDENTIAL_NAME = re.compile(r"TOKEN|AUTH|KEY|SECRET|PASSWORD|PASS|CRED", re.I)
|
|
231
|
+
_LOOPBACK_PROXY = re.compile(r"^http://(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}/?$")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def child_env(parent=None, values=None):
|
|
235
|
+
"""The environment of a child process: the kit's allow-list from `parent`, then `values` on top.
|
|
236
|
+
The rule is childEnv of guards.mjs, word for word."""
|
|
237
|
+
parent = os.environ if parent is None else parent
|
|
238
|
+
out = {}
|
|
239
|
+
for name, value in parent.items():
|
|
240
|
+
if value is None:
|
|
241
|
+
continue
|
|
242
|
+
if _ALLOWED.match(name) or (name.startswith("NODE_") and name != "NODE_TEST_CONTEXT" and not _CREDENTIAL_NAME.search(name)):
|
|
243
|
+
out[name] = value
|
|
244
|
+
if parent.get("CLAUDE_CODE_REMOTE") == "true":
|
|
245
|
+
proxies = [n for n in ("HTTPS_PROXY", "https_proxy") if parent.get(n) is not None]
|
|
246
|
+
if proxies and all(_LOOPBACK_PROXY.match(parent[n]) for n in proxies):
|
|
247
|
+
for name in proxies + ["NO_PROXY", "no_proxy"]:
|
|
248
|
+
if parent.get(name) is not None:
|
|
249
|
+
out[name] = parent[name]
|
|
250
|
+
for name, value in (values or {}).items():
|
|
251
|
+
if value is not None:
|
|
252
|
+
out[name] = str(value)
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------- waits (wait.mjs)
|
|
257
|
+
|
|
258
|
+
_UNITS = {"ms": 1, "millisecond": 1, "milliseconds": 1, "s": 1000, "second": 1000, "seconds": 1000,
|
|
259
|
+
"m": 60000, "min": 60000, "minute": 60000, "minutes": 60000, "h": 3600000, "hour": 3600000, "hours": 3600000}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_limit(limit):
|
|
263
|
+
""""30s", "2m", "2 minutes" or a number of milliseconds. Returns milliseconds."""
|
|
264
|
+
if isinstance(limit, (int, float)) and not isinstance(limit, bool) and limit > 0:
|
|
265
|
+
return limit
|
|
266
|
+
m = re.match(r"^(\d+(?:\.\d+)?)\s*([a-z]+)$", str(limit if limit is not None else "").strip(), re.I)
|
|
267
|
+
if not m or m.group(2).lower() not in _UNITS:
|
|
268
|
+
raise ValueError(f'a limit is a number of milliseconds, or like "30s" or "2m"; found "{limit}"')
|
|
269
|
+
return float(m.group(1)) * _UNITS[m.group(2).lower()]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _show(last):
|
|
273
|
+
body = last.get("body", last) if isinstance(last, dict) else getattr(last, "body", last)
|
|
274
|
+
return json.dumps(body, default=str)[:600]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def wait_until(read, until, fail=None, absent=None, limit=None, every="250ms", absent_limit=10):
|
|
278
|
+
"""Wait for a state, never for a fixed time. A wait with no limit is refused."""
|
|
279
|
+
if not callable(read):
|
|
280
|
+
raise ValueError("wait_until needs a function that reads the state")
|
|
281
|
+
if not callable(until):
|
|
282
|
+
raise ValueError("wait_until needs an `until` state")
|
|
283
|
+
if limit is None:
|
|
284
|
+
raise ValueError("wait_until needs a `limit`: a wait with no time limit is refused")
|
|
285
|
+
deadline = time.monotonic() + parse_limit(limit) / 1000
|
|
286
|
+
absent_reads = 0
|
|
287
|
+
while True:
|
|
288
|
+
last = read()
|
|
289
|
+
if fail and fail(last):
|
|
290
|
+
raise WaitFailed(f"the wait met its fail state: {_show(last)}", last)
|
|
291
|
+
if until(last):
|
|
292
|
+
return last
|
|
293
|
+
absent_reads = absent_reads + 1 if absent and absent(last) else 0
|
|
294
|
+
if absent_reads >= absent_limit:
|
|
295
|
+
raise WaitFailed(f"the field that the wait reads was absent for {absent_reads} reads in a row", last)
|
|
296
|
+
if time.monotonic() >= deadline:
|
|
297
|
+
raise WaitTimeout(f"the wait ran out after {limit}; last state: {_show(last)}", last)
|
|
298
|
+
time.sleep(parse_limit(every) / 1000)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------- the evidence (evidence.mjs)
|
|
302
|
+
|
|
303
|
+
def _now():
|
|
304
|
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _commit_of(root):
|
|
308
|
+
env = os.environ
|
|
309
|
+
if env.get("GITHUB_HEAD_SHA"):
|
|
310
|
+
return env["GITHUB_HEAD_SHA"]
|
|
311
|
+
if env.get("GITHUB_EVENT_PATH"):
|
|
312
|
+
try:
|
|
313
|
+
sha = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text()).get("pull_request", {}).get("head", {}).get("sha")
|
|
314
|
+
if sha:
|
|
315
|
+
return sha
|
|
316
|
+
except (OSError, ValueError, AttributeError):
|
|
317
|
+
pass
|
|
318
|
+
if env.get("GITHUB_SHA"):
|
|
319
|
+
return env["GITHUB_SHA"]
|
|
320
|
+
try:
|
|
321
|
+
r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, timeout=10)
|
|
322
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
323
|
+
except (OSError, subprocess.SubprocessError):
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class Evidence:
|
|
328
|
+
"""One evidence file for each run and scenario. It is claimed with an exclusive create, so no
|
|
329
|
+
earlier run is overwritten. Every string is redacted by value before it is written."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, evidence_dir, run_id, environment, sc, root, redactor):
|
|
332
|
+
self.dir = Path(evidence_dir) / run_id
|
|
333
|
+
self.redactor = redactor
|
|
334
|
+
self.record = {
|
|
335
|
+
"evidence": EVIDENCE_SCHEMA, "run_id": run_id, "environment": environment,
|
|
336
|
+
"scenario": sc["id"], "title": sc["title"],
|
|
337
|
+
"source": os.path.relpath(sc["file"], root).replace(os.sep, "/"), "source_sha256": sc["sha256"],
|
|
338
|
+
"fingerprint_method": sc["method"],
|
|
339
|
+
"commit": _commit_of(root), "started": _now(), "ended": None, "verdict": "not finished",
|
|
340
|
+
"ways": {}, "assertions": [], "tables": {}, "fresh": [], "calls": [], "notes": [], "files": [],
|
|
341
|
+
"provided_secrets": [], "fault_run": os.environ.get("ATLAS_FAULT_RUN") or None,
|
|
342
|
+
"language": "python",
|
|
343
|
+
}
|
|
344
|
+
for way in sc["ways"]:
|
|
345
|
+
self.record["ways"][way] = {"verdict": "not finished", "reason": None, "started": None, "ended": None}
|
|
346
|
+
for a in sc["assertions"]:
|
|
347
|
+
for way in a["ways"]:
|
|
348
|
+
self.record["assertions"].append({"id": a["fullId"], "way": way, "result": "not checked", "proof": None})
|
|
349
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
self.path = self._claim(sc["id"])
|
|
351
|
+
self.write()
|
|
352
|
+
|
|
353
|
+
def _claim(self, scenario_id):
|
|
354
|
+
n = 1
|
|
355
|
+
while True:
|
|
356
|
+
path = self.dir / (f"{scenario_id}.json" if n == 1 else f"{scenario_id}.{n}.json")
|
|
357
|
+
try:
|
|
358
|
+
os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
359
|
+
return path
|
|
360
|
+
except FileExistsError:
|
|
361
|
+
n += 1
|
|
362
|
+
|
|
363
|
+
def entry(self, full_id, way):
|
|
364
|
+
for a in self.record["assertions"]:
|
|
365
|
+
if a["id"] == full_id and a["way"] == way:
|
|
366
|
+
return a
|
|
367
|
+
raise ValueError(f'the evidence has no assertion {full_id} for the way in "{way}"')
|
|
368
|
+
|
|
369
|
+
def set_result(self, full_id, way, result, proof=None):
|
|
370
|
+
if result not in RESULTS:
|
|
371
|
+
raise ValueError(f'"{result}" is not a result')
|
|
372
|
+
entry = self.entry(full_id, way)
|
|
373
|
+
entry["result"] = result
|
|
374
|
+
entry["proof"] = {k: v for k, v in (proof or {}).items() if v is not None} or None
|
|
375
|
+
self.write()
|
|
376
|
+
|
|
377
|
+
def set_way(self, way, **patch):
|
|
378
|
+
self.record["ways"][way].update(patch)
|
|
379
|
+
verdicts = [w["verdict"] for w in self.record["ways"].values()]
|
|
380
|
+
if "fail" in verdicts:
|
|
381
|
+
self.record["verdict"] = "fail"
|
|
382
|
+
elif "blocked" in verdicts:
|
|
383
|
+
self.record["verdict"] = "blocked"
|
|
384
|
+
elif all(v in ("pass", "not here") for v in verdicts):
|
|
385
|
+
self.record["verdict"] = "not here" if all(v == "not here" for v in verdicts) else "pass"
|
|
386
|
+
else:
|
|
387
|
+
self.record["verdict"] = "not finished"
|
|
388
|
+
if "not finished" not in verdicts:
|
|
389
|
+
self.record["ended"] = _now()
|
|
390
|
+
self.write()
|
|
391
|
+
|
|
392
|
+
def add_call(self, call):
|
|
393
|
+
n = len(self.record["calls"]) + 1
|
|
394
|
+
self.record["calls"].append({"n": n, **call})
|
|
395
|
+
return n
|
|
396
|
+
|
|
397
|
+
def add_file(self, path):
|
|
398
|
+
self.record["files"].append(os.path.relpath(path, self.dir).replace(os.sep, "/"))
|
|
399
|
+
self.write()
|
|
400
|
+
|
|
401
|
+
def note(self, text):
|
|
402
|
+
self.record["notes"].append(str(text))
|
|
403
|
+
self.write()
|
|
404
|
+
|
|
405
|
+
def write(self):
|
|
406
|
+
safe = self.redactor.deep(self.record)
|
|
407
|
+
temp = self.path.with_name(self.path.name + ".tmp")
|
|
408
|
+
temp.write_text(json.dumps(safe, indent=2, ensure_ascii=False) + "\n")
|
|
409
|
+
os.replace(temp, self.path)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------- reading the scenario
|
|
413
|
+
|
|
414
|
+
def _load(file):
|
|
415
|
+
try:
|
|
416
|
+
r = subprocess.run(["node", str(BRIDGE), "load", str(file)], capture_output=True, text=True, timeout=60)
|
|
417
|
+
except FileNotFoundError as error:
|
|
418
|
+
raise RuntimeError("the Python contract library needs node to read a scenario (the bridge, bridge.mjs)") from error
|
|
419
|
+
try:
|
|
420
|
+
data = json.loads(r.stdout)
|
|
421
|
+
except ValueError as error:
|
|
422
|
+
raise RuntimeError(f"the bridge gave no answer: {r.stderr.strip()[:400]}") from error
|
|
423
|
+
if "error" in data:
|
|
424
|
+
raise RuntimeError(data["error"])
|
|
425
|
+
return data
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _caller_dir():
|
|
429
|
+
for frame in inspect.stack()[2:]:
|
|
430
|
+
if Path(frame.filename).resolve() != Path(__file__).resolve():
|
|
431
|
+
return Path(frame.filename).resolve().parent
|
|
432
|
+
return Path.cwd()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _as_list(x):
|
|
436
|
+
if isinstance(x, list):
|
|
437
|
+
return [str(v) for v in x]
|
|
438
|
+
return [] if x is None else [str(x)]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _one_line(text):
|
|
442
|
+
return re.sub(r"\s+", " ", str(text or "")).strip()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------- the check block
|
|
446
|
+
|
|
447
|
+
class _Check:
|
|
448
|
+
def __init__(self, run, full, mode):
|
|
449
|
+
self.run = run
|
|
450
|
+
self.full = full
|
|
451
|
+
self.mode = mode
|
|
452
|
+
self.value = None
|
|
453
|
+
|
|
454
|
+
def __enter__(self):
|
|
455
|
+
self.run._open(f"run.{self.mode}")
|
|
456
|
+
self.found = self.run._find(self.full)
|
|
457
|
+
return self
|
|
458
|
+
|
|
459
|
+
def __exit__(self, kind, error, trace):
|
|
460
|
+
# A block or a stopped gate inside the block is no product result of this check: it goes on up.
|
|
461
|
+
if kind is not None and (not issubclass(kind, Exception) or issubclass(kind, (Blocked, GateFailed))):
|
|
462
|
+
return False
|
|
463
|
+
a, fp = self.found
|
|
464
|
+
failure = None
|
|
465
|
+
if fp != a["fp"]:
|
|
466
|
+
failure = f"the fingerprint {fp} is old: the words of {a['id']} now print {a['fp']}; convert the test again"
|
|
467
|
+
elif error is not None:
|
|
468
|
+
message = str(error)
|
|
469
|
+
if isinstance(error, AssertionError):
|
|
470
|
+
at = trace.tb_lineno if trace else "?"
|
|
471
|
+
while trace is not None and trace.tb_next is not None:
|
|
472
|
+
trace = trace.tb_next
|
|
473
|
+
at = trace.tb_lineno
|
|
474
|
+
failure = message or f"an assert statement failed (line {at})"
|
|
475
|
+
else:
|
|
476
|
+
failure = f"{type(error).__name__}: {message}" if message else type(error).__name__
|
|
477
|
+
self.run._settle_one(a, failure, self.value)
|
|
478
|
+
if failure and self.mode == "gate":
|
|
479
|
+
raise GateFailed(f"[{a['id']}] {failure}")
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ---------------------------------------------------------------- the run object
|
|
484
|
+
|
|
485
|
+
class Run:
|
|
486
|
+
def __init__(self, ctx, state, way, ev, adapter):
|
|
487
|
+
self._ctx = ctx
|
|
488
|
+
self._state = state
|
|
489
|
+
self._ev = ev
|
|
490
|
+
self._adapter = adapter
|
|
491
|
+
self._closed = False
|
|
492
|
+
self._mark = len(ev.record["calls"])
|
|
493
|
+
sc = ctx["scenario"]
|
|
494
|
+
self.id = sc["id"]
|
|
495
|
+
self.title = sc["title"]
|
|
496
|
+
self.run_id = RUN_ID
|
|
497
|
+
self.way = way
|
|
498
|
+
self.scenario = sc
|
|
499
|
+
self.tests = ctx["tests"]
|
|
500
|
+
self.root = ctx["root"]
|
|
501
|
+
self.round = None
|
|
502
|
+
self.cleanups = []
|
|
503
|
+
if state.get("fresh") is None:
|
|
504
|
+
state["fresh"] = make_fresh(RUN_ID, self.tests.get("data"), getattr(adapter, "data", None))
|
|
505
|
+
actions = getattr(adapter, "actions", None)
|
|
506
|
+
actions = actions(self) if callable(actions) else (actions or {})
|
|
507
|
+
self.actions = types.SimpleNamespace(**actions) if isinstance(actions, dict) else actions
|
|
508
|
+
|
|
509
|
+
# -- internal
|
|
510
|
+
|
|
511
|
+
def _open(self, what):
|
|
512
|
+
if self._closed:
|
|
513
|
+
raise RuntimeError(f"the run is closed: {what} came after the scenario ended, so it changed nothing")
|
|
514
|
+
|
|
515
|
+
def _calls_since(self):
|
|
516
|
+
start = self._mark
|
|
517
|
+
self._mark = len(self._ev.record["calls"])
|
|
518
|
+
return [c["n"] for c in self._ev.record["calls"][start:]]
|
|
519
|
+
|
|
520
|
+
def _find(self, full):
|
|
521
|
+
sc = self.scenario
|
|
522
|
+
m = FULL_ID.match(str(full))
|
|
523
|
+
if not m:
|
|
524
|
+
raise ValueError(f'"{full}" is not a full assertion id like {sc["id"]}/e1#abc123')
|
|
525
|
+
if m.group(1) != sc["id"]:
|
|
526
|
+
raise ValueError(f'"{full}" names the scenario "{m.group(1)}"; this is "{sc["id"]}"')
|
|
527
|
+
for a in sc["assertions"]:
|
|
528
|
+
if a["id"] == m.group(2):
|
|
529
|
+
return a, m.group(3)
|
|
530
|
+
raise ValueError(f"the scenario has no assertion {m.group(2)}")
|
|
531
|
+
|
|
532
|
+
def _target(self, a):
|
|
533
|
+
return self.way if self.way in a["ways"] else a["ways"][0]
|
|
534
|
+
|
|
535
|
+
def _settle_one(self, a, failure, value):
|
|
536
|
+
if a["kind"] == "judged":
|
|
537
|
+
failure = failure or f"{a['id']} is a judged assertion; the Python library has no judge yet"
|
|
538
|
+
target = self._target(a)
|
|
539
|
+
entry = self._ev.entry(a["fullId"], target)
|
|
540
|
+
calls = self._calls_since()
|
|
541
|
+
earlier = entry if (entry.get("proof") or {}).get("settled_by") not in (None, self.way) else None
|
|
542
|
+
also = ([*earlier["proof"].get("also", []), {"run": earlier["proof"]["settled_by"], "result": earlier["result"]}]
|
|
543
|
+
if earlier else (entry.get("proof") or {}).get("also"))
|
|
544
|
+
if failure:
|
|
545
|
+
self._ev.set_result(a["fullId"], target, "fail", {"error": self.redact(failure), "calls": calls, "settled_by": self.way, "also": also})
|
|
546
|
+
elif entry["result"] != "fail":
|
|
547
|
+
self._ev.set_result(a["fullId"], target, "pass", {"value": None if value is None else clip(value, self._state["redactor"]), "calls": calls, "settled_by": self.way, "also": also})
|
|
548
|
+
else:
|
|
549
|
+
entry["proof"] = {**(entry.get("proof") or {}), "also": [*(entry["proof"] or {}).get("also", []), {"run": self.way, "result": "pass"}]}
|
|
550
|
+
self._ev.write()
|
|
551
|
+
|
|
552
|
+
def _record(self, call):
|
|
553
|
+
if self._closed:
|
|
554
|
+
return None
|
|
555
|
+
return self._ev.add_call(self._state["redactor"].deep(call))
|
|
556
|
+
|
|
557
|
+
def close(self):
|
|
558
|
+
self._closed = True
|
|
559
|
+
|
|
560
|
+
# -- the parts of section 8.1
|
|
561
|
+
|
|
562
|
+
def check(self, full):
|
|
563
|
+
"""One assertion, as a `with` block. A failure is recorded and the run goes on."""
|
|
564
|
+
return _Check(self, full, "check")
|
|
565
|
+
|
|
566
|
+
def gate(self, full):
|
|
567
|
+
"""The same, and a failure stops the run. Later assertions read `not checked`."""
|
|
568
|
+
return _Check(self, full, "gate")
|
|
569
|
+
|
|
570
|
+
def not_exercised(self, full, why):
|
|
571
|
+
"""A "when" assertion whose situation never happened in this run."""
|
|
572
|
+
self._open("run.not_exercised")
|
|
573
|
+
a, fp = self._find(full)
|
|
574
|
+
target = self._target(a)
|
|
575
|
+
if fp != a["fp"]:
|
|
576
|
+
self._settle_one(a, f"the fingerprint {fp} is old: the words of {a['id']} now print {a['fp']}; convert the test again", None)
|
|
577
|
+
return
|
|
578
|
+
if self._ev.entry(a["fullId"], target)["result"] in ("pass", "fail"):
|
|
579
|
+
return
|
|
580
|
+
self._ev.set_result(a["fullId"], target, "not exercised", {"why": str(why), "calls": self._calls_since()})
|
|
581
|
+
|
|
582
|
+
def blocked(self, why):
|
|
583
|
+
"""The test could not run. Never use it for a product result."""
|
|
584
|
+
raise Blocked(why)
|
|
585
|
+
|
|
586
|
+
def judge(self, full, evidence=None):
|
|
587
|
+
raise Blocked(f"{full}: the Python contract library has no judge yet; write this scenario with contract.mjs")
|
|
588
|
+
|
|
589
|
+
def _table(self, name, fingerprint=None):
|
|
590
|
+
t = self.scenario["tables"].get(name)
|
|
591
|
+
if not t:
|
|
592
|
+
raise ValueError(f"the scenario has no case table {name}")
|
|
593
|
+
self._ev.record["tables"][name] = t["fingerprint"]
|
|
594
|
+
if fingerprint and fingerprint != t["fingerprint"]:
|
|
595
|
+
raise ValueError(f"the table {name} now prints {t['fingerprint']}, not {fingerprint}; its rows changed")
|
|
596
|
+
return t
|
|
597
|
+
|
|
598
|
+
def table(self, name, fingerprint=None):
|
|
599
|
+
"""The rows of a rule table, as dicts keyed by the header, read at run time."""
|
|
600
|
+
return [dict(row) for row in self._table(name, fingerprint)["rows"]]
|
|
601
|
+
|
|
602
|
+
def cells(self, name, fingerprint=None):
|
|
603
|
+
t = self._table(name, fingerprint)
|
|
604
|
+
if t["kind"] != "rule":
|
|
605
|
+
raise ValueError(f"{name} is a combination table; use run.cases")
|
|
606
|
+
first, *rest = t["header"]
|
|
607
|
+
return [{"row": row[first], "column": col, "value": row[col], "row_index": i, "label": f"{name}: {row[first]} / {col}"}
|
|
608
|
+
for i, row in enumerate(t["rows"]) for col in rest]
|
|
609
|
+
|
|
610
|
+
def cases(self, name, fingerprint=None):
|
|
611
|
+
t = self._table(name, fingerprint)
|
|
612
|
+
if t["kind"] != "combination":
|
|
613
|
+
raise ValueError(f"{name} is a rule table; use run.table or run.cells")
|
|
614
|
+
return [{**one, "label": f"{name} case {i + 1}"} for i, one in enumerate(t["cases"])]
|
|
615
|
+
|
|
616
|
+
def driver(self, way_name=None):
|
|
617
|
+
way_name = way_name or self.way
|
|
618
|
+
drivers = self._state["drivers"]
|
|
619
|
+
if way_name not in drivers:
|
|
620
|
+
spec = (self.tests.get("ways-in") or {}).get(way_name)
|
|
621
|
+
if not spec:
|
|
622
|
+
raise ValueError(f'there is no way in "{way_name}" in tests.yaml')
|
|
623
|
+
# A kit driver and the guard parts of a way in hold guards (hosts, the model meter, tools)
|
|
624
|
+
# that the Python library does not apply yet. An adapter driver must not stand in for them.
|
|
625
|
+
if spec.get("driver") in self._ctx["shippedDrivers"]:
|
|
626
|
+
raise Blocked(f'the way in "{way_name}" uses the kit driver "{spec["driver"]}", which has no Python form yet; write the test with contract.mjs')
|
|
627
|
+
guarded = [k for k in self._ctx["wayGuardParts"] if k in spec]
|
|
628
|
+
if guarded:
|
|
629
|
+
raise Blocked(f'the way in "{way_name}" sets {", ".join(guarded)}, guard parts that the Python library does not apply yet; write the test with contract.mjs')
|
|
630
|
+
given = getattr(self._adapter, "drivers", None) or {}
|
|
631
|
+
make = given.get(way_name) or given.get(spec.get("driver"))
|
|
632
|
+
if not make:
|
|
633
|
+
raise Blocked(f'the way in "{way_name}" uses the driver "{spec.get("driver")}", which has no Python form in the kit yet; give it in the adapter (drivers), or write the test with contract.mjs')
|
|
634
|
+
state = self._state
|
|
635
|
+
drivers[way_name] = make({
|
|
636
|
+
"way": way_name, "spec": spec, "root": self.root, "run_id": RUN_ID, "tests": self.tests,
|
|
637
|
+
"redactor": state["redactor"], "record": lambda call: state["live"]._record(call),
|
|
638
|
+
})
|
|
639
|
+
return drivers[way_name]
|
|
640
|
+
|
|
641
|
+
def persona(self, name, way_name=None):
|
|
642
|
+
"""A test user from tests.yaml, made or loaded for this run."""
|
|
643
|
+
way_name = way_name or self.way
|
|
644
|
+
key = (name, way_name)
|
|
645
|
+
personas = self._state["personas"]
|
|
646
|
+
if key in personas:
|
|
647
|
+
return personas[key]
|
|
648
|
+
spec = (self.tests.get("personas") or {}).get(name)
|
|
649
|
+
if spec is None:
|
|
650
|
+
raise ValueError(f'there is no persona "{name}" in tests.yaml')
|
|
651
|
+
drv = self.driver(way_name)
|
|
652
|
+
extra = {}
|
|
653
|
+
if spec.get("make") or spec.get("sign-in"):
|
|
654
|
+
kind, made_by = ("makers", spec["make"]) if spec.get("make") else ("sign_ins", spec["sign-in"])
|
|
655
|
+
maker = (getattr(self._adapter, kind, None) or {}).get(made_by)
|
|
656
|
+
if not maker:
|
|
657
|
+
raise Blocked(f'persona "{name}" needs the {"maker" if kind == "makers" else "sign-in"} "{made_by}" and the adapter does not give it')
|
|
658
|
+
extra = maker({"run": self, "name": name, "spec": spec, "way": way_name, "driver": drv, "fresh": self.fresh}) or {}
|
|
659
|
+
if extra.get("cleanup"):
|
|
660
|
+
self.cleanups.append(extra["cleanup"])
|
|
661
|
+
elif spec.get("secret"):
|
|
662
|
+
raise Blocked(f'persona "{name}" needs the secret {spec["secret"]}; the Python library gives no run secrets yet')
|
|
663
|
+
credential = extra.get("credential")
|
|
664
|
+
self._state["redactor"].add(credential)
|
|
665
|
+
for value in (extra.get("headers") or {}).values():
|
|
666
|
+
self._state["redactor"].add(str(value))
|
|
667
|
+
|
|
668
|
+
def call(request=None, **opts):
|
|
669
|
+
return drv.call(request or {}, credential=credential, headers=extra.get("headers") or {}, persona=name, **opts)
|
|
670
|
+
|
|
671
|
+
persona = types.SimpleNamespace(**{k: v for k, v in extra.items() if k not in ("credential", "cleanup")},
|
|
672
|
+
name=name, role=spec.get("role"), way=way_name, credential=credential, call=call)
|
|
673
|
+
personas[key] = persona
|
|
674
|
+
return persona
|
|
675
|
+
|
|
676
|
+
def fresh(self, rule):
|
|
677
|
+
"""A fresh value from a data rule. It carries the run id; a date is picked from it."""
|
|
678
|
+
self._open("run.fresh")
|
|
679
|
+
value = self._state["fresh"](rule)
|
|
680
|
+
self._ev.record["fresh"] = list(self._state["fresh"].made)
|
|
681
|
+
self._ev.write()
|
|
682
|
+
return value
|
|
683
|
+
|
|
684
|
+
def wait(self, read, **opts):
|
|
685
|
+
return wait_until(read, **opts)
|
|
686
|
+
|
|
687
|
+
def wait_for(self, full, read, **opts):
|
|
688
|
+
"""A wait that serves one assertion: a wait that fails or runs out fails it, and stops the run."""
|
|
689
|
+
self._open("run.wait_for")
|
|
690
|
+
a, _ = self._find(full)
|
|
691
|
+
try:
|
|
692
|
+
return wait_until(read, **opts)
|
|
693
|
+
except (WaitFailed, WaitTimeout) as error:
|
|
694
|
+
self._ev.set_result(a["fullId"], self._target(a), "fail", {"error": str(error), "last": clip(error.last, self._state["redactor"])})
|
|
695
|
+
raise GateFailed(f"[{a['id']}] {error}") from error
|
|
696
|
+
|
|
697
|
+
def note(self, text):
|
|
698
|
+
self._open("run.note")
|
|
699
|
+
self._ev.note(self.redact(str(text)))
|
|
700
|
+
|
|
701
|
+
def attach(self, file_name):
|
|
702
|
+
"""Keep a file as evidence. Returns the path to write to."""
|
|
703
|
+
self._open("run.attach")
|
|
704
|
+
folder = self._ev.dir / self.id
|
|
705
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
706
|
+
path = folder / file_name
|
|
707
|
+
self._ev.add_file(path)
|
|
708
|
+
return path
|
|
709
|
+
|
|
710
|
+
def on_cleanup(self, fn):
|
|
711
|
+
self.cleanups.append(fn)
|
|
712
|
+
|
|
713
|
+
def redact(self, text):
|
|
714
|
+
return self._state["redactor"].text(text)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# ---------------------------------------------------------------- settling a way in
|
|
718
|
+
|
|
719
|
+
def _settle(sc, way, ev, error):
|
|
720
|
+
"""Turns the end of a body into results, a verdict and a list of problems (settle of contract.mjs)."""
|
|
721
|
+
mine = [a for a in ev.record["assertions"] if a["way"] == way or a["way"] not in sc["through"]]
|
|
722
|
+
problems = []
|
|
723
|
+
verdict, reason = "pass", None
|
|
724
|
+
if isinstance(error, Blocked):
|
|
725
|
+
verdict, reason = "blocked", str(error)
|
|
726
|
+
for a in mine:
|
|
727
|
+
if a["result"] == "not checked":
|
|
728
|
+
a["result"], a["proof"] = "blocked", {"why": reason}
|
|
729
|
+
elif isinstance(error, GateFailed):
|
|
730
|
+
verdict, reason = "fail", "a gate assertion failed, so the run stopped"
|
|
731
|
+
elif error is not None:
|
|
732
|
+
verdict, reason = "fail", f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
|
|
733
|
+
for a in mine:
|
|
734
|
+
if a["result"] in ("pass", "not here"):
|
|
735
|
+
continue
|
|
736
|
+
if verdict == "pass":
|
|
737
|
+
verdict = "fail"
|
|
738
|
+
proof = a.get("proof") or {}
|
|
739
|
+
detail = proof.get("error") or proof.get("why")
|
|
740
|
+
problems.append(f"[{a['id']}] {a['result']}" + (f": {detail}" if detail else ""))
|
|
741
|
+
if verdict == "blocked":
|
|
742
|
+
problems.insert(0, f"BLOCKED: {reason}")
|
|
743
|
+
elif reason and not isinstance(error, GateFailed):
|
|
744
|
+
problems.append(f"the run stopped: {reason}")
|
|
745
|
+
ev.set_way(way, verdict=verdict, reason=reason, ended=_now())
|
|
746
|
+
for extra in [w for w in sc["ways"] if w not in sc["through"]]:
|
|
747
|
+
own = [a["result"] for a in ev.record["assertions"] if a["way"] == extra]
|
|
748
|
+
stopped = all(r in ("pass", "not here", "not checked", "blocked") for r in own) and any(r in ("not checked", "blocked") for r in own)
|
|
749
|
+
extra_reason = None
|
|
750
|
+
if "fail" in own:
|
|
751
|
+
extra_verdict, extra_reason = "fail", f"an assertion through {extra} failed"
|
|
752
|
+
elif stopped:
|
|
753
|
+
extra_verdict = "blocked"
|
|
754
|
+
extra_reason = ("the gate stopped the run" if isinstance(error, GateFailed) else reason if verdict == "blocked"
|
|
755
|
+
else "the run stopped before every assertion through this way in was checked")
|
|
756
|
+
elif all(r in ("pass", "not here") for r in own):
|
|
757
|
+
extra_verdict = "not here" if all(r == "not here" for r in own) else "pass"
|
|
758
|
+
else:
|
|
759
|
+
extra_verdict, extra_reason = "fail", f"an assertion through {extra} is not a pass"
|
|
760
|
+
ev.set_way(extra, verdict=extra_verdict, reason=extra_reason, started=ev.record["ways"][way]["started"], ended=_now())
|
|
761
|
+
return verdict, problems
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class _Limit:
|
|
765
|
+
"""The time limit of the scenario. It needs SIGALRM on the main thread; elsewhere no limit applies."""
|
|
766
|
+
|
|
767
|
+
def __init__(self, ms, text):
|
|
768
|
+
self.seconds = ms / 1000
|
|
769
|
+
self.text = text
|
|
770
|
+
self.armed = False
|
|
771
|
+
|
|
772
|
+
def __enter__(self):
|
|
773
|
+
if hasattr(signal, "setitimer") and threading.current_thread() is threading.main_thread():
|
|
774
|
+
def ring(signum, frame):
|
|
775
|
+
raise _OutOfTime(f"the scenario ran out of time: its limit is {self.text}")
|
|
776
|
+
self.before = signal.signal(signal.SIGALRM, ring)
|
|
777
|
+
signal.setitimer(signal.ITIMER_REAL, self.seconds)
|
|
778
|
+
self.armed = True
|
|
779
|
+
return self
|
|
780
|
+
|
|
781
|
+
def __exit__(self, *exc):
|
|
782
|
+
if self.armed:
|
|
783
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
784
|
+
signal.signal(signal.SIGALRM, self.before)
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _method_name(way):
|
|
789
|
+
return "test_" + re.sub(r"\W", "_", way)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def scenario(path, adapter=None):
|
|
793
|
+
"""Decorates the body of a scenario test, (run, way) -> None, and returns a unittest.TestCase class.
|
|
794
|
+
A relative path is read from the folder of the test file."""
|
|
795
|
+
base = _caller_dir()
|
|
796
|
+
adapter = adapter or define_adapter()
|
|
797
|
+
|
|
798
|
+
def wrap(body):
|
|
799
|
+
file = Path(path) if Path(path).is_absolute() else (base / path)
|
|
800
|
+
ctx = _load(file.resolve())
|
|
801
|
+
sc = ctx["scenario"]
|
|
802
|
+
environment = ctx["environment"]
|
|
803
|
+
runs_here = environment in _as_list(sc["fields"].get("runs-in"))
|
|
804
|
+
limit_text = sc["fields"].get("limit")
|
|
805
|
+
limit_ms = parse_limit(limit_text)
|
|
806
|
+
state = {"redactor": Redactor(), "evidence": None, "drivers": {}, "personas": {}, "fresh": None, "warmed": False}
|
|
807
|
+
|
|
808
|
+
def evidence():
|
|
809
|
+
if state["evidence"] is None:
|
|
810
|
+
state["evidence"] = Evidence(ctx["evidenceDir"], RUN_ID, environment, sc, ctx["root"], state["redactor"])
|
|
811
|
+
return state["evidence"]
|
|
812
|
+
|
|
813
|
+
def ready():
|
|
814
|
+
if ctx["guardRefusal"]:
|
|
815
|
+
raise Blocked(ctx["guardRefusal"])
|
|
816
|
+
# Under -O or PYTHONOPTIMIZE, Python drops every assert statement, so every check would pass.
|
|
817
|
+
if sys.flags.optimize or not __debug__:
|
|
818
|
+
raise Blocked("Python runs with -O or PYTHONOPTIMIZE, which removes every assert statement; run it without them")
|
|
819
|
+
named = [n for n in sc.get("names", []) if n in (ctx["tests"].get("secrets") or {})]
|
|
820
|
+
if named:
|
|
821
|
+
raise Blocked(f"the scenario names the secret {', '.join(named)}; the Python library gives no run secrets yet")
|
|
822
|
+
spec = ctx["envSpec"] or {}
|
|
823
|
+
if spec.get("start") or ctx["tests"].get("stand-ins"):
|
|
824
|
+
raise Blocked("the environment starts a product or a stand-in; the Python contract library does not start one yet")
|
|
825
|
+
if sc.get("repeat"):
|
|
826
|
+
raise Blocked("the scenario has repeat; the Python contract library does not repeat a run yet")
|
|
827
|
+
# Each way in of the run is checked before the body runs, also one the body never reaches through run.driver.
|
|
828
|
+
for way in sc["through"]:
|
|
829
|
+
spec_way = (ctx["tests"].get("ways-in") or {}).get(way) or {}
|
|
830
|
+
if spec_way.get("driver") in ctx["shippedDrivers"]:
|
|
831
|
+
raise Blocked(f'the way in "{way}" uses the kit driver "{spec_way["driver"]}", which has no Python form yet; write the test with contract.mjs')
|
|
832
|
+
guarded = [k for k in ctx["wayGuardParts"] if k in spec_way]
|
|
833
|
+
if guarded:
|
|
834
|
+
raise Blocked(f'the way in "{way}" sets {", ".join(guarded)}, guard parts that the Python library does not apply yet; write the test with contract.mjs')
|
|
835
|
+
if not state["warmed"]:
|
|
836
|
+
warm = getattr(adapter, "warm_up", None)
|
|
837
|
+
if warm:
|
|
838
|
+
warm(spec)
|
|
839
|
+
state["warmed"] = True
|
|
840
|
+
|
|
841
|
+
def close_drivers():
|
|
842
|
+
for drv in state["drivers"].values():
|
|
843
|
+
try:
|
|
844
|
+
drv.close()
|
|
845
|
+
except Exception: # the stop goes on
|
|
846
|
+
pass
|
|
847
|
+
state["drivers"].clear()
|
|
848
|
+
|
|
849
|
+
def run_way(case, way):
|
|
850
|
+
ev = evidence()
|
|
851
|
+
ev.set_way(way, started=_now())
|
|
852
|
+
run, error = None, None
|
|
853
|
+
try:
|
|
854
|
+
with _Limit(limit_ms, limit_text):
|
|
855
|
+
ready()
|
|
856
|
+
run = Run(ctx, state, way, ev, adapter)
|
|
857
|
+
state["live"] = run
|
|
858
|
+
body(run, way)
|
|
859
|
+
except (Blocked, GateFailed) as thrown:
|
|
860
|
+
error = thrown
|
|
861
|
+
except _OutOfTime as thrown:
|
|
862
|
+
error = thrown
|
|
863
|
+
except Exception as thrown: # a stop of the body is a fail, with its reason
|
|
864
|
+
error = thrown
|
|
865
|
+
verdict, problems = _settle(sc, way, ev, error)
|
|
866
|
+
if run is not None:
|
|
867
|
+
run.close()
|
|
868
|
+
for fn in reversed(run.cleanups):
|
|
869
|
+
try:
|
|
870
|
+
fn()
|
|
871
|
+
except Exception as failed:
|
|
872
|
+
ev.note(f"clean-up failed: {failed}")
|
|
873
|
+
if problems:
|
|
874
|
+
case.fail(state["redactor"].text("\n".join(problems)))
|
|
875
|
+
|
|
876
|
+
def not_here(case):
|
|
877
|
+
ev = evidence()
|
|
878
|
+
for a in sc["assertions"]:
|
|
879
|
+
for way in a["ways"]:
|
|
880
|
+
ev.set_result(a["fullId"], way, "not here", {"why": f"runs-in does not list {environment}"})
|
|
881
|
+
for way in sc["ways"]:
|
|
882
|
+
ev.set_way(way, verdict="not here", started=_now(), ended=_now())
|
|
883
|
+
|
|
884
|
+
members = {"__doc__": f"{sc['id']}: {sc['title']}", "__module__": body.__module__,
|
|
885
|
+
"tearDownClass": classmethod(lambda cls: close_drivers())}
|
|
886
|
+
if not runs_here:
|
|
887
|
+
members["test_not_here"] = lambda case: not_here(case)
|
|
888
|
+
else:
|
|
889
|
+
for way in sc["through"]:
|
|
890
|
+
members[_method_name(way)] = (lambda w: lambda case: run_way(case, w))(way)
|
|
891
|
+
return type(body.__name__, (unittest.TestCase,), members)
|
|
892
|
+
|
|
893
|
+
return wrap
|